CLS was 0.192 on every public route, and one element accounted for all of it: the footer moved at t≈482ms, right when the lazily loaded route chunk arrived. The site frame laid the footer out in normal flow, so before the content existed it sat at the bottom edge of the viewport — visible — and then dropped out of view when the page grew to 2400px. The frame is a flex column now with the footer pinned by `margin-top: auto`, and the content slot holds a viewport of height so the footer starts below the fold and only ever moves further out of sight. The slot is `main` once the route renders and `section.ui-page` while Suspense is pending; covering only the first left the shift in place, which is what the intermediate measurements showed. / /explore /projects /releases /search /profile 0.1924 → 0.0001 360 / 768 / 1440 across four routes: no horizontal overflow Separately, the route gate stopped the Studio page but not the Studio shell, so a signed-out visitor who typed /studio still got the whole workspace navigation — 작업본, 게시 기록, 새 문서, by name. No data crosses an href, but "비로그인 사용자가 Studio 화면을 볼 수 없다" is not satisfied by hiding the contents of a screen while showing the screen. The header is drawn only for an authenticated session; `children` is already the router's sign-in surface, which is the whole of what such a visitor gets. signed out h1 "세션이 필요합니다." 0 nav, 0 studio links signed in h1 "작업 흐름" 2 nav, 8 links, sign-out present Three test updates follow from behaviour that changed rather than broke: the frozen route inventory now derives `access` from each route's own layoutGroup instead of asserting "public" for all 28 — so a Studio route added without a gate fails that table too — and the router and shell harnesses supply the session the Studio surface now reads. The router suite also gains a test that a signed-out visitor gets neither the Studio heading nor its navigation, which is the regression the gate exists for. test:all is 1811 passed; the eight remaining failures are the three load-dependent flake families (ci-artifact-contract, provider-guardian-transaction, security-followup), all of which pass in isolation and reference none of the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
9.8 KiB
TypeScript
225 lines
9.8 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_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_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],
|
|
]);
|
|
|
|
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(56);
|
|
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);
|
|
});
|
|
});
|