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