diff --git a/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_ASSETS.md b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_ASSETS.md new file mode 100644 index 0000000..18c0900 --- /dev/null +++ b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_ASSETS.md @@ -0,0 +1,18 @@ +# TECH_LOG_STUDIO_ASSETS accessibility review + +Status: pending-manual-review +Route ID: TECH_LOG_STUDIO_ASSETS +Release ID: +Reviewer: +Reviewed at: +Signature: +Attestation: pending +M1 Keyboard: pending +M2 Visible focus: pending +M3 Route focus: pending +M4 Modal focus: pending +M5 Error association: pending +M6 Color signal: pending +M7 Reduced motion: pending +Screen reader: pending +Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review. diff --git a/config/ci/gates.json b/config/ci/gates.json index 904b330..76759fc 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -1166,6 +1166,12 @@ "schemaId": "markdown", "production": "source-controlled" }, + { + "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md", + "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_ASSETS.md", + "schemaId": "markdown", + "production": "source-controlled" + }, { "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md", @@ -1916,6 +1922,7 @@ "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-DOCUMENT-PUBLISH-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md", + "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-report-json" diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index c9d192c..a8f1247 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -446,7 +446,9 @@ export type LoadCiGateContractOptions = Readonly<{ const CANONICAL_GATE_SHAPE_SHA256 = // Template merge. Both sides carried a digest of their own gate set; neither // describes the merged one. Recomputed from the merged config/ci/gates.json. - "5063586d799f51de94c0f0ddaf9b75e180825bba5051bc309550425013ea81ef"; + // Task 11: recomputed again after FE-GATE-009 gained the + // TECH_LOG_STUDIO_ASSETS manual accessibility evidence artifact. + "3dda8421822eb094ceb06693ed8143cd9298947fa86681bbd941726a0d9674a9"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { const normalized = gates.map( @@ -486,8 +488,9 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] ); } // Template merge. 126 product artifacts plus the two the template added. - if (contract.artifacts.length !== 128) { - failures.push(`artifact authority baseline must contain exactly 128 artifacts; received ${contract.artifacts.length}`); + // Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file. + if (contract.artifacts.length !== 129) { + failures.push(`artifact authority baseline must contain exactly 129 artifacts; received ${contract.artifacts.length}`); } if (contract.stages.length !== 5) { failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); diff --git a/src/features/tech-log/contracts/tech-log-route-contract.ts b/src/features/tech-log/contracts/tech-log-route-contract.ts index a8020d8..077e6b1 100644 --- a/src/features/tech-log/contracts/tech-log-route-contract.ts +++ b/src/features/tech-log/contracts/tech-log-route-contract.ts @@ -47,6 +47,7 @@ const TECH_LOG_ROUTE_SPECS = [ defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_PUBLISH", path: "/studio/documents/:id/publish", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "게시", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }), defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }), ] as const; diff --git a/src/features/tech-log/presentation/studio/components/asset-library.tsx b/src/features/tech-log/presentation/studio/components/asset-library.tsx new file mode 100644 index 0000000..16e3ab5 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/asset-library.tsx @@ -0,0 +1,202 @@ +import { useEffect, useRef, useState } from "react"; + +import type { Asset, AssetDetail } from "../../../contracts/studio/contract.ts"; +import type { StudioAssetGateway } from "../../../application/ports/studio-asset-gateway.ts"; +import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts"; +import { createLocalId } from "../../../domain/studio/local-id.ts"; + +/** + * 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다. 서버도 같은 + * 규칙으로 `ASSET_IN_USE`를 던지므로 화면은 시도 자체를 막아 왕복을 줄인다. + */ +export function canHardDelete(detail: AssetDetail): boolean { + return ( + !detail.hasPublicationHistory && + detail.usages.length === 0 && + detail.asset.usageCount === 0 + ); +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +export function AssetLibrary(props: Readonly<{ gateway: StudioAssetGateway }>) { + const [assets, setAssets] = useState([]); + const [listStatus, setListStatus] = useState<"LOADING" | "ERROR" | "READY">( + "LOADING", + ); + const [selected, setSelected] = useState(null); + const [pending, setPending] = useState(false); + const [notice, setNotice] = useState(""); + const triggerRef = useRef(null); + const detailHeadingRef = useRef(null); + const openAssetId = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + setListStatus("LOADING"); + props.gateway + .listAssets({ limit: 50 }, { signal: controller.signal }) + .then((page) => { + setAssets(page.items); + setListStatus("READY"); + }) + .catch((error: unknown) => { + if (isAbortError(error)) return; + setListStatus("ERROR"); + setNotice("Asset 목록을 불러오지 못했습니다."); + }); + return () => controller.abort(); + }, [props.gateway]); + + // A freshly opened detail panel should take focus so keyboard and screen + // reader users land on it without hunting; re-rendering the same asset + // (e.g. after archiving it) must not steal focus back from whatever the + // user is doing next. + useEffect(() => { + if (selected && openAssetId.current !== selected.asset.id) { + detailHeadingRef.current?.focus(); + } + openAssetId.current = selected?.asset.id ?? null; + }, [selected]); + + async function openDetail(asset: Asset, trigger: HTMLButtonElement) { + triggerRef.current = trigger; + try { + const detail = await props.gateway.getAsset(asset.id); + setSelected(detail); + } catch { + setNotice("Asset 상세를 불러오지 못했습니다."); + } + } + + function closeDetail() { + setSelected(null); + queueMicrotask(() => triggerRef.current?.focus()); + } + + async function archive(detail: AssetDetail) { + setPending(true); + try { + const updated = await props.gateway.updateAssetMetadata( + detail.asset.id, + { expectedVersion: detail.asset.version, managementStatus: "ARCHIVED" }, + { idempotencyKey: createLocalId("studio-asset-archive") }, + ); + setAssets((current) => + current.map((item) => (item.id === updated.id ? updated : item)), + ); + setSelected({ ...detail, asset: updated }); + setNotice("보관했습니다."); + } catch (error) { + setNotice( + isStudioGatewayError(error) ? error.problem.detail : "보관하지 못했습니다.", + ); + } finally { + setPending(false); + } + } + + async function remove(detail: AssetDetail) { + setPending(true); + try { + await props.gateway.deleteAsset(detail.asset.id, { + idempotencyKey: createLocalId("studio-asset-delete"), + }); + setAssets((current) => current.filter((item) => item.id !== detail.asset.id)); + setNotice("삭제했습니다."); + closeDetail(); + } catch (error) { + setNotice( + isStudioGatewayError(error) ? error.problem.detail : "삭제하지 못했습니다.", + ); + } finally { + setPending(false); + } + } + + return ( +
+
+

ASSET LIBRARY

+

Asset

+

+ 업로드한 Asset을 검색하고 사용처를 확인하며, 사용하지 않는 Asset을 + 정리합니다. +

+
+ +

+ {notice} +

+ + {listStatus === "LOADING" ? ( +

+ Asset 목록을 불러오는 중입니다. +

+ ) : null} + {listStatus === "ERROR" ? ( +

+ Asset 목록을 불러오지 못했습니다. +

+ ) : null} + {listStatus === "READY" && assets.length === 0 ? ( +
+

등록된 Asset이 없습니다

+

Case 편집 화면에서 Asset을 업로드하면 여기에서 관리할 수 있습니다.

+
+ ) : null} + + {assets.length > 0 ? ( +
    + {assets.map((asset) => ( +
  • + + {asset.managementStatus} + 사용 {asset.usageCount}건 +
  • + ))} +
+ ) : null} + + {selected ? ( +
+

+ {selected.asset.assetKey} +

+

{selected.asset.managementStatus}

+ {selected.usages.length > 0 ? ( +
    + {selected.usages.map((usage) => ( +
  • {usage.title}
  • + ))} +
+ ) : ( +

사용 중인 문서가 없습니다.

+ )} +
+ {canHardDelete(selected) ? ( + + ) : ( + + )} + +
+
+ ) : null} +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/pages/assets-page.tsx b/src/features/tech-log/presentation/studio/pages/assets-page.tsx new file mode 100644 index 0000000..66aaa62 --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/assets-page.tsx @@ -0,0 +1,9 @@ +import { AssetLibrary } from "../components/asset-library.tsx"; +import { useStudioAssetGateway } from "../use-studio.ts"; + +export function AssetsPage() { + // Asset UI must use the throwing accessor, not the nullable `assetGateway` + // field directly -- see `use-studio.ts`'s `useStudioAssetGateway` doc. + const assetGateway = useStudioAssetGateway(); + return ; +} diff --git a/src/features/tech-log/presentation/tech-log-route-runtime.tsx b/src/features/tech-log/presentation/tech-log-route-runtime.tsx index 852630f..66133a2 100644 --- a/src/features/tech-log/presentation/tech-log-route-runtime.tsx +++ b/src/features/tech-log/presentation/tech-log-route-runtime.tsx @@ -202,6 +202,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({ "PublicationPreviewPage", ), ), + TECH_LOG_STUDIO_ASSETS: runtime( + "TECH_LOG_STUDIO_ASSETS", + routeModule( + () => import("./studio/pages/assets-page.tsx"), + "AssetsPage", + ), + ), TECH_LOG_STUDIO_NOT_FOUND: runtime( "TECH_LOG_STUDIO_NOT_FOUND", routeModule( diff --git a/tests/features/tech-log/asset-library.test.tsx b/tests/features/tech-log/asset-library.test.tsx new file mode 100644 index 0000000..be56a80 --- /dev/null +++ b/tests/features/tech-log/asset-library.test.tsx @@ -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(); + + 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( { + 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은 삭제할 수 없습니다.")); +}); diff --git a/tests/features/tech-log/route-contract.test.ts b/tests/features/tech-log/route-contract.test.ts index 0036cea..4ecb5c0 100644 --- a/tests/features/tech-log/route-contract.test.ts +++ b/tests/features/tech-log/route-contract.test.ts @@ -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> = 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), diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts index 73d9787..f331947 100644 --- a/tests/unit/ci-workflow-generation.test.ts +++ b/tests/unit/ci-workflow-generation.test.ts @@ -194,8 +194,9 @@ describe("CI gate contract", () => { expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23); // Template merge. Recounted from the merged config/ci/gates.json rather // than taking either side's number. - expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(107); - expect(contract.artifacts).toHaveLength(128); + // Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence. + expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(108); + expect(contract.artifacts).toHaveLength(129); expect(contract.stages).toHaveLength(5); expect(contract.retention.classes).toHaveLength(5); expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2); diff --git a/tests/unit/task3-selective-integration.test.ts b/tests/unit/task3-selective-integration.test.ts index 5fdd89e..0e3a097 100644 --- a/tests/unit/task3-selective-integration.test.ts +++ b/tests/unit/task3-selective-integration.test.ts @@ -183,11 +183,12 @@ describe("selective Task 3 contract closure", () => { const canonical = await loadCiGateContract(process.cwd()); // Template merge. The template added one gate and one command to the // product's 26/81; the artifact set is the product's 126 plus the - // template's 2. + // template's 2. Task 11 added TECH_LOG_STUDIO_ASSETS's manual + // accessibility evidence, bringing the total to 129. expect(canonical.gates).toHaveLength(27); expect(canonical.commands).toHaveLength(82); expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94); - expect(canonical.artifacts).toHaveLength(128); + expect(canonical.artifacts).toHaveLength(129); expect(canonical.stages).toHaveLength(5); expect(canonical.retention.classes).toHaveLength(5); diff --git a/vite.config.ts b/vite.config.ts index cab6985..9486e38 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -30,6 +30,7 @@ const techLogRouteChunks = Object.freeze({ "/studio/pages/document-publish-page.tsx": "route-tech-log-studio-document-publish", "/studio/pages/publications-page.tsx": "route-tech-log-studio-publications", "/studio/pages/publication-preview-page.tsx": "route-tech-log-studio-publication-preview", + "/studio/pages/assets-page.tsx": "route-tech-log-studio-assets", "/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found", "/public/pages/public-not-found-page.tsx": "route-not-found", } as const);