test: cross-product Instant Preview's evidence-key pair with the adapter's

`evidence-key-agreement.test.ts` cross-products 144 adversarial combinations
across the mock adapter's gate and descriptor resolver, `validateWorkingCopy`
and the shared pixel resolver -- but omitted `instant-preview.tsx`'s own gate
and descriptor resolver. That pair is byte-parallel to the adapter's (it
lives in `presentation/`, which may not import `adapters/`, so it carries its
own copy of the legacy-key predicate) and it is exactly the pair three earlier
fix rounds regressed.

`instant-preview.tsx` now exports the two expressions it already used, so the
test drives the production code rather than rebuilding it. Added assertions:
gate agreement in both array orders, descriptor equality with the adapter's,
pixel/descriptor agreement, and order independence. The one sanctioned
divergence -- the legacy static key's `assetId`, a fixed literal here versus a
registry-derived one there -- is pinned to that case rather than ignored.

Mutation-checked: breaking the legacy predicate reports 96 of 144 combinations
disagreeing, breaking the legacy descriptor path reports 64 of 144.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 09:12:06 +09:00
co-authored by Claude Opus 5
parent e2a0e695dd
commit 65f8528ccc
2 changed files with 81 additions and 4 deletions
@@ -23,17 +23,29 @@ type PublicRenderModel = components["schemas"]["PublicRenderModel"];
* catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record * catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record
* backs it, so `assets` never resolves it. * backs it, so `assets` never resolves it.
*/ */
function isLegacyStaticEvidenceKey(key: string): boolean { export function isLegacyStaticEvidenceKey(key: string): boolean {
return key === "fetch-strategy-boundary"; 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 * Total -- never throws. Resolves through `findResolvableAsset`, the same
* function this file's key gate and `createAssetCatalogResolver` (which * function this file's key gate and `createAssetCatalogResolver` (which
* supplies the pixels) use, so the descriptor attached to a block and the * supplies the pixels) use, so the descriptor attached to a block and the
* image rendered for it always come from one Asset. * image rendered for it always come from one Asset.
*/ */
function resolveAssetDescriptor(assets: readonly Asset[]) { export function resolveInstantPreviewAssetDescriptor(assets: readonly Asset[]) {
return (key: string): ResolvedAsset => { return (key: string): ResolvedAsset => {
const asset = findResolvableAsset(assets, key); const asset = findResolvableAsset(assets, key);
if (asset) { if (asset) {
@@ -96,9 +108,9 @@ export function InstantPreview({
// key. A key this rejects fails the projection, and the error panel // key. A key this rejects fails the projection, and the error panel
// below names it -- the same key `validateDocument` reports // below names it -- the same key `validateDocument` reports
// `EVIDENCE_UNSUPPORTED` for. // `EVIDENCE_UNSUPPORTED` for.
supportsResolvableEvidenceKey(assets, isLegacyStaticEvidenceKey), instantPreviewEvidenceKeyGate(assets),
), ),
resolveAssetDescriptor(assets), resolveInstantPreviewAssetDescriptor(assets),
); );
} catch (error) { } catch (error) {
issues = error instanceof ContentFormatError issues = error instanceof ContentFormatError
@@ -9,6 +9,16 @@
// agreement itself, through production entry points only. // agreement itself, through production entry points only.
// //
// Against fix round 3's code this reported 53 of 144 combinations disagreeing. // 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 assert from "node:assert/strict";
import { test } from "vitest"; 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 { 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 { 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 { 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"; import { ContentFormatError } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts";
const LEGACY = "fetch-strategy-boundary"; 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. // 1. Validation and the preview projection accept exactly the same keys.
check(!unsupported === projected.accepted, `${where}: validation and projection disagree`); 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) { if (!projected.accepted) {
check(projected.error instanceof ContentFormatError, `${where}: expected a gate rejection, got ${String(projected.error)}`); check(projected.error instanceof ContentFormatError, `${where}: expected a gate rejection, got ${String(projected.error)}`);
check( check(
@@ -227,6 +252,46 @@ test("gate, projection resolver, pixel resolver and validation never disagree",
const emptyAlt = validate(key, "", assets); const emptyAlt = validate(key, "", assets);
const altRequired = emptyAlt.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"); 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`); 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<string, unknown> & { 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`); assert.ok(checked >= 100, `only ${checked} combinations checked`);