From 783e9b2cf1b19aa27a8599e599872de90a4ac732 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 06:24:46 +0900 Subject: [PATCH] fix: rebuild gate 1 from the resolver's own predicate, not a catalog lookup Fix round 3 (review of 7ff9728): Round 2's gate 1 (supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))) still routed through evidenceCatalogEntryFor, matching on id || label || publicPath -- gate 2's question, asked over fewer rows, not the resolver's actual success condition (assetKey === key && managementStatus === "READY" && Boolean(publicPath)). Two Asset shapes made the two predicates disagree: 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 isn't its own assetKey. Both passed gate 1 while the resolver could not produce real pixels, reproducing the validateDocument-VALID / createPreview-throws-raw-Error disagreement a second time. Rebuilt gate 1 in validate-working-copy.ts and adapters/mock/project-public-render-model.ts directly from a new domain function, supportsEvidenceKeyFromReadyAssets, that mirrors the resolver's exact condition -- never through evidenceCatalogEntryFor again. Gate 2 keeps reading the merged catalog. Made the mock resolver total (returns a placeholder instead of throwing, matching instant-preview.tsx's resolver), removing a comment that asserted an invariant the code did not hold. Wrapped mock-studio-gateway.ts's idempotent() so any non-StudioGatewayError that reaches its catch is normalized before crossing the port -- closing the class generally, not just this instance. Reverted instant-preview.tsx's own gate 1 to the merged catalog (unlike the mock adapters, its resolver is provably total, so a loose gate there only ever degrades to a placeholder) -- round 2's narrowing there was a separate regression: a CASE referencing a document-catalog-backed key outside the editor's currently-loaded Asset list blanked the entire preview instead of degrading one figure. Pinned both slip-through shapes failing on both mock paths with the thrown/rejected error's type asserted (StudioGatewayError, never a raw Error), pinned idempotent()'s new wrapping via a validate/preview race, pinned EVIDENCE_NOT_FOUND as reachable through validateWorkingCopy directly, and pinned Instant Preview's graceful degradation. Co-Authored-By: Claude Opus 5 (1M context) --- .../adapters/mock/mock-studio-gateway.ts | 22 +- .../mock/project-public-render-model.ts | 77 +++-- .../adapters/mock/validate-working-copy.ts | 35 ++- .../content-format/asset-evidence-catalog.ts | 80 ++++-- .../studio/components/instant-preview.tsx | 41 +-- tests/features/tech-log/asset-picker.test.tsx | 271 ++++++++++++++++++ 6 files changed, 449 insertions(+), 77 deletions(-) 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 acd9f77..5b9230d 100644 --- a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -86,7 +86,27 @@ export function createMockStudioGateway(supplied: Partial { const value = state.documents.get(id); if (!value) throw gatewayProblem(404, "DOCUMENT_NOT_FOUND", `Document ${id} was not found.`); return value; }; diff --git a/src/features/tech-log/adapters/mock/project-public-render-model.ts b/src/features/tech-log/adapters/mock/project-public-render-model.ts index d8b3150..4f8dd92 100644 --- a/src/features/tech-log/adapters/mock/project-public-render-model.ts +++ b/src/features/tech-log/adapters/mock/project-public-render-model.ts @@ -1,7 +1,8 @@ +import type { components } from "../../contracts/studio/generated.ts"; import type { Asset } from "../../contracts/studio/contract.ts"; import { evidenceCatalogEntriesFromAssets, - supportsEvidenceKeyIn, + supportsEvidenceKeyFromReadyAssets, } from "../../domain/content-format/asset-evidence-catalog.ts"; import { projectWorkingCopy as projectWorkingCopyWithEvidence, @@ -12,6 +13,7 @@ import { resolveEvidenceAssetDescriptor, } from "../static/evidence-assets.ts"; +type ResolvedAsset = components["schemas"]["ResolvedAsset"]; type ProjectArguments = Parameters; // The domain projection has no asset catalog access, so a CASE projection's @@ -23,30 +25,30 @@ type ProjectArguments = Parameters; // `validateDocument` about which evidence keys exist), so it resolves the // descriptor here to produce a genuine, fully-resolved `PublicRenderModel`. // -// Fix round 2 (I2 regression). Gate 1 (`supportsEvidenceKey` below) must ask -// "does a real Asset (or the legacy key) back this", not "is there any -// EVIDENCE catalog row for it" -- `catalog` carries EVIDENCE rows unrelated -// to media (e.g. a QUESTION resolution-target row). Handing gate 1 the -// merged catalog let a key backed by nothing pass the projection and reach -// the resolver below, whose own `isSupportedEvidenceKey`-only fallback then -// threw a raw, unwrapped `Error` -- the disagreement in the opposite -// direction from I2 (preview crashing instead of `validateDocument` -// rejecting), and a contract violation on top (`StudioGatewayError` is what -// may leave this port, never a bare `Error`). With gate 1 narrowed to -// asset-backing/legacy only, any key the resolver would fail to resolve was -// already rejected before `projectWorkingCopyWithEvidence` returns, so the -// resolver's fallback throw below is unreachable for every key that made it -// this far. +// 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. export function projectWorkingCopy( input: ProjectArguments[0], catalog: ProjectArguments[1], context: ProjectArguments[2], assets: readonly Asset[] = [], ) { - const assetEntries = evidenceCatalogEntriesFromAssets(assets); - const evidenceCatalog = [...catalog, ...assetEntries]; + const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)]; const supportsEvidenceKey = (key: string) => - isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key); + isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(assets)(key); const model = projectWorkingCopyWithEvidence( input, evidenceCatalog, @@ -54,7 +56,24 @@ export function projectWorkingCopy( supportsEvidenceKey, ); - return resolveCaseEvidenceAssets(model, (key) => { + 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. + */ +function resolveAssetDescriptor(assets: readonly Asset[]) { + return (key: string): ResolvedAsset => { const asset = assets.find( (candidate) => candidate.assetKey === key && candidate.managementStatus === "READY", ); @@ -69,13 +88,17 @@ export function projectWorkingCopy( decorative: asset.decorative, }; } - if (!isSupportedEvidenceKey(key)) { - // Defensive only -- gate 1 above already rejects any key that would - // reach here without a resolvable Asset or the legacy key. Kept as a - // guard rather than removed so a future gate regression fails loudly - // here too, not silently. - throw new Error(`Unknown local evidence asset: ${key}`); + if (isSupportedEvidenceKey(key)) { + return resolveEvidenceAssetDescriptor(key); } - return resolveEvidenceAssetDescriptor(key); - }); + return { + assetId: "00000000-0000-4000-8000-000000000000", + assetKey: key, + mediaType: "application/octet-stream", + publicPath: "", + width: null, + height: null, + decorative: true, + }; + }; } 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 207d6d3..7d1e27c 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,7 @@ import type { components } from "../../contracts/studio/generated.ts"; import type { Asset, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts"; import { evidenceCatalogEntriesFromAssets, - supportsEvidenceKeyIn, + supportsEvidenceKeyFromReadyAssets, } 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"; @@ -29,6 +29,11 @@ export type ValidationDependencies = { * `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. */ assets: ReadonlyArray; }; @@ -182,19 +187,23 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요."); if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요."); else try { - const assetEntries = evidenceCatalogEntriesFromAssets(dependencies.assets); - const evidenceCatalog = [...dependencies.catalog, ...assetEntries]; - // Fix round 2 (I2 regression). Gate 1 must ask "does a real Asset (or - // the legacy key) back this" -- NOT "is there any EVIDENCE catalog row - // for it". `dependencies.catalog` carries EVIDENCE rows unrelated to - // media (e.g. a QUESTION resolution-target row), so handing gate 1 the - // merged catalog (as fix round 1 did) let a key backed by nothing - // resolve here while `createPreview`'s resolver -- which only ever - // knew about the legacy key and real Assets -- threw a raw, unwrapped - // Error. Gate 2 (`evidenceCatalogEntryFor` below) still reads the - // merged catalog; only gate 1 narrows. + 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) || supportsEvidenceKeyIn(assetEntries)(key); + isSupportedEvidenceKey(key) || supportsEvidenceKeyFromReadyAssets(dependencies.assets)(key); const readyAssetsByKey = new Map(dependencies.assets.filter((asset) => asset.managementStatus === "READY").map((asset) => [asset.assetKey, asset])); for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") { if (!supportsEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`); 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 26df8eb..5057d87 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 @@ -17,22 +17,34 @@ type CatalogEntry = components["schemas"]["CatalogEntry"]; * preview perfectly and then fail validation. Both paths now call this one * function instead. * - * Fix round 2 (I2 regression): this module supplies the *building blocks* for - * gate 1 (`supportsEvidenceKey` — "may this document reference this key at - * all") and gate 2 (`evidenceCatalogEntryFor` — "does some catalog actually - * carry it"); it deliberately does not hand callers a single combined - * predicate. Round 1 called `supportsEvidenceKeyIn(mergedCatalog)` for BOTH - * gates, which made gate 1 ask the identical question gate 2 already asks — - * 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. Callers must instead compose gate 1 from *asset-backing only*: - * `(key) => isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))(key)`, - * then hand gate 2 the merged catalog (`[...documentCatalog, - * ...evidenceCatalogEntriesFromAssets(assets)]`) separately. See - * `validate-working-copy.ts`, `adapters/mock/project-public-render-model.ts`, - * and `instant-preview.tsx` for the three compositions (each has its own - * notion of "legacy key" per its layer). + * 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`. + * + * 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). */ export function evidenceCatalogEntriesFromAssets( assets: readonly Asset[], @@ -55,9 +67,43 @@ export function evidenceCatalogEntriesFromAssets( })); } -/** `SupportsEvidenceKey` built from the same rule the catalog-entry gate uses. */ +/** + * `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/studio/components/instant-preview.tsx b/src/features/tech-log/presentation/studio/components/instant-preview.tsx index 14fabdb..24846fb 100644 --- a/src/features/tech-log/presentation/studio/components/instant-preview.tsx +++ b/src/features/tech-log/presentation/studio/components/instant-preview.tsx @@ -9,7 +9,6 @@ import { projectWorkingCopy, resolveCaseEvidenceAssets, } from "../../../domain/content-format/project-public-render-model.ts"; -import type { SupportsEvidenceKey } from "../../../domain/public-render-content.ts"; import { createAssetCatalogResolver } from "../../shared/public-render/asset-resolvers.ts"; import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; import { useStudio } from "../use-studio.ts"; @@ -21,28 +20,12 @@ type PublicRenderModel = components["schemas"]["PublicRenderModel"]; /** * The one legacy key predates the Asset gateway entirely: the document * catalog carries a fixture `EVIDENCE` row for it, but no `Asset` record - * backs it, so `assets` never resolves it. This is also gate 1's "legacy" - * half now (fix round 2) -- see `supportsEvidenceKeyForAssets` below. + * backs it, so `assets` never resolves it. */ function isLegacyStaticEvidenceKey(key: string): boolean { return key === "fetch-strategy-boundary"; } -/** - * Gate 1: "may this document reference this key at all" -- the legacy key, - * or a `READY` Asset actually backs it. Deliberately narrower than gate 2 - * (the merged-catalog lookup `projectWorkingCopy` runs internally): fix - * round 1 collapsed this into `supportsEvidenceKeyIn(effectiveCatalog)`, - * which made gate 1 ask the same question as gate 2 and let any `EVIDENCE` - * catalog row backing something other than an Asset (e.g. a QUESTION - * resolution-target row) pass gate 1 with no Asset and no legacy key behind - * it. See `asset-evidence-catalog.ts`'s module doc for the shared shape. - */ -function supportsEvidenceKeyForAssets(assets: readonly Asset[]): SupportsEvidenceKey { - const assetOnly = supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets)); - return (key: string) => isLegacyStaticEvidenceKey(key) || assetOnly(key); -} - /** * `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring * model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset` @@ -111,7 +94,27 @@ export function InstantPreview({ draft, effectiveCatalog, { mode: "PREVIEW", publishedAt: null }, - supportsEvidenceKeyForAssets(assets), + // 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), ), resolveAssetDescriptor(assets), ); diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx index c03baf7..e7a6cb4 100644 --- a/tests/features/tech-log/asset-picker.test.tsx +++ b/tests/features/tech-log/asset-picker.test.tsx @@ -20,6 +20,7 @@ import { StudioProvider } from "../../../src/features/tech-log/presentation/stud 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"; import { projectWorkingCopy as mockProjectWorkingCopy } 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 { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; import { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.ts"; import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts"; @@ -788,3 +789,273 @@ test("EVIDENCE_ALT_REQUIRED respects Asset.decorative -- empty alt passes for a JSON.stringify(informativeReport.issues), ); }); + +// --- Fix round 3 --- + +// Round 2's gate 1 (`supportsEvidenceKeyIn(evidenceCatalogEntriesFromAssets(assets))`) +// still routed through `evidenceCatalogEntryFor` (id || label || publicPath), +// which is gate 2's question over fewer rows -- not the resolver's actual +// success condition (`assetKey === key && READY && publicPath truthy`). Two +// Asset shapes make the two predicates disagree; both are exercised through +// `MockStudioDependencies.assets` injection directly, the same way a test or +// harness (or a future adapter) could construct one, bypassing the mock +// uploader (which always sets a real, matching `publicPath` and so cannot +// produce either shape itself). +function assetFixture(overrides: Partial): Asset { + return { + id: "77777777-7777-4777-8777-777777777771", + assetKey: "shape-check", + kind: "IMAGE", + mediaType: "image/svg+xml", + originalFilename: "shape.svg", + byteSize: 10, + width: null, + height: null, + altText: null, + decorative: false, + managementStatus: "READY", + publicPath: "/media/shape-check.svg", + usageCount: 0, + version: 1, + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + ...overrides, + }; +} + +async function expectBothPathsRejectSlipThrough( + assets: Map, + directiveKey: string, + label: string, +) { + const gateway = createMockStudioGateway({ assets }); + + const created = await gateway.createDocument( + { + kind: "CASE", + title: `slip-through 확인 (${label})`, + slug: `slip-through-${label}`, + summary: "gate 1과 resolver가 다른 조건을 쓰면 일어나는 문제를 확인합니다.", + topicId: FIXTURE_IDS.topicJpa, + projectId: FIXTURE_IDS.projectBackend, + relations: [], + problem: "문제", + conclusion: "결론", + environment: "env", + reproduction: "repro", + lastVerifiedOn: "2026-08-14", + bodyMarkdown: `:::evidence key="${directiveKey}" alt="근거" caption="근거" zoom="false"\n:::`, + }, + { idempotencyKey: `slip-through-create-${label}` }, + ); + + const report = await gateway.validateDocument( + created.id, + { expectedVersion: created.version }, + { idempotencyKey: `slip-through-validate-${label}` }, + ); + assert.equal(report.status, "INVALID", JSON.stringify(report.issues)); + assert.ok( + report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"), + JSON.stringify(report.issues), + ); + + await assert.rejects( + gateway.createPreview( + created.id, + { expectedVersion: created.version, validationId: report.validationId }, + { idempotencyKey: `slip-through-preview-${label}` }, + ), + (error: unknown) => { + assert.ok( + error instanceof StudioGatewayError, + `expected a StudioGatewayError, got ${String(error)}`, + ); + assert.equal(error.code, "VALIDATION_STALE"); + return true; + }, + ); +} + +for (const [publicPath, label] of [ + [null, "null-path"], + ["", "empty-path"], +] as const) { + test(`a READY asset with a ${label === "null-path" ? "null" : "empty-string"} publicPath fails both mock paths, never as a raw Error`, async () => { + const key = `${label}-check`; + const asset = assetFixture({ assetKey: key, publicPath }); + await expectBothPathsRejectSlipThrough(new Map([[asset.assetKey, asset]]), key, label); + }); +} + +test("a READY asset whose publicPath satisfies a different key's legacy convention fails both mock paths, never as a raw Error", async () => { + // assetKey "real-owner" legitimately owns "/media/real-owner.svg", but its + // publicPath is set to "/media/decoy-key.svg" instead -- which happens to + // satisfy `evidenceCatalogEntryFor`'s legacy `/media/${key}.svg` match for + // the UNRELATED key "decoy-key", a key this asset does not own + // (`assetKey !== "decoy-key"`). The resolver requires `assetKey === key`, + // so it can never resolve "decoy-key" from this asset. + const asset = assetFixture({ assetKey: "real-owner", publicPath: "/media/decoy-key.svg" }); + await expectBothPathsRejectSlipThrough( + new Map([[asset.assetKey, asset]]), + "decoy-key", + "mismatched-path", + ); +}); + +// idempotent()'s new wrapping (part 3 of the ruling) is not exercised by the +// shapes above -- those are caught by validateDocument's now-correct gate, +// so createPreview only ever reaches its pre-existing VALIDATION_STALE +// guard, itself already a StudioGatewayError. To exercise the *new* +// wrapping, force a genuine internal disagreement: validate while the Asset +// still backs the key (VALID), then remove it before createPreview, without +// bumping dependencyRevision -- a narrow but real staleness gap the mock's +// existing guard does not close on its own. projectWorkingCopy's gate, +// 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 () => { + const asset = assetFixture({ assetKey: "race-check" }); + const assets = new Map([[asset.assetKey, asset]]); + const gateway = createMockStudioGateway({ assets }); + + const created = await gateway.createDocument( + { + kind: "CASE", + title: "검증과 미리보기 사이의 경쟁 상태", + slug: "validate-preview-race-check", + summary: "검증 이후 Asset이 사라지면 gate가 새로 평가되어 거부해야 합니다.", + topicId: FIXTURE_IDS.topicJpa, + projectId: FIXTURE_IDS.projectBackend, + relations: [], + problem: "문제", + conclusion: "결론", + environment: "env", + reproduction: "repro", + lastVerifiedOn: "2026-08-14", + bodyMarkdown: `:::evidence key="race-check" alt="근거" caption="근거" zoom="false"\n:::`, + }, + { idempotencyKey: "race-create" }, + ); + + const report = await gateway.validateDocument( + created.id, + { expectedVersion: created.version }, + { idempotencyKey: "race-validate" }, + ); + assert.equal(report.status, "VALID", JSON.stringify(report.issues)); + + assets.delete("race-check"); + + 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; + }, + ); +}); + +// EVIDENCE_NOT_FOUND has been unreachable through `createMockStudioGateway()` +// on its own: the fixture catalog it always loads already carries a row for +// the one legacy key, so gate 1 passing (via the legacy key) always implied +// gate 2 passing too. Pinned directly against `validateWorkingCopy` instead, +// with a hand-built catalog that omits the row on purpose. +test("EVIDENCE_NOT_FOUND is reachable through validateWorkingCopy directly: the legacy key with no catalog EVIDENCE row", () => { + const document = { + id: "88888888-8888-4888-8888-888888888881", + version: 1, + updatedAt: "2026-08-14T00:00:00.000Z", + kind: "CASE", + title: "레거시 키인데 카탈로그에 없음", + slug: "legacy-key-no-catalog-row", + summary: "레거시 키는 gate 1을 통과하지만 카탈로그에 행이 없으면 gate 2가 거부해야 합니다.", + topicId: FIXTURE_IDS.topicJpa, + projectId: FIXTURE_IDS.projectBackend, + relations: [], + problem: "문제", + conclusion: "결론", + environment: "env", + reproduction: "repro", + lastVerifiedOn: "2026-08-14", + bodyMarkdown: ':::evidence key="fetch-strategy-boundary" alt="근거" caption="근거" zoom="false"\n:::', + }; + + const report = validateWorkingCopy(document as never, { + now: new Date("2026-08-14T01:00:00.000Z"), + validationId: "evidence-not-found-check", + dependencyRevision: "r1", + catalog: [ + { id: FIXTURE_IDS.topicJpa, type: "TOPIC", label: "JPA", dependencyRevision: "r1" }, + { id: FIXTURE_IDS.projectBackend, type: "PROJECT", label: "Backend", dependencyRevision: "r1" }, + // Deliberately no EVIDENCE row for "fetch-strategy-boundary" -- gate 1 + // (the legacy key) passes, gate 2 (this catalog) must not. + ] as never, + documents: [document as never], + assets: [], + }); + + assert.equal(report.status, "INVALID", JSON.stringify(report.issues)); + assert.ok( + report.issues.some((issue) => issue.code === "EVIDENCE_NOT_FOUND"), + JSON.stringify(report.issues), + ); +}); + +// 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([]); + + const created = await gateway.createDocument( + { + kind: "CASE", + title: "카탈로그로만 뒷받침되는 근거 미리보기", + slug: "catalog-only-evidence-preview-check", + summary: "로드된 Asset 없이 카탈로그만으로 뒷받침되는 키의 미리보기 동작을 확인합니다.", + topicId: FIXTURE_IDS.topicJpa, + projectId: FIXTURE_IDS.projectBackend, + relations: [], + problem: "문제", + conclusion: "결론", + environment: "env", + reproduction: "repro", + lastVerifiedOn: "2026-08-14", + bodyMarkdown: `## 제목\n\n:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`, + }, + { idempotencyKey: "catalog-only-evidence-preview-create" }, + ); + + render( + + gateway} createAssetGateway={() => assetGateway}> + + + , + ); + + await screen.findByLabelText("본문 Markdown"); + await user.click(screen.getByRole("tab", { name: "즉시 미리보기" })); + const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" }); + + assert.equal(within(panel).queryByRole("alert"), null); + assert.ok( + within(panel).getByRole("heading", { level: 1, name: "카탈로그로만 뒷받침되는 근거 미리보기" }), + ); +});