diff --git a/src/features/tech-log/presentation/studio/components/instant-preview.tsx b/src/features/tech-log/presentation/studio/components/instant-preview.tsx index 9a18898..23cab62 100644 --- a/src/features/tech-log/presentation/studio/components/instant-preview.tsx +++ b/src/features/tech-log/presentation/studio/components/instant-preview.tsx @@ -23,17 +23,29 @@ type PublicRenderModel = components["schemas"]["PublicRenderModel"]; * catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record * backs it, so `assets` never resolves it. */ -function isLegacyStaticEvidenceKey(key: string): boolean { +export function isLegacyStaticEvidenceKey(key: string): boolean { return key === "fetch-strategy-boundary"; } +/** + * Instant Preview's evidence-key gate. Exported so + * `tests/features/tech-log/evidence-key-agreement.test.ts` cross-products + * *this* expression against the mock adapter's byte-parallel pair rather than + * rebuilding it -- three earlier fix rounds regressed exactly this pair, each + * time by writing a second expression that agreed only on the examples that + * round happened to use. + */ +export function instantPreviewEvidenceKeyGate(assets: readonly Asset[]) { + return supportsResolvableEvidenceKey(assets, isLegacyStaticEvidenceKey); +} + /** * Total -- never throws. Resolves through `findResolvableAsset`, the same * function this file's key gate and `createAssetCatalogResolver` (which * supplies the pixels) use, so the descriptor attached to a block and the * image rendered for it always come from one Asset. */ -function resolveAssetDescriptor(assets: readonly Asset[]) { +export function resolveInstantPreviewAssetDescriptor(assets: readonly Asset[]) { return (key: string): ResolvedAsset => { const asset = findResolvableAsset(assets, key); if (asset) { @@ -96,9 +108,9 @@ export function InstantPreview({ // key. A key this rejects fails the projection, and the error panel // below names it -- the same key `validateDocument` reports // `EVIDENCE_UNSUPPORTED` for. - supportsResolvableEvidenceKey(assets, isLegacyStaticEvidenceKey), + instantPreviewEvidenceKeyGate(assets), ), - resolveAssetDescriptor(assets), + resolveInstantPreviewAssetDescriptor(assets), ); } catch (error) { issues = error instanceof ContentFormatError diff --git a/tests/features/tech-log/evidence-key-agreement.test.ts b/tests/features/tech-log/evidence-key-agreement.test.ts index b704562..5815fb2 100644 --- a/tests/features/tech-log/evidence-key-agreement.test.ts +++ b/tests/features/tech-log/evidence-key-agreement.test.ts @@ -9,6 +9,16 @@ // agreement itself, through production entry points only. // // Against fix round 3's code this reported 53 of 144 combinations disagreeing. +// +// The callers covered are the mock adapter's projection gate and descriptor +// resolver, `validateWorkingCopy`, the shared pixel resolver, and -- added in +// the final fix wave -- `instant-preview.tsx`'s own gate and descriptor +// resolver. The last pair is byte-parallel to the adapter's (they live in +// different layers: `presentation/` may not import `adapters/`), and is the +// pair three earlier fix rounds regressed. Its one sanctioned divergence is +// the legacy static key's `assetId`, which the assertions below pin rather +// than ignore. Mutating either half of that pair reports 96 and 64 of 144 +// combinations disagreeing respectively. import assert from "node:assert/strict"; import { test } from "vitest"; @@ -18,6 +28,10 @@ import { projectWorkingCopy } from "../../../src/features/tech-log/adapters/mock import { validateWorkingCopy } from "../../../src/features/tech-log/adapters/mock/validate-working-copy.ts"; import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts"; import { createAssetCatalogResolver } from "../../../src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts"; +import { + instantPreviewEvidenceKeyGate, + resolveInstantPreviewAssetDescriptor, +} from "../../../src/features/tech-log/presentation/studio/components/instant-preview.tsx"; import { ContentFormatError } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts"; const LEGACY = "fetch-strategy-boundary"; @@ -185,6 +199,17 @@ test("gate, projection resolver, pixel resolver and validation never disagree", // 1. Validation and the preview projection accept exactly the same keys. check(!unsupported === projected.accepted, `${where}: validation and projection disagree`); + // 1b. Instant Preview's gate is a fourth caller of the same decision, + // living in `presentation/` (which may not import `adapters/`) and + // therefore carrying its own copy of the legacy-key predicate. It + // must accept exactly the same keys, in either array order. + const previewAccepts = instantPreviewEvidenceKeyGate(assets)(key); + check(previewAccepts === projected.accepted, `${where}: Instant Preview gate and the projection disagree`); + check( + instantPreviewEvidenceKeyGate([...assets].reverse())(key) === previewAccepts, + `${where}: order-dependent Instant Preview gate`, + ); + if (!projected.accepted) { check(projected.error instanceof ContentFormatError, `${where}: expected a gate rejection, got ${String(projected.error)}`); check( @@ -227,6 +252,46 @@ test("gate, projection resolver, pixel resolver and validation never disagree", const emptyAlt = validate(key, "", assets); const altRequired = emptyAlt.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"); check(altRequired === !projected.descriptor.decorative, `${where}: alt rule judged a different asset than the one rendered`); + + // 7. Instant Preview's own pair agrees with the mock adapter's. The two + // are byte-parallel expressions in different layers (`presentation/` + // may not import `adapters/`), and the pair regressed in three + // separate fix rounds, so the agreement is asserted rather than + // assumed. The single sanctioned divergence is the legacy static + // key's `assetId`: with no Asset backing it, Instant Preview returns + // a fixed literal where the adapter derives one from the static + // registry. Everything else -- acceptance, path, dimensions, + // mediaType, decorative -- must match exactly, and the divergence is + // pinned so a future change cannot widen it unnoticed. + const previewDescriptor = resolveInstantPreviewAssetDescriptor(assets)(key); + const { assetId: previewAssetId, ...previewRest } = previewDescriptor; + const { assetId: mockAssetId, ...mockRest } = projected.descriptor as unknown as + Record & { assetId: string }; + check( + JSON.stringify(previewRest) === JSON.stringify(mockRest), + `${where}: Instant Preview descriptor differs from the adapter's beyond assetId (${JSON.stringify(previewRest)} vs ${JSON.stringify(mockRest)})`, + ); + check( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(previewAssetId), + `${where}: Instant Preview assetId is not a stable UUID (${previewAssetId})`, + ); + check( + (previewAssetId === mockAssetId) === Boolean(assets.some((candidate) => candidate.assetKey === key && candidate.id === mockAssetId)), + `${where}: assetId agreement is not confined to the legacy fallback`, + ); + + // 8. Instant Preview's descriptor and the pixels it renders are one asset. + check( + pixels.src === previewDescriptor.publicPath, + `${where}: Instant Preview descriptor (${previewDescriptor.publicPath}) and pixels (${pixels.src}) disagree`, + ); + + // 9. Order independence holds for Instant Preview's resolver too. + check( + JSON.stringify(resolveInstantPreviewAssetDescriptor([...assets].reverse())(key)) === + JSON.stringify(previewDescriptor), + `${where}: order-dependent Instant Preview descriptor`, + ); } } assert.ok(checked >= 100, `only ${checked} combinations checked`);