`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>
300 lines
14 KiB
TypeScript
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")}`);
|
|
});
|