Skip to content

Commit 40a5d74

Browse files
alexbastubaclaude
andcommitted
Clear the second review's findings, though it approved
**A derive that throws was retried for ever, silently.** `sweepMissingAnalyses` discarded `analyseMissing`'s `skipped` map, so a video whose derive throws — an imported map with `holeRadius_px: 0` reaches this, since the map file accepts any finite radius and `geometry.ts` demands a positive one — was retried on every notification, with nothing said and the figures reporting only "1 of 3 videos not analysed yet" beside a button to the Track step that cannot help, because the video is tracked. The failures are now remembered until the maze map or the parameters change, announced once, and named on the figures' own line with the Track button hidden: the Track step fixes "not tracked", not this. **The try/catch added last commit was dead under test.** happy-dom returns null from `getContext`, so no test in the suite ever entered the draw path, and the assertion I wrote about the cards after a failure ("still current, not stale") was checking captions written before the try block from `describe()`, which cannot throw — it passed whether or not `draw()` threw. That is the same shape of error as the tier test the first pass caught. The path is now driven with a stubbed context and a figure whose `draw` throws, asserting the failed card's note, its disabled Save button and the next card drawing normally. Nits: the comment claiming 3.4 million cells understated it by three orders of magnitude (the platform is 92 cm across, not 46, so 0.001 cm asks for 8.46e9 cells); `CELL_CM_RANGE` no longer claims any positive number works; `trimNumber`'s comment no longer promises behaviour the clamp makes unreachable; and `app-smoke.spec.ts` names the button this chunk actually shipped rather than one that never existed. One limitation recorded: a maze nudge re-derives the whole cohort from a step the user is not on — 10.5 ms for three videos, but roughly 0.4 s for sixty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HLagxs47Fmp7TGEidN8wqk
1 parent a67e895 commit 40a5d74

8 files changed

Lines changed: 135 additions & 14 deletions

File tree

‎docs/known-limitations.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ session it is found (D39).
2323
but a 59 cm "entry" is a number a user can export without noticing. Whether a user assertion
2424
should be believed unconditionally, warned about, or refused beyond some distance from a hole is
2525
an operational definition, so it is recorded rather than changed here.
26+
- **Every maze nudge re-derives the whole cohort, from a step the user is not looking at.** The app
27+
shell refreshes every step on every store notification, and the Review step re-derives whatever
28+
the store invalidated so its figures and its export line are not left claiming that analysed
29+
videos are unanalysed. So one arrow key on the Maze step costs a derive per video. Measured on the
30+
example cohort (5,539 / 741 / 905 frames): 10.5 ms per nudge steady-state, 7.3 ms of it the long
31+
video. Comfortable at three videos; a sixty-video cohort would put roughly 0.4 s of main-thread
32+
work behind each keypress. The fix is to re-derive lazily when the Review step is shown rather
33+
than on every notification, or to move the sweep off the main thread.
2634
- **The cohort figures always plot primary latency.** `PlottableMetric` offers seven measures and
2735
`src/viz/cohort.ts` names them all in `METRIC_LABELS`, but the Review step's figures section
2836
exposes no control for it, so the learning curve and the group comparison are fixed to

‎src/ui/review-figures.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ export interface ReviewFiguresProps {
8383
session: SessionFile;
8484
/** The video on screen; null when none can be shown. */
8585
videoId: string | null;
86+
/**
87+
* Videos that are tracked but whose analysis could not be computed at all, and
88+
* why. Different from "not analysed yet": the Track step cannot help, so the
89+
* reason is shown and the button to it is not (D16 — say what went wrong
90+
* rather than a true-but-misleading count).
91+
*/
92+
problems?: readonly string[];
8693
}
8794

8895
export interface ReviewFiguresCallbacks {
@@ -365,10 +372,20 @@ export function createReviewFigures(
365372
}
366373

367374
function renderMissing(): void {
375+
const problems = current.problems ?? [];
368376
const line = notAnalysedInFigures(current.session);
369-
missing.hidden = line === null;
370-
missingLine.textContent = line ?? '';
377+
missing.hidden = line === null && problems.length === 0;
371378
const { notAnalysed, total } = notAnalysedCount(current.session);
379+
380+
if (problems.length > 0) {
381+
// The Track step cannot fix a derive that throws, so it is not offered.
382+
missingLine.textContent = `${problems.length} of ${total} video${total === 1 ? '' : 's'} could not be analysed, so ${problems.length === 1 ? 'it is' : 'they are'} not in these figures: ${problems.join('; ')}.`;
383+
trackButton.hidden = true;
384+
return;
385+
}
386+
387+
missingLine.textContent = line ?? '';
388+
trackButton.hidden = line === null;
372389
trackButton.setAttribute(
373390
'aria-label',
374391
`Go to the Track step to analyse the ${notAnalysed} of ${total} videos that are not in these figures`,

‎src/ui/review-step.ts‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,10 @@ export function createReviewStep(context: AppContext): Step {
178178
let adopted: { videoId: VideoId; run: AnalysisRun } | null = null;
179179
/** True while a cohort sweep is writing derived caches; one render follows, not one per video. */
180180
let sweeping = false;
181+
/** Videos whose derive threw, and why, so the sweep stops retrying them silently. */
182+
const failedAnalyses = new Map<VideoId, string>();
183+
/** The maze map and parameters those failures were recorded against. */
184+
let failedInputs: { mazeMap: unknown; parameters: unknown } | null = null;
181185

182186
const body = el('div', { class: 'review-step' });
183187

@@ -995,16 +999,44 @@ export function createReviewStep(context: AppContext): Step {
995999
*/
9961000
function sweepMissingAnalyses(): void {
9971001
if (sweeping) return;
1002+
// A derive that throws would otherwise be retried on every notification for
1003+
// ever, silently, while the figures said only "not analysed yet" and offered
1004+
// the Track step — which cannot help, because the video *is* tracked. The
1005+
// failures are remembered until an input changes, and the reason is shown.
1006+
const inputs = { mazeMap: store.current.mazeMap, parameters: store.current.parameters };
1007+
if (failedInputs?.mazeMap !== inputs.mazeMap || failedInputs?.parameters !== inputs.parameters) {
1008+
failedAnalyses.clear();
1009+
failedInputs = inputs;
1010+
}
9981011
const stale = store.videos.some(
999-
(video) => analysisBlockedReason(store, video.id) === null && !store.analysisFor(video.id)?.derived,
1012+
(video) =>
1013+
analysisBlockedReason(store, video.id) === null &&
1014+
!store.analysisFor(video.id)?.derived &&
1015+
!failedAnalyses.has(video.id),
10001016
);
10011017
if (!stale) return;
10021018
sweeping = true;
1019+
let run;
10031020
try {
1004-
analyseMissing(store);
1021+
run = analyseMissing(store);
10051022
} finally {
10061023
sweeping = false;
10071024
}
1025+
for (const [videoId, reason] of run.skipped) {
1026+
// Only a throw, not "not tracked yet" — that one the Track step does fix.
1027+
if (analysisBlockedReason(store, videoId) !== null) continue;
1028+
if (failedAnalyses.has(videoId)) continue;
1029+
failedAnalyses.set(videoId, reason);
1030+
const name = store.videoById(videoId)?.filename ?? videoId;
1031+
context.announce(`${name} could not be analysed: ${reason}`);
1032+
}
1033+
}
1034+
1035+
/** The reason each failed video failed, in the words the figures show. */
1036+
function analysisProblems(): string[] {
1037+
return [...failedAnalyses].map(
1038+
([videoId, reason]) => `${store.videoById(videoId)?.filename ?? videoId} — ${reason}`,
1039+
);
10081040
}
10091041

10101042
function render(): void {
@@ -1367,6 +1399,7 @@ export function createReviewStep(context: AppContext): Step {
13671399
const figuresProps: ReviewFiguresProps = {
13681400
session: store.current,
13691401
videoId: currentVideo()?.id ?? null,
1402+
problems: analysisProblems(),
13701403
};
13711404
if (figures) {
13721405
figures.update(figuresProps);

‎src/viz/figure-export.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,11 @@ function defaultColormapOf(figure: FigureSpec): ColormapName {
107107
return figure.id === 'heatmap' ? 'cividis' : 'viridis';
108108
}
109109

110-
/** `4`, not `4.0`; `4.5` stays `4.5`. A filename should not carry a stray zero. */
110+
/**
111+
* `4`, not `4.0`. `cellSizeCm` rounds to the option's step, so today every value
112+
* reaching this is an integer; the `.` substitution keeps a fractional step from
113+
* putting a second dot in the filename if one is ever offered.
114+
*/
111115
function trimNumber(value: number): string {
112116
return String(Number(value.toFixed(2))).replace('.', '-');
113117
}

‎src/viz/heatmap.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const TITLE = 'Occupancy heatmap';
3232
*/
3333
export const DEFAULT_CELL_CM = 4;
3434

35-
/** The bin sizes the UI offers; any positive number works. */
35+
/** The bin sizes on offer. `cellSizeCm` clamps to this range and rounds to the step. */
3636
export const CELL_CM_RANGE = { min: 1, max: 12, step: 1 };
3737

3838
/**
@@ -51,10 +51,10 @@ function occupancyMap(name: ColormapName | undefined): Colormap {
5151
*
5252
* Clamped here rather than at the control, so every consumer agrees on one
5353
* value — the drawing, the `describe()` table and the PNG filename. The grid
54-
* allocates cells as the square of the span, so an unclamped 0.001 cm asks for
55-
* 3.4 million cells on a 46 cm platform and throws `Invalid array length` from
56-
* inside `draw()`; and an unrounded 4.001 and 4.002 would produce two different
57-
* figures under one filename.
54+
* allocates cells as the square of the span, so on a platform 92 cm across an
55+
* unclamped 0.001 cm asks for a span of 92,000 and 8.46e9 cells, which throws
56+
* `Invalid array length` from inside `draw()`; and an unrounded 4.001 and 4.002
57+
* would produce two different figures under one filename.
5858
*/
5959
export function cellSizeCm(options: FigureOptions | undefined): number {
6060
const chosen = options?.heatmapCellSize_cm;

‎tests/browser/app-smoke.spec.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,8 @@ test.describe('the whole demo flow through the shipped UI', () => {
442442
// The export bundle carries the six files of D11.
443443
const [download] = await Promise.all([
444444
page.waitForEvent('download'),
445-
page.getByRole('button', { name: /Download export bundle/i }).click(),
445+
// Chunk 7b's button, which did not exist when this spec was written.
446+
page.getByRole('button', { name: /Export bundle \(\.zip\)/i }).click(),
446447
]);
447448
const zipPath = await download.path();
448449
const { readFileSync } = await import('node:fs');

‎tests/ui/review-figures.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
previewRatio,
2222
} from '../../src/ui/review-figures.js';
2323
import { syntheticSession } from '../fixtures/synthetic-analysis.js';
24+
import { fakeContext } from '../viz/fake-context.js';
2425

2526
function withOneUnanalysed(session: SessionFile): SessionFile {
2627
const last = session.videos[session.videos.length - 1]!;
@@ -201,7 +202,6 @@ describe('the mounted figures section', () => {
201202
expect(bin.value).toBe('1');
202203
expect(caption()).toContain('1 cm cell');
203204
expect(announce).toHaveBeenCalledWith(expect.stringContaining('outside the 1–12 cm range'));
204-
// The cards after the heatmap are still current, not stale from before.
205205
const captions = [...container.querySelectorAll('.figure-caption')].filter(
206206
(node) => (node.textContent ?? '').trim().length > 0,
207207
);
@@ -260,6 +260,64 @@ describe('the mounted figures section', () => {
260260
expect(label()).not.toBe(before);
261261
});
262262

263+
it('reports a figure that throws on its own card, and still draws the rest', () => {
264+
// happy-dom returns null from `getContext`, so nothing in this suite entered
265+
// the draw path at all until this stub — the catch was dead code under test.
266+
const session = syntheticSession();
267+
const context = fakeContext();
268+
const getContext = vi
269+
.spyOn(HTMLCanvasElement.prototype, 'getContext')
270+
.mockReturnValue(context as unknown as CanvasRenderingContext2D);
271+
const broken = TRIAL_FIGURES[1]!;
272+
const draw = vi.spyOn(broken, 'draw').mockImplementation(() => {
273+
throw new Error('deliberate failure');
274+
});
275+
try {
276+
createReviewFigures(container, { session, videoId: session.videos[0]!.id }, {
277+
onAnnounce: announce,
278+
onGoToTrack: goToTrack,
279+
});
280+
281+
const cards = [...container.querySelectorAll('.figure-card')];
282+
const failed = cards[1]!;
283+
expect(failed.querySelector('h5')!.textContent).toBe(broken.title);
284+
expect(failed.querySelector<HTMLElement>('.figure-note')!.hidden).toBe(false);
285+
expect(failed.querySelector('.figure-note')!.textContent).toContain('deliberate failure');
286+
expect(failed.querySelector('button')!.disabled).toBe(true);
287+
expect(announce).toHaveBeenCalledWith(expect.stringContaining('deliberate failure'));
288+
289+
// The cards after it drew normally: one bad figure is not eight.
290+
const after = cards[2]!;
291+
expect(after.querySelector<HTMLElement>('.figure-note')!.hidden).toBe(true);
292+
expect(after.querySelector('button')!.disabled).toBe(false);
293+
expect(after.querySelector('.figure-caption')!.textContent).toBeTruthy();
294+
} finally {
295+
draw.mockRestore();
296+
getContext.mockRestore();
297+
}
298+
});
299+
300+
it('names the videos it could not analyse instead of calling them untracked', () => {
301+
const session = syntheticSession();
302+
const figures = createReviewFigures(container, { session, videoId: session.videos[0]!.id }, {
303+
onAnnounce: announce,
304+
onGoToTrack: goToTrack,
305+
});
306+
const block = container.querySelector<HTMLElement>('.figure-missing-block')!;
307+
const button = block.querySelector<HTMLButtonElement>('button')!;
308+
309+
figures.update({
310+
session,
311+
videoId: session.videos[0]!.id,
312+
problems: ['test51.mp4 — the maze has no usable hole radius'],
313+
});
314+
315+
expect(block.hidden).toBe(false);
316+
expect(block.textContent).toContain('the maze has no usable hole radius');
317+
// The Track step cannot fix a derive that throws, so it is not offered.
318+
expect(button.hidden).toBe(true);
319+
});
320+
263321
it('takes its listeners with it when destroyed', () => {
264322
const session = syntheticSession();
265323
const figures = createReviewFigures(container, { session, videoId: session.videos[0]!.id }, {

‎tests/viz/figures.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@ describe('figure options in the PNG name (D53)', () => {
138138

139139
it('refuses a bin size that would allocate millions of cells', () => {
140140
const heatmap = FIGURES.find((f) => f.id === 'heatmap')!;
141-
// The grid is the square of the span, so 0.001 cm on a 46 cm platform asked
142-
// for 3.4 million cells and threw `Invalid array length` from inside `draw`.
141+
// The grid is the square of the span, so 0.001 cm on a platform 92 cm across
142+
// asked for 8.46e9 cells and threw `Invalid array length` from inside `draw`.
143143
expect(cellSizeCm({ heatmapCellSize_cm: 0.001 })).toBe(CELL_CM_RANGE.min);
144144
expect(cellSizeCm({ heatmapCellSize_cm: 1e6 })).toBe(CELL_CM_RANGE.max);
145145
expect(() => heatmap.describe(data, { heatmapCellSize_cm: 0.001 })).not.toThrow();

0 commit comments

Comments
 (0)