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:
co-authored by
Claude Opus 5
parent
073fda87eb
commit
b11aa94f1c
@@ -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.
|
||||||
@@ -1166,6 +1166,12 @@
|
|||||||
"schemaId": "markdown",
|
"schemaId": "markdown",
|
||||||
"production": "source-controlled"
|
"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",
|
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||||
"path": "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-DOCUMENT-PUBLISH-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATIONS-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-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-TECH-LOG-STUDIO-NOT-FOUND-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
|
||||||
"artifact-artifacts-tests-a11y-manual-report-json"
|
"artifact-artifacts-tests-a11y-manual-report-json"
|
||||||
|
|||||||
@@ -446,7 +446,9 @@ export type LoadCiGateContractOptions = Readonly<{
|
|||||||
const CANONICAL_GATE_SHAPE_SHA256 =
|
const CANONICAL_GATE_SHAPE_SHA256 =
|
||||||
// Template merge. Both sides carried a digest of their own gate set; neither
|
// 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.
|
// 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 {
|
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||||
const normalized = gates.map(
|
const normalized = gates.map(
|
||||||
@@ -486,8 +488,9 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Template merge. 126 product artifacts plus the two the template added.
|
// Template merge. 126 product artifacts plus the two the template added.
|
||||||
if (contract.artifacts.length !== 128) {
|
// Task 11 added one more: the TECH_LOG_STUDIO_ASSETS manual a11y evidence file.
|
||||||
failures.push(`artifact authority baseline must contain exactly 128 artifacts; received ${contract.artifacts.length}`);
|
if (contract.artifacts.length !== 129) {
|
||||||
|
failures.push(`artifact authority baseline must contain exactly 129 artifacts; received ${contract.artifacts.length}`);
|
||||||
}
|
}
|
||||||
if (contract.stages.length !== 5) {
|
if (contract.stages.length !== 5) {
|
||||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||||
|
|||||||
@@ -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_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_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_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: "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 }),
|
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -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<readonly Asset[]>([]);
|
||||||
|
const [listStatus, setListStatus] = useState<"LOADING" | "ERROR" | "READY">(
|
||||||
|
"LOADING",
|
||||||
|
);
|
||||||
|
const [selected, setSelected] = useState<AssetDetail | null>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const detailHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
||||||
|
const openAssetId = useRef<string | null>(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 (
|
||||||
|
<div className="studio-page studio-assets-page">
|
||||||
|
<header className="studio-page-heading">
|
||||||
|
<p className="studio-eyebrow">ASSET LIBRARY</p>
|
||||||
|
<h1>Asset</h1>
|
||||||
|
<p>
|
||||||
|
업로드한 Asset을 검색하고 사용처를 확인하며, 사용하지 않는 Asset을
|
||||||
|
정리합니다.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p role="status" aria-live="polite">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{listStatus === "LOADING" ? (
|
||||||
|
<p className="studio-loading" role="status">
|
||||||
|
Asset 목록을 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{listStatus === "ERROR" ? (
|
||||||
|
<p className="studio-screen-error" role="alert">
|
||||||
|
Asset 목록을 불러오지 못했습니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{listStatus === "READY" && assets.length === 0 ? (
|
||||||
|
<section className="studio-empty-state">
|
||||||
|
<h2>등록된 Asset이 없습니다</h2>
|
||||||
|
<p>Case 편집 화면에서 Asset을 업로드하면 여기에서 관리할 수 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{assets.length > 0 ? (
|
||||||
|
<ul className="asset-library-list">
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<li key={asset.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-current={selected?.asset.id === asset.id ? "true" : undefined}
|
||||||
|
onClick={(event) => void openDetail(asset, event.currentTarget)}
|
||||||
|
>
|
||||||
|
{asset.assetKey}
|
||||||
|
</button>
|
||||||
|
<span>{asset.managementStatus}</span>
|
||||||
|
<span>사용 {asset.usageCount}건</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<div className="asset-detail" aria-label={`${selected.asset.assetKey} 상세`}>
|
||||||
|
<h2 ref={detailHeadingRef} tabIndex={-1}>
|
||||||
|
{selected.asset.assetKey}
|
||||||
|
</h2>
|
||||||
|
<p>{selected.asset.managementStatus}</p>
|
||||||
|
{selected.usages.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{selected.usages.map((usage) => (
|
||||||
|
<li key={usage.documentId}>{usage.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p>사용 중인 문서가 없습니다.</p>
|
||||||
|
)}
|
||||||
|
<div className="asset-detail-actions">
|
||||||
|
{canHardDelete(selected) ? (
|
||||||
|
<button type="button" disabled={pending} onClick={() => void remove(selected)}>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={pending} onClick={() => void archive(selected)}>
|
||||||
|
보관
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={closeDetail}>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <AssetLibrary gateway={assetGateway} />;
|
||||||
|
}
|
||||||
@@ -202,6 +202,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
|
|||||||
"PublicationPreviewPage",
|
"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: runtime(
|
||||||
"TECH_LOG_STUDIO_NOT_FOUND",
|
"TECH_LOG_STUDIO_NOT_FOUND",
|
||||||
routeModule(
|
routeModule(
|
||||||
|
|||||||
@@ -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은 삭제할 수 없습니다."));
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ const expectedRoutes = [
|
|||||||
["TECH_LOG_STUDIO_DOCUMENT_PUBLISH", "/studio/documents/:id/publish", "STUDIO", "TechLogDocumentIdParams", null],
|
["TECH_LOG_STUDIO_DOCUMENT_PUBLISH", "/studio/documents/:id/publish", "STUDIO", "TechLogDocumentIdParams", null],
|
||||||
["TECH_LOG_STUDIO_PUBLICATIONS", "/studio/publications", "STUDIO", null, 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_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],
|
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
|
||||||
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
|
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
|
||||||
] as const;
|
] as const;
|
||||||
@@ -67,6 +68,7 @@ const expectedTitles = {
|
|||||||
TECH_LOG_STUDIO_DOCUMENT_PUBLISH: "게시",
|
TECH_LOG_STUDIO_DOCUMENT_PUBLISH: "게시",
|
||||||
TECH_LOG_STUDIO_PUBLICATIONS: "게시 기록",
|
TECH_LOG_STUDIO_PUBLICATIONS: "게시 기록",
|
||||||
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
|
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
|
||||||
|
TECH_LOG_STUDIO_ASSETS: "Asset",
|
||||||
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
||||||
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
||||||
} as const;
|
} as const;
|
||||||
@@ -140,7 +142,7 @@ describe("TechLog route boundary contract", () => {
|
|||||||
for (const locale of ["ko-KR", "en-US"] as const) {
|
for (const locale of ["ko-KR", "en-US"] as const) {
|
||||||
const catalog: Readonly<Record<string, string>> =
|
const catalog: Readonly<Record<string, string>> =
|
||||||
TECH_LOG_MESSAGE_CATALOGS[locale];
|
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)) {
|
for (const [routeId, title] of Object.entries(expectedTitles)) {
|
||||||
expect(catalog[`route.${routeId}.title`]).toBe(title);
|
expect(catalog[`route.${routeId}.title`]).toBe(title);
|
||||||
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
|
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
|
||||||
@@ -197,6 +199,16 @@ describe("TechLog route boundary contract", () => {
|
|||||||
).toThrow();
|
).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", () => {
|
it("atomically installs the complete TechLog route and runtime inventories", () => {
|
||||||
expect(Object.keys(ROUTE_REGISTRY)).toEqual(
|
expect(Object.keys(ROUTE_REGISTRY)).toEqual(
|
||||||
expectedRoutes.map(([routeId]) => routeId),
|
expectedRoutes.map(([routeId]) => routeId),
|
||||||
|
|||||||
@@ -194,8 +194,9 @@ describe("CI gate contract", () => {
|
|||||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
||||||
// Template merge. Recounted from the merged config/ci/gates.json rather
|
// Template merge. Recounted from the merged config/ci/gates.json rather
|
||||||
// than taking either side's number.
|
// than taking either side's number.
|
||||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(107);
|
// Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence.
|
||||||
expect(contract.artifacts).toHaveLength(128);
|
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.stages).toHaveLength(5);
|
||||||
expect(contract.retention.classes).toHaveLength(5);
|
expect(contract.retention.classes).toHaveLength(5);
|
||||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
||||||
|
|||||||
@@ -183,11 +183,12 @@ describe("selective Task 3 contract closure", () => {
|
|||||||
const canonical = await loadCiGateContract(process.cwd());
|
const canonical = await loadCiGateContract(process.cwd());
|
||||||
// Template merge. The template added one gate and one command to the
|
// 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
|
// 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.gates).toHaveLength(27);
|
||||||
expect(canonical.commands).toHaveLength(82);
|
expect(canonical.commands).toHaveLength(82);
|
||||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
|
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.stages).toHaveLength(5);
|
||||||
expect(canonical.retention.classes).toHaveLength(5);
|
expect(canonical.retention.classes).toHaveLength(5);
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const techLogRouteChunks = Object.freeze({
|
|||||||
"/studio/pages/document-publish-page.tsx": "route-tech-log-studio-document-publish",
|
"/studio/pages/document-publish-page.tsx": "route-tech-log-studio-document-publish",
|
||||||
"/studio/pages/publications-page.tsx": "route-tech-log-studio-publications",
|
"/studio/pages/publications-page.tsx": "route-tech-log-studio-publications",
|
||||||
"/studio/pages/publication-preview-page.tsx": "route-tech-log-studio-publication-preview",
|
"/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",
|
"/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found",
|
||||||
"/public/pages/public-not-found-page.tsx": "route-not-found",
|
"/public/pages/public-not-found-page.tsx": "route-not-found",
|
||||||
} as const);
|
} as const);
|
||||||
|
|||||||
Reference in New Issue
Block a user