The public site shipped a Releases page and a footer link to a release, and neither could ever have content: the read path existed, the write path did not. This adds the seven release operations to the contract contribution and the gateway, and a Studio screen that can actually write one. The editor is six markdown fields rather than one, because that is what the contract models and what a release note is — why, what, what a reader notices, what it leaves in the code, how it was verified, what is still open. Publishing is separate from saving: a draft saves in any state, but the public query keys on PUBLISHED alone, so publish is where completeness is demanded. No new CSS. The screen reuses the document editor's field classes and the working-copy list's row classes, so it inherits Studio's spacing and type instead of introducing a second look. The footer previously linked `/releases/0.1.0` — a version that did not exist, so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now points at the changelog index, which is the only place that knows what the latest release is and which reads correctly when there are none. Route inventory, navigation order, message catalog, manual accessibility evidence, artifact baseline, and the pinned gate-shape digest all move with the new route. The digest was recomputed by first reproducing the previous constant from the previous gates.json, so the computation is known to be the one it was pinned under.
231 lines
10 KiB
TypeScript
231 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_RELEASES", "/studio/releases", "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_RELEASES: "릴리즈",
|
|
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
|
|
NOT_FOUND: "페이지를 찾을 수 없습니다.",
|
|
} as const;
|
|
|
|
describe("TechLog route boundary contract", () => {
|
|
it("freezes the standalone 29-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],
|
|
["TECH_LOG_STUDIO_RELEASES", "STUDIO", "릴리즈", 50],
|
|
]);
|
|
|
|
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(60);
|
|
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);
|
|
});
|
|
});
|