diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 691294f..33edbbd 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -122,7 +122,22 @@ bench, or one whose `/data` has been wiped, starts from a full campaign. and the following ones reuse the live session; nothing switches back - switch on the control panel when done. `cloud.mode-switch` is the one round trip and starts in GRBL mode. -4. When *Release authorized: YES*, **Export release artifact**, download +4. Or hand the whole list to a queue. **Run what is left** offers two: + **Unattended** takes every `auto` test the campaign does not already + count as satisfied, and needs nobody in the room; **Operator and live** + takes the `operator` and `live` ones, and needs somebody at the + machine, since it prompts and it fires the laser. Each button says how + many it would run, and asks before it starts: the live queue names the + tests that fire and takes the acknowledgment once, for all of them. + A queue runs one test at a time in prerequisite order and stops on the + first result that is not a PASS, because a FAIL closes the campaign. A + test it cannot start is skipped with the reason on the page and the + rest carry on, which is what happens to an `auto` test waiting on an + `operator` one: run the attended queue, then the unattended one again. + **Stop the queue** cancels what is still waiting and lets the run in + progress finish; **Abort** ends that one too. The queue lives in the + runner, so closing the page or reloading it does not disturb the run. +5. When *Release authorized: YES*, **Export release artifact**, download `acceptance.json` and `acceptance.md`, and commit them as `releases/v/acceptance.json` and `.md`. @@ -253,7 +268,7 @@ recorded in `/data/forgetest/bench.jsonl` and never enter a campaign. catalog.py @test registry, catalog hash campaign.py the rules (pure functions) artifact.py export + gate verification - runner.py one run at a time, prompts, abort, takeover + runner.py one run at a time, prompts, abort, takeover, queues baseline.py the fresh-boot idle state around every run server.py / page.py HTTP API + the page (forgectrl's access rules) bench.py / coverage.py bench registry + subprocess runner; the lint diff --git a/forgetest/forgetest/catalog.py b/forgetest/forgetest/catalog.py index e9fb64f..affe669 100644 --- a/forgetest/forgetest/catalog.py +++ b/forgetest/forgetest/catalog.py @@ -120,6 +120,35 @@ def get(id, registry=None): return (registry if registry is not None else REGISTRY).get(id) +def order_by_requires(tests, selected): + """`selected` ids in an order that runs a prerequisite before the test + that names it. + + Registration order otherwise, so a run reads down the page. A + prerequisite outside the selection places nothing: either it is + already satisfied, or the run that needs it will be refused and + recorded as skipped. validate() has ruled out cycles; the stack check + only keeps a malformed registry from recursing forever. + """ + want = set(selected) + by_id = {t.id: t for t in tests} + out, placed = [], set() + + def visit(tid, stack): + if tid in placed or tid not in want or tid not in by_id or tid in stack: + return + stack.add(tid) + for r in by_id[tid].requires: + visit(r, stack) + stack.discard(tid) + placed.add(tid) + out.append(tid) + + for t in tests: + visit(t.id, set()) + return out + + def validate(registry=None): """Every `requires` names a known test and there are no cycles.""" reg = registry if registry is not None else REGISTRY diff --git a/forgetest/forgetest/page.py b/forgetest/forgetest/page.py index 2f839e6..af860d8 100644 --- a/forgetest/forgetest/page.py +++ b/forgetest/forgetest/page.py @@ -76,6 +76,16 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos .switch .on{color:var(--warn);font-weight:600} .req.over{color:var(--dim)} .grp{margin-top:6px} +.queue{background:#f7f8fa;margin:10px 0 0;padding:10px 12px} +.queue h2{margin-bottom:8px} +.queue .hint{margin-top:8px} +.qline{font-size:12.5px;color:var(--dim);line-height:1.7;margin-top:8px} +.qline b{color:var(--txt)} +.qbar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:#e2e4e9;margin:6px 0} +.qbar i{display:block} +.qbar i.ok{background:var(--ok)}.qbar i.bad{background:var(--red)} +.qbar i.skip{background:var(--warn)}.qbar i.now{background:var(--blue)} +.badge.queued{background:#dde7f5;color:#24507f} .tool .argrow{display:flex;gap:8px;flex-wrap:wrap;margin:6px 0} .tool .argrow label{font-size:12px;color:var(--dim)} @@ -88,6 +98,19 @@ pre#log{background:#1d1e26;color:#d7dae0;font-family:ui-monospace,Consolas,monos
+

Run what is left

+
+ + + +
+
+

Each queue takes every test of its kind that the campaign does not + already count as satisfied, runs them one at a time in prerequisite order, and stops + on the first result that is not a PASS. The unattended queue needs nobody in the room. + The other one does: it prompts, and it fires the laser. Stop-the-queue cancels what is + still waiting and lets the run in progress finish; Abort ends that one too.

+
@@ -159,7 +182,7 @@ function api(method,path,body,cb,hdrs,timeoutMs){var x=new XMLHttpRequest();x.op /* The state poll. It carries the last ETag, so an unchanged state costs a 304 and no re-render; a run gets a faster tick, and every action pulls the next poll forward instead of waiting out the interval. */ -function pollDelay(){return (state&&state.running)?1000:2000} +function pollDelay(){return (state&&(state.running||batchActive()))?1000:2000} function schedule(ms){if(pollTimer)window.clearTimeout(pollTimer);pollTimer=window.setTimeout(poll,ms)} function kick(){schedule(120)} function poll(){if(polling){schedule(150);return}polling=true; @@ -181,12 +204,14 @@ function setIgnoreReq(on){ignoreReq=!!on;try{window.localStorage.setItem('forget if(state&&catalog)renderGroups()} /* A start already sent but not yet seen in the state counts as busy, so the buttons grey out on the click rather than on the next poll. The - window is capped in case the answer never arrives. */ -function isBusy(){if(state&&state.running)return true; + window is capped in case the answer never arrives. A queue holds the + machine between its tests as well as during them. */ +function batchActive(){return !!(state&&state.batch&&!state.batch.finished)} +function isBusy(){if(state&&(state.running||batchActive()))return true; return !!(pending&&(nowMs()-pending)Full campaign required since "+esc(fmtTs(inv.ts))+": "+esc(inv.reason)+"
":''); var ms='';(state.messages||[]).forEach(function(x){ms+="
"+esc(x)+"
"});setHtml($('msgs'),ms); - renderGroups();renderRun();if(bench)renderBench()} + renderQueue();renderGroups();renderRun();if(bench)renderBench()} +/* The two queues: what each would run now, and how the running one is + getting on. Built from the state, so a reload picks the queue back up + exactly where it is - the queue lives in the runner, not in this tab. */ +var QUEUES=[['unattended','Unattended'],['attended','Operator and live']]; +function renderQueue(){var av=state.batch_available||{},b=state.batch,busy=isBusy(); + QUEUES.forEach(function(p){var e=$('q-'+p[0]);if(!e)return; + var ids=av[p[0]]||[]; + setText(e,ids.length?(p[1]+' ('+ids.length+')'):(p[1]+' (none left)')); + setDis(e,busy||!ids.length); + setProp(e,'title',!ids.length?('nothing left: every '+p[0]+' test is satisfied') + :(busy?'a run is in progress':('in order: '+ids.join(', '))))}); + setDis($('q-stop'),!batchActive()||!!(b&&b.stopping)); + if(!b){setHtml($('qstate'),'');return} + var total=b.order.length||1,ok=0,bad=0; + b.done.forEach(function(x){if(x.result==='PASS')ok++;else bad++}); + function seg(cls,n){return n?(""):''} + var h="
"+seg('ok',ok)+seg('bad',bad)+seg('skip',b.skipped.length)+ + (b.current?seg('now',1):'')+"
"+esc(b.group)+" queue, opened "+ + esc(fmtTs(b.ts))+" · "; + h+=b.finished?('finished '+esc(fmtTs(b.finished))):(b.current?('running '+esc(b.current)+''): + (b.stopping?'stopping':'starting')); + h+=" · "+ok+" passed, "+bad+" not, "+b.skipped.length+ + " skipped, "+b.pending.length+" waiting"; + if(b.stopped)h+="
stopped: "+esc(b.stopped)+""; + if(b.skipped.length)h+="
skipped: "+esc(b.skipped.map(function(x){return x.test+' ('+x.reason+')'}).join('; ')); + if(b.pending.length)h+="
waiting: "+esc(b.pending.join(', '))+""; + setHtml($('qstate'),h+"
")} /* The rows are built once for a given catalog and then only updated in place: a poll never rewrites the table, so a Start button survives the press that is landing on it. */ @@ -225,11 +277,13 @@ function buildGroups(){var groups={},order=[]; catalog.forEach(function(t){rowEls[t.id]={st:$('st-'+t.id),last:$('last-'+t.id),btn:$('btn-'+t.id), unmet:$('unmet-'+t.id),note:$('note-'+t.id),detdyn:$('detdyn-'+t.id)}})} function updateGroups(){if(!rowEls||!state)return;var busy=isBusy(); + var queued={};if(batchActive()){(state.batch.pending||[]).forEach(function(x){queued[x]=1})} catalog.forEach(function(t){var e=rowEls[t.id];if(!e)return;var s=state.tests[t.id]||{}; var st; if(pendingId===t.id&&isBusy()&&!state.running){st="starting…"} else{st=""+esc(s.status||'none')+""; if(s.required&&s.status!=='running')st+="
required: "+esc(s.reason)+""} + if(queued[t.id])st+="
queued"; setHtml(e.st,st); var last=s.last?(esc(s.last.result)+' '+esc(fmtTs(s.last.ts))):'-'; if(s.status==='inherited'&&s.origin)last+="
from "+esc(s.origin.campaign)+" on "+esc(s.origin.image)+""; @@ -275,7 +329,28 @@ function startTest(id){if(isBusy())return;var t=findTest(id);var body={test:id}; api('POST','/start',body,function(s,d){ if(s!==200){pending=0;pendingId=null;rowMsg[id]=d.message||d.error;updateGroups()} setMsg('actmsg',d.message||d.error,s!==200);kick()})} -function confirmLive(){return window.confirm('LIVE LASER TEST.\n\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\nStart the test?')} +function confirmLive(live){return window.confirm( + (live?('LIVE LASER QUEUE.\n\nThese fire the laser:\n - '+live.join('\n - ')+'\n'):'LIVE LASER TEST.\n')+ + '\nConfirm before starting:\n - eye protection on, everyone in the room\n - fire watch present, extinguisher at hand\n - exhaust running, lid closed, scrap in place\n - you will press the physical button to arm when prompted\n\n'+ + (live?'Start the queue?':'Start the test?'))} +/* A queue takes the machine for a long stretch, so both what it will run + and the acknowledgment it needs are put in front of the operator once, + before anything starts. */ +function startBatch(group){if(isBusy())return; + var ids=(state&&state.batch_available&&state.batch_available[group])||[]; + if(!ids.length)return; + var body={group:group};if(ignoreReq)body.ignore_requires=true; + var live=[];catalog.forEach(function(t){if(t.kind==='live'&&ids.indexOf(t.id)>=0)live.push(t.id)}); + if(live.length){if(!confirmLive(live))return;body.ack_live=true} + else if(!window.confirm('Run these '+ids.length+' test(s), in this order?\n\n - '+ids.join('\n - ')))return; + pending=nowMs();pendingId=null;rowMsg={};updateGroups();renderQueue(); + setMsg('qmsg','starting the '+group+' queue...'); + api('POST','/batch',body,function(s,d){ + if(s!==200){pending=0;updateGroups();renderQueue()} + setMsg('qmsg',d.message||d.error,s!==200);kick()})} +function stopBatch(){var e=$('q-stop');if(e.disabled)return;setDis(e,true); + setMsg('qmsg','stopping the queue...'); + api('POST','/batch/stop',{},function(s,d){setMsg('qmsg',d.message||d.error,s!==200);kick()})} function promptBusy(on){var b=$('promptb').getElementsByTagName('button');for(var i=0;i