diff --git a/dist/editor.js b/dist/editor.js index e1b89a4..8ab71eb 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -3030,16 +3030,22 @@ export function mountEditor(root, { focusSum = null; focusBest = null; focusErr = null; focusHold = null; } + /* One grid, summarised. Shared by the poll and by the sweep so the two + * cannot come to different conclusions about the same camera. */ + async function readGrid() { + const g = await focus.zones(); + const zones = g.zones.map((z) => (Array.isArray(z) + ? { h1: z[0], h2: z[1], v1: z[2], v2: z[3], y: z[4], hlcnt: z[5] } + : z)); + return summarise(zones, g.rows, g.cols); + } + async function focusTick() { if (!focus) return; const gen = focusGen; let sum = null, err = null; try { - const g = await focus.zones(); - const zones = g.zones.map((z) => (Array.isArray(z) - ? { h1: z[0], h2: z[1], v1: z[2], v2: z[3], y: z[4], hlcnt: z[5] } - : z)); - sum = summarise(zones, g.rows, g.cols); + sum = await readGrid(); } catch (e) { err = e && e.message ? e.message : String(e); } @@ -3199,9 +3205,44 @@ export function mountEditor(root, { moveSend(moveVerb, moveRelease); } + /* The sweep needs to KNOW whether a move happened, where the hold-to-run + * buttons only need to stop asking: it counts the steps it took out so it + * can give exactly that many back, and a step it counted but the camera + * refused is a step it will hand back one too many of. So this one reports + * the outcome instead of calling back, and treats a rejection and a throw + * alike -- a host may do either. */ + function moveOnce(verb) { + let p; + try { + p = focus.move(verb); + } catch (e) { + return Promise.resolve(false); + } + if (p && typeof p.then === 'function') { + return p.then(function () { return true; }, function () { return false; }); + } + return Promise.resolve(true); + } + + /* The sweep drives the lens for several seconds at a time, and Near and Far + * drive the same lens. Uncounted travel in the middle of a measurement + * invalidates both the ratio and the claim that the lens went back where it + * started, so for the duration the buttons are held down rather than + * trusted: disabled where they are visible, and refused in the handler in + * case anything reaches one another way (a keyboard, a stale pointer). */ + let motorBtns = []; + let lensOwned = false; + + function ownLens(on) { + lensOwned = on; + motorBtns.forEach(function (b) { b.disabled = on; }); + if (on) moveRelease(); + } + function holdToRun(btn, verb) { const press = function (ev) { if (ev && ev.button !== undefined && ev.button !== 0) return; + if (lensOwned) return; /* One hold at a time, and a second pointer is ignored rather than * taking over. Taking over meant releasing EITHER pointer stopped * whatever was running, so the finger lifted was not necessarily @@ -3236,6 +3277,7 @@ export function mountEditor(root, { * configured with, and treating one as a fresh press turns a held * key into a burst of nudges at a cadence this side never chose. */ if (ev.repeat) return; + if (lensOwned) return; ev.preventDefault(); moveSend(verb, null); }); @@ -3269,6 +3311,140 @@ export function mountEditor(root, { let filterGen = 0; let filterBusy = false; + /* + * How sharply the filter peaks, rather than how big a number it makes. + * + * This exists because the obvious way to read the panel is wrong. Turning + * on the third filter section more than doubles the value on a focused + * scene -- and makes the filter worse, because that section reads higher as + * the picture BLURS. Anyone tuning by "make the number go up" ends up with + * a camera that focuses badly and a reading that says it is doing well. + * + * What separates a good filter from a big one is the RATIO between focus + * and not-focus, and nothing on a still scene can show that. So the lens is + * walked away from where it sits, the value read at each step, and the + * spread reported. A filter worth keeping falls away steeply; one that is + * merely loud barely moves. + * + * One direction, and back. A sweep through focus and out the other side + * would measure more, but it has to leave the lens where it found it -- an + * operator who asked for a measurement did not ask to have their focus + * moved -- and every extra step is another chance for the return to fall + * short of where it started. + */ + const SWEEP_STEPS = 8; + const SWEEP_SETTLE_MS = 700; + let sweepGen = 0; + /* A sweep in flight when the editor is torn down still has a return walk to + * finish -- the lens is real and must go back -- but what it must NOT do is + * restart the poll afterwards. destroy() stops the poll and empties the + * root without changing `mode`, so a finally that asks only "are we still in + * Focus?" answers yes for a panel that no longer exists and reads the + * camera for as long as the page is open. */ + let sweepClosed = false; + /* Non-null while a sweep owns the lens, INCLUDING its return walk. Aborting + * a sweep bumps the generation, which stops it measuring; it cannot stop it + * walking back, and for those few seconds there is still a sweep sending + * `near`. A second sweep starting then sends `far` against it, and neither + * the reading nor the final position means anything. */ + let sweepBusy = null; + + function sweepStop() { sweepGen++; } + + function runSweep(say, onStep) { + const prior = sweepBusy; + /* The generation this was ASKED under. A sweep queued behind another's + * walk back can be cancelled before it ever starts -- the operator left + * Focus again while it waited -- and starting then would drive the lens + * for a panel nobody is looking at. */ + const at = sweepGen; + const p = (async function () { + if (prior) { try { await prior; } catch (e) { /* not ours to report */ } } + if (sweepGen !== at) return null; + return sweepRun(say, onStep); + })(); + sweepBusy = p; + const clear = function () { if (sweepBusy === p) sweepBusy = null; }; + p.then(clear, clear); + return p; + } + + async function sweepRun(say, onStep) { + const gen = ++sweepGen; + const mine = () => gen === sweepGen; + const vals = []; + let out = 0, failed = null, lost = 0; + /* The poll is stopped for the duration: it and the sweep would be + * asking the same camera for the same grid at once, and its answers + * would land in the panel out of step with where the lens actually is. + * The manual controls go with it -- see ownLens. */ + stopFocusPoll(); + ownLens(true); + try { + for (let i = 0; i <= SWEEP_STEPS; i++) { + if (!mine()) break; + try { + const s = await readGrid(); + if (s.peak !== null) vals.push(s.peak); + } catch (e) { + failed = e && e.message ? e.message : String(e); + break; + } + if (!mine()) break; + onStep(i, SWEEP_STEPS * 2); + if (i === SWEEP_STEPS) break; + /* Counted only if it actually happened. A refused move that was + * counted anyway is a step handed back that was never taken, + * which walks the lens PAST where the operator left it. */ + if (!(await moveOnce('far'))) { + failed = 'the camera would not move the lens'; + break; + } + out++; + await pause(SWEEP_SETTLE_MS); + } + } finally { + /* Every step taken is a step given back, and NOT conditional on the + * sweep still being the current one. Abandoning the lens where an + * abort found it is worse than never measuring: the operator's + * focus is gone and nothing said so. Stopping, leaving the tab, a + * camera that stopped answering and a thrown error all arrive + * here. */ + for (let i = 0; i < out; i++) { + /* A refused step does not stop the walk: the next one may well + * be taken, and stopping early strands the lens further out + * than carrying on can. It is counted, and said out loud at the + * end -- claiming the lens is back when a step was refused is + * the one outcome worse than saying nothing. */ + if (!(await moveOnce('near'))) lost++; + await pause(SWEEP_SETTLE_MS); + onStep(SWEEP_STEPS + i + 1, SWEEP_STEPS * 2); + } + await moveOnce('stop'); + ownLens(false); + /* The panel is useless without its live grid, and a camera left + * un-polled looks broken -- but only for a panel that still exists. + * `mode` alone does not answer that: destroy() leaves it as it + * found it. */ + if (mode === 'focus' && !sweepClosed) startFocusPoll(); + } + const back = lost + ? ' The lens may not be back where it started: the camera refused ' + + lost + ' of the ' + out + ' steps back.' + : ''; + if (failed) return { failed: failed + '.' + back, lost: lost }; + if (!mine()) return { stopped: true, lost: lost, back: back }; + if (!vals.length) return { failed: 'nothing measurable along the sweep.' + back, lost: lost }; + const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); + return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, + lost: lost, back: back }; + } + + + function pause(ms) { + return new Promise(function (r) { setTimeout(r, ms); }); + } + function buildFilterDesigner(panel) { const box = el('div', 're-panel'); box.style.marginTop = '10px'; @@ -3281,6 +3457,9 @@ export function mountEditor(root, { })); const status = el('div'); + /* Named so a reader -- or a test -- can tell this panel's message from + * the grid's own above it; both are notices and both can be warnings. */ + status.dataset.act = 'af-status'; box.append(status); const rows = el('div'); box.append(rows); @@ -3368,6 +3547,21 @@ export function mountEditor(root, { back.dataset.act = 'af-reload'; back.textContent = 'Read from camera'; acts.append(send, back); + + /* Only where the lens can be driven. Measuring how sharply a filter + * peaks means moving the focus, and a camera focused by hand cannot be + * asked to do that from here. */ + let measure = null, stop = null; + if (typeof focus.move === 'function') { + measure = el('button', 're-btn', ''); + measure.dataset.act = 'af-measure'; + measure.textContent = 'Measure it'; + stop = el('button', 're-btn', ''); + stop.dataset.act = 'af-measure-stop'; + stop.textContent = 'Stop'; + stop.hidden = true; + acts.append(measure, stop); + } box.append(acts); const say = (msg, warn) => { @@ -3483,12 +3677,60 @@ export function mountEditor(root, { }); }); + if (measure) { + const busy = (on) => { + measure.hidden = on; + stop.hidden = !on; + send.disabled = on; + back.disabled = on; + }; + stop.addEventListener('click', function () { sweepStop(); }); + measure.addEventListener('click', function () { + if (busyHolding(status)) return; + busy(true); + runSweep(say, function (i, n) { + say('Walking the lens and reading as it goes — ' + i + ' of ' + n + + '. It will be put back where it started.'); + }).then(function (r) { + busy(false); + /* Cancelled before it ever started, behind another sweep's + * walk back. Nothing was measured and nothing was moved, so + * there is nothing to report either. */ + if (!r) { say(''); return; } + if (r.stopped) { + say(r.lost + ? 'Stopped.' + r.back + : 'Stopped. The lens is back where it started.', !!r.lost); + return; + } + if (r.failed) { say('Could not measure it: ' + r.failed, true); return; } + /* The ratio, not the peak. A filter that reads loudly + * everywhere is worse than a quiet one that falls away, + * and the peak alone cannot tell them apart -- which is + * exactly the mistake this button exists to prevent. */ + say((r.ratio === null + ? 'Highest ' + r.hi + ', lowest ' + r.lo + ' across the sweep.' + : 'Falls to 1/' + r.ratio.toFixed(1) + ' of its peak across the ' + + 'sweep (' + r.hi + ' down to ' + r.lo + '). A filter worth ' + + 'keeping falls away steeply; a loud one barely moves.') + r.back, + !!r.lost); + }).catch(function (e) { + busy(false); + say('Could not measure it: ' + (e && e.message ? e.message : e), true); + }); + }); + } + load(); panel.append(box); } function buildFocus() { insp.replaceChildren(); + /* The panel is rebuilt from scratch on every entry, so the buttons the + * last one made are detached and must not be kept -- disabling a node + * nobody can see is a leak that also hides a bug. */ + motorBtns = []; const panel = el('div', 're-panel'); panel.append(Object.assign(el('div', 're-shead'), { innerHTML: '

Focus

', @@ -3519,7 +3761,9 @@ export function mountEditor(root, { const b = el('button', 're-btn', ''); b.dataset.act = 'focus-' + pair[0]; b.textContent = pair[1]; + b.disabled = lensOwned; holdToRun(b, pair[0]); + motorBtns.push(b); row.append(b); }); } @@ -3620,7 +3864,7 @@ export function mountEditor(root, { abandonHold(); /* A poll that outlived its tab would keep a camera answering for a * panel nobody is looking at. */ - if (m !== 'focus') { stopFocusPoll(); moveRelease(); focusStatus = null; } + if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); focusStatus = null; } /* Anything a filter write has outstanding belonged to the panel that is * going. Its answer must not come back and arm a trial here. */ filterGen++; @@ -3901,6 +4145,10 @@ export function mountEditor(root, { stopHold(); stopFocusPoll(); moveRelease(); + sweepStop(); + // The walk back still has to happen -- the lens is real -- but + // nothing after it may touch a panel that is being removed. + sweepClosed = true; filterGen++; abandonAll('the editor was closed'); worker?.terminate(); diff --git a/src/editor.js b/src/editor.js index e1b89a4..8ab71eb 100644 --- a/src/editor.js +++ b/src/editor.js @@ -3030,16 +3030,22 @@ export function mountEditor(root, { focusSum = null; focusBest = null; focusErr = null; focusHold = null; } + /* One grid, summarised. Shared by the poll and by the sweep so the two + * cannot come to different conclusions about the same camera. */ + async function readGrid() { + const g = await focus.zones(); + const zones = g.zones.map((z) => (Array.isArray(z) + ? { h1: z[0], h2: z[1], v1: z[2], v2: z[3], y: z[4], hlcnt: z[5] } + : z)); + return summarise(zones, g.rows, g.cols); + } + async function focusTick() { if (!focus) return; const gen = focusGen; let sum = null, err = null; try { - const g = await focus.zones(); - const zones = g.zones.map((z) => (Array.isArray(z) - ? { h1: z[0], h2: z[1], v1: z[2], v2: z[3], y: z[4], hlcnt: z[5] } - : z)); - sum = summarise(zones, g.rows, g.cols); + sum = await readGrid(); } catch (e) { err = e && e.message ? e.message : String(e); } @@ -3199,9 +3205,44 @@ export function mountEditor(root, { moveSend(moveVerb, moveRelease); } + /* The sweep needs to KNOW whether a move happened, where the hold-to-run + * buttons only need to stop asking: it counts the steps it took out so it + * can give exactly that many back, and a step it counted but the camera + * refused is a step it will hand back one too many of. So this one reports + * the outcome instead of calling back, and treats a rejection and a throw + * alike -- a host may do either. */ + function moveOnce(verb) { + let p; + try { + p = focus.move(verb); + } catch (e) { + return Promise.resolve(false); + } + if (p && typeof p.then === 'function') { + return p.then(function () { return true; }, function () { return false; }); + } + return Promise.resolve(true); + } + + /* The sweep drives the lens for several seconds at a time, and Near and Far + * drive the same lens. Uncounted travel in the middle of a measurement + * invalidates both the ratio and the claim that the lens went back where it + * started, so for the duration the buttons are held down rather than + * trusted: disabled where they are visible, and refused in the handler in + * case anything reaches one another way (a keyboard, a stale pointer). */ + let motorBtns = []; + let lensOwned = false; + + function ownLens(on) { + lensOwned = on; + motorBtns.forEach(function (b) { b.disabled = on; }); + if (on) moveRelease(); + } + function holdToRun(btn, verb) { const press = function (ev) { if (ev && ev.button !== undefined && ev.button !== 0) return; + if (lensOwned) return; /* One hold at a time, and a second pointer is ignored rather than * taking over. Taking over meant releasing EITHER pointer stopped * whatever was running, so the finger lifted was not necessarily @@ -3236,6 +3277,7 @@ export function mountEditor(root, { * configured with, and treating one as a fresh press turns a held * key into a burst of nudges at a cadence this side never chose. */ if (ev.repeat) return; + if (lensOwned) return; ev.preventDefault(); moveSend(verb, null); }); @@ -3269,6 +3311,140 @@ export function mountEditor(root, { let filterGen = 0; let filterBusy = false; + /* + * How sharply the filter peaks, rather than how big a number it makes. + * + * This exists because the obvious way to read the panel is wrong. Turning + * on the third filter section more than doubles the value on a focused + * scene -- and makes the filter worse, because that section reads higher as + * the picture BLURS. Anyone tuning by "make the number go up" ends up with + * a camera that focuses badly and a reading that says it is doing well. + * + * What separates a good filter from a big one is the RATIO between focus + * and not-focus, and nothing on a still scene can show that. So the lens is + * walked away from where it sits, the value read at each step, and the + * spread reported. A filter worth keeping falls away steeply; one that is + * merely loud barely moves. + * + * One direction, and back. A sweep through focus and out the other side + * would measure more, but it has to leave the lens where it found it -- an + * operator who asked for a measurement did not ask to have their focus + * moved -- and every extra step is another chance for the return to fall + * short of where it started. + */ + const SWEEP_STEPS = 8; + const SWEEP_SETTLE_MS = 700; + let sweepGen = 0; + /* A sweep in flight when the editor is torn down still has a return walk to + * finish -- the lens is real and must go back -- but what it must NOT do is + * restart the poll afterwards. destroy() stops the poll and empties the + * root without changing `mode`, so a finally that asks only "are we still in + * Focus?" answers yes for a panel that no longer exists and reads the + * camera for as long as the page is open. */ + let sweepClosed = false; + /* Non-null while a sweep owns the lens, INCLUDING its return walk. Aborting + * a sweep bumps the generation, which stops it measuring; it cannot stop it + * walking back, and for those few seconds there is still a sweep sending + * `near`. A second sweep starting then sends `far` against it, and neither + * the reading nor the final position means anything. */ + let sweepBusy = null; + + function sweepStop() { sweepGen++; } + + function runSweep(say, onStep) { + const prior = sweepBusy; + /* The generation this was ASKED under. A sweep queued behind another's + * walk back can be cancelled before it ever starts -- the operator left + * Focus again while it waited -- and starting then would drive the lens + * for a panel nobody is looking at. */ + const at = sweepGen; + const p = (async function () { + if (prior) { try { await prior; } catch (e) { /* not ours to report */ } } + if (sweepGen !== at) return null; + return sweepRun(say, onStep); + })(); + sweepBusy = p; + const clear = function () { if (sweepBusy === p) sweepBusy = null; }; + p.then(clear, clear); + return p; + } + + async function sweepRun(say, onStep) { + const gen = ++sweepGen; + const mine = () => gen === sweepGen; + const vals = []; + let out = 0, failed = null, lost = 0; + /* The poll is stopped for the duration: it and the sweep would be + * asking the same camera for the same grid at once, and its answers + * would land in the panel out of step with where the lens actually is. + * The manual controls go with it -- see ownLens. */ + stopFocusPoll(); + ownLens(true); + try { + for (let i = 0; i <= SWEEP_STEPS; i++) { + if (!mine()) break; + try { + const s = await readGrid(); + if (s.peak !== null) vals.push(s.peak); + } catch (e) { + failed = e && e.message ? e.message : String(e); + break; + } + if (!mine()) break; + onStep(i, SWEEP_STEPS * 2); + if (i === SWEEP_STEPS) break; + /* Counted only if it actually happened. A refused move that was + * counted anyway is a step handed back that was never taken, + * which walks the lens PAST where the operator left it. */ + if (!(await moveOnce('far'))) { + failed = 'the camera would not move the lens'; + break; + } + out++; + await pause(SWEEP_SETTLE_MS); + } + } finally { + /* Every step taken is a step given back, and NOT conditional on the + * sweep still being the current one. Abandoning the lens where an + * abort found it is worse than never measuring: the operator's + * focus is gone and nothing said so. Stopping, leaving the tab, a + * camera that stopped answering and a thrown error all arrive + * here. */ + for (let i = 0; i < out; i++) { + /* A refused step does not stop the walk: the next one may well + * be taken, and stopping early strands the lens further out + * than carrying on can. It is counted, and said out loud at the + * end -- claiming the lens is back when a step was refused is + * the one outcome worse than saying nothing. */ + if (!(await moveOnce('near'))) lost++; + await pause(SWEEP_SETTLE_MS); + onStep(SWEEP_STEPS + i + 1, SWEEP_STEPS * 2); + } + await moveOnce('stop'); + ownLens(false); + /* The panel is useless without its live grid, and a camera left + * un-polled looks broken -- but only for a panel that still exists. + * `mode` alone does not answer that: destroy() leaves it as it + * found it. */ + if (mode === 'focus' && !sweepClosed) startFocusPoll(); + } + const back = lost + ? ' The lens may not be back where it started: the camera refused ' + + lost + ' of the ' + out + ' steps back.' + : ''; + if (failed) return { failed: failed + '.' + back, lost: lost }; + if (!mine()) return { stopped: true, lost: lost, back: back }; + if (!vals.length) return { failed: 'nothing measurable along the sweep.' + back, lost: lost }; + const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); + return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, + lost: lost, back: back }; + } + + + function pause(ms) { + return new Promise(function (r) { setTimeout(r, ms); }); + } + function buildFilterDesigner(panel) { const box = el('div', 're-panel'); box.style.marginTop = '10px'; @@ -3281,6 +3457,9 @@ export function mountEditor(root, { })); const status = el('div'); + /* Named so a reader -- or a test -- can tell this panel's message from + * the grid's own above it; both are notices and both can be warnings. */ + status.dataset.act = 'af-status'; box.append(status); const rows = el('div'); box.append(rows); @@ -3368,6 +3547,21 @@ export function mountEditor(root, { back.dataset.act = 'af-reload'; back.textContent = 'Read from camera'; acts.append(send, back); + + /* Only where the lens can be driven. Measuring how sharply a filter + * peaks means moving the focus, and a camera focused by hand cannot be + * asked to do that from here. */ + let measure = null, stop = null; + if (typeof focus.move === 'function') { + measure = el('button', 're-btn', ''); + measure.dataset.act = 'af-measure'; + measure.textContent = 'Measure it'; + stop = el('button', 're-btn', ''); + stop.dataset.act = 'af-measure-stop'; + stop.textContent = 'Stop'; + stop.hidden = true; + acts.append(measure, stop); + } box.append(acts); const say = (msg, warn) => { @@ -3483,12 +3677,60 @@ export function mountEditor(root, { }); }); + if (measure) { + const busy = (on) => { + measure.hidden = on; + stop.hidden = !on; + send.disabled = on; + back.disabled = on; + }; + stop.addEventListener('click', function () { sweepStop(); }); + measure.addEventListener('click', function () { + if (busyHolding(status)) return; + busy(true); + runSweep(say, function (i, n) { + say('Walking the lens and reading as it goes — ' + i + ' of ' + n + + '. It will be put back where it started.'); + }).then(function (r) { + busy(false); + /* Cancelled before it ever started, behind another sweep's + * walk back. Nothing was measured and nothing was moved, so + * there is nothing to report either. */ + if (!r) { say(''); return; } + if (r.stopped) { + say(r.lost + ? 'Stopped.' + r.back + : 'Stopped. The lens is back where it started.', !!r.lost); + return; + } + if (r.failed) { say('Could not measure it: ' + r.failed, true); return; } + /* The ratio, not the peak. A filter that reads loudly + * everywhere is worse than a quiet one that falls away, + * and the peak alone cannot tell them apart -- which is + * exactly the mistake this button exists to prevent. */ + say((r.ratio === null + ? 'Highest ' + r.hi + ', lowest ' + r.lo + ' across the sweep.' + : 'Falls to 1/' + r.ratio.toFixed(1) + ' of its peak across the ' + + 'sweep (' + r.hi + ' down to ' + r.lo + '). A filter worth ' + + 'keeping falls away steeply; a loud one barely moves.') + r.back, + !!r.lost); + }).catch(function (e) { + busy(false); + say('Could not measure it: ' + (e && e.message ? e.message : e), true); + }); + }); + } + load(); panel.append(box); } function buildFocus() { insp.replaceChildren(); + /* The panel is rebuilt from scratch on every entry, so the buttons the + * last one made are detached and must not be kept -- disabling a node + * nobody can see is a leak that also hides a bug. */ + motorBtns = []; const panel = el('div', 're-panel'); panel.append(Object.assign(el('div', 're-shead'), { innerHTML: '

Focus

', @@ -3519,7 +3761,9 @@ export function mountEditor(root, { const b = el('button', 're-btn', ''); b.dataset.act = 'focus-' + pair[0]; b.textContent = pair[1]; + b.disabled = lensOwned; holdToRun(b, pair[0]); + motorBtns.push(b); row.append(b); }); } @@ -3620,7 +3864,7 @@ export function mountEditor(root, { abandonHold(); /* A poll that outlived its tab would keep a camera answering for a * panel nobody is looking at. */ - if (m !== 'focus') { stopFocusPoll(); moveRelease(); focusStatus = null; } + if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); focusStatus = null; } /* Anything a filter write has outstanding belonged to the panel that is * going. Its answer must not come back and arm a trial here. */ filterGen++; @@ -3901,6 +4145,10 @@ export function mountEditor(root, { stopHold(); stopFocusPoll(); moveRelease(); + sweepStop(); + // The walk back still has to happen -- the lens is real -- but + // nothing after it may touch a panel that is being removed. + sweepClosed = true; filterGen++; abandonAll('the editor was closed'); worker?.terminate(); diff --git a/tests/ui-check.html b/tests/ui-check.html index 22ae253..d8c90d9 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -2010,9 +2010,37 @@ let grid = opts.grid || GRID; const focus = { intervalMs: 40, - zones: () => Promise.resolve(grid), + zones: () => { + seen.reads = (seen.reads || 0) + 1; + // From that read ONWARDS, not on it alone: the live poll reads + // too, so an exact count picks whichever of the two got there + // first, and the sweep saw a healthy camera the once it mattered. + if (opts.readFailsAt && seen.reads >= opts.readFailsAt) + return Promise.reject(new Error('the camera stopped answering')); + return Promise.resolve(opts.sweepGrid ? opts.sweepGrid(seen.pos || 0) : grid); + }, }; seen.setGrid = (g) => { grid = g; }; + if (opts.lens) { + seen.moves = []; + seen.trail = []; + seen.pos = 0; + focus.move = (v) => { + seen.moves.push(v); + // A camera that REJECTS rather than throws, which is the shape + // a fetch-backed host actually fails in. The lens does not move + // when it does -- that is the whole point of counting only + // moves that were accepted. + if (opts.moveFails === v || opts.moveFails === true) + return Promise.reject(new Error('the lens is jammed')); + if (v === 'far') seen.pos++; + if (v === 'near') seen.pos--; + seen.trail.push(v + '@' + seen.pos); + seen.lastMove = Date.now(); + return Promise.resolve(); + }; + focus.moveRepeatMs = 20; + } if (!opts.readOnly) { focus.holdSeconds = opts.hold || 30; focus.filters = () => (opts.readFails @@ -2296,6 +2324,150 @@ t('and works again once it has landed', g.seen.applied.length === madeBefore + 1, String(g.seen.applied.length)); g.ed.destroy(); + // Measuring how sharply a filter peaks means MOVING the lens, so every + // check below is about it going back. An operator who asked for a + // measurement did not ask to have their focus moved. + + // No lens, no button: it cannot be done from here. + g = await mk({}); + t('no way to measure without a lens to drive', !q(g.host, 'af-measure')); + g.ed.destroy(); + + // A filter that falls away steeply is the one worth keeping. The peak + // alone cannot tell it from one that is merely loud everywhere, which is + // the mistake this exists to prevent. + g = await mk({ lens: true, sweepGrid: (pos) => ({ rows: 1, cols: 2, zones: [ + // sharp at the start, falling as the lens walks away + [0, Math.max(10, 900 - pos * 110), 0, 9, 1000, 0], [0, 10, 0, 1, 1000, 0]] }) }); + t('a lens grows the button', !!q(g.host, 'af-measure')); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + const said = q(g.host, 'af-status') ? q(g.host, 'af-status').textContent : ''; + t('it reports how far the reading falls, not how big it got', + /Falls to 1\/\d/.test(said), said.slice(0, 90)); + // Out and back, step for step. + const far = g.seen.moves.filter((m) => m === 'far').length; + const near = g.seen.moves.filter((m) => m === 'near').length; + t('the lens is walked out and walked back the same distance', + far === near && far > 0, far + ' out, ' + near + ' back'); + t('and ends where it started', g.seen.pos === 0, String(g.seen.pos)); + g.ed.destroy(); + + // Stopping half way is the case that most needs the lens returned. + g = await mk({ lens: true }); + q(g.host, 'af-measure').click(); + await pause(1600); + q(g.host, 'af-measure-stop').click(); + // Waited out rather than guessed: the walk back takes as many steps as + // were taken out, and a fixed pause that was short would report the lens + // stranded when it was merely still moving. + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + t('stopping still brings the lens back', g.seen.pos === 0, String(g.seen.pos)); + t('and says so', /back where it started/.test( + q(g.host, 'af-status') ? q(g.host, 'af-status').textContent : ''), + q(g.host, 'af-status') ? q(g.host, 'af-status').textContent.slice(0, 60) : ''); + g.ed.destroy(); + + // Leaving the tab mid-sweep, which is the same problem without a button. + g = await mk({ lens: true }); + q(g.host, 'af-measure').click(); + await pause(1600); + [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Develop').click(); + for (let i = 0; i < 200 && g.seen.pos !== 0; i++) await pause(100); + t('leaving the tab brings it back too', g.seen.pos === 0, String(g.seen.pos)); + g.ed.destroy(); + + // A camera that stops answering half way through. + g = await mk({ lens: true, readFailsAt: 3 }); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + t('a camera that stops answering is reported', + /Could not measure/.test(q(g.host, 'af-status') ? q(g.host, 'af-status').textContent : ''), + q(g.host, 'af-status') ? q(g.host, 'af-status').textContent.slice(0, 70) : '(none)'); + t('and the button comes back', q(g.host, 'af-measure').hidden === false); + g.ed.destroy(); + + // A lens that will not move at all. A rejected move is the shape a + // fetch-backed host fails in, and counting a refused step as taken would + // hand back a step never taken -- walking the lens PAST where it started. + g = await mk({ lens: true, moveFails: 'far' }); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + t('a lens that will not move is reported, not measured', + /Could not measure/.test(q(g.host, 'af-status').textContent), + q(g.host, 'af-status').textContent.slice(0, 70)); + t('and a refused step is never handed back', g.seen.pos === 0, String(g.seen.pos)); + g.ed.destroy(); + + // It went out but will not come back. Claiming the lens is where it started + // when the camera refused every step back is worse than saying nothing. + g = await mk({ lens: true, moveFails: 'near' }); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 300 && q(g.host, 'af-measure').hidden; i++) await pause(100); + t('a lens that will not come back says so, instead of claiming it is back', + /may not be back/.test(q(g.host, 'af-status').textContent) && + !/is back where it started/.test(q(g.host, 'af-status').textContent), + q(g.host, 'af-status').textContent.slice(0, 90)); + g.ed.destroy(); + + // Destroyed mid-sweep. The walk back still has to happen -- the lens is + // real -- but nothing may poll a panel that has been removed. + g = await mk({ lens: true }); + q(g.host, 'af-measure').click(); + await pause(1600); + g.ed.destroy(); + await pause(300); + const readsAtKill = g.seen.reads; + await pause(2500); + t('a destroyed editor does not go on reading the camera', + g.seen.reads === readsAtKill, readsAtKill + ' -> ' + g.seen.reads); + t('but the lens still comes home', g.seen.pos === 0, String(g.seen.pos)); + + // Leave Focus and come straight back. The old sweep is still walking the + // lens home; a new one starting now sends `far` against its `near`. + g = await mk({ lens: true }); + q(g.host, 'af-measure').click(); + await pause(1600); + [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Develop').click(); + [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Focus').click(); + await pause(50); + q(g.host, 'af-measure').click(); + // Sampling a position mid-walk proves nothing -- 2 on the way home and 2 on + // the way out look identical. What distinguishes them is ORDER: once the + // old sweep starts walking back, nothing may send `far` until the lens is + // home. So let it all settle, then read the trail. + for (let i = 0; i < 300; i++) { + await pause(100); + if (Date.now() - (g.seen.lastMove || 0) > 1500) break; + } + let home = true, clash = null; + g.seen.trail.forEach(function (step) { + const v = step.split('@')[0], at = Number(step.split('@')[1]); + if (v === 'near') home = false; + if (v === 'far' && !home && clash === null) clash = step; + if (at === 0) home = true; + }); + t('a new sweep waits for the old one to bring the lens back', + clash === null, clash ? 'drove out at ' + clash : g.seen.trail.join(' ')); + t('and the lens ends where it began', g.seen.pos === 0, String(g.seen.pos)); + g.ed.destroy(); + + // Near and Far drive the same lens the sweep is driving. + g = await mk({ lens: true }); + q(g.host, 'af-measure').click(); + await pause(900); + t('the manual controls are held down for the sweep', + q(g.host, 'focus-near').disabled && q(g.host, 'focus-far').disabled, + 'near ' + q(g.host, 'focus-near').disabled + ', far ' + q(g.host, 'focus-far').disabled); + const movesBefore = g.seen.moves.length; + q(g.host, 'focus-far').dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); + await pause(200); + q(g.host, 'focus-far').dispatchEvent(new PointerEvent('pointerup', { button: 0, bubbles: true })); + t('and holding one during a sweep moves nothing', + g.seen.moves.length === movesBefore, + movesBefore + ' -> ' + g.seen.moves.length); + g.ed.destroy(); + } catch (e) { t('the filter designer is handled', false, e.message); }