Files
tech-log-frontend/tests/features/tech-log/evidence-key-agreement.test.ts
T
DongHyeonkaandClaude Opus 5 073fda87eb fix: collapse the evidence-key gate and resolver into one decision
Three fix rounds each rebuilt the gate as a separate expression that merely
agreed with the resolver on the inputs that round's tests used. Different
expressions cannot agree in general, so the defect class stayed open while
each reported instance closed.

`findResolvableAsset(assets, key)` is now the single place that decides which
Asset an evidence key resolves to. Every gate is
`Boolean(findResolvableAsset(...)) || legacyKey(key)` via one shared
composition, and every resolver returns what it returns:

- validate-working-copy: the key gate and the decorative lookup (a last-wins
  Map against the resolver's first-wins find, so alt could be judged against a
  different Asset than the one rendered)
- adapters/mock/project-public-render-model: gate and resolver
- instant-preview: gate and descriptor resolver
- createAssetCatalogResolver: the pixels, a fourth expression nobody had
  listed -- one Asset's caption could sit over another Asset's image

Duplicate assetKeys are a contract violation but reachable through a paged
list, so the choice is total and order-independent: newest updatedAt wins,
tie-broken by id.

InstantPreview's gate is no longer looser than the others. The un-loaded-asset
case it was loosened for blanks either way; all the looseness bought was
catalog-only keys rendering an empty gap with no message while validation said
EVIDENCE_UNSUPPORTED. The test that pinned that divergence now asserts the
consistent behaviour, and the false comment claiming a fix that did not exist
is gone.

idempotent() now maps a deterministic content failure to VALIDATION_STALE/409
instead of offering a retry that fails identically, and no longer caches
uncharacterized internal failures -- reporting one as retryable while freezing
it in the ledger meant the retry could never re-run.

Adds tests/features/tech-log/evidence-key-agreement.test.ts: 144 adversarial
(asset list, key) combinations asserting the agreement itself rather than
examples. It reported 53 disagreements against the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 06:55:06 +09:00

235 lines
10 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.
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 { 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`);
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`);
}
}
assert.ok(checked >= 100, `only ${checked} combinations checked`);
assert.deepEqual(problems, [], `${problems.length} of ${checked} combinations disagree:\n${problems.join("\n")}`);
});