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,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.
+7
View File
@@ -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"
+6 -3
View File
@@ -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은 삭제할 수 없습니다."));
});
+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_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),
+3 -2
View File
@@ -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);
+1
View File
@@ -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);