feat: add the TechLog Studio asset library route and screen

Adds TECH_LOG_STUDIO_ASSETS (/studio/assets) as a route reachable but
excluded from primary Studio navigation (navigationLabel/navigationOrder
null), plus the AssetLibrary screen that lists assets, shows usage, and
lets an operator archive or hard-delete one. canHardDelete() is a pure
gate mirroring the server's ASSET_IN_USE rule so the screen never offers
an action the server would refuse.

Adding a 28th route also required updating the route-scoped CI
accessibility-evidence gate (FE-GATE-009 in config/ci/gates.json, plus
its authority-baseline counts and shape digest in
scripts/contracts/ci-gates.ts) and the Vite route-to-chunk map that
scripts/generate-build-manifest.ts depends on, or test:unit and the
production build both fail. See task-11-report.md for the full
breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 07:26:05 +09:00
co-authored by Claude Opus 5
parent 073fda87eb
commit b11aa94f1c
12 changed files with 375 additions and 8 deletions
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import assert from "node:assert/strict";
import { test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
AssetLibrary,
canHardDelete,
} from "../../../src/features/tech-log/presentation/studio/components/asset-library.tsx";
import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
const ASSET = {
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;
function gatewayOf(detail: unknown, onDelete?: () => never) {
return {
async listAssets() {
return { items: [ASSET], nextCursor: null } as never;
},
async getAsset() {
return detail as never;
},
async uploadAsset() {
throw new Error("not used");
},
async updateAssetMetadata() {
return ASSET;
},
async deleteAsset() {
if (onDelete) onDelete();
},
} as never;
}
test("offers hard delete only for an unused asset with no publication history", () => {
assert.equal(
canHardDelete({ asset: ASSET, usages: [], hasPublicationHistory: false } as never),
true,
);
assert.equal(
canHardDelete({ asset: ASSET, usages: [], hasPublicationHistory: true } as never),
false,
);
assert.equal(
canHardDelete({
asset: ASSET,
usages: [{ documentId: "d", documentKind: "CASE", title: "문서", published: true }],
hasPublicationHistory: false,
} as never),
false,
);
});
test("shows archive instead of delete for an asset in use", async () => {
const user = userEvent.setup();
render(<AssetLibrary gateway={gatewayOf({
asset: { ...(ASSET as object), usageCount: 1 },
usages: [{ documentId: "d", documentKind: "CASE", title: "사용 중 문서", published: true }],
hasPublicationHistory: true,
})} />);
await user.click(await screen.findByRole("button", { name: "boundary" }));
assert.ok(await screen.findByRole("button", { name: "보관" }));
assert.equal(screen.queryByRole("button", { name: "삭제" }), null);
});
test("surfaces ASSET_IN_USE when the server rejects a delete", async () => {
const user = userEvent.setup();
render(<AssetLibrary gateway={gatewayOf(
{ asset: ASSET, usages: [], hasPublicationHistory: false },
() => {
throw new StudioGatewayError({
type: "https://techlog.local/problems/asset-in-use",
title: "ASSET_IN_USE",
status: 409,
detail: "사용 중인 Asset은 삭제할 수 없습니다.",
code: "ASSET_IN_USE",
});
},
)} />);
await user.click(await screen.findByRole("button", { name: "boundary" }));
await user.click(await screen.findByRole("button", { name: "삭제" }));
assert.ok(await screen.findByText("사용 중인 Asset은 삭제할 수 없습니다."));
});
+13 -1
View File
@@ -37,6 +37,7 @@ const expectedRoutes = [
["TECH_LOG_STUDIO_DOCUMENT_PUBLISH", "/studio/documents/:id/publish", "STUDIO", "TechLogDocumentIdParams", null],
["TECH_LOG_STUDIO_PUBLICATIONS", "/studio/publications", "STUDIO", null, null],
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
] as const;
@@ -67,6 +68,7 @@ const expectedTitles = {
TECH_LOG_STUDIO_DOCUMENT_PUBLISH: "게시",
TECH_LOG_STUDIO_PUBLICATIONS: "게시 기록",
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
TECH_LOG_STUDIO_ASSETS: "Asset",
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
NOT_FOUND: "페이지를 찾을 수 없습니다.",
} as const;
@@ -140,7 +142,7 @@ describe("TechLog route boundary contract", () => {
for (const locale of ["ko-KR", "en-US"] as const) {
const catalog: Readonly<Record<string, string>> =
TECH_LOG_MESSAGE_CATALOGS[locale];
expect(Object.keys(catalog)).toHaveLength(54);
expect(Object.keys(catalog)).toHaveLength(56);
for (const [routeId, title] of Object.entries(expectedTitles)) {
expect(catalog[`route.${routeId}.title`]).toBe(title);
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
@@ -197,6 +199,16 @@ describe("TechLog route boundary contract", () => {
).toThrow();
});
it("registers the studio asset library outside primary navigation", () => {
const route = TECH_LOG_ROUTE_REGISTRY.TECH_LOG_STUDIO_ASSETS;
expect(route).toBeTruthy();
expect(route.path).toBe("/studio/assets");
expect(route.layoutGroup).toBe("STUDIO");
expect(route.navigationLabel).toBeNull();
expect(route.navigationOrder).toBeNull();
});
it("atomically installs the complete TechLog route and runtime inventories", () => {
expect(Object.keys(ROUTE_REGISTRY)).toEqual(
expectedRoutes.map(([routeId]) => routeId),