test(photos): pick calibration before Mask (#60)
Failing session/UI tests for M2-3: Continue → pickCalibration (not first-photo Mask proxy), user pick enters Mask on that Source, draft remembers calibrationIndex, Resume restores. No production change yet (TDD).
This commit is contained in:
@@ -68,15 +68,16 @@ test('continueBatch is a no-op when batch is empty', () => {
|
|||||||
s.dispose();
|
s.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('continueBatch with ≥1 photo advances (M1 bridge: mask on first photo)', async () => {
|
test('continueBatch with ≥1 photo advances to pickCalibration (not Mask)', async () => {
|
||||||
|
// #60: Continue must not silently Mask the first photo — user picks calibration next.
|
||||||
const s = createCaptureSession({ draft: store });
|
const s = createCaptureSession({ draft: store });
|
||||||
await s.addToBatch(tinyJpeg('a'));
|
await s.addToBatch(tinyJpeg('a'));
|
||||||
await s.addToBatch(tinyJpeg('b'));
|
await s.addToBatch(tinyJpeg('b'));
|
||||||
s.continueBatch();
|
s.continueBatch();
|
||||||
const cap = get(s.capture);
|
const cap = get(s.capture);
|
||||||
assert.equal(cap.k, 'mask');
|
assert.equal(cap.k, 'pickCalibration');
|
||||||
if (cap.k === 'mask') {
|
if (cap.k === 'pickCalibration') {
|
||||||
assert.ok(cap.source.blob instanceof Blob);
|
assert.equal(cap.photos.length, 2);
|
||||||
}
|
}
|
||||||
s.dispose();
|
s.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { test, beforeEach } from 'node:test';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import type { Draft, Source } from './capture.types';
|
||||||
|
import { createCaptureSession, type DraftStore } from './session';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function tinyJpeg(label: string): File {
|
||||||
|
const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, label.charCodeAt(0) & 0xff]);
|
||||||
|
return new File([bytes], `${label}.jpg`, { type: 'image/jpeg' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function memStore(): DraftStore & { peek: () => Draft | null; reset: () => void } {
|
||||||
|
let mem: Draft | null = null;
|
||||||
|
return {
|
||||||
|
loadDraft: async () => mem,
|
||||||
|
saveDraft: async (d) => {
|
||||||
|
mem = d;
|
||||||
|
},
|
||||||
|
clearDraft: async () => {
|
||||||
|
mem = null;
|
||||||
|
},
|
||||||
|
peek: () => mem,
|
||||||
|
reset: () => {
|
||||||
|
mem = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: ReturnType<typeof memStore>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = memStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('continueBatch with ≥1 photo goes to pickCalibration (not mask — no first-photo proxy)', async () => {
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
await s.addToBatch(tinyJpeg('a'));
|
||||||
|
await s.addToBatch(tinyJpeg('b'));
|
||||||
|
s.continueBatch();
|
||||||
|
const cap = get(s.capture);
|
||||||
|
assert.equal(cap.k, 'pickCalibration', 'Continue must open pick-calibration, not skip to Mask');
|
||||||
|
if (cap.k === 'pickCalibration') {
|
||||||
|
assert.equal(cap.photos.length, 2);
|
||||||
|
}
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-photo batch: Continue still opens pickCalibration so user confirms the only photo', async () => {
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
await s.addToBatch(tinyJpeg('only'));
|
||||||
|
s.continueBatch();
|
||||||
|
const cap = get(s.capture);
|
||||||
|
assert.equal(cap.k, 'pickCalibration');
|
||||||
|
if (cap.k === 'pickCalibration') {
|
||||||
|
assert.equal(cap.photos.length, 1);
|
||||||
|
}
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickCalibration(index) enters Mask on that Source only (not always first)', async () => {
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
const b = tinyJpeg('b');
|
||||||
|
const c = tinyJpeg('c');
|
||||||
|
await s.addToBatch(a);
|
||||||
|
await s.addToBatch(b);
|
||||||
|
await s.addToBatch(c);
|
||||||
|
s.continueBatch();
|
||||||
|
assert.equal(get(s.capture).k, 'pickCalibration');
|
||||||
|
|
||||||
|
s.pickCalibration(1);
|
||||||
|
const cap = get(s.capture);
|
||||||
|
assert.equal(cap.k, 'mask');
|
||||||
|
if (cap.k === 'mask') {
|
||||||
|
// Chosen photo must be the second batch blob, not photos[0].
|
||||||
|
assert.equal(cap.source.blob, b, 'Mask source must be the user-picked photo');
|
||||||
|
assert.notEqual(cap.source.blob, a, 'must not silently use first photo');
|
||||||
|
}
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-photo pick enters Mask on that photo', async () => {
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
const only = tinyJpeg('only');
|
||||||
|
await s.addToBatch(only);
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(0);
|
||||||
|
const cap = get(s.capture);
|
||||||
|
assert.equal(cap.k, 'mask');
|
||||||
|
if (cap.k === 'mask') {
|
||||||
|
assert.equal(cap.source.blob, only);
|
||||||
|
}
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickCalibration out of range is a no-op', async () => {
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
await s.addToBatch(tinyJpeg('a'));
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(-1);
|
||||||
|
assert.equal(get(s.capture).k, 'pickCalibration');
|
||||||
|
s.pickCalibration(99);
|
||||||
|
assert.equal(get(s.capture).k, 'pickCalibration');
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('draft persists pickCalibration stage with batch photos', async () => {
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
await s.addToBatch(tinyJpeg('a'));
|
||||||
|
await s.addToBatch(tinyJpeg('b'));
|
||||||
|
s.continueBatch();
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const d = store.peek();
|
||||||
|
assert.ok(d, 'draft must exist on pickCalibration');
|
||||||
|
assert.equal(d!.stage, 'pickCalibration');
|
||||||
|
assert.equal((d!.batch ?? []).length, 2);
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('draft remembers calibration choice after pick', async () => {
|
||||||
|
const s = createCaptureSession({ draft: store });
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
const b = tinyJpeg('b');
|
||||||
|
await s.addToBatch(a);
|
||||||
|
await s.addToBatch(b);
|
||||||
|
s.continueBatch();
|
||||||
|
s.pickCalibration(1);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const d = store.peek();
|
||||||
|
assert.ok(d);
|
||||||
|
assert.equal(d!.stage, 'mask');
|
||||||
|
assert.equal(d!.calibrationIndex, 1, 'draft must store which batch photo is calibration');
|
||||||
|
assert.equal(d!.source.blob, b, 'draft.source must be the chosen calibration photo');
|
||||||
|
assert.equal((d!.batch ?? []).length, 2, 'full batch still stored');
|
||||||
|
s.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh: resumeProbe + Continue restores pickCalibration with batch', async () => {
|
||||||
|
const s1 = createCaptureSession({ draft: store });
|
||||||
|
await s1.addToBatch(tinyJpeg('a'));
|
||||||
|
await s1.addToBatch(tinyJpeg('b'));
|
||||||
|
s1.continueBatch();
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
s1.dispose();
|
||||||
|
|
||||||
|
const s2 = createCaptureSession({ draft: store });
|
||||||
|
await s2.resumeProbe();
|
||||||
|
assert.equal(get(s2.capture).k, 'resume');
|
||||||
|
s2.continueDraft();
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
const cap = get(s2.capture);
|
||||||
|
assert.equal(cap.k, 'pickCalibration');
|
||||||
|
if (cap.k === 'pickCalibration') {
|
||||||
|
assert.equal(cap.photos.length, 2);
|
||||||
|
assert.ok(cap.photos.every((p: Source) => p.blob instanceof Blob));
|
||||||
|
}
|
||||||
|
s2.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh: resume after pick restores Mask on the chosen calibration photo', async () => {
|
||||||
|
const s1 = createCaptureSession({ draft: store });
|
||||||
|
const a = tinyJpeg('a');
|
||||||
|
const b = tinyJpeg('b');
|
||||||
|
await s1.addToBatch(a);
|
||||||
|
await s1.addToBatch(b);
|
||||||
|
s1.continueBatch();
|
||||||
|
s1.pickCalibration(1);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
s1.dispose();
|
||||||
|
|
||||||
|
const s2 = createCaptureSession({ draft: store });
|
||||||
|
await s2.resumeProbe();
|
||||||
|
s2.continueDraft();
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
const cap = get(s2.capture);
|
||||||
|
assert.equal(cap.k, 'mask');
|
||||||
|
if (cap.k === 'mask') {
|
||||||
|
// Blob identity may round-trip as a new Blob with same bytes; compare size/type content.
|
||||||
|
assert.ok(cap.source.blob instanceof Blob);
|
||||||
|
const chosen = new Uint8Array(await b.arrayBuffer());
|
||||||
|
const got = new Uint8Array(await cap.source.blob.arrayBuffer());
|
||||||
|
assert.deepEqual(got, chosen, 'restored Mask source must be the picked calibration photo');
|
||||||
|
}
|
||||||
|
// calibrationIndex still on draft
|
||||||
|
const d = store.peek();
|
||||||
|
assert.equal(d?.calibrationIndex, 1);
|
||||||
|
s2.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PickCalibration UI: thumbs over batch + pick action (not auto-first)', () => {
|
||||||
|
const path = join(here, 'PickCalibration.svelte');
|
||||||
|
let src = '';
|
||||||
|
try {
|
||||||
|
src = readFileSync(path, 'utf8');
|
||||||
|
} catch {
|
||||||
|
/* missing = fail */
|
||||||
|
}
|
||||||
|
assert.ok(src, 'expected PickCalibration.svelte');
|
||||||
|
assert.match(src, /calibrat/i, 'UI must mention calibration');
|
||||||
|
assert.match(src, /photos|thumb/i, 'UI must show batch thumbs');
|
||||||
|
assert.ok(
|
||||||
|
/onPick|pickCalibration|on:click/.test(src),
|
||||||
|
'UI must wire a per-photo pick action',
|
||||||
|
);
|
||||||
|
// Must not hardcode always-first without user interaction.
|
||||||
|
assert.ok(
|
||||||
|
!/photos\[0\].*mask|always.*first/i.test(src) || /onPick|on:click/.test(src),
|
||||||
|
'must require user pick',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('App wires pickCalibration state to PickCalibration panel', () => {
|
||||||
|
const app = readFileSync(join(here, '..', 'App.svelte'), 'utf8');
|
||||||
|
assert.match(app, /PickCalibration/, 'App must import/render PickCalibration');
|
||||||
|
assert.match(app, /pickCalibration/, 'App must branch on pickCalibration state');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user