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) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 03:54:30 +09:00
co-authored by Claude Opus 5
parent 0c071aaabc
commit 9d91001a31
5 changed files with 279 additions and 62 deletions
@@ -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<Record<string, EvidenceAsset>> = {
"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<string, Asset>();
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<string, ResolvedAssetLike>();
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));
}
@@ -1,10 +1,11 @@
import type { components } from "../../../contracts/studio/generated.ts"; 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 { ContentFormatError } from "../../../domain/content-format/parse-case-content.ts";
import { import {
projectWorkingCopy, projectWorkingCopy,
resolveCaseEvidenceAssets, resolveCaseEvidenceAssets,
} from "../../../domain/content-format/project-public-render-model.ts"; } 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 { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
@@ -16,39 +17,64 @@ function supportsPreviewEvidenceKey(key: string): boolean {
return key === "fetch-strategy-boundary"; return key === "fetch-strategy-boundary";
} }
function resolvePreviewEvidenceAsset(key: string) { /**
if (!supportsPreviewEvidenceKey(key)) { * `resolveCaseEvidenceAssets` only needs *a* `ResolvedAsset` to turn the authoring
throw new Error(`Unknown local evidence asset: ${key}`); * model into a genuine `PublicRenderModel` -- nothing downstream reads `block.asset`
} * for the pixels actually shown; that comes from `resolveEvidenceAsset` below
return { * (`createAssetCatalogResolver`). So this must never throw: a key the Asset Picker
src: "/media/fetch-strategy-boundary.svg", * (Task 10) inserts, with no catalog match yet, still gets a placeholder descriptor
width: 1080, * instead of blanking the whole preview behind an error panel.
height: 420, */
triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기", function resolveAssetDescriptor(assets: readonly Asset[]) {
dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대", 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 export function InstantPreview({
// canonical descriptor from the same local fixture data as `resolvePreviewEvidenceAsset` draft,
// above. The `assetId` is a fixed literal (not derived) because this file only ever catalog,
// resolves this one key; it only needs to be stable, not computed. assets = [],
function resolvePreviewEvidenceAssetDescriptor(key: string): ResolvedAsset { }: {
if (!supportsPreviewEvidenceKey(key)) { draft: WorkingCopyInput;
throw new Error(`Unknown local evidence asset: ${key}`); catalog: CatalogEntry[];
} assets?: readonly Asset[];
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[] }) {
const studio = useStudio(); const studio = useStudio();
let model: PublicRenderModel | null = null; let model: PublicRenderModel | null = null;
let issues: string[] | null = null; let issues: string[] | null = null;
@@ -60,7 +86,7 @@ export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; ca
{ mode: "PREVIEW", publishedAt: null }, { mode: "PREVIEW", publishedAt: null },
supportsPreviewEvidenceKey, supportsPreviewEvidenceKey,
), ),
resolvePreviewEvidenceAssetDescriptor, resolveAssetDescriptor(assets),
); );
} catch (error) { } catch (error) {
issues = error instanceof ContentFormatError issues = error instanceof ContentFormatError
@@ -73,7 +99,7 @@ export function InstantPreview({ draft, catalog }: { draft: WorkingCopyInput; ca
<PublicRecordRenderer <PublicRecordRenderer
model={model!} model={model!}
embedded embedded
resolveEvidenceAsset={resolvePreviewEvidenceAsset} resolveEvidenceAsset={createAssetCatalogResolver(assets)}
resolvePublishedLabel={studio.resolvePublishedLabel} resolvePublishedLabel={studio.resolvePublishedLabel}
/> />
</div> </div>
@@ -10,6 +10,7 @@ import type {
} from "../../../contracts/studio/contract.ts"; } from "../../../contracts/studio/contract.ts";
import { deriveValidationState } from "../../../domain/studio/document-state.ts"; import { deriveValidationState } from "../../../domain/studio/document-state.ts";
import { createLocalId } from "../../../domain/studio/local-id.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 { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { GuardedStudioLink } from "./guarded-studio-link.tsx";
@@ -45,19 +46,6 @@ function formatDateTime(value: string): string {
}).format(date); }).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 }) { export function PublicPreviewScreen({ documentId }: { documentId: string }) {
const studio = useStudio(); const studio = useStudio();
const [retryKey, setRetryKey] = useState(0); const [retryKey, setRetryKey] = useState(0);
@@ -287,7 +275,11 @@ export function PublicPreviewScreen({ documentId }: { documentId: string }) {
<PublicRecordRenderer <PublicRecordRenderer
model={previewDetail.preview.renderModel} model={previewDetail.preview.renderModel}
embedded embedded
resolveEvidenceAsset={resolvePreviewEvidenceAsset} resolveEvidenceAsset={createResolvedAssetResolver(
previewDetail.preview.renderModel.kind === "CASE"
? previewDetail.preview.renderModel.bodyBlocks
: [],
)}
resolvePublishedLabel={studio.resolvePublishedLabel} resolvePublishedLabel={studio.resolvePublishedLabel}
/> />
</div> </div>
@@ -5,7 +5,7 @@ import {
type StudioGatewayError, type StudioGatewayError,
} from "../../../application/ports/studio-gateway-error.ts"; } from "../../../application/ports/studio-gateway-error.ts";
import type { PublicationSnapshot } from "../../../contracts/studio/contract.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 { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { import {
@@ -20,19 +20,6 @@ const eventLabels = {
UNPUBLISHED: "게시 취소", UNPUBLISHED: "게시 취소",
} as const; } 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) { function dateTime(value: string) {
return new Intl.DateTimeFormat("ko-KR", { return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "long", dateStyle: "long",
@@ -166,7 +153,9 @@ export function PublicationEventPreviewScreen({
<PublicRecordRenderer <PublicRecordRenderer
model={renderModel} model={renderModel}
embedded embedded
resolveEvidenceAsset={resolveEvidenceAsset} resolveEvidenceAsset={createResolvedAssetResolver(
renderModel.kind === "CASE" ? renderModel.bodyBlocks : [],
)}
resolvePublishedLabel={studio.resolvePublishedLabel} resolvePublishedLabel={studio.resolvePublishedLabel}
/> />
</div> </div>
@@ -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");
});