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 15625de..d8b3150 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 @@ -22,18 +22,36 @@ type ProjectArguments = Parameters; // and Instant Preview use, so this mock's own `createPreview` agrees with // `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. export function projectWorkingCopy( input: ProjectArguments[0], catalog: ProjectArguments[1], context: ProjectArguments[2], assets: readonly Asset[] = [], ) { - const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)]; + const assetEntries = evidenceCatalogEntriesFromAssets(assets); + const evidenceCatalog = [...catalog, ...assetEntries]; + const supportsEvidenceKey = (key: string) => + isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(key); const model = projectWorkingCopyWithEvidence( input, evidenceCatalog, context, - supportsEvidenceKeyIn(evidenceCatalog), + supportsEvidenceKey, ); return resolveCaseEvidenceAssets(model, (key) => { @@ -52,6 +70,10 @@ export function projectWorkingCopy( }; } 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}`); } return resolveEvidenceAssetDescriptor(key); 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 6809bf4..207d6d3 100644 --- a/src/features/tech-log/adapters/mock/validate-working-copy.ts +++ b/src/features/tech-log/adapters/mock/validate-working-copy.ts @@ -6,6 +6,7 @@ import { } 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"; +import { isSupportedEvidenceKey } from "../static/evidence-assets.ts"; type CatalogEntry = components["schemas"]["CatalogEntry"]; type WorkingCopyInput = components["schemas"]["WorkingCopyInput"]; @@ -23,6 +24,11 @@ export type ValidationDependencies = { * failed validation. Merged into `catalog` via * `evidenceCatalogEntriesFromAssets`, the same function Instant Preview * uses, so both paths agree on which keys a document may reference. + * + * 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. */ assets: ReadonlyArray; }; @@ -176,8 +182,19 @@ 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 evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)]; - const supportsEvidenceKey = supportsEvidenceKeyIn(evidenceCatalog); + 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 supportsEvidenceKey = (key: string) => + isSupportedEvidenceKey(key) || supportsEvidenceKeyIn(assetEntries)(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 44a4d46..26df8eb 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,14 +17,22 @@ type CatalogEntry = components["schemas"]["CatalogEntry"]; * preview perfectly and then fail validation. Both paths now call this one * function instead. * - * Every `EVIDENCE` `CatalogEntry` the document catalog issues today already - * carries the referenced key in `label` (see the one pre-existing fixture: - * `{ type: "EVIDENCE", label: "fetch-strategy-boundary" }`), so a `READY` - * Asset is mapped the same way: `label` <- `assetKey`. A key backed by no - * loaded `READY` Asset and no catalog row synthesizes no entry here, so - * `evidenceCatalogEntryFor` (the domain's one matching rule, shared by both - * gates) still rejects it -- this cannot forge a pass for a dangling - * reference. + * 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). */ export function evidenceCatalogEntriesFromAssets( assets: readonly Asset[], @@ -32,7 +40,14 @@ export function evidenceCatalogEntriesFromAssets( return assets .filter((asset) => asset.managementStatus === "READY") .map((asset) => ({ - id: asset.id, + // 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, publicPath: asset.publicPath ?? undefined, 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 1cfbe40..14fabdb 100644 --- a/src/features/tech-log/presentation/studio/components/instant-preview.tsx +++ b/src/features/tech-log/presentation/studio/components/instant-preview.tsx @@ -9,6 +9,7 @@ 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"; @@ -19,16 +20,29 @@ type PublicRenderModel = components["schemas"]["PublicRenderModel"]; /** * The one legacy key predates the Asset gateway entirely: the document - * catalog carries a fixture `EVIDENCE` row for it (so the gates above pass - * it), but no `Asset` record backs it, so `assets` never resolves it. Kept - * narrow and separate from `supportsEvidenceKeyIn` -- that function decides - * whether a document is *allowed* to reference a key; this one only fills in - * the one pre-existing key's pixels when nothing else can. + * 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. */ 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` @@ -97,7 +111,7 @@ export function InstantPreview({ draft, effectiveCatalog, { mode: "PREVIEW", publishedAt: null }, - supportsEvidenceKeyIn(effectiveCatalog), + supportsEvidenceKeyForAssets(assets), ), resolveAssetDescriptor(assets), ); diff --git a/tests/features/tech-log/asset-picker.test.tsx b/tests/features/tech-log/asset-picker.test.tsx index 520540f..c03baf7 100644 --- a/tests/features/tech-log/asset-picker.test.tsx +++ b/tests/features/tech-log/asset-picker.test.tsx @@ -19,6 +19,7 @@ import { DocumentEditorScreen } from "../../../src/features/tech-log/presentatio 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"; +import { projectWorkingCopy as mockProjectWorkingCopy } from "../../../src/features/tech-log/adapters/mock/project-public-render-model.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"; @@ -609,3 +610,181 @@ test("a key backed by no asset and no catalog entry still fails mock validation" ); assert.ok(report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED")); }); + +// --- Fix round 2 --- + +// Round 1's `supportsEvidenceKeyIn(mergedCatalog)` made gate 1 ask the exact +// question gate 2 asks, so a real `EVIDENCE` catalog row backing something +// other than media (fixtures.ts:37 -- a row that exists for QUESTION +// resolution targets, not an Asset) passed gate 1 with no Asset and no +// legacy key behind it. `validateDocument` said VALID; `createPreview`'s +// resolver, which only ever knew the legacy key, then threw a raw +// `Error` -- I2's disagreement in the opposite direction, plus a bare +// `Error` escaping a port whose contract is `StudioGatewayError` only. +test("a directive key equal to a non-asset EVIDENCE catalog row's id is rejected on both mock paths, never as a raw Error", async () => { + const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; + const gateway = installed.createStudioGateway(); + + const created = await gateway.createDocument( + { + kind: "CASE", + title: "카탈로그 행 오용", + slug: "catalog-row-misuse-check", + summary: "카탈로그에 EVIDENCE 행이 있다고 해서 Asset이 있는 건 아닙니다.", + topicId: FIXTURE_IDS.topicJpa, + projectId: FIXTURE_IDS.projectBackend, + relations: [], + problem: "문제", + conclusion: "결론", + environment: "env", + reproduction: "repro", + lastVerifiedOn: "2026-08-14", + // FIXTURE_IDS.fetchJoinCase backs an EVIDENCE catalog row too + // (fixtures.ts:37, label "Fetch Join Case") -- that row is for + // QUESTION resolution targets, not media. + bodyMarkdown: `:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="근거" caption="근거" zoom="false"\n:::`, + }, + { idempotencyKey: "catalog-row-misuse-create" }, + ); + + const report = await gateway.validateDocument( + created.id, + { expectedVersion: created.version }, + { idempotencyKey: "catalog-row-misuse-validate" }, + ); + assert.equal(report.status, "INVALID", JSON.stringify(report.issues)); + assert.ok( + report.issues.some((issue) => issue.code === "EVIDENCE_UNSUPPORTED"), + JSON.stringify(report.issues), + ); + + // createPreview requires a current, error-free validation -- an INVALID + // one must reject with a StudioGatewayError (VALIDATION_STALE), never let + // a raw Error escape the port. + await assert.rejects( + gateway.createPreview( + created.id, + { expectedVersion: created.version, validationId: report.validationId }, + { idempotencyKey: "catalog-row-misuse-preview" }, + ), + (error: unknown) => { + assert.ok( + error instanceof StudioGatewayError, + `expected a StudioGatewayError, got ${String(error)}`, + ); + assert.equal(error.code, "VALIDATION_STALE"); + return true; + }, + ); +}); + +// The same case pinned at the lower level: the mock's own `projectWorkingCopy` +// wrapper (adapters/mock/project-public-render-model.ts) must reject at the +// gate -- a `ContentFormatError`-flavored "supported local evidence key not +// found" -- rather than ever reaching the resolver's `isSupportedEvidenceKey` +// fallback and its raw `Error("Unknown local evidence asset: ...")`. This +// pins the fix even if some future change lets `createPreview`'s own +// staleness guard stop being the thing that prevents the call in practice. +test("the mock's projectWorkingCopy wrapper rejects a non-asset EVIDENCE catalog row at the gate, not at the resolver", () => { + const catalog = [ + { id: FIXTURE_IDS.topicJpa, type: "TOPIC" as const, label: "JPA", dependencyRevision: "r1" }, + { + id: FIXTURE_IDS.fetchJoinCase, + type: "EVIDENCE" as const, + label: "Fetch Join Case", + publicPath: "/cases/collection-fetch-join-pagination", + dependencyRevision: "r1", + }, + ]; + const input = { + kind: "CASE" as const, + title: "t", + slug: "wrapper-gate-check", + summary: "s", + topicId: FIXTURE_IDS.topicJpa, + projectId: null, + relations: [], + problem: "p", + conclusion: "c", + environment: "e", + reproduction: "r", + lastVerifiedOn: "2026-08-14", + bodyMarkdown: `:::evidence key="${FIXTURE_IDS.fetchJoinCase}" alt="a" caption="c" zoom="false"\n:::`, + }; + + assert.throws( + () => + mockProjectWorkingCopy( + input as never, + catalog as never, + { mode: "PREVIEW", publishedAt: null }, + [], + ), + /supported local evidence key not found/, + ); +}); + +// The rode-along fix (validate-working-copy.ts's EVIDENCE_ALT_REQUIRED check +// now reads Asset.decorative): a decorative Asset's empty alt must pass, a +// non-decorative one's must still fail. No repo test covered this pair +// before round 2. +test("EVIDENCE_ALT_REQUIRED respects Asset.decorative -- empty alt passes for a decorative Asset, fails for a non-decorative one", async () => { + const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input; + const gateway = installed.createStudioGateway(); + const assetGateway = installed.createStudioAssetGateway(); + + const decorative = await assetGateway.uploadAsset( + { file: new File([""], "deco.svg", { type: "image/svg+xml" }), kind: "IMAGE", decorative: true }, + { idempotencyKey: "decorative-alt-upload" }, + ); + const informative = await assetGateway.uploadAsset( + { file: new File([""], "info.svg", { type: "image/svg+xml" }), kind: "IMAGE" }, + { idempotencyKey: "informative-alt-upload" }, + ); + assert.equal(decorative.decorative, true); + assert.equal(informative.decorative, false); + + async function validateBody(bodyMarkdown: string, slugSuffix: string) { + const created = await gateway.createDocument( + { + kind: "CASE", + title: "대체 텍스트 규칙", + slug: `alt-rule-check-${slugSuffix}`, + summary: "decorative 여부에 따라 대체 텍스트 요구가 달라집니다.", + topicId: FIXTURE_IDS.topicJpa, + projectId: FIXTURE_IDS.projectBackend, + relations: [], + problem: "문제", + conclusion: "결론", + environment: "env", + reproduction: "repro", + lastVerifiedOn: "2026-08-14", + bodyMarkdown, + }, + { idempotencyKey: `alt-rule-create-${slugSuffix}` }, + ); + return gateway.validateDocument( + created.id, + { expectedVersion: created.version }, + { idempotencyKey: `alt-rule-validate-${slugSuffix}` }, + ); + } + + const decorativeReport = await validateBody( + `:::evidence key="${decorative.assetKey}" alt="" caption="" zoom="false"\n:::`, + "decorative", + ); + assert.ok( + !decorativeReport.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), + JSON.stringify(decorativeReport.issues), + ); + + const informativeReport = await validateBody( + `:::evidence key="${informative.assetKey}" alt="" caption="" zoom="false"\n:::`, + "informative", + ); + assert.ok( + informativeReport.issues.some((issue) => issue.code === "EVIDENCE_ALT_REQUIRED"), + JSON.stringify(informativeReport.issues), + ); +}); diff --git a/tests/features/tech-log/content-format.test.ts b/tests/features/tech-log/content-format.test.ts index 6033064..c4596ee 100644 --- a/tests/features/tech-log/content-format.test.ts +++ b/tests/features/tech-log/content-format.test.ts @@ -1,13 +1,42 @@ 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 { ContentFormatError, parseCaseContent, } from "../../../src/features/tech-log/domain/content-format/parse-case-content.ts"; -import { projectWorkingCopy } from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts"; +import { + evidenceCatalogEntryFor, + projectWorkingCopy, +} from "../../../src/features/tech-log/domain/content-format/project-public-render-model.ts"; import { serializeCaseContent } from "../../../src/features/tech-log/domain/content-format/serialize-case-content.ts"; import type { components } from "../../../src/features/tech-log/contracts/studio/generated.ts"; +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. + */ +function supportsEvidenceKey( + key: string, + assets: readonly Asset[] = [], +): boolean { + return ( + isSupportedEvidenceKey(key) || + Boolean(evidenceCatalogEntryFor(evidenceCatalogEntriesFromAssets(assets), key)) + ); +} const rich = `## 측정 결과 {#measurements} @@ -354,7 +383,7 @@ describe("Public render model projection", () => { generatedAt: "2026-08-14T00:00:00Z", dependencyRevision: "r1", }, - isSupportedEvidenceKey, + supportsEvidenceKey, ); expect(model.kind).toBe("CASE"); @@ -390,7 +419,7 @@ describe("Public render model projection", () => { input, catalog, { mode: "PREVIEW", publishedAt: null }, - isSupportedEvidenceKey, + supportsEvidenceKey, ); expect(model.kind).toBe("QUESTION"); @@ -411,7 +440,7 @@ describe("Public render model projection", () => { }, catalog, { mode: "PREVIEW", publishedAt: null }, - isSupportedEvidenceKey, + supportsEvidenceKey, ), ).toThrow(/EVIDENCE catalog/); }); @@ -440,7 +469,7 @@ describe("Public render model projection", () => { caseWithEvidence(key), unsupportedEvidenceCatalog, { mode: "PREVIEW", publishedAt: null }, - isSupportedEvidenceKey, + supportsEvidenceKey, ), ).toThrow(/supported local evidence key/); } @@ -456,4 +485,58 @@ describe("Public render model projection", () => { it("rejects a catalog-matching unsupported evidence label", () => { expectUnsupportedEvidence("unsupported-label"); }); + + // Fix round 2 regression coverage. `catalog` above already carries + // "resolution-evidence" -- a real `EVIDENCE` row that exists for a + // QUESTION resolution target (see "requires EVIDENCE" above), not media. + // A production caller whose gate 1 collapsed into gate 2 (round 1's bug) + // would let this key through with no Asset and no legacy key backing it; + // this is the exact class of catalog row the reviewer used to reproduce + // that regression. + it("rejects a real EVIDENCE catalog row that exists for a different purpose than media (round 2 regression)", () => { + expect(() => + projectWorkingCopy( + caseWithEvidence("resolution-evidence"), + catalog, + { mode: "PREVIEW", publishedAt: null }, + supportsEvidenceKey, + ), + ).toThrow(/supported local evidence key/); + }); + + it("accepts a key backed by a loaded READY Asset even though the document catalog carries no matching row", () => { + const assets: Asset[] = [ + { + id: "99999999-9999-4999-8999-999999999999", + assetKey: "boundary-check", + kind: "DIAGRAM", + mediaType: "image/svg+xml", + originalFilename: "boundary.svg", + byteSize: 10, + width: null, + height: null, + altText: null, + decorative: false, + managementStatus: "READY", + publicPath: "/media/boundary-check.svg", + usageCount: 0, + version: 1, + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + }, + ]; + // `catalog` alone has no row for "boundary-check" -- gate 2 only passes + // because the caller merges in `evidenceCatalogEntriesFromAssets`, the + // same way every production caller does. + const evidenceCatalog = [...catalog, ...evidenceCatalogEntriesFromAssets(assets)]; + + const model = projectWorkingCopy( + caseWithEvidence("boundary-check"), + evidenceCatalog, + { mode: "PREVIEW", publishedAt: null }, + (key: string) => supportsEvidenceKey(key, assets), + ); + + expect(model.kind).toBe("CASE"); + }); });