From 073fda87ebefd9f7773645ad7451ffd3f7901236 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 06:55:06 +0900 Subject: [PATCH] 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) --- .../adapters/mock/mock-studio-gateway.ts | 60 ++- .../mock/project-public-render-model.ts | 59 +-- .../adapters/mock/validate-working-copy.ts | 55 +-- .../content-format/asset-evidence-catalog.ts | 168 ++++----- .../shared/public-render/asset-resolvers.ts | 22 +- .../studio/components/instant-preview.tsx | 46 +-- tests/features/tech-log/asset-picker.test.tsx | 354 ++++++++++++++++-- .../features/tech-log/content-format.test.ts | 31 +- .../tech-log/evidence-key-agreement.test.ts | 234 ++++++++++++ 9 files changed, 754 insertions(+), 275 deletions(-) create mode 100644 tests/features/tech-log/evidence-key-agreement.test.ts 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")}`); +});