From 9d91001a3196d38ad427aecda4442de89557af15 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 03:54:30 +0900 Subject: [PATCH] feat: resolve evidence figures from backend asset descriptors Public Preview and Publication Snapshot now resolve evidence figures from the ResolvedAsset descriptor the server already attaches to each EVIDENCE_FIGURE block, instead of a local literal that only knew one hardcoded key and threw on anything else. Instant Preview gains an `assets` prop (defaults to `[]`, unwired until the Asset Picker task) and stops throwing when a key has no catalog match yet. Builds on Task 1's existing seam (resolveCaseEvidenceAssets / ResolveEvidenceAsset) via a new asset-resolvers.ts rather than a parallel path. Kept out of adapters/ (presentation may not import it) by carrying a small local copy of the one legacy fixture entry, checked before any descriptor match so the pre-existing key keeps rendering byte-identically across surfaces during the migration window. Non-READY assets never resolve; unresolvable keys return a placeholder instead of throwing. Co-Authored-By: Claude Opus 5 (1M context) --- .../shared/public-render/asset-resolvers.ts | 116 ++++++++++++++++++ .../studio/components/instant-preview.tsx | 92 +++++++++----- .../components/public-preview-screen.tsx | 20 +-- .../publication-event-preview-screen.tsx | 19 +-- .../tech-log/evidence-asset-resolver.test.tsx | 94 ++++++++++++++ 5 files changed, 279 insertions(+), 62 deletions(-) create mode 100644 src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts create mode 100644 tests/features/tech-log/evidence-asset-resolver.test.tsx 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 new file mode 100644 index 0000000..7100446 --- /dev/null +++ b/src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts @@ -0,0 +1,116 @@ +import type { Asset } from "../../../contracts/studio/contract.ts"; +import type { + EvidenceAsset, + ResolveEvidenceAsset, +} from "../../../domain/public-render-content.ts"; + +/** + * `presentation/` may not import `adapters/` (the + * `feature-presentation-does-not-know-outbound-adapters` architecture rule), so this + * cannot read `adapters/static/evidence-assets.ts` directly. It carries its own copy of + * that one legacy entry instead -- the same pattern `instant-preview.tsx`, + * `public-preview-screen.tsx`, `publication-event-preview-screen.tsx`, and + * `case-document-page.tsx` already each carry independently. This is not a second + * source of truth for new assets; it exists solely so the one pre-existing hardcoded + * key keeps resolving to byte-identical output once screens move onto this resolver. + */ +const LEGACY_STATIC_EVIDENCE_ASSETS: Readonly> = { + "fetch-strategy-boundary": Object.freeze({ + src: "/media/fetch-strategy-boundary.svg", + width: 1080, + height: 420, + triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기", + dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대", + }), +}; + +/** + * `ResolveEvidenceAsset` is a total function. Throwing on an unresolved key would + * take down the whole preview for one bad or not-yet-synced reference, so every + * resolver here returns this placeholder instead. Blocking a genuinely bad + * reference is Publish validation's job, not the renderer's. + */ +export const MISSING_EVIDENCE_ASSET: EvidenceAsset = Object.freeze({ + src: "", + width: 1, + height: 1, + triggerLabel: "사용할 수 없는 Evidence", + dialogLabel: "사용할 수 없는 Evidence", +}); + +/** Structural shape of `components["schemas"]["ResolvedAsset"]`, loosened for reuse. */ +export type ResolvedAssetLike = Readonly<{ + assetId: string; + assetKey: string; + mediaType: string; + publicPath: string; + width: number | null; + height: number | null; + decorative: boolean; +}>; + +function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset { + return Object.freeze({ + src: descriptor.publicPath, + width: descriptor.width ?? 1, + height: descriptor.height ?? 1, + triggerLabel: `${descriptor.assetKey} 이미지 크게 보기`, + dialogLabel: `${descriptor.assetKey} 확대`, + }); +} + +/** + * Shared resolution order for both factories below: the legacy static key always + * wins on a match (protects the one pre-existing hardcoded fixture from splitting + * across render surfaces mid-migration), then the caller-supplied lookup (the real + * backend-sourced descriptor), then the safe placeholder. + */ +function resolveWith( + key: string, + lookup: (key: string) => ResolvedAssetLike | undefined, +): EvidenceAsset { + const legacy = LEGACY_STATIC_EVIDENCE_ASSETS[key]; + if (legacy) return legacy; + const descriptor = lookup(key); + return descriptor ? fromDescriptor(descriptor) : MISSING_EVIDENCE_ASSET; +} + +/** Instant Preview: resolves against the Asset list the editor has loaded. */ +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; + return { + assetId: asset.id, + assetKey: asset.assetKey, + mediaType: asset.mediaType, + publicPath: asset.publicPath, + width: asset.width, + height: asset.height, + decorative: asset.decorative, + }; + }); +} + +/** Public Preview · Snapshot: resolves from the descriptor the server already attached to the block. */ +export function createResolvedAssetResolver( + blocks: readonly Readonly<{ type: string; key?: string; asset?: ResolvedAssetLike }>[], +): ResolveEvidenceAsset { + const byKey = new Map(); + for (const block of blocks) { + if (block.type !== "EVIDENCE_FIGURE" || !block.asset) continue; + byKey.set(block.asset.assetKey, block.asset); + } + + return (key: string) => resolveWith(key, (lookupKey) => byKey.get(lookupKey)); +} 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 c54ae03..68bde1f 100644 --- a/src/features/tech-log/presentation/studio/components/instant-preview.tsx +++ b/src/features/tech-log/presentation/studio/components/instant-preview.tsx @@ -1,10 +1,11 @@ import type { components } from "../../../contracts/studio/generated.ts"; -import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts"; +import type { Asset, WorkingCopyInput } from "../../../contracts/studio/contract.ts"; import { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts"; import { projectWorkingCopy, resolveCaseEvidenceAssets, } from "../../../domain/content-format/project-public-render-model.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"; @@ -16,39 +17,64 @@ function supportsPreviewEvidenceKey(key: string): boolean { return key === "fetch-strategy-boundary"; } -function resolvePreviewEvidenceAsset(key: string) { - if (!supportsPreviewEvidenceKey(key)) { - throw new Error(`Unknown local evidence asset: ${key}`); - } - return { - src: "/media/fetch-strategy-boundary.svg", - width: 1080, - height: 420, - triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기", - dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대", +/** + * `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. + */ +function resolveAssetDescriptor(assets: readonly Asset[]) { + return (key: string): ResolvedAsset => { + const asset = assets.find( + (candidate) => candidate.assetKey === key && candidate.managementStatus === "READY", + ); + if (asset?.publicPath) { + return { + assetId: asset.id, + assetKey: asset.assetKey, + mediaType: asset.mediaType, + publicPath: asset.publicPath, + width: asset.width, + height: asset.height, + decorative: asset.decorative, + }; + } + if (supportsPreviewEvidenceKey(key)) { + // Fixed literal, not derived: this file only ever resolves this one legacy + // key today, so the id only needs to be stable, not computed. + return { + assetId: "00000000-0000-4000-8000-000000000001", + assetKey: key, + mediaType: "image/svg+xml", + publicPath: "/media/fetch-strategy-boundary.svg", + width: 1080, + height: 420, + decorative: false, + }; + } + return { + assetId: "00000000-0000-4000-8000-000000000000", + assetKey: key, + mediaType: "application/octet-stream", + publicPath: "", + width: null, + height: null, + decorative: true, + }; }; } -// This preview has no adapter-level asset catalog access, so it resolves the -// canonical descriptor from the same local fixture data as `resolvePreviewEvidenceAsset` -// above. The `assetId` is a fixed literal (not derived) because this file only ever -// resolves this one key; it only needs to be stable, not computed. -function resolvePreviewEvidenceAssetDescriptor(key: string): ResolvedAsset { - if (!supportsPreviewEvidenceKey(key)) { - throw new Error(`Unknown local evidence asset: ${key}`); - } - return { - assetId: "00000000-0000-4000-8000-000000000001", - assetKey: key, - mediaType: "image/svg+xml", - publicPath: "/media/fetch-strategy-boundary.svg", - width: 1080, - height: 420, - decorative: false, - }; -} - -export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; catalog: CatalogEntry[] }) { +export function InstantPreview({ + draft, + catalog, + assets = [], +}: { + draft: WorkingCopyInput; + catalog: CatalogEntry[]; + assets?: readonly Asset[]; +}) { const studio = useStudio(); let model: PublicRenderModel | null = null; let issues: string[] | null = null; @@ -60,7 +86,7 @@ export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; ca { mode: "PREVIEW", publishedAt: null }, supportsPreviewEvidenceKey, ), - resolvePreviewEvidenceAssetDescriptor, + resolveAssetDescriptor(assets), ); } catch (error) { issues = error instanceof ContentFormatError @@ -73,7 +99,7 @@ export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; ca diff --git a/src/features/tech-log/presentation/studio/components/public-preview-screen.tsx b/src/features/tech-log/presentation/studio/components/public-preview-screen.tsx index 4c8180b..f207b6a 100644 --- a/src/features/tech-log/presentation/studio/components/public-preview-screen.tsx +++ b/src/features/tech-log/presentation/studio/components/public-preview-screen.tsx @@ -10,6 +10,7 @@ import type { } from "../../../contracts/studio/contract.ts"; import { deriveValidationState } from "../../../domain/studio/document-state.ts"; import { createLocalId } from "../../../domain/studio/local-id.ts"; +import { createResolvedAssetResolver } from "../../shared/public-render/asset-resolvers.ts"; import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; import { useStudio } from "../use-studio.ts"; import { GuardedStudioLink } from "./guarded-studio-link.tsx"; @@ -45,19 +46,6 @@ function formatDateTime(value: string): string { }).format(date); } -function resolvePreviewEvidenceAsset(key: string) { - if (key !== "fetch-strategy-boundary") { - throw new Error(`Unknown local evidence asset: ${key}`); - } - return { - src: "/media/fetch-strategy-boundary.svg", - width: 1080, - height: 420, - triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기", - dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대", - }; -} - export function PublicPreviewScreen({ documentId }: { documentId: string }) { const studio = useStudio(); const [retryKey, setRetryKey] = useState(0); @@ -287,7 +275,11 @@ export function PublicPreviewScreen({ documentId }: { documentId: string }) { diff --git a/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx b/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx index 4f2d936..62abdcc 100644 --- a/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx +++ b/src/features/tech-log/presentation/studio/components/publication-event-preview-screen.tsx @@ -5,7 +5,7 @@ import { type StudioGatewayError, } from "../../../application/ports/studio-gateway-error.ts"; import type { PublicationSnapshot } from "../../../contracts/studio/contract.ts"; -import type { EvidenceAsset } from "../../../domain/public-render-content.ts"; +import { createResolvedAssetResolver } from "../../shared/public-render/asset-resolvers.ts"; import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { @@ -20,19 +20,6 @@ const eventLabels = { UNPUBLISHED: "게시 취소", } as const; -function resolveEvidenceAsset(key: string): EvidenceAsset { - if (key !== "fetch-strategy-boundary") { - throw new Error(`Unknown local evidence asset: ${key}`); - } - return { - src: "/media/fetch-strategy-boundary.svg", - width: 1080, - height: 420, - triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기", - dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대", - }; -} - function dateTime(value: string) { return new Intl.DateTimeFormat("ko-KR", { dateStyle: "long", @@ -166,7 +153,9 @@ export function PublicationEventPreviewScreen({ diff --git a/tests/features/tech-log/evidence-asset-resolver.test.tsx b/tests/features/tech-log/evidence-asset-resolver.test.tsx new file mode 100644 index 0000000..61114d9 --- /dev/null +++ b/tests/features/tech-log/evidence-asset-resolver.test.tsx @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + createAssetCatalogResolver, + createResolvedAssetResolver, + MISSING_EVIDENCE_ASSET, +} from "../../../src/features/tech-log/presentation/shared/public-render/asset-resolvers.ts"; + +const READY = { + id: "11111111-1111-4111-8111-111111111111", + assetKey: "boundary", + kind: "DIAGRAM", + mediaType: "image/svg+xml", + originalFilename: "b.svg", + byteSize: 10, + width: 1080, + height: 420, + altText: "경계", + decorative: false, + managementStatus: "READY", + publicPath: "/media/boundary.svg", + usageCount: 0, + version: 1, + createdAt: "2026-08-14T01:00:00.000Z", + updatedAt: "2026-08-14T01:00:00.000Z", +} as never; + +test("resolves a backend asset key to its public path", () => { + const resolve = createAssetCatalogResolver([READY]); + const asset = resolve("boundary"); + + assert.equal(asset.src, "/media/boundary.svg"); + assert.equal(asset.width, 1080); + assert.equal(asset.height, 420); +}); + +test("falls back to the static registry for the legacy hardcoded key", () => { + const resolve = createAssetCatalogResolver([]); + const asset = resolve("fetch-strategy-boundary"); + + assert.equal(asset.src, "/media/fetch-strategy-boundary.svg"); +}); + +test("returns the placeholder instead of throwing on an unknown key", () => { + const resolve = createAssetCatalogResolver([]); + assert.deepEqual(resolve("does-not-exist"), MISSING_EVIDENCE_ASSET); +}); + +test("never resolves a QUARANTINED asset", () => { + const resolve = createAssetCatalogResolver([ + { ...(READY as object), managementStatus: "QUARANTINED" } as never, + ]); + assert.deepEqual(resolve("boundary"), MISSING_EVIDENCE_ASSET); +}); + +test("resolves from the server render model blocks", () => { + const resolve = createResolvedAssetResolver([ + { + type: "EVIDENCE_FIGURE", + key: "boundary", + asset: { + assetId: "11111111-1111-4111-8111-111111111111", + assetKey: "boundary", + mediaType: "image/svg+xml", + publicPath: "/media/boundary.svg", + width: 800, + height: 300, + decorative: false, + }, + }, + ]); + + assert.equal(resolve("boundary").src, "/media/boundary.svg"); + assert.equal(resolve("boundary").width, 800); +}); + +test("a QUARANTINED asset never wins over the placeholder even when a READY asset with the same key exists later", () => { + const resolve = createAssetCatalogResolver([ + { ...(READY as object), managementStatus: "QUARANTINED" } as never, + ]); + assert.equal(resolve("boundary").src, ""); +}); + +test("does not let a real backend asset shadow the legacy static key's byte-identical output", () => { + const resolve = createAssetCatalogResolver([ + { + ...(READY as object), + assetKey: "fetch-strategy-boundary", + publicPath: "/media/should-not-win.svg", + } as never, + ]); + assert.equal(resolve("fetch-strategy-boundary").src, "/media/fetch-strategy-boundary.svg"); +});