Publishing needs a topic and nothing could create one. The backend now owns that surface; this is its consumer — the management contract vendored, a gateway over its nine operations, and one Studio screen that lists, creates, and deletes topics and projects. The screen adds no CSS. It reuses the classes the working-copy list already uses, so it inherits Studio's spacing, type, and colour rather than growing a second visual vocabulary beside them. Scope stops at list/create/delete: renaming, phase changes, and visibility are implemented in the backend and declared in the contract, but their screens are a separate design. Two real defects surfaced while making the public port async, and both would have shipped: The search page and the header search dialog shared a query key. With an empty query, `["tech-log","search",""]` was identical for both, so react-query handed one surface the other's cache — different shapes — and the page died reading a field that was not there. Keys now name the surface. The explore filter's selects are uncontrolled and read `defaultValue`, which React applies once. Their options arrive later now, so the first render had nothing to match and the value stayed empty: a topic in the URL no longer showed as selected. The form key includes whether the catalog has arrived, so it remounts with the options present. Controlled inputs would be the other answer, but this form submits to build a URL — the URL owns the value. The route brought its own bookkeeping: a build chunk, a manual accessibility evidence file, and the CI artifact baseline that counts them. The gate pins a digest of its own shape precisely so a new route cannot slip in without that count being reviewed. Test harnesses that render public screens now assemble the query providers and await the settled paint, because the screens they render became async.
228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
TECH_LOG_ROUTE_REGISTRY,
|
|
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
|
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
|
import { TECH_LOG_MESSAGE_CATALOGS } from "../../../src/features/tech-log/contracts/tech-log-message-catalog.ts";
|
|
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
|
import { ROUTE_REGISTRY } from "../../../src/features/installed-feature-contracts.ts";
|
|
import { ROUTE_RUNTIME } from "../../../src/features/installed-feature-runtimes.tsx";
|
|
import { buildRouteUrlFromContract } from "../../../src/presentation/routes/route-codecs.ts";
|
|
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
|
|
|
const expectedRoutes = [
|
|
["TECH_LOG_HOME", "/", "PUBLIC", null, "TechLogHomeSearch"],
|
|
["TECH_LOG_EXPLORE", "/explore", "PUBLIC", null, "TechLogExploreSearch"],
|
|
["TECH_LOG_EXPLORE_KIND", "/explore/:kind", "PUBLIC", "TechLogExploreKindParams", "TechLogExploreKindSearch"],
|
|
["TECH_LOG_CASE", "/cases/:slug", "PUBLIC", "TechLogSlugParams", "TechLogCaseStateSearch"],
|
|
["TECH_LOG_REFERENCE", "/references/:slug", "PUBLIC", "TechLogSlugParams", null],
|
|
["TECH_LOG_QUESTION", "/questions/:slug", "PUBLIC", "TechLogSlugParams", null],
|
|
["TECH_LOG_TOPIC", "/topics/:slug", "PUBLIC", "TechLogSlugParams", null],
|
|
["TECH_LOG_PROJECTS", "/projects", "PUBLIC", null, null],
|
|
["TECH_LOG_PROJECT", "/projects/:slug", "PUBLIC", "TechLogSlugParams", null],
|
|
["TECH_LOG_PROJECT_RECORDS", "/projects/:slug/records", "PUBLIC", "TechLogSlugParams", null],
|
|
["TECH_LOG_PROJECT_DECISIONS", "/projects/:slug/decisions", "PUBLIC", "TechLogSlugParams", null],
|
|
["TECH_LOG_PROJECT_ACTIVITY", "/projects/:slug/activity", "PUBLIC", "TechLogSlugParams", null],
|
|
["TECH_LOG_RELEASES", "/releases", "PUBLIC", null, null],
|
|
["TECH_LOG_RELEASE", "/releases/:version", "PUBLIC", "TechLogVersionParams", null],
|
|
["TECH_LOG_PROFILE", "/profile", "PUBLIC", null, null],
|
|
["TECH_LOG_SEARCH", "/search", "PUBLIC", null, "TechLogSearchQuery"],
|
|
["TECH_LOG_STUDIO_HOME", "/studio", "STUDIO", null, null],
|
|
["TECH_LOG_STUDIO_DOCUMENTS", "/studio/documents", "STUDIO", null, null],
|
|
["TECH_LOG_STUDIO_DOCUMENT_NEW", "/studio/documents/new", "STUDIO", null, null],
|
|
["TECH_LOG_STUDIO_DOCUMENT_EDIT", "/studio/documents/:id/edit", "STUDIO", "TechLogDocumentIdParams", null],
|
|
["TECH_LOG_STUDIO_DOCUMENT_VALIDATION", "/studio/documents/:id/validation", "STUDIO", "TechLogDocumentIdParams", null],
|
|
["TECH_LOG_STUDIO_DOCUMENT_PREVIEW", "/studio/documents/:id/preview", "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_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
|
|
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
|
|
["TECH_LOG_STUDIO_TAXONOMY", "/studio/taxonomy", "STUDIO", null, null],
|
|
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
|
|
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
|
|
] as const;
|
|
|
|
const expectedTitles = {
|
|
TECH_LOG_HOME: "TechLog",
|
|
TECH_LOG_EXPLORE: "탐색",
|
|
TECH_LOG_EXPLORE_KIND: "유형별 탐색",
|
|
TECH_LOG_CASE: "Case",
|
|
TECH_LOG_REFERENCE: "Reference",
|
|
TECH_LOG_QUESTION: "Open Question",
|
|
TECH_LOG_TOPIC: "Topic",
|
|
TECH_LOG_PROJECTS: "프로젝트",
|
|
TECH_LOG_PROJECT: "프로젝트",
|
|
TECH_LOG_PROJECT_RECORDS: "프로젝트 기록",
|
|
TECH_LOG_PROJECT_DECISIONS: "프로젝트 결정",
|
|
TECH_LOG_PROJECT_ACTIVITY: "프로젝트 활동",
|
|
TECH_LOG_RELEASES: "변경 기록",
|
|
TECH_LOG_RELEASE: "변경 기록",
|
|
TECH_LOG_PROFILE: "프로필",
|
|
TECH_LOG_SEARCH: "검색",
|
|
TECH_LOG_STUDIO_HOME: "TechLog Studio",
|
|
TECH_LOG_STUDIO_DOCUMENTS: "작업본",
|
|
TECH_LOG_STUDIO_DOCUMENT_NEW: "새 문서",
|
|
TECH_LOG_STUDIO_DOCUMENT_EDIT: "문서 편집",
|
|
TECH_LOG_STUDIO_DOCUMENT_VALIDATION: "문서 검증",
|
|
TECH_LOG_STUDIO_DOCUMENT_PREVIEW: "Public Preview",
|
|
TECH_LOG_STUDIO_DOCUMENT_PUBLISH: "게시",
|
|
TECH_LOG_STUDIO_PUBLICATIONS: "게시 기록",
|
|
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
|
|
TECH_LOG_STUDIO_ASSETS: "Asset",
|
|
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
|
|
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
|
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
|
} as const;
|
|
|
|
describe("TechLog route boundary contract", () => {
|
|
it("freezes the standalone 28-route inventory before runtime installation", () => {
|
|
expect(
|
|
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
|
|
definition.routeId,
|
|
definition.path,
|
|
definition.layoutGroup,
|
|
definition.paramsSchema,
|
|
definition.searchSchema,
|
|
]),
|
|
).toEqual(expectedRoutes);
|
|
|
|
expect(
|
|
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
|
|
definition.routeId,
|
|
definition.access,
|
|
definition.chunkId,
|
|
]),
|
|
).toEqual(
|
|
// Studio is the authenticated surface, Public is not. Derived here from
|
|
// the frozen inventory's own layoutGroup column rather than restated per
|
|
// route, so a Studio route added without a gate fails this table too —
|
|
// every TechLog route used to be registered "public", which made the
|
|
// router's access decision a no-op for Studio.
|
|
expectedRoutes.map(([routeId, , layoutGroup]) => [
|
|
routeId,
|
|
layoutGroup === "STUDIO" ? "session-required" : "public",
|
|
routeId === "NOT_FOUND"
|
|
? "route-not-found"
|
|
: `route-${routeId.toLowerCase().replaceAll("_", "-")}`,
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("keeps runtime module and codec bindings aligned with every dormant route", () => {
|
|
expect(Object.keys(TECH_LOG_ROUTE_RUNTIME_CONTRACT)).toEqual(
|
|
expectedRoutes.map(([routeId]) => routeId),
|
|
);
|
|
for (const [routeId, , , paramsSchema, searchSchema] of expectedRoutes) {
|
|
expect(TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId]).toEqual({
|
|
routeId,
|
|
moduleId:
|
|
routeId === "NOT_FOUND"
|
|
? "route-not-found"
|
|
: `route-${routeId.toLowerCase().replaceAll("_", "-")}`,
|
|
paramsCodec: paramsSchema ?? "none",
|
|
searchCodec: searchSchema ?? "none",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("preserves source navigation labels and the complete route message catalog", () => {
|
|
expect(
|
|
Object.values(TECH_LOG_ROUTE_REGISTRY)
|
|
.filter((definition) => definition.navigationLabel !== null)
|
|
.map(({ routeId, layoutGroup, navigationLabel, navigationOrder }) => [
|
|
routeId,
|
|
layoutGroup,
|
|
navigationLabel,
|
|
navigationOrder,
|
|
]),
|
|
).toEqual([
|
|
["TECH_LOG_EXPLORE", "PUBLIC", "탐색", 10],
|
|
["TECH_LOG_PROJECTS", "PUBLIC", "프로젝트", 20],
|
|
["TECH_LOG_RELEASES", "PUBLIC", "변경 기록", 30],
|
|
["TECH_LOG_PROFILE", "PUBLIC", "프로필", 40],
|
|
["TECH_LOG_STUDIO_DOCUMENTS", "STUDIO", "작업본", 10],
|
|
["TECH_LOG_STUDIO_DOCUMENT_NEW", "STUDIO", "새 문서", 30],
|
|
["TECH_LOG_STUDIO_PUBLICATIONS", "STUDIO", "게시 기록", 20],
|
|
["TECH_LOG_STUDIO_TAXONOMY", "STUDIO", "주제·프로젝트", 40],
|
|
]);
|
|
|
|
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(58);
|
|
for (const [routeId, title] of Object.entries(expectedTitles)) {
|
|
expect(catalog[`route.${routeId}.title`]).toBe(title);
|
|
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("normalizes source search semantics and rejects empty path parameters", () => {
|
|
expect(
|
|
TECH_LOG_ROUTE_CODECS.TechLogExploreSearch.parse({
|
|
type: ["CASE", "REFERENCE"],
|
|
topic: [" JPA ", "Redis"],
|
|
project: " tech-log ",
|
|
unknown: "drop-me",
|
|
}),
|
|
).toEqual({ type: "CASE", topic: "JPA", project: "tech-log" });
|
|
expect(
|
|
TECH_LOG_ROUTE_CODECS.TechLogHomeSearch.parse({
|
|
focus: ["architecture", "delivery"],
|
|
state: ["latest-empty", "site-error"],
|
|
}),
|
|
).toEqual({ focus: "architecture", state: "latest-empty" });
|
|
expect(
|
|
TECH_LOG_ROUTE_CODECS.TechLogSearchQuery.parse({ q: [" gateway ", "ignored"] }),
|
|
).toEqual({ q: "gateway" });
|
|
expect(
|
|
TECH_LOG_ROUTE_CODECS.TechLogSlugParams.safeParse({ slug: "" }).success,
|
|
).toBe(false);
|
|
expect(
|
|
TECH_LOG_ROUTE_CODECS.TechLogDocumentIdParams.safeParse({ id: "" }).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("builds canonical TechLog URLs with non-empty string codecs", () => {
|
|
const route = TECH_LOG_ROUTE_REGISTRY.TECH_LOG_STUDIO_PUBLICATION_PREVIEW;
|
|
const runtime =
|
|
TECH_LOG_ROUTE_RUNTIME_CONTRACT.TECH_LOG_STUDIO_PUBLICATION_PREVIEW;
|
|
expect(
|
|
buildRouteUrlFromContract(route, runtime, {
|
|
...PLATFORM_ROUTE_CODECS,
|
|
...TECH_LOG_ROUTE_CODECS,
|
|
}, {
|
|
params: { publicationEventId: "event/01" },
|
|
}),
|
|
).toBe("/studio/publications/event%2F01/preview");
|
|
|
|
expect(() =>
|
|
buildRouteUrlFromContract(route, runtime, {
|
|
...PLATFORM_ROUTE_CODECS,
|
|
...TECH_LOG_ROUTE_CODECS,
|
|
}, {
|
|
params: { publicationEventId: "" },
|
|
}),
|
|
).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),
|
|
);
|
|
expect(Object.keys(ROUTE_RUNTIME)).toEqual(Object.keys(ROUTE_REGISTRY));
|
|
expect(ROUTE_REGISTRY).toEqual(TECH_LOG_ROUTE_REGISTRY);
|
|
});
|
|
});
|