Files
tech-log-frontend/tests/features/tech-log/evidence-key-agreement.test.ts
T
DongHyeonkaandClaude Opus 5 65f8528ccc 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>
2026-08-18 09:12:06 +09:00

300 lines
14 KiB
TypeScript

// Every caller that answers "can this evidence key be rendered" and "which
// Asset does it render" must give the same answer, because they are one
// decision (`findResolvableAsset`). Three fix rounds each rebuilt one of them
// as a separate expression that agreed with the others only on the inputs
// that round's examples used, so this file does not test examples: it
// cross-products adversarial Asset lists (duplicate `assetKey`s, mixed
// `managementStatus`, null/empty `publicPath`, both array orders, the legacy
// static key shadowed by a real Asset) against a set of keys and asserts the
// 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";
import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts";
import { projectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.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 { 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";
function asset(overrides: Partial<Asset>): Asset {
return {
id: "00000000-0000-4000-8000-000000000001",
assetKey: "k",
kind: "IMAGE",
mediaType: "image/svg+xml",
originalFilename: "k.svg",
byteSize: 1,
width: null,
height: null,
altText: null,
decorative: false,
managementStatus: "READY",
publicPath: "/media/k.svg",
usageCount: 0,
version: 1,
createdAt: "2026-08-14T00:00:00.000Z",
updatedAt: "2026-08-14T00:00:00.000Z",
...overrides,
} as Asset;
}
const id = (n: number) => `00000000-0000-4000-8000-00000000000${n}`;
const lists: Array<{ label: string; assets: Asset[] }> = [
{ label: "empty", assets: [] },
{ label: "single READY", assets: [asset({ id: id(1) })] },
{ label: "single READY null path", assets: [asset({ id: id(1), publicPath: null })] },
{ label: "single READY empty path", assets: [asset({ id: id(1), publicPath: "" })] },
{ label: "single QUARANTINED", assets: [asset({ id: id(1), managementStatus: "QUARANTINED" })] },
{ label: "single REJECTED", assets: [asset({ id: id(1), managementStatus: "REJECTED" })] },
{ label: "single ARCHIVED", assets: [asset({ id: id(1), managementStatus: "ARCHIVED" })] },
{
label: "dup: null path first, real second",
assets: [asset({ id: id(1), publicPath: null }), asset({ id: id(2), publicPath: "/media/k-2.svg", updatedAt: "2026-08-14T00:00:01.000Z" })],
},
{
label: "dup: empty path first, real second",
assets: [asset({ id: id(1), publicPath: "" }), asset({ id: id(2), publicPath: "/media/k-2.svg", updatedAt: "2026-08-14T00:00:01.000Z" })],
},
{
label: "dup: QUARANTINED newest, READY older",
assets: [asset({ id: id(1), managementStatus: "QUARANTINED", updatedAt: "2026-08-14T00:00:09.000Z" }), asset({ id: id(2) })],
},
{
label: "dup: two resolvable, different paths",
assets: [
asset({ id: id(1), publicPath: "/media/k-1.svg", updatedAt: "2026-08-14T00:00:00.000Z" }),
asset({ id: id(2), publicPath: "/media/k-2.svg", updatedAt: "2026-08-14T00:00:01.000Z" }),
],
},
{
label: "dup: identical updatedAt (id tiebreak)",
assets: [asset({ id: id(3), publicPath: "/media/k-3.svg" }), asset({ id: id(2), publicPath: "/media/k-2.svg" })],
},
{
label: "dup: decorative split",
assets: [
asset({ id: id(1), publicPath: "/media/k-1.svg", decorative: true, updatedAt: "2026-08-14T00:00:02.000Z" }),
asset({ id: id(2), publicPath: "/media/k-2.svg", decorative: false, updatedAt: "2026-08-14T00:00:01.000Z" }),
],
},
{
label: "decoy: other key owns /media/k.svg",
assets: [asset({ id: id(1), assetKey: "other", publicPath: "/media/k.svg" })],
},
{
label: "legacy key backed by a real READY asset",
assets: [asset({ id: id(1), assetKey: LEGACY, publicPath: "/media/legacy-override.svg" })],
},
{
label: "legacy key backed by a READY asset with null path",
assets: [asset({ id: id(1), assetKey: LEGACY, publicPath: null })],
},
{
label: "legacy key: null-path first, real second",
assets: [
asset({ id: id(1), assetKey: LEGACY, publicPath: null }),
asset({ id: id(2), assetKey: LEGACY, publicPath: "/media/legacy-override.svg", updatedAt: "2026-08-14T00:00:01.000Z" }),
],
},
{
label: "three-way dup with mixed status and paths",
assets: [
asset({ id: id(1), publicPath: "" }),
asset({ id: id(2), managementStatus: "QUARANTINED", updatedAt: "2026-08-14T00:00:05.000Z" }),
asset({ id: id(3), publicPath: "/media/k-3.svg", updatedAt: "2026-08-14T00:00:02.000Z" }),
asset({ id: id(4), publicPath: "/media/k-4.svg", updatedAt: "2026-08-14T00:00:01.000Z" }),
],
},
];
const keys = ["k", "other", LEGACY, "missing"];
const catalog = [
{ id: "topic", type: "TOPIC", label: "T", publicPath: "/t", dependencyRevision: "r1" },
{ id: "evidence-row", type: "EVIDENCE", label: "some-label", publicPath: "/cases/x", dependencyRevision: "r1" },
{ id: "legacy-row", type: "EVIDENCE", label: LEGACY, publicPath: `/media/${LEGACY}.svg`, dependencyRevision: "r1" },
] as never[];
function draft(key: string, alt: string) {
return {
kind: "CASE",
title: "t",
slug: "s",
summary: "s",
topicId: "topic",
projectId: null,
relations: [],
problem: "p",
conclusion: "c",
environment: "e",
reproduction: "r",
lastVerifiedOn: "2026-08-14",
bodyMarkdown: `:::evidence key="${key}" alt="${alt}" caption="c" zoom="false"\n:::`,
} as never;
}
type Descriptor = { assetId: string; assetKey: string; publicPath: string; decorative: boolean };
function project(key: string, alt: string, assets: readonly Asset[]) {
try {
const model = projectWorkingCopy(draft(key, alt), catalog, { mode: "PREVIEW", publishedAt: null }, assets) as {
bodyBlocks: Array<{ type: string; asset?: Descriptor }>;
};
const figure = model.bodyBlocks.find((block) => block.type === "EVIDENCE_FIGURE");
return { accepted: true as const, descriptor: figure!.asset! };
} catch (error) {
return { accepted: false as const, error };
}
}
function validate(key: string, alt: string, assets: readonly Asset[]) {
return validateWorkingCopy(
{ ...(draft(key, alt) as object), id: "11111111-1111-4111-8111-111111111111", version: 1, updatedAt: "2026-08-14T00:00:00.000Z" } as never,
{ now: new Date("2026-08-14T01:00:00.000Z"), validationId: "v", dependencyRevision: "r1", catalog, documents: [], assets },
);
}
test("gate, projection resolver, pixel resolver and validation never disagree", () => {
let checked = 0;
const problems: string[] = [];
const check = (condition: boolean, message: string) => {
if (!condition) problems.push(message);
return condition;
};
const cases = lists.flatMap(({ label, assets }) => [
{ label, assets },
{ label: `${label} (reversed)`, assets: [...assets].reverse() },
]);
for (const { label, assets } of cases) {
for (const key of keys) {
checked += 1;
const where = `${label} / ${key}`;
const projected = project(key, "a", assets);
const report = validate(key, "a", assets);
const unsupported = report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED");
const pixels = createAssetCatalogResolver(assets)(key);
// 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(
projected.error instanceof ContentFormatError &&
/supported local evidence key not found/.test(projected.error.issues[0]!.detail),
`${where}: rejection is not the gate's`,
);
continue;
}
// 2. Nothing the gate accepts may render as an empty path.
check(projected.descriptor.publicPath !== "", `${where}: accepted a key that renders as an empty publicPath`);
// 3. The block descriptor and the pixels are the same asset.
check(pixels.src === projected.descriptor.publicPath, `${where}: descriptor (${projected.descriptor.publicPath}) and pixels (${pixels.src}) disagree`);
// 4. The descriptor names an asset that really is in the list and really
// is renderable (or the legacy static asset, which is in no list).
const named = assets.find((candidate) => candidate.id === projected.descriptor.assetId);
if (named) {
check(named.assetKey === key, `${where}: descriptor names an asset with a different assetKey`);
check(named.managementStatus === "READY", `${where}: descriptor names a non-READY asset`);
check(Boolean(named.publicPath), `${where}: descriptor names an asset with no publicPath`);
} else {
check(isSupportedEvidenceKey(key), `${where}: descriptor names an asset that is not in the list`);
}
// 5. Reversing the array changes nothing anywhere.
const reversed = [...assets].reverse();
const reProjected = project(key, "a", reversed);
if (check(reProjected.accepted, `${where}: order-dependent acceptance`) && reProjected.accepted) {
check(
JSON.stringify(reProjected.descriptor) === JSON.stringify(projected.descriptor),
`${where}: order-dependent descriptor (${projected.descriptor.publicPath} vs ${reProjected.descriptor.publicPath})`,
);
}
check(createAssetCatalogResolver(reversed)(key).src === pixels.src, `${where}: order-dependent pixels`);
// 6. The alt rule is judged against the asset that actually renders.
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<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.deepEqual(problems, [], `${problems.length} of ${checked} combinations disagree:\n${problems.join("\n")}`);
});