e5afc7524b
ensureServerSlab sent calibration_photo_id at create, but slab_photos rows are
only created by POST /slabs/{id}/photos, which the frontend never called - so
the INSERT hit FOREIGN KEY (calibration_photo_id REFERENCES slab_photos(id))
and every create failed with a generic 400, blocking the whole create->publish
walk.
- stop sending calibration_photo_id at create
- upload every draft photo (original + processed PNG) right after the slab row
exists, via new review/photoUpload.ts (multipart FormData, same error
envelope so failures surface through fail(e))
- link calibration_photo_id in the existing review PUT, once the row exists
- extract the role rule into lineup.photoRole() so the review strip and the
uploader cannot disagree
91 lines
3.7 KiB
TypeScript
91 lines
3.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import {
|
|
keptInOrder,
|
|
moveItem,
|
|
missingMinimums,
|
|
lineupValid,
|
|
photoRole,
|
|
roleLabel,
|
|
resolvePhotoUrl,
|
|
type LineupItem,
|
|
} from './lineup';
|
|
|
|
const p = (id: string, role: LineupItem['role'], kept = true): LineupItem => ({
|
|
id,
|
|
role,
|
|
isCalibration: role === 'calibration',
|
|
kept,
|
|
});
|
|
|
|
test('lineup: keptInOrder returns kept photos in their current order', () => {
|
|
const items = [p('cal', 'calibration'), p('ov', 'overlay', false), p('b1', 'batch')];
|
|
assert.deepEqual(keptInOrder(items).map((x) => x.id), ['cal', 'b1']);
|
|
});
|
|
|
|
test('lineup: leftmost kept is the primary', () => {
|
|
const items = [p('b1', 'batch'), p('cal', 'calibration'), p('ov', 'overlay')];
|
|
// After a drag the owner re-ordered; primary follows position, not role.
|
|
assert.equal(keptInOrder(items)[0].id, 'b1');
|
|
});
|
|
|
|
test('lineup: primary auto-promotes when the leftmost kept is removed', () => {
|
|
const items = [p('cal', 'calibration', false), p('ov', 'overlay'), p('b1', 'batch')];
|
|
assert.equal(keptInOrder(items)[0].id, 'ov');
|
|
});
|
|
|
|
test('lineup: moveItem reorders (drag-to-front) and is a no-op on same index', () => {
|
|
const a = ['x', 'y', 'z'];
|
|
assert.deepEqual(moveItem(a, 2, 0), ['z', 'x', 'y']);
|
|
assert.deepEqual(moveItem(a, 0, 0), ['x', 'y', 'z']);
|
|
// Out-of-range is a no-op, never throws.
|
|
assert.deepEqual(moveItem(a, 0, 5), ['x', 'y', 'z']);
|
|
// Returns a new array, does not mutate the input.
|
|
const before = a.slice();
|
|
moveItem(a, 0, 2);
|
|
assert.deepEqual(a, before);
|
|
});
|
|
|
|
test('lineup: minimum requires a clean AND an overlay copy (REVIEW-5)', () => {
|
|
assert.deepEqual(missingMinimums([p('cal', 'calibration'), p('ov', 'overlay')]), []);
|
|
assert.deepEqual(missingMinimums([p('cal', 'calibration')]), ['overlay']);
|
|
assert.deepEqual(missingMinimums([p('ov', 'overlay')]), ['calibration']);
|
|
assert.deepEqual(missingMinimums([p('b1', 'batch')]), ['calibration', 'overlay']);
|
|
assert.deepEqual(missingMinimums([]), ['calibration', 'overlay']);
|
|
});
|
|
|
|
test('lineup: batch-only is not valid; cal+overlay+batch is', () => {
|
|
assert.equal(lineupValid([p('b1', 'batch')]), false);
|
|
assert.equal(lineupValid([p('cal', 'calibration'), p('ov', 'overlay'), p('b1', 'batch')]), true);
|
|
});
|
|
|
|
test('lineup: missingMinimums only counts KEPT photos', () => {
|
|
// Both roles present in the array, but the cal is not kept.
|
|
assert.deepEqual(missingMinimums([p('cal', 'calibration', false), p('ov', 'overlay')]), ['calibration']);
|
|
});
|
|
|
|
test('lineup: photoRole is the single role rule (cal > overlay > batch)', () => {
|
|
assert.equal(photoRole('cal', 'cal', true), 'calibration');
|
|
assert.equal(photoRole('ov', 'cal', true), 'overlay');
|
|
assert.equal(photoRole('b1', 'cal', false), 'batch');
|
|
// Calibration wins even if it carries a processed copy.
|
|
assert.equal(photoRole('cal', 'cal', true), 'calibration');
|
|
// No calibration selected: processed photos are overlays.
|
|
assert.equal(photoRole('ov', '', true), 'overlay');
|
|
assert.equal(photoRole('b1', '', false), 'batch');
|
|
});
|
|
|
|
test('lineup: roleLabel is owner-facing', () => {
|
|
assert.match(roleLabel('calibration'), /clean/);
|
|
assert.match(roleLabel('overlay'), /overlay/);
|
|
});
|
|
|
|
test('lineup: resolvePhotoUrl turns an in-memory blob into a blob: URL', () => {
|
|
const blob = new Blob(['png-bytes'], { type: 'image/png' });
|
|
const fake = (b: Blob) => `blob:fake-${b.size}`;
|
|
assert.equal(resolvePhotoUrl(blob, '/api/endpoints/photo', fake), 'blob:fake-9');
|
|
// Empty/absent blob falls back to the stored endpoint.
|
|
assert.equal(resolvePhotoUrl(new Blob(), '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
|
assert.equal(resolvePhotoUrl(null, '/api/endpoints/photo', fake), '/api/endpoints/photo');
|
|
});
|