diff --git a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts index 5b9230d..967d197 100644 --- a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -2,6 +2,7 @@ import { StudioGatewayError } from "../../application/ports/studio-gateway-error import type { IdempotentOptions, RequestOptions, StudioGateway } from "../../application/ports/studio-gateway.ts"; import type { components } from "../../contracts/studio/generated.ts"; import type { Asset, CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts"; +import { ContentFormatError } from "../../domain/content-format/parse-case-content.ts"; import { deriveDocumentState, derivePreviewState } from "../../domain/studio/document-state.ts"; import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts"; import { createMockStudioState, MockStudioState } from "./mock-state.ts"; @@ -63,6 +64,34 @@ function uuid(value: unknown, path: string): asserts value is string { if (typeof value !== "string" || !UUID.test(value)) throw requestError([{ path, message: "Must be a UUID." }]); } +/** + * Classifies whatever escaped a handler into the problem this port reports, + * and whether that outcome may be replayed from the idempotency ledger. + * + * A `ContentFormatError` is deterministic: the document no longer projects + * against the dependencies as they stand (an Asset deleted between validate + * and preview is the live case). Reporting that as `STUDIO_UNAVAILABLE`/500 + * `retryable: true` invites a retry that fails identically -- the honest + * signal is `VALIDATION_STALE`/409, "re-validate, then try again", which is + * not a retry of this request at all. Anything else is an uncharacterized + * internal defect: we cannot claim it is deterministic, so it is reported as + * retryable and deliberately left out of the ledger. + */ +function failureOf(error: unknown): { problem: ProblemDetails; replayable: boolean } { + if (error instanceof StudioGatewayError) return { problem: error.problem, replayable: true }; + if (error instanceof ContentFormatError) { + const detail = error.issues.map((issue) => `${issue.line}:${issue.column} ${issue.detail}`).join("; "); + return { + problem: gatewayProblem(409, "VALIDATION_STALE", `The document no longer renders against current dependencies: ${detail}`).problem, + replayable: true, + }; + } + return { + problem: gatewayProblem(500, "STUDIO_UNAVAILABLE", error instanceof Error ? error.message : "Studio가 요청을 처리하지 못했습니다.", { retryable: true }).problem, + replayable: false, + }; +} + function exactSet(left: string[], right: string[]) { return new Set(left).size === left.length && new Set(right).size === right.length && [...left].sort().join("\0") === [...right].sort().join("\0"); } @@ -87,25 +116,18 @@ export function createMockStudioGateway(supplied: Partial; // The domain projection has no asset catalog access, so a CASE projection's -// EVIDENCE_FIGURE blocks come back without a resolved `asset`. This adapter owns -// the asset catalog (`adapters/static/evidence-assets.ts`, plus -- fix round 1 -// (I2) -- whatever `Asset`s the caller passes, merged via -// `evidenceCatalogEntriesFromAssets`, the same function `validate-working-copy.ts` -// and Instant Preview use, so this mock's own `createPreview` agrees with -// `validateDocument` about which evidence keys exist), so it resolves the +// EVIDENCE_FIGURE blocks come back without a resolved `asset`. This adapter +// owns the asset catalog (`adapters/static/evidence-assets.ts` for the one +// legacy key, plus whatever `Asset`s the caller passes), so it resolves the // descriptor here to produce a genuine, fully-resolved `PublicRenderModel`. // -// Fix round 3 (I2 regression, third instance). Gate 1 (`supportsEvidenceKey` -// below) is built from `supportsEvidenceKeyFromReadyAssets` -- the exact -// predicate the resolver below uses (`assetKey === key && READY && -// publicPath truthy`) -- not a catalog lookup. Round 1 gated on "is there -// any EVIDENCE catalog row for it" (too loose: rows that exist for -// something other than media, e.g. a QUESTION resolution target, passed). -// Round 2 narrowed to a catalog synthesized *from* Assets, but that catalog -// lookup (`evidenceCatalogEntryFor`, matching `id || label || publicPath`) -// is still a different expression than the resolver's own condition: a -// `READY` Asset with `publicPath: null`/`""`, or one whose `publicPath` -// happens to satisfy the legacy `/media/${someOtherKey}.svg` convention for -// a key that is not its own `assetKey`, passed that gate while the resolver -// below could not produce real pixels for it -- the same -// `validateDocument`-says-VALID / `createPreview`-throws disagreement, twice. -// Gate 2 (`evidenceCatalogEntryFor` below) keeps reading the merged catalog. +// The key gate and the resolver below are the same expression over the same +// inputs: both ask `findResolvableAsset(assets, key)` first and fall back to +// the same `isSupportedEvidenceKey` legacy check. Nothing else decides which +// Asset a key resolves to. export function projectWorkingCopy( input: ProjectArguments[0], catalog: ProjectArguments[1], context: ProjectArguments[2], assets: readonly Asset[] = [], ) { - const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)]; - const supportsEvidenceKey = (key: string) => - isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(assets)(key); const model = projectWorkingCopyWithEvidence( input, - evidenceCatalog, + [...catalog, ...evidenceCatalogEntriesFromAssets(assets)], context, - supportsEvidenceKey, + supportsResolvableEvidenceKey(assets, isSupportedEvidenceKey), ); return resolveCaseEvidenceAssets(model, resolveAssetDescriptor(assets)); } /** - * Fix round 3. Total -- never throws -- the same discipline - * `instant-preview.tsx`'s resolver already followed. This class of bug has - * now recurred twice as "gate 1 passes a key the resolver then cannot - * resolve, and the resolver throws a raw, unwrapped `Error`". With gate 1 - * above built from this function's exact condition, that specific - * disagreement cannot happen through `projectWorkingCopy`'s own interface - * any more -- but a resolver that structurally cannot throw is worth more - * than a resolver plus a comment asserting it never will (round 2's comment - * here made exactly that claim, and was wrong). Kept as defence in depth: - * if a future change reintroduces a mismatch, this degrades to a - * placeholder instead of putting a bare `Error` through the gateway port. + * Total -- never throws. Tries the same two sources the key gate above tries, + * in the same order, over the same `assets`; the final placeholder is what a + * key neither source can supply degrades to, rather than a bare `Error` put + * through the gateway port. */ function resolveAssetDescriptor(assets: readonly Asset[]) { return (key: string): ResolvedAsset => { - const asset = assets.find( - (candidate) => candidate.assetKey === key && candidate.managementStatus === "READY", - ); - if (asset?.publicPath) { + const asset = findResolvableAsset(assets, key); + if (asset) { return { assetId: asset.id, assetKey: asset.assetKey, diff --git a/src/features/tech-log/adapters/mock/validate-working-copy.ts b/src/features/tech-log/adapters/mock/validate-working-copy.ts index 7d1e27c..7172ade 100644 --- a/src/features/tech-log/adapters/mock/validate-working-copy.ts +++ b/src/features/tech-log/adapters/mock/validate-working-copy.ts @@ -2,7 +2,8 @@ import type { components } from "../../contracts/studio/generated.ts"; import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts"; import { evidenceCatalogEntriesFromAssets, - supportsEvidenceKeyFromReadyAssets, + findResolvableAsset, + supportsResolvableEvidenceKey, } from "../../domain/content-format/asset-evidence-catalog.ts"; import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts"; import { evidenceCatalogEntryFor } from "../../domain/content-format/project-public-render-model.ts"; @@ -17,23 +18,16 @@ export type ValidationDependencies = { now: Date; validationId: string; dependencyRevision: string; catalog: ReadonlyArray; documents: ReadonlyArray; /** - * Fix round 1 (I2). Without this, the mock's validate step only ever - * recognized the one hardcoded legacy evidence key -- disconnected from - * the Asset system (Tasks 6/7) the Picker (Task 10) actually inserts keys - * from -- so every Picker-inserted directive previewed live and then - * failed validation. Merged into `catalog` via - * `evidenceCatalogEntriesFromAssets`, the same function Instant Preview - * uses, so both paths agree on which keys a document may reference. + * The Assets the session has loaded. Without them the mock's validate step + * would only recognize the one hardcoded legacy evidence key, disconnected + * from the Asset system the Picker inserts keys from, and every + * Picker-inserted directive would preview live and then fail validation. * - * Fix round 2: only used for gate 2 (the merged-catalog lookup) and the - * `EVIDENCE_ALT_REQUIRED` decorative check. Gate 1 (`supportsEvidenceKey` - * below) intentionally does NOT read the merged catalog -- see that - * variable's comment. - * - * Fix round 3: gate 1 also stopped reading a catalog derived from these - * assets at all -- it now calls `supportsEvidenceKeyFromReadyAssets` - * directly against this array, the same predicate the resolver uses, so - * the two can no longer disagree. + * Every question asked of this array -- may the document reference the key, + * and is the referenced Asset decorative -- goes through + * `findResolvableAsset`, the same function the preview projection resolves + * with, so validation can never judge a different Asset than the one that + * renders. */ assets: ReadonlyArray; }; @@ -188,27 +182,18 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요."); else try { const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)]; - // Fix round 3 (I2 regression, third instance). Gate 1 must ask "does a - // real, resolvable Asset (or the legacy key) back this" -- not "is - // there a catalog row for it" (round 1's bug) and not "is there a - // catalog row synthesized *from* an Asset for it" either (round 2's - // bug: `evidenceCatalogEntryFor` matches on `id || label || - // publicPath`, which is still gate 2's question over fewer rows, and a - // `READY` Asset with a null/empty `publicPath`, or one whose - // `publicPath` happens to satisfy the legacy convention for a - // *different* key, passed it while the resolver could not produce - // real pixels for it). Gate 1 is now built directly from - // `supportsEvidenceKeyFromReadyAssets` -- the exact predicate the - // resolver uses (`assetKey === key && READY && publicPath truthy`) -- - // so the two cannot diverge. Gate 2 (`evidenceCatalogEntryFor` below) - // still reads the merged catalog; only gate 1 changed. - const supportsEvidenceKey = (key: string) => - isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(dependencies.assets)(key); - const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset])); + // The key gate is the preview projection's gate, verbatim: a resolvable + // Asset or the legacy static key. The catalog check below is a separate + // question ("does some catalog carry a row for this key"), and the + // decorative lookup reads the *same* Asset the projection resolves -- + // it used to be a last-wins `Map` against a first-wins `find`, so two + // READY Assets sharing one key could have alt judged against one Asset + // and rendered from another. + const supportsEvidenceKey = supportsResolvableEvidenceKey(dependencies.assets, isSupportedEvidenceKey); for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") { if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`); else if (!evidenceCatalogEntryFor(evidenceCatalog, block.key)) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`); - else if (block.alt.trim().length === 0 && !readyAssetsByKey.get(block.key)?.decorative) { + else if (block.alt.trim().length === 0 && !findResolvableAsset(dependencies.assets, block.key)?.decorative) { // 정적 evidence 자산은 장식용이 아니다. Asset 기반 경로는 실제 // Asset.decorative로 같은 판정을 한다(decorative Asset은 대체 // 텍스트가 없어도 통과한다 -- asset-picker.tsx가 그렇게 directive를 diff --git a/src/features/tech-log/domain/content-format/asset-evidence-catalog.ts b/src/features/tech-log/domain/content-format/asset-evidence-catalog.ts index 5057d87..2170c52 100644 --- a/src/features/tech-log/domain/content-format/asset-evidence-catalog.ts +++ b/src/features/tech-log/domain/content-format/asset-evidence-catalog.ts @@ -1,50 +1,94 @@ import type { components } from "../../contracts/studio/generated.ts"; import type { Asset } from "../../contracts/studio/contract.ts"; import type { SupportsEvidenceKey } from "../public-render-content.ts"; -import { evidenceCatalogEntryFor } from "./project-public-render-model.ts"; type CatalogEntry = components["schemas"]["CatalogEntry"]; /** - * The single place that bridges the Asset system (Tasks 6/7) and the - * document catalog `projectWorkingCopy` (Instant Preview) and - * `validateWorkingCopy` (mock validation) gate `EVIDENCE_FIGURE` blocks - * against. Fix round 1 (I2): both paths previously derived their own, - * independently-written notion of "does a loaded Asset make this key - * referenceable" -- Instant Preview learned to accept a freshly loaded - * Asset, but the mock's validate step still only recognized the one - * hardcoded legacy key, so a directive the Asset Picker inserted could - * preview perfectly and then fail validation. Both paths now call this one - * function instead. + * An `Asset` that can actually produce pixels for an evidence key: `READY`, + * and carrying a real `publicPath`. The narrowed `publicPath` is what lets a + * caller build a `ResolvedAsset` without re-testing (or defaulting) the field + * the gate already tested. + */ +export type ResolvableAsset = Asset & { publicPath: string }; + +/** + * The one place that decides which `Asset` an evidence key resolves to. * - * Fix round 2 (I2 regression): gate 1 ("may this document reference this key - * at all") and gate 2 ("does some catalog actually carry it") must ask - * different questions. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)` - * for BOTH gates, so any `EVIDENCE` catalog row backing something other than - * an Asset (e.g. the pre-existing "Fetch Join Case" row that exists for - * QUESTION resolution targets, not media) passed gate 1 even though no Asset - * and no legacy key back it. Round 2 narrowed gate 1 to - * `supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))` -- - * still `evidenceCatalogEntryFor` under the hood, matching on - * `id || label || publicPath`. + * Every "can this key be rendered" question is `Boolean(findResolvableAsset(...))` + * and every "what does it render" answer is the `Asset` this returns, so the + * two cannot disagree. Fix rounds 1-3 each rebuilt the gate as a *separate* + * expression that merely agreed with the resolver on the inputs that round's + * tests used -- a catalog lookup over merged rows, then over synthesized rows, + * then `assets.some(a => P(a) && Q(a))` against a resolver doing + * `Q(first a satisfying P)`. The last pair still disagreed whenever two + * `READY` Assets shared one `assetKey` and the first had no `publicPath`: + * `some` found the second, `find` returned the first, and the document + * validated clean while the render model carried `publicPath: ""`. * - * Fix round 3 (same regression, third instance): `evidenceCatalogEntryFor` - * is gate 2's question, asked over fewer rows -- it is still not the same - * predicate as the resolver's actual success condition (`assetKey === key && - * managementStatus === "READY" && Boolean(publicPath)`), so a `READY` Asset - * with `publicPath: null`/`""`, or one whose `publicPath` happens to match - * `/media/${someOtherKey}.svg` for a key that isn't its own `assetKey`, - * still passed gate 1 while the resolver could not produce real pixels for - * it. Gate 1 must therefore be built directly from - * `supportsEvidenceKeyFromReadyAssets` below -- the resolver's own - * condition, not a catalog lookup -- never through - * `evidenceCatalogEntryFor`/`supportsEvidenceKeyIn`. Gate 2 keeps using the - * merged catalog via `evidenceCatalogEntryFor` as before. See - * `validate-working-copy.ts` and `adapters/mock/project-public-render-model.ts` - * for the compositions; `instant-preview.tsx` deliberately keeps gate 1 on - * the merged catalog (its resolver can never throw, so a loose gate there - * only ever degrades to a placeholder, and a strict assets-only gate was - * itself a regression -- see that file's comment). + * ## Which Asset wins when several share a key + * + * The contract calls `assetKey` immutable and forbids reuse after publication + * history, so two `Asset`s sharing one is a backend contract violation -- but + * "that cannot happen" is exactly the reasoning that let this bug recur three + * times, and a list assembled from paged `listAssets` responses can carry the + * shape regardless. The choice made here is therefore **total** (every key + * gets an answer) and **order-independent** (the answer does not depend on how + * the caller happened to sort or page the array): among the resolvable + * candidates, the most recently updated one wins, ties broken by the greater + * `id`. Newest-wins matches what a reader expects of a re-uploaded asset; + * `id` is a total, stable tiebreak so equal timestamps still yield one answer. + * Plain string comparison, not `localeCompare` -- both fields are ASCII + * (RFC 3339 timestamps, UUIDs) and must not vary with the ambient locale. + */ +export function findResolvableAsset( + assets: readonly Asset[], + key: string, +): ResolvableAsset | undefined { + let winner: ResolvableAsset | undefined; + for (const asset of assets) { + if (asset.assetKey !== key) continue; + if (asset.managementStatus !== "READY") continue; + if (!asset.publicPath) continue; + const candidate = asset as ResolvableAsset; + if (!winner || outranks(candidate, winner)) winner = candidate; + } + return winner; +} + +function outranks(candidate: ResolvableAsset, incumbent: ResolvableAsset): boolean { + if (candidate.updatedAt !== incumbent.updatedAt) { + return candidate.updatedAt > incumbent.updatedAt; + } + return candidate.id > incumbent.id; +} + +/** + * The evidence-key gate every caller uses. A key is referenceable when a real + * Asset resolves it, or when it is the caller's own legacy static key -- the + * one key that predates the Asset gateway and is served from a hardcoded + * registry instead. `presentation/` may not import `adapters/`, so each layer + * passes its own copy of that predicate rather than sharing an import; the + * composition itself lives here so it cannot drift between callers. + */ +export function supportsResolvableEvidenceKey( + assets: readonly Asset[], + isLegacyEvidenceKey: (key: string) => boolean, +): SupportsEvidenceKey { + return (key: string) => + Boolean(findResolvableAsset(assets, key)) || isLegacyEvidenceKey(key); +} + +/** + * Bridges the Asset system into the document catalog the projection's + * *catalog* check (a separate question: "does some catalog carry a row for + * this key at all") reads. Only `READY` Assets synthesize a row, so an asset + * under review never makes a key referenceable. + * + * `id` is deliberately not `asset.id`: the catalog check also matches on `id`, + * so reusing the real Asset UUID would let anyone who has seen it elsewhere + * type it directly as a directive key. Prefixed so it can never collide with + * a real `asset.id`. */ export function evidenceCatalogEntriesFromAssets( assets: readonly Asset[], @@ -52,13 +96,6 @@ export function evidenceCatalogEntriesFromAssets( return assets .filter((asset) => asset.managementStatus === "READY") .map((asset) => ({ - // Not `asset.id` (fix round 2): `evidenceCatalogEntryFor` also matches - // on `id`, so reusing the real Asset UUID here would let anyone who - // has seen that UUID elsewhere (an API response, an admin view) type - // it directly as a directive key and have it resolve — a path the - // Picker never produces (it always emits `assetKey`) and that widens - // the reference surface for no benefit. Prefixed so it can never - // collide with a real `asset.id` a caller might paste in. id: `asset-evidence:${asset.id}`, type: "EVIDENCE", label: asset.assetKey, @@ -66,44 +103,3 @@ export function evidenceCatalogEntriesFromAssets( dependencyRevision: asset.updatedAt, })); } - -/** - * `SupportsEvidenceKey` built from the same rule the catalog-entry gate - * uses. Deliberately kept -- `instant-preview.tsx`'s gate 1 still uses this - * over the merged catalog on purpose (see that file), but fix round 3 - * removed it from `validate-working-copy.ts` and - * `adapters/mock/project-public-render-model.ts`'s gate 1: it answers gate - * 2's question, not "can a real Asset resolve this key", and reusing it for - * gate 1 is what let this bug recur twice. - */ -export function supportsEvidenceKeyIn( - catalog: ReadonlyArray, -): SupportsEvidenceKey { - return (key: string) => Boolean(evidenceCatalogEntryFor(catalog, key)); -} - -/** - * Fix round 3. The resolver's own success condition, extracted so gate 1 can - * be built from the *exact* predicate that decides whether a key actually - * produces pixels, instead of a catalog lookup that can diverge from it - * (`evidenceCatalogEntryFor` matches on `id || label || publicPath` against - * whatever `evidenceCatalogEntriesFromAssets` happened to synthesize, which - * is not the same expression as "does some `READY` Asset's own `assetKey` - * equal this key and does it have real `publicPath`"). Two shapes where they - * disagreed: a `READY` Asset with `publicPath: null`/`""`, and a `READY` - * Asset whose `publicPath` happens to satisfy the legacy - * `/media/${someOtherKey}.svg` convention for a key that is not its own - * `assetKey`. Both passed the catalog-based gate while the resolver could - * not produce a real descriptor for them. - */ -export function supportsEvidenceKeyFromReadyAssets( - assets: readonly Asset[], -): SupportsEvidenceKey { - return (key: string) => - assets.some( - (asset) => - asset.assetKey === key && - asset.managementStatus === "READY" && - Boolean(asset.publicPath), - ); -} diff --git a/src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts b/src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts index 516cc2d..a2a7541 100644 --- a/src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts +++ b/src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts @@ -1,4 +1,5 @@ import type { Asset } from "../../../contracts/studio/contract.ts"; +import { findResolvableAsset } from "../../../domain/content-format/asset-evidence-catalog.ts"; import type { EvidenceAsset, ResolveEvidenceAsset, @@ -92,21 +93,22 @@ function resolveWith( return legacy ?? MISSING_EVIDENCE_ASSET; } -/** Instant Preview: resolves against the Asset list the editor has loaded. */ +/** + * Instant Preview: resolves against the Asset list the editor has loaded. + * `findResolvableAsset` is the single decision -- the same one the preview's + * key gate and its block-descriptor resolver make -- so the image shown here + * is always the Asset the gate accepted. Building a local `Map` here instead + * was last-wins where the descriptor resolver was first-wins, which meant two + * READY Assets sharing one `assetKey` could put one Asset's caption over + * another Asset's image. + */ export function createAssetCatalogResolver( assets: readonly Asset[], ): ResolveEvidenceAsset { - const byKey = new Map(); - for (const asset of assets) { - // QUARANTINED/REJECTED must never render on any surface. - if (asset.managementStatus !== "READY") continue; - byKey.set(asset.assetKey, asset); - } - return (key: string) => resolveWith(key, (lookupKey) => { - const asset = byKey.get(lookupKey); - if (!asset || asset.publicPath === null) return undefined; + const asset = findResolvableAsset(assets, lookupKey); + if (!asset) return undefined; return { assetId: asset.id, assetKey: asset.assetKey, diff --git a/src/features/tech-log/presentation/studio/components/instant-preview.tsx b/src/features/tech-log/presentation/studio/components/instant-preview.tsx index 24846fb..9a18898 100644 --- a/src/features/tech-log/presentation/studio/components/instant-preview.tsx +++ b/src/features/tech-log/presentation/studio/components/instant-preview.tsx @@ -2,7 +2,8 @@ import type { components } from "../../../contracts/studio/generated.ts"; import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts"; import { evidenceCatalogEntriesFromAssets, - supportsEvidenceKeyIn, + findResolvableAsset, + supportsResolvableEvidenceKey, } from "../../../domain/content-format/asset-evidence-catalog.ts"; import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts"; import { @@ -27,19 +28,15 @@ function isLegacyStaticEvidenceKey(key: string): boolean { } /** - * `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring - * model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset` - * for the pixels actually shown; that comes from `resolveEvidenceAsset` below - * (`createAssetCatalogResolver`). So this must never throw: a key the Asset Picker - * (Task 10) inserts, with no catalog match yet, still gets a placeholder descriptor - * instead of blanking the whole preview behind an error panel. + * Total -- never throws. Resolves through `findResolvableAsset`, the same + * function this file's key gate and `createAssetCatalogResolver` (which + * supplies the pixels) use, so the descriptor attached to a block and the + * image rendered for it always come from one Asset. */ function resolveAssetDescriptor(assets: readonly Asset[]) { return (key: string): ResolvedAsset => { - const asset = assets.find( - (candidate) => candidate.assetKey === key && candidate.managementStatus === "READY", - ); - if (asset?.publicPath) { + const asset = findResolvableAsset(assets, key); + if (asset) { return { assetId: asset.id, assetKey: asset.assetKey, @@ -94,27 +91,12 @@ export function InstantPreview({ draft, effectiveCatalog, { mode: "PREVIEW", publishedAt: null }, - // Gate 1 deliberately asks the SAME question gate 2 asks here - // (`supportsEvidenceKeyIn` over the merged catalog) -- unlike - // `validate-working-copy.ts`/`adapters/mock/project-public-render-model.ts`, - // where reusing gate 2's question for gate 1 was the bug across fix - // rounds 1-3 (see `asset-evidence-catalog.ts`'s module doc). The - // difference: `resolveAssetDescriptor` below is total and cannot - // throw, so a gate 1 that passes a key the resolver cannot fully - // resolve only ever degrades that one figure to a placeholder -- - // never a crash, and there is no port-contract to violate here. - // Fix round 2 narrowed this to an assets-only check (mirroring the - // mock's gate at the time), which was itself a regression: a CASE - // referencing an Asset the editor has not yet loaded -- outside the - // Picker's 50-item page, or simply because `assets` is still `[]` - // while `listAssets` is in flight on first mount -- failed gate 1 - // entirely and blanked the WHOLE preview, for every block in the - // document, not just the one unresolved reference. Reverted to the - // merged catalog (fix round 3): a key any part of the backend - // already considers legitimate (document catalog OR a loaded Asset) - // passes gate 1, and the resolver fills in real pixels when it has - // them or a placeholder when it does not. - supportsEvidenceKeyIn(effectiveCatalog), + // The same expression `validate-working-copy.ts` and the mock's + // preview projection use: a resolvable Asset, or the legacy static + // key. A key this rejects fails the projection, and the error panel + // below names it -- the same key `validateDocument` reports + // `EVIDENCE_UNSUPPORTED` for. + supportsResolvableEvidenceKey(assets, isLegacyStaticEvidenceKey), ), resolveAssetDescriptor(assets), ); diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx index e7a6cb4..420f458 100644 --- a/tests/features/tech-log/asset-picker.test.tsx +++ b/tests/features/tech-log/asset-picker.test.tsx @@ -16,6 +16,7 @@ import { stateForUploaded, } from "../../../src/features/tech-log/presentation/studio/components/asset-upload-dialog.tsx"; import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx"; +import { InstantPreview } from "../../../src/features/tech-log/presentation/studio/components/instant-preview.tsx"; import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx"; import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; import { createMockStudioGateway } from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; @@ -914,7 +915,14 @@ test("a READY asset whose publicPath satisfies a different key's legacy conventi // re-evaluated fresh inside createPreview against the now-mutated assets, // correctly rejects -- and that internal ContentFormatError must not leave // the port unwrapped. -test("idempotent() wraps a raw domain error into a StudioGatewayError, never lets it escape the port", async () => { +// +// Fix round 4 amends the expected code. Round 3 mapped this to +// STUDIO_UNAVAILABLE/500 with `retryable: true`, which invites a retry that +// fails identically -- the Asset is gone, the projection will refuse again. +// A deterministic content failure between validate and preview is exactly +// what VALIDATION_STALE/409 means, and it is not retryable without +// re-validating first. +test("idempotent() maps a deterministic content failure to VALIDATION_STALE, never lets a raw error escape the port", async () => { const asset = assetFixture({ assetKey: "race-check" }); const assets = new Map([[asset.assetKey, asset]]); const gateway = createMockStudioGateway({ assets }); @@ -947,20 +955,35 @@ test("idempotent() wraps a raw domain error into a StudioGatewayError, never let assets.delete("race-check"); + const expectStale = (error: unknown) => { + assert.ok( + error instanceof StudioGatewayError, + `expected a StudioGatewayError, got ${String(error)}`, + ); + assert.equal(error.code, "VALIDATION_STALE"); + assert.equal(error.status, 409); + assert.equal(error.retryable, false); + return true; + }; + await assert.rejects( gateway.createPreview( created.id, { expectedVersion: created.version, validationId: report.validationId }, { idempotencyKey: "race-preview" }, ), - (error: unknown) => { - assert.ok( - error instanceof StudioGatewayError, - `expected a StudioGatewayError, got ${String(error)}`, - ); - assert.equal(error.code, "STUDIO_UNAVAILABLE"); - return true; - }, + expectStale, + ); + + // Deterministic outcomes stay in the ledger: a same-key retry replays the + // same 409 rather than re-running work that cannot succeed. + await assert.rejects( + gateway.createPreview( + created.id, + { expectedVersion: created.version, validationId: report.validationId }, + { idempotencyKey: "race-preview" }, + ), + expectStale, ); }); @@ -1010,25 +1033,37 @@ test("EVIDENCE_NOT_FOUND is reachable through validateWorkingCopy directly: the ); }); -// Low note (reviewer): round 2's assets-only gate 1 in InstantPreview made a -// CASE referencing a document-catalog-backed key blank the WHOLE preview -// behind the error panel whenever that key's Asset was not (yet) in the -// loaded `assets` array -- outside the Picker's 50-item page, or simply -// because `assets` is still `[]` while `listAssets` is in flight. Fix round -// 3 reverted InstantPreview's gate 1 to the merged catalog; this pins that -// such a document now degrades the one unresolved figure to a placeholder -// instead of failing the whole preview. -test("Instant Preview degrades to a placeholder for a document-catalog-backed key with no loaded Asset, instead of blanking the whole preview", async () => { - const user = userEvent.setup(); - const gateway = createMockStudioGateway(); - const assetGateway = gatewayOf([]); +// --- Fix round 4 --- +// +// Rounds 1-3 each rebuilt gate 1 as a *separate expression* that happened to +// agree with the resolver on the inputs the round's tests used. They cannot +// agree in general, because they were never the same function. Round 4 +// collapses "can this key be rendered" and "which Asset does it render" into +// one exported decision, `findResolvableAsset`, and puts every caller +// (validation's gate, validation's decorative lookup, the mock projection's +// gate and resolver, InstantPreview's gate, resolver, and pixel resolver) on +// it. The tests below are the shapes that broke the round-3 pair. +// `MockStudioDependencies.assets` is keyed by whatever the caller chooses -- +// the gateway only ever reads `.values()`. Keying by `id` here is what lets a +// test express the shape the whole round is about: two Assets that share one +// `assetKey`. +function duplicateKeyAssets(...assets: Asset[]): Map { + return new Map(assets.map((asset) => [asset.id, asset])); +} + +async function previewFor( + assets: Map, + bodyMarkdown: string, + slugSuffix: string, +) { + const gateway = createMockStudioGateway({ assets }); const created = await gateway.createDocument( { kind: "CASE", - title: "카탈로그로만 뒷받침되는 근거 미리보기", - slug: "catalog-only-evidence-preview-check", - summary: "로드된 Asset 없이 카탈로그만으로 뒷받침되는 키의 미리보기 동작을 확인합니다.", + title: `중복 키 확인 (${slugSuffix})`, + slug: `duplicate-key-${slugSuffix}`, + summary: "하나의 assetKey를 공유하는 Asset이 둘일 때의 동작을 확인합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], @@ -1037,25 +1072,276 @@ test("Instant Preview degrades to a placeholder for a document-catalog-backed ke environment: "env", reproduction: "repro", lastVerifiedOn: "2026-08-14", - bodyMarkdown: `## 제목\n\n:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`, + bodyMarkdown, }, - { idempotencyKey: "catalog-only-evidence-preview-create" }, + { idempotencyKey: `duplicate-key-create-${slugSuffix}` }, + ); + const report = await gateway.validateDocument( + created.id, + { expectedVersion: created.version }, + { idempotencyKey: `duplicate-key-validate-${slugSuffix}` }, + ); + const preview = await gateway.createPreview( + created.id, + { expectedVersion: created.version, validationId: report.validationId }, + { idempotencyKey: `duplicate-key-preview-${slugSuffix}` }, + ); + const model = preview.renderModel; + assert.equal(model.kind, "CASE"); + const figure = model.kind === "CASE" + ? model.bodyBlocks.find((block) => block.type === "EVIDENCE_FIGURE") + : undefined; + assert.ok(figure && figure.type === "EVIDENCE_FIGURE", "expected an EVIDENCE_FIGURE block"); + return { report, asset: figure.asset }; +} + +// The third live instance the reviewer found: two READY Assets share one +// `assetKey`, the first carries `publicPath: null`, the second a real path. +// `∃a. P(a) ∧ Q(a)` says yes (the second one). `Q(first a satisfying P)` says +// no, and the resolver falls through to the generic placeholder -- so +// `validateDocument` returns VALID with zero issues, `createPreview` succeeds, +// and `publishDocument` would snapshot a render model carrying +// `publicPath: ""` with nothing anywhere reporting an error. +test("two READY assets sharing one assetKey: the gate and the resolver agree, and the preview never carries an empty publicPath", async () => { + const withoutPath = assetFixture({ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + assetKey: "dup-null-first", + publicPath: null, + updatedAt: "2026-08-14T00:00:00.000Z", + }); + const withPath = assetFixture({ + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", + assetKey: "dup-null-first", + publicPath: "/media/dup-null-first.svg", + updatedAt: "2026-08-14T00:00:01.000Z", + }); + + const { report, asset } = await previewFor( + duplicateKeyAssets(withoutPath, withPath), + ':::evidence key="dup-null-first" alt="근거" caption="근거" zoom="false"\n:::', + "null-first", ); + assert.equal(report.status, "VALID", JSON.stringify(report.issues)); + assert.equal(asset.publicPath, "/media/dup-null-first.svg"); + assert.equal(asset.assetId, withPath.id); +}); + +// Order-independence. The same two resolvable Assets in either array order +// must resolve to the same one; `find`-based first-wins picks whichever the +// caller happened to list first. +test("duplicate resolvable assets resolve to the same asset in either array order", async () => { + const older = assetFixture({ + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1", + assetKey: "dup-order", + publicPath: "/media/dup-order-older.svg", + updatedAt: "2026-08-14T00:00:00.000Z", + }); + const newer = assetFixture({ + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2", + assetKey: "dup-order", + publicPath: "/media/dup-order-newer.svg", + updatedAt: "2026-08-14T00:00:01.000Z", + }); + const body = ':::evidence key="dup-order" alt="근거" caption="근거" zoom="false"\n:::'; + + const forward = await previewFor(duplicateKeyAssets(older, newer), body, "order-forward"); + const reverse = await previewFor(duplicateKeyAssets(newer, older), body, "order-reverse"); + + assert.deepEqual(forward.asset, reverse.asset); + assert.equal(forward.asset.assetId, newer.id); + assert.equal(forward.asset.publicPath, "/media/dup-order-newer.svg"); +}); + +// The third rule with the same disease: `readyAssetsByKey` was built with +// `new Map(...)` (last-wins) while the resolver used `find` (first-wins), so +// EVIDENCE_ALT_REQUIRED could be judged against a different Asset than the +// one actually rendered. Here the rendered Asset is `decorative` (empty alt +// is legitimate for it) but the last-wins map judges the other one. +test("EVIDENCE_ALT_REQUIRED is judged against the asset that actually renders, not a different one sharing the key", async () => { + const rendered = assetFixture({ + id: "cccccccc-cccc-4ccc-8ccc-ccccccccccc1", + assetKey: "alt-mismatch", + publicPath: "/media/alt-mismatch-rendered.svg", + decorative: true, + updatedAt: "2026-08-14T00:00:02.000Z", + }); + const shadow = assetFixture({ + id: "cccccccc-cccc-4ccc-8ccc-ccccccccccc2", + assetKey: "alt-mismatch", + publicPath: "/media/alt-mismatch-shadow.svg", + decorative: false, + updatedAt: "2026-08-14T00:00:01.000Z", + }); + + const { report, asset } = await previewFor( + duplicateKeyAssets(rendered, shadow), + ':::evidence key="alt-mismatch" alt="" caption="근거" zoom="false"\n:::', + "alt-mismatch", + ); + + assert.ok( + !report.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), + JSON.stringify(report.issues), + ); + assert.equal(asset.assetId, rendered.id); + assert.equal(asset.decorative, true); +}); + +const PREVIEW_CATALOG = [ + { id: "topic-jpa", type: "TOPIC", label: "JPA", publicPath: "/topics/jpa", dependencyRevision: "r1" }, + { + id: "resolution-evidence", + type: "EVIDENCE", + label: "결론 근거", + publicPath: "/cases/some-case", + dependencyRevision: "r1", + }, +] as never[]; + +function caseDraft(bodyMarkdown: string) { + return { + kind: "CASE", + title: "미리보기 게이트 확인", + slug: "preview-gate-check", + summary: "요약", + topicId: "topic-jpa", + projectId: null, + relations: [], + problem: "문제", + conclusion: "결론", + environment: "env", + reproduction: "repro", + lastVerifiedOn: "2026-08-14", + bodyMarkdown, + } as never; +} + +function renderInstantPreview(bodyMarkdown: string, assets: readonly Asset[]) { render( - - gateway} createAssetGateway={() => assetGateway}> - + + createMockStudioGateway()}> + , ); +} - await screen.findByLabelText("본문 Markdown"); - await user.click(screen.getByRole("tab", { name: "즉시 미리보기" })); - const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" }); +// Round 3 loosened InstantPreview's gate back to the merged catalog, arguing +// the un-loaded-asset case would otherwise blank the preview. It blanks +// either way -- an un-loaded Asset contributes no catalog row either. All the +// looseness bought was keys the fetched EVIDENCE catalog carries: those +// rendered a caption with an empty gap and no message while validation said +// EVIDENCE_UNSUPPORTED. Every caller is now on the same expression, so the +// preview refuses exactly what validation refuses -- and says so. +test("Instant Preview refuses a key only the document catalog carries, exactly as mock validation does", () => { + const body = ':::evidence key="resolution-evidence" alt="근거" caption="근거" zoom="false"\n:::'; + renderInstantPreview(body, []); - assert.equal(within(panel).queryByRole("alert"), null); + const alert = screen.getByRole("alert"); + assert.match(alert.textContent ?? "", /resolution-evidence/); + + const report = validateWorkingCopy( + { ...(caseDraft(body) as object), id: "88888888-8888-4888-8888-888888888882", version: 1, updatedAt: "2026-08-14T00:00:00.000Z" } as never, + { + now: new Date("2026-08-14T01:00:00.000Z"), + validationId: "preview-gate-consistency", + dependencyRevision: "r1", + catalog: PREVIEW_CATALOG, + documents: [], + assets: [], + }, + ); assert.ok( - within(panel).getByRole("heading", { level: 1, name: "카탈로그로만 뒷받침되는 근거 미리보기" }), + report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"), + JSON.stringify(report.issues), ); }); + +// The pixels come from `createAssetCatalogResolver` (a last-wins `Map`) while +// the block descriptor came from `resolveAssetDescriptor` (a first-wins +// `find`) -- a fourth expression in the same path, disagreeing with the third +// whenever two READY Assets share a key. +test("Instant Preview renders the same asset the gate and the descriptor picked when two assets share a key", () => { + const older = assetFixture({ + id: "dddddddd-dddd-4ddd-8ddd-ddddddddddd1", + assetKey: "dup-pixels", + publicPath: "/media/dup-pixels-older.svg", + updatedAt: "2026-08-14T00:00:00.000Z", + }); + const newer = assetFixture({ + id: "dddddddd-dddd-4ddd-8ddd-ddddddddddd2", + assetKey: "dup-pixels", + publicPath: "/media/dup-pixels-newer.svg", + updatedAt: "2026-08-14T00:00:01.000Z", + }); + + renderInstantPreview( + ':::evidence key="dup-pixels" alt="중복 키 그림" caption="근거" zoom="false"\n:::', + [newer, older], + ); + + assert.equal(screen.queryByRole("alert"), null); + assert.equal( + screen.getByAltText("중복 키 그림").getAttribute("src"), + "/media/dup-pixels-newer.svg", + ); +}); + +// The idempotency ledger must not freeze an *uncharacterized* internal +// failure: the port reports it as retryable, so a same-key retry has to +// actually re-run the work instead of replaying the cached 500. +test("a same-key retry re-runs after an uncharacterized internal failure instead of replaying a cached 500", async () => { + let remainingFailures = 1; + const gateway = createMockStudioGateway({ + dependencyRevision: { + current: () => { + if (remainingFailures > 0) { + remainingFailures -= 1; + throw new Error("의존성 리비전을 읽지 못했습니다."); + } + return "r1"; + }, + }, + }); + + const created = await gateway.createDocument( + { + kind: "CASE", + title: "일시적 내부 실패 재시도", + slug: "transient-internal-failure-retry", + summary: "성격이 규명되지 않은 내부 실패는 원장에 얼려두면 안 됩니다.", + topicId: FIXTURE_IDS.topicJpa, + projectId: FIXTURE_IDS.projectBackend, + relations: [], + problem: "문제", + conclusion: "결론", + environment: "env", + reproduction: "repro", + lastVerifiedOn: "2026-08-14", + bodyMarkdown: "## 본문\n\n내용입니다.", + }, + { idempotencyKey: "transient-create" }, + ); + + await assert.rejects( + gateway.validateDocument( + created.id, + { expectedVersion: created.version }, + { idempotencyKey: "transient-validate" }, + ), + (error: unknown) => { + assert.ok(error instanceof StudioGatewayError, `expected a StudioGatewayError, got ${String(error)}`); + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + assert.equal(error.retryable, true); + return true; + }, + ); + + const report = await gateway.validateDocument( + created.id, + { expectedVersion: created.version }, + { idempotencyKey: "transient-validate" }, + ); + assert.equal(report.documentId, created.id); +}); diff --git a/tests/features/tech-log/content-format.test.ts b/tests/features/tech-log/content-format.test.ts index c4596ee..4e3298a 100644 --- a/tests/features/tech-log/content-format.test.ts +++ b/tests/features/tech-log/content-format.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { isSupportedEvidenceKey } from "../../../src/features/tech-log/adapters/static/evidence-assets.ts"; -import { evidenceCatalogEntriesFromAssets } from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts"; +import { + evidenceCatalogEntriesFromAssets, + supportsResolvableEvidenceKey, +} from "../../../src/features/tech-log/domain/content-format/asset-evidence-catalog.ts"; import { ContentFormatError, parseCaseContent, @@ -15,27 +18,21 @@ import type { components } from "../../../src/features/tech-log/contracts/studio import type { Asset } from "../../../src/features/tech-log/contracts/studio/contract.ts"; /** - * Fix round 2. Mirrors the gate-1 composition every production caller now - * uses (`validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`, - * `instant-preview.tsx`): the legacy key, or an actually-loaded Asset backs - * it -- never "is there any EVIDENCE catalog row for it" (that's gate 2, - * `evidenceCatalogEntryFor`, a separate question `projectWorkingCopy` asks - * of its own `catalog` argument). Round 1 collapsed gate 1 into gate 2 in - * every production caller, which let a real EVIDENCE catalog row backing - * something other than media (e.g. a QUESTION resolution-target row) pass - * gate 1 with no Asset and no legacy key behind it. Tests below pass no - * `assets`, so this reduces to the legacy check alone for them -- the same - * outcome bare `isSupportedEvidenceKey` gave before, just now visibly tied - * to the real composition instead of standing in for it by coincidence. + * Fix round 4. Calls the production composition itself + * (`supportsResolvableEvidenceKey`) rather than re-deriving it: every caller + * -- `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`, + * `instant-preview.tsx` -- builds its key gate from this exact function, so a + * caller drifting away from it now shows up here too. The question is "does a + * resolvable Asset (or the legacy static key) back this", never "is there any + * EVIDENCE catalog row for it" -- that is the projection's separate catalog + * check against its own `catalog` argument. Tests below that pass no `assets` + * reduce to the legacy check alone. */ function supportsEvidenceKey( key: string, assets: readonly Asset[] = [], ): boolean { - return ( - isSupportedEvidenceKey(key) || - Boolean(evidenceCatalogEntryFor(evidenceCatalogEntriesFromAssets(assets), key)) - ); + return supportsResolvableEvidenceKey(assets, isSupportedEvidenceKey)(key); } const rich = `## 측정 결과 {#measurements} diff --git a/tests/features/tech-log/evidence-key-agreement.test.ts b/tests/features/tech-log/evidence-key-agreement.test.ts new file mode 100644 index 0000000..b704562 --- /dev/null +++ b/tests/features/tech-log/evidence-key-agreement.test.ts @@ -0,0 +1,234 @@ +// 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 { + 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")}`); +});