From adb8613cb9cef177c6662c9b0e2622a771c8060d Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 15 Aug 2026 20:30:32 +0900 Subject: [PATCH] feat: add grouped TechLog route contracts --- config/contracts/registry-governance.json | 26 ++- src/contracts/route-runtime-contract.ts | 17 +- src/contracts/routes.ts | 9 + src/features/installed-feature-contracts.ts | 6 +- src/features/installed-feature-runtimes.tsx | 2 + .../contracts/reference-feature-contract.ts | 4 + .../contracts/tech-log-message-catalog.ts | 24 ++ .../contracts/tech-log-route-contract.ts | 95 ++++++++ .../presentation/tech-log-route-codecs.ts | 56 +++++ src/presentation/routes/app-router.tsx | 179 +++++++++++---- src/presentation/routes/navigation-policy.ts | 11 +- .../routes/platform-route-codecs.ts | 2 +- src/presentation/routes/route-codecs.ts | 131 ++++++++++- tests/component/router.test.tsx | 187 +++++++++++++++- .../features/tech-log/route-contract.test.ts | 205 ++++++++++++++++++ 15 files changed, 887 insertions(+), 67 deletions(-) create mode 100644 src/features/tech-log/contracts/tech-log-message-catalog.ts create mode 100644 src/features/tech-log/contracts/tech-log-route-contract.ts create mode 100644 src/features/tech-log/presentation/tech-log-route-codecs.ts create mode 100644 tests/features/tech-log/route-contract.test.ts diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index a19036d..9ccb27f 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -15,6 +15,7 @@ "requiredFields": [ "routeId", "path", + "layoutGroup", "paramsSchema", "searchSchema", "access", @@ -28,6 +29,7 @@ "fieldTypes": { "routeId": "string", "path": "string", + "layoutGroup": "string", "paramsSchema": "string|null", "searchSchema": "string|null", "access": "string", @@ -41,8 +43,27 @@ "uniqueFields": ["routeId", "path", "chunkId"], "allowedValues": { "access": ["public", "session-required"], - "paramsSchema": [null, "NotFoundSplat", "ReferenceResourceParams"], - "searchSchema": [null, "ReferenceResourceListQuery"], + "layoutGroup": ["PUBLIC", "STUDIO"], + "paramsSchema": [ + null, + "NotFoundSplat", + "ReferenceResourceParams", + "TechLogExploreKindParams", + "TechLogSlugParams", + "TechLogVersionParams", + "TechLogDocumentIdParams", + "TechLogPublicationEventIdParams", + "TechLogStudioSplat" + ], + "searchSchema": [ + null, + "ReferenceResourceListQuery", + "TechLogHomeSearch", + "TechLogExploreSearch", + "TechLogExploreKindSearch", + "TechLogSearchQuery", + "TechLogCaseStateSearch" + ], "loadingSurface": [ "app-shell", "example-page", @@ -84,6 +105,7 @@ "breakingFields": [ "routeId", "path", + "layoutGroup", "paramsSchema", "searchSchema", "access", diff --git a/src/contracts/route-runtime-contract.ts b/src/contracts/route-runtime-contract.ts index ddbbdfa..a4ee913 100644 --- a/src/contracts/route-runtime-contract.ts +++ b/src/contracts/route-runtime-contract.ts @@ -1,4 +1,19 @@ -export type RouteCodecId = "none" | "NotFoundSplat"; +export type RouteCodecId = + | "none" + | "NotFoundSplat" + | "ReferenceResourceParams" + | "ReferenceResourceListQuery" + | "TechLogExploreKindParams" + | "TechLogSlugParams" + | "TechLogVersionParams" + | "TechLogDocumentIdParams" + | "TechLogPublicationEventIdParams" + | "TechLogStudioSplat" + | "TechLogHomeSearch" + | "TechLogExploreSearch" + | "TechLogExploreKindSearch" + | "TechLogSearchQuery" + | "TechLogCaseStateSearch"; export type RouteRuntimeDefinition = Readonly<{ routeId: string; moduleId: string; diff --git a/src/contracts/routes.ts b/src/contracts/routes.ts index 8318f31..c2b29e0 100644 --- a/src/contracts/routes.ts +++ b/src/contracts/routes.ts @@ -1,6 +1,9 @@ +export type RouteLayoutGroup = "PUBLIC" | "STUDIO"; + export type RouteDefinition = Readonly<{ routeId: string; path: string; + layoutGroup: RouteLayoutGroup; paramsSchema: string | null; searchSchema: string | null; access: "public" | "session-required"; @@ -20,6 +23,7 @@ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ APP_HOME: route({ routeId: "APP_HOME", path: "/", + layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, access: "public", @@ -33,6 +37,7 @@ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ EXAMPLES_PLATFORM: route({ routeId: "EXAMPLES_PLATFORM", path: "/examples/platform", + layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, access: "public", @@ -46,6 +51,7 @@ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ EXAMPLES_UI: route({ routeId: "EXAMPLES_UI", path: "/examples/ui", + layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, access: "public", @@ -59,6 +65,7 @@ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ EXAMPLES_STATES: route({ routeId: "EXAMPLES_STATES", path: "/examples/states", + layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, access: "public", @@ -72,6 +79,7 @@ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ EXAMPLES_AUTH: route({ routeId: "EXAMPLES_AUTH", path: "/examples/auth", + layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, access: "public", @@ -85,6 +93,7 @@ export const PLATFORM_ROUTE_REGISTRY = Object.freeze({ NOT_FOUND: route({ routeId: "NOT_FOUND", path: "*", + layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, access: "public", diff --git a/src/features/installed-feature-contracts.ts b/src/features/installed-feature-contracts.ts index e5ce974..19a65eb 100644 --- a/src/features/installed-feature-contracts.ts +++ b/src/features/installed-feature-contracts.ts @@ -1,5 +1,7 @@ import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts"; +import type { RouteRuntimeDefinition } from "../contracts/route-runtime-contract.ts"; import { PLATFORM_ROUTE_REGISTRY } from "../contracts/routes.ts"; +import type { RouteDefinition } from "../contracts/routes.ts"; import { composeSchemaRegistry, PLATFORM_SCHEMA_REGISTRY, @@ -20,11 +22,11 @@ export const INSTALLED_FEATURE_CONTRACTS = Object.freeze([ export const ROUTE_REGISTRY = Object.freeze({ ...PLATFORM_ROUTE_REGISTRY, ...REFERENCE_FEATURE_CONTRACT.routes, -}); +}) satisfies Readonly>; export const ROUTE_RUNTIME_CONTRACT = Object.freeze({ ...PLATFORM_ROUTE_RUNTIME_CONTRACT, ...REFERENCE_FEATURE_CONTRACT.routeRuntimeContracts, -}); +}) satisfies Readonly>; export const API_OPERATIONS = composeApiOperations( INSTALLED_FEATURE_CONTRACTS.map((contract) => contract.apiOperations), ); diff --git a/src/features/installed-feature-runtimes.tsx b/src/features/installed-feature-runtimes.tsx index cc14993..e706ba6 100644 --- a/src/features/installed-feature-runtimes.tsx +++ b/src/features/installed-feature-runtimes.tsx @@ -4,10 +4,12 @@ import { REFERENCE_FEATURE_ROUTE_CODECS, REFERENCE_FEATURE_ROUTE_RUNTIME, } from "./reference-feature/presentation/reference-feature-runtime.tsx"; +import { TECH_LOG_ROUTE_CODECS } from "./tech-log/presentation/tech-log-route-codecs.ts"; export const ROUTE_CODECS = Object.freeze({ ...PLATFORM_ROUTE_CODECS, ...REFERENCE_FEATURE_ROUTE_CODECS, + ...TECH_LOG_ROUTE_CODECS, }); export const ROUTE_RUNTIME = Object.freeze({ diff --git a/src/features/reference-feature/contracts/reference-feature-contract.ts b/src/features/reference-feature/contracts/reference-feature-contract.ts index 3bac006..ee43e9d 100644 --- a/src/features/reference-feature/contracts/reference-feature-contract.ts +++ b/src/features/reference-feature/contracts/reference-feature-contract.ts @@ -76,6 +76,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ REFERENCE_RESOURCE_LIST: Object.freeze({ routeId: "REFERENCE_RESOURCE_LIST", path: "/examples/reference-resources", + layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: "ReferenceResourceListQuery", access: "session-required", @@ -89,6 +90,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ REFERENCE_RESOURCE_DETAIL: Object.freeze({ routeId: "REFERENCE_RESOURCE_DETAIL", path: "/examples/reference-resources/:resourceId", + layoutGroup: "PUBLIC", paramsSchema: "ReferenceResourceParams", searchSchema: null, access: "session-required", @@ -102,6 +104,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ REFERENCE_RESOURCE_FORM: Object.freeze({ routeId: "REFERENCE_RESOURCE_FORM", path: "/examples/reference-resources/new", + layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, access: "session-required", @@ -115,6 +118,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({ REFERENCE_RESOURCE_STATUS: Object.freeze({ routeId: "REFERENCE_RESOURCE_STATUS", path: "/examples/reference-resources/status", + layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, access: "session-required", diff --git a/src/features/tech-log/contracts/tech-log-message-catalog.ts b/src/features/tech-log/contracts/tech-log-message-catalog.ts new file mode 100644 index 0000000..a1f7488 --- /dev/null +++ b/src/features/tech-log/contracts/tech-log-message-catalog.ts @@ -0,0 +1,24 @@ +import { + TECH_LOG_ROUTE_REGISTRY, + type TechLogRouteId, +} from "./tech-log-route-contract.ts"; + +export type TechLogRouteMessageKey = + | `route.${TechLogRouteId}.title` + | `route.${TechLogRouteId}.navigation`; + +function createCatalog(): Readonly> { + return Object.freeze( + Object.fromEntries( + Object.values(TECH_LOG_ROUTE_REGISTRY).flatMap((definition) => [ + [`route.${definition.routeId}.title`, definition.title], + [`route.${definition.routeId}.navigation`, definition.title], + ]), + ), + ) as Readonly>; +} + +export const TECH_LOG_MESSAGE_CATALOGS = Object.freeze({ + "ko-KR": createCatalog(), + "en-US": createCatalog(), +}); diff --git a/src/features/tech-log/contracts/tech-log-route-contract.ts b/src/features/tech-log/contracts/tech-log-route-contract.ts new file mode 100644 index 0000000..b8c94f5 --- /dev/null +++ b/src/features/tech-log/contracts/tech-log-route-contract.ts @@ -0,0 +1,95 @@ +import type { + RouteCodecId, + RouteRuntimeDefinition, +} from "../../../contracts/route-runtime-contract.ts"; +import type { + RouteDefinition, + RouteLayoutGroup, +} from "../../../contracts/routes.ts"; + +type RouteSpec = Readonly<{ + routeId: string; + path: string; + layoutGroup: RouteLayoutGroup; + paramsSchema: RouteCodecId | null; + searchSchema: RouteCodecId | null; + title: string; + navigationLabel: string | null; + navigationOrder: number | null; +}>; + +const defineSpec = (spec: Spec): Spec => spec; + +const TECH_LOG_ROUTE_SPECS = [ + defineSpec({ routeId: "TECH_LOG_HOME", path: "/", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: "TechLogHomeSearch", title: "TechLog", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_EXPLORE", path: "/explore", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: "TechLogExploreSearch", title: "탐색", navigationLabel: "탐색", navigationOrder: 10 }), + defineSpec({ routeId: "TECH_LOG_EXPLORE_KIND", path: "/explore/:kind", layoutGroup: "PUBLIC", paramsSchema: "TechLogExploreKindParams", searchSchema: "TechLogExploreKindSearch", title: "유형별 탐색", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_CASE", path: "/cases/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: "TechLogCaseStateSearch", title: "Case", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_REFERENCE", path: "/references/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "Reference", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_QUESTION", path: "/questions/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "Open Question", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_TOPIC", path: "/topics/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "Topic", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_PROJECTS", path: "/projects", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, title: "프로젝트", navigationLabel: "프로젝트", navigationOrder: 20 }), + defineSpec({ routeId: "TECH_LOG_PROJECT", path: "/projects/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "프로젝트", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_PROJECT_RECORDS", path: "/projects/:slug/records", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "프로젝트 기록", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_PROJECT_DECISIONS", path: "/projects/:slug/decisions", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "프로젝트 결정", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_PROJECT_ACTIVITY", path: "/projects/:slug/activity", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "프로젝트 활동", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_RELEASES", path: "/releases", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, title: "변경 기록", navigationLabel: "변경 기록", navigationOrder: 30 }), + defineSpec({ routeId: "TECH_LOG_RELEASE", path: "/releases/:version", layoutGroup: "PUBLIC", paramsSchema: "TechLogVersionParams", searchSchema: null, title: "변경 기록", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_PROFILE", path: "/profile", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, title: "프로필", navigationLabel: "프로필", navigationOrder: 40 }), + defineSpec({ routeId: "TECH_LOG_SEARCH", path: "/search", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: "TechLogSearchQuery", title: "검색", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_STUDIO_HOME", path: "/studio", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "TechLog Studio", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENTS", path: "/studio/documents", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "작업본", navigationLabel: "작업본", navigationOrder: 10 }), + defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_NEW", path: "/studio/documents/new", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "새 문서", navigationLabel: "새 문서", navigationOrder: 30 }), + defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_EDIT", path: "/studio/documents/:id/edit", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "문서 편집", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_VALIDATION", path: "/studio/documents/:id/validation", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "문서 검증", navigationLabel: null, navigationOrder: null }), + defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_PREVIEW", path: "/studio/documents/:id/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "Public Preview", 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_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", 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 }), +] as const; + +export type TechLogRouteId = (typeof TECH_LOG_ROUTE_SPECS)[number]["routeId"]; + +function chunkId(routeId: TechLogRouteId): string { + return routeId === "NOT_FOUND" + ? "route-not-found" + : `route-${routeId.toLowerCase().replaceAll("_", "-")}`; +} + +export const TECH_LOG_ROUTE_REGISTRY = Object.freeze( + Object.fromEntries( + TECH_LOG_ROUTE_SPECS.map((spec) => [ + spec.routeId, + Object.freeze({ + ...spec, + access: "public", + loadingSurface: spec.path.endsWith("*") ? "none" : "app-shell", + errorSurface: spec.path.endsWith("*") + ? "not-found" + : "feature-boundary", + chunkId: chunkId(spec.routeId), + } satisfies RouteDefinition), + ]), + ), +) as Readonly>; + +export const TECH_LOG_ROUTE_RUNTIME_CONTRACT = Object.freeze( + Object.fromEntries( + TECH_LOG_ROUTE_SPECS.map((spec) => [ + spec.routeId, + Object.freeze({ + routeId: spec.routeId, + moduleId: chunkId(spec.routeId), + paramsCodec: spec.paramsSchema ?? "none", + searchCodec: spec.searchSchema ?? "none", + } satisfies RouteRuntimeDefinition), + ]), + ), +) as Readonly>; + +export const TECH_LOG_ROUTE_CONTRACT = Object.freeze({ + routes: TECH_LOG_ROUTE_REGISTRY, + routeRuntimeContracts: TECH_LOG_ROUTE_RUNTIME_CONTRACT, +}); diff --git a/src/features/tech-log/presentation/tech-log-route-codecs.ts b/src/features/tech-log/presentation/tech-log-route-codecs.ts new file mode 100644 index 0000000..d4da982 --- /dev/null +++ b/src/features/tech-log/presentation/tech-log-route-codecs.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +function first(value: unknown): unknown { + return Array.isArray(value) ? value[0] : value; +} + +const optionalString = z.preprocess(first, z.string().optional()); +const optionalSelection = z.preprocess((value) => { + const selected = first(value); + if (typeof selected !== "string") return selected; + const normalized = selected.trim(); + return normalized || undefined; +}, z.string().optional()); + +const nonEmptyParams = (key: Key) => + z.object({ [key]: z.string().min(1) } as Record).strict(); + +export const TECH_LOG_ROUTE_CODECS = Object.freeze({ + TechLogExploreKindParams: nonEmptyParams("kind"), + TechLogSlugParams: nonEmptyParams("slug"), + TechLogVersionParams: nonEmptyParams("version"), + TechLogDocumentIdParams: nonEmptyParams("id"), + TechLogPublicationEventIdParams: nonEmptyParams("publicationEventId"), + TechLogStudioSplat: z + .object({ "*": z.string().min(1).optional() }) + .strict(), + TechLogHomeSearch: z + .object({ + focus: optionalString, + state: optionalString, + }) + .strip(), + TechLogExploreSearch: z + .object({ + type: optionalString, + topic: optionalSelection, + project: optionalSelection, + }) + .strip(), + TechLogExploreKindSearch: z + .object({ + topic: optionalSelection, + project: optionalSelection, + }) + .strip(), + TechLogSearchQuery: z + .object({ + q: optionalSelection, + }) + .strip(), + TechLogCaseStateSearch: z + .object({ + state: optionalString, + }) + .strip(), +} as const); diff --git a/src/presentation/routes/app-router.tsx b/src/presentation/routes/app-router.tsx index 6565fb1..b9b8d27 100644 --- a/src/presentation/routes/app-router.tsx +++ b/src/presentation/routes/app-router.tsx @@ -1,4 +1,5 @@ import { + type ComponentType, type ReactNode, Suspense, useEffect, @@ -8,6 +9,7 @@ import { } from "react"; import { createBrowserRouter, + Outlet, RouterProvider, type RouteObject, useLocation, @@ -16,11 +18,11 @@ import { useSearchParams, } from "react-router-dom"; -import { - getRoute, - ROUTE_REGISTRY, -} from "../../features/installed-feature-contracts.ts"; -import type { RouteDefinition } from "../../contracts/routes.ts"; +import { ROUTE_REGISTRY } from "../../features/installed-feature-contracts.ts"; +import type { + RouteDefinition, + RouteLayoutGroup, +} from "../../contracts/routes.ts"; import { FeatureBoundary, RouteBoundary, @@ -34,14 +36,18 @@ import { SessionProvider, useSession } from "../providers/session-provider.tsx"; import { ThemeProvider } from "../providers/theme-provider.tsx"; import { createRedirectLoopGuard, - decideRouteAccess, + decideRouteAccessForDefinition, } from "./navigation-policy.ts"; import { - buildRouteUrl, - parseRouteInput, + buildRouteUrlFromDefinition, + parseRouteInputFromContract, + type RouteCodecRegistry, type RouteId, } from "./route-codecs.ts"; -import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.tsx"; +import { + ROUTE_CODECS, + ROUTE_RUNTIME, +} from "../../features/installed-feature-runtimes.tsx"; import type { ParsedRouteInput } from "./route-contract.ts"; import { RouteInputProvider } from "./route-input.tsx"; @@ -138,33 +144,41 @@ function RouteLifecycle({ function CanonicalRouteRedirect({ input, + definition, + codecs, }: { input: ParsedRouteInput; + definition: RouteDefinition; + codecs: RouteCodecRegistry; }) { const location = useLocation(); const navigate = useNavigate(); const guard = useRef(createRedirectLoopGuard(3)); useEffect(() => { - if (input.routeId === "NOT_FOUND") return; + if (definition.path.includes("*")) return; const source = `${location.pathname}${location.search}`; - const target = buildRouteUrl(input.routeId, { - params: input.params, - search: input.search, - }); + const target = buildRouteUrlFromDefinition( + definition, + codecs, + { + params: input.params, + search: input.search, + }, + ); if (source === target) { guard.current.reset(); } else if (guard.current.allow(source, target)) { void navigate(target, { replace: true }); } - }, [input, location.pathname, location.search, navigate]); + }, [codecs, definition, input, location.pathname, location.search, navigate]); return null; } function ProtectedRoute({ - routeId, + definition, children, }: { - routeId: RouteId; + definition: RouteDefinition; children: ReactNode; }) { const location = useLocation(); @@ -172,7 +186,7 @@ function ProtectedRoute({ const { message } = useLocale(); const [pending, setPending] = useState(false); const [failed, setFailed] = useState(false); - const decision = decideRouteAccess(routeId, sessionState); + const decision = decideRouteAccessForDefinition(definition, sessionState); async function continueSession() { setPending(true); @@ -240,29 +254,45 @@ function ProtectedRoute({ function RegisteredRoute({ routeId, buildId, + definition, + runtime, + codecs, }: { routeId: RouteId; buildId: string; + definition: RouteDefinition; + runtime: GroupedRouteRuntimeDefinition; + codecs: RouteCodecRegistry; }) { - const definition = getRoute(routeId); - const runtime = ROUTE_RUNTIME[routeId]; const params = useParams(); const [search] = useSearchParams(); const location = useLocation(); const { diagnostics, recovery } = useApplication(); - const parsed = parseRouteInput(routeId, params, search); + const parsed = parseRouteInputFromContract( + routeId, + definition, + codecs, + params, + search, + ); if (!parsed.success) return ; + const routeInput = parsed.data as ParsedRouteInput; + const RuntimeComponent = runtime.Component; const content = ( - - + + }> - + @@ -271,7 +301,7 @@ function RegisteredRoute({ definition.access === "public" ? ( content ) : ( - {content} + {content} ); const boundaryProps = { routeId, @@ -288,34 +318,82 @@ function RegisteredRoute({ ); } -function createRegisteredRoutes(buildId: string): RouteObject[] { - const children = Object.values(ROUTE_REGISTRY).map((definition) => { +type GroupedRouteRuntimeDefinition = Readonly<{ + moduleId: string; + Component: ComponentType; +}>; + +type GroupedRouteRegistry = Readonly>; +type GroupedRouteRuntime = Readonly< + Record +>; +type GroupedRouteLayouts = Readonly>; + +export function createGroupedRouteObjects( + registry: GroupedRouteRegistry, + runtime: GroupedRouteRuntime, + layouts: GroupedRouteLayouts, + buildId: string, + codecs: RouteCodecRegistry = ROUTE_CODECS, +): RouteObject[] { + for (const routeId of Object.keys(runtime)) { + if (!registry[routeId]) { + throw new TypeError(`Missing route definition: ${routeId}`); + } + } + + const children: Record = { + PUBLIC: [], + STUDIO: [], + }; + for (const definition of Object.values(registry)) { const routeId = definition.routeId as RouteId; + const routeRuntime = runtime[definition.routeId]; + if (!routeRuntime) { + throw new TypeError(`Missing route runtime: ${definition.routeId}`); + } + const element = ( + + ); if (definition.path === "/") { - return { + children[definition.layoutGroup].push({ id: routeId, index: true, - element: , - }; + element, + }); + continue; } - return { + children[definition.layoutGroup].push({ id: routeId, path: definition.path === "*" ? "*" : definition.path.replace(/^\//, ""), - element: , - }; - }); - return [ - { - id: "APP_SHELL", - path: "/", - element: , - errorElement: , - children, - }, - ]; + element, + }); + } + + for (const layoutGroup of ["PUBLIC", "STUDIO"] as const) { + children[layoutGroup].sort( + (left, right) => + Number(Boolean(left.path?.endsWith("*"))) - + Number(Boolean(right.path?.endsWith("*"))), + ); + } + + return (["STUDIO", "PUBLIC"] as const).map((layoutGroup) => ({ + id: `${layoutGroup}_LAYOUT`, + path: "/", + element: layouts[layoutGroup], + errorElement: , + children: children[layoutGroup], + })); } export function AppRouter({ @@ -324,9 +402,18 @@ export function AppRouter({ }: Readonly<{ basename?: string; buildId?: string }>) { const router = useMemo( () => - createBrowserRouter(createRegisteredRoutes(buildId), { - basename, - }), + createBrowserRouter( + createGroupedRouteObjects( + ROUTE_REGISTRY, + ROUTE_RUNTIME, + { + PUBLIC: , + STUDIO: , + }, + buildId, + ), + { basename }, + ), [basename, buildId], ); return ( diff --git a/src/presentation/routes/navigation-policy.ts b/src/presentation/routes/navigation-policy.ts index ca84557..9793339 100644 --- a/src/presentation/routes/navigation-policy.ts +++ b/src/presentation/routes/navigation-policy.ts @@ -1,4 +1,5 @@ import type { SessionState } from "../../application/ports/in/application-api.ts"; +import type { RouteDefinition } from "../../contracts/routes.ts"; import { getRoute } from "../../features/installed-feature-contracts.ts"; export type RouteAccessDecision = @@ -12,8 +13,14 @@ export function decideRouteAccess( routeId: string, sessionState: SessionState, ): RouteAccessDecision { - const route = getRoute(routeId); - if (route.access === "public") return { allowed: true, action: "none" }; + return decideRouteAccessForDefinition(getRoute(routeId), sessionState); +} + +export function decideRouteAccessForDefinition( + definition: Pick, + sessionState: SessionState, +): RouteAccessDecision { + if (definition.access === "public") return { allowed: true, action: "none" }; if (sessionState === "authenticated") { return { allowed: true, action: "none" }; } diff --git a/src/presentation/routes/platform-route-codecs.ts b/src/presentation/routes/platform-route-codecs.ts index 830f2e6..e0c8f6d 100644 --- a/src/presentation/routes/platform-route-codecs.ts +++ b/src/presentation/routes/platform-route-codecs.ts @@ -2,5 +2,5 @@ import { z } from "zod"; export const PLATFORM_ROUTE_CODECS = { none: z.object({}).strict(), - NotFoundSplat: z.object({ "*": z.string().optional() }).strict(), + NotFoundSplat: z.object({ "*": z.string().min(1).optional() }).strict(), } as const; diff --git a/src/presentation/routes/route-codecs.ts b/src/presentation/routes/route-codecs.ts index 7ea4dd9..9c2eb69 100644 --- a/src/presentation/routes/route-codecs.ts +++ b/src/presentation/routes/route-codecs.ts @@ -1,5 +1,10 @@ -import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.ts"; +import { + getRoute, + ROUTE_RUNTIME_CONTRACT, +} from "../../features/installed-feature-contracts.ts"; import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.tsx"; +import type { RouteDefinition } from "../../contracts/routes.ts"; +import type { RouteRuntimeDefinition } from "../../contracts/route-runtime-contract.ts"; import type { ParsedRouteInput, RouteId, @@ -8,8 +13,33 @@ import type { export type { ParsedRouteInput, RouteId, RouteInputResult }; -function codecById(codecId: string) { - const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS]; +type RouteCodec = Readonly<{ + parse(value: unknown): Readonly>; + safeParse(value: unknown): + | Readonly<{ + success: true; + data: Readonly>; + }> + | Readonly<{ success: false }>; +}>; + +export type RouteCodecRegistry = Readonly>; +export type ContractRouteInputResult = + | Readonly<{ + success: true; + data: Readonly<{ + routeId: string; + params: Readonly>; + search: Readonly>; + }>; + }> + | Readonly<{ + success: false; + code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID"; + }>; + +function codecById(codecId: string, codecs: RouteCodecRegistry = ROUTE_CODECS) { + const codec = codecs[codecId]; if (!codec) throw new TypeError(`Unregistered route codec: ${codecId}`); return codec; } @@ -19,12 +49,33 @@ export function parseRouteInput( rawParams: Readonly>, rawSearch: URLSearchParams, ): RouteInputResult { - const runtime = ROUTE_RUNTIME_CONTRACT[routeId]; - const params = codecById(runtime.paramsCodec).safeParse(rawParams); + return parseRouteInputFromContract( + routeId, + getRoute(routeId), + ROUTE_CODECS, + rawParams, + rawSearch, + ) as RouteInputResult; +} + +export function parseRouteInputFromContract( + routeId: string, + definition: Pick, + codecs: RouteCodecRegistry, + rawParams: Readonly>, + rawSearch: URLSearchParams, +): ContractRouteInputResult { + const params = codecById( + definition.paramsSchema ?? "none", + codecs, + ).safeParse(rawParams); if (!params.success) { return { success: false, code: "ROUTE_PARAMS_INVALID" }; } - const search = codecById(runtime.searchCodec).safeParse( + const search = codecById( + definition.searchSchema ?? "none", + codecs, + ).safeParse( searchRecord(rawSearch), ); if (!search.success) { @@ -50,15 +101,71 @@ export function buildRouteUrl( }> = {}, ): string { const definition = getRoute(routeId); - if (definition.path === "*") { - throw new TypeError("The not-found route cannot build a canonical URL"); - } const runtime = ROUTE_RUNTIME_CONTRACT[routeId]; - const params = codecById(runtime.paramsCodec).parse(input.params ?? {}); - const search = codecById(runtime.searchCodec).parse(input.search ?? {}); + return buildRouteUrlFromContract( + definition, + runtime, + ROUTE_CODECS, + input, + ); +} + +export function buildRouteUrlFromContract( + definition: Pick, + runtime: Pick, + codecs: RouteCodecRegistry, + input: Readonly<{ + params?: Readonly>; + search?: Readonly>; + }> = {}, +): string { + return buildRouteUrlWithCodecs( + definition.path, + runtime.paramsCodec, + runtime.searchCodec, + codecs, + input, + ); +} + +export function buildRouteUrlFromDefinition( + definition: Pick< + RouteDefinition, + "path" | "paramsSchema" | "searchSchema" + >, + codecs: RouteCodecRegistry, + input: Readonly<{ + params?: Readonly>; + search?: Readonly>; + }> = {}, +): string { + return buildRouteUrlWithCodecs( + definition.path, + definition.paramsSchema ?? "none", + definition.searchSchema ?? "none", + codecs, + input, + ); +} + +function buildRouteUrlWithCodecs( + pathTemplate: string, + paramsCodec: string, + searchCodec: string, + codecs: RouteCodecRegistry, + input: Readonly<{ + params?: Readonly>; + search?: Readonly>; + }>, +): string { + if (pathTemplate.includes("*")) { + throw new TypeError("A wildcard route cannot build a canonical URL"); + } + const params = codecById(paramsCodec, codecs).parse(input.params ?? {}); + const search = codecById(searchCodec, codecs).parse(input.search ?? {}); const parsedParams: Record = { ...params }; const parsedSearch: Record = { ...search }; - let path = definition.path; + let path = pathTemplate; path = path.replace( /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g, ( diff --git a/tests/component/router.test.tsx b/tests/component/router.test.tsx index a769656..95b41b2 100644 --- a/tests/component/router.test.tsx +++ b/tests/component/router.test.tsx @@ -3,11 +3,31 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; +import { + createMemoryRouter, + matchRoutes, + Outlet, + RouterProvider, +} from "react-router-dom"; import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts"; import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx"; -import { AppRouter } from "../../src/presentation/routes/app-router.tsx"; +import { + AppRouter, + createGroupedRouteObjects, +} from "../../src/presentation/routes/app-router.tsx"; import { createTestApplication } from "../helpers/create-test-application.ts"; +import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts"; +import { ROUTE_RUNTIME } from "../../src/features/installed-feature-runtimes.tsx"; +import { LocaleProvider } from "../../src/presentation/i18n/index.ts"; +import { SessionProvider } from "../../src/presentation/providers/session-provider.tsx"; +import { ThemeProvider } from "../../src/presentation/providers/theme-provider.tsx"; +import { useRouteInput } from "../../src/presentation/routes/route-input.tsx"; + +function StudioFallbackFixture() { + const input = useRouteInput(); + return

{String(input.params["*"])}

; +} function renderRouter() { return render( @@ -22,6 +42,161 @@ function renderRouter() { } describe("generic application router", () => { + it("assembles generic Public and Studio parents with Studio catch-all precedence", () => { + const registry = { + APP_HOME: ROUTE_REGISTRY.APP_HOME, + STUDIO_FIXTURE: { + ...ROUTE_REGISTRY.NOT_FOUND, + routeId: "STUDIO_FIXTURE", + path: "/studio/*", + layoutGroup: "STUDIO" as const, + }, + NOT_FOUND: ROUTE_REGISTRY.NOT_FOUND, + }; + const runtime = { + APP_HOME: ROUTE_RUNTIME.APP_HOME, + STUDIO_FIXTURE: ROUTE_RUNTIME.NOT_FOUND, + NOT_FOUND: ROUTE_RUNTIME.NOT_FOUND, + }; + const routes = createGroupedRouteObjects( + registry, + runtime, + { + PUBLIC:
, + STUDIO:
, + }, + "test-build", + ); + + expect(routes.map((route) => route.id)).toEqual([ + "STUDIO_LAYOUT", + "PUBLIC_LAYOUT", + ]); + expect( + matchRoutes(routes, "/studio/unknown")?.map((match) => match.route.id), + ).toEqual(["STUDIO_LAYOUT", "STUDIO_FIXTURE"]); + expect( + matchRoutes(routes, "/publicly-unknown")?.map((match) => match.route.id), + ).toEqual(["PUBLIC_LAYOUT", "NOT_FOUND"]); + + const installedRoutes = createGroupedRouteObjects( + ROUTE_REGISTRY, + ROUTE_RUNTIME, + { + PUBLIC:
, + STUDIO:
, + }, + "test-build", + ); + expect(installedRoutes[1]?.children?.at(-1)?.id).toBe("NOT_FOUND"); + }); + + it("rejects a grouped registry whose leaf runtime is missing", () => { + expect(() => + createGroupedRouteObjects( + ROUTE_REGISTRY, + {}, + { + PUBLIC:
, + STUDIO:
, + }, + "test-build", + ), + ).toThrow("Missing route runtime: APP_HOME"); + }); + + it("renders a non-installed Studio wildcard leaf without rewriting its URL", async () => { + const routes = createGroupedRouteObjects( + { + STUDIO_FIXTURE: { + ...ROUTE_REGISTRY.NOT_FOUND, + routeId: "STUDIO_FIXTURE", + path: "/studio/*", + layoutGroup: "STUDIO", + }, + }, + { + STUDIO_FIXTURE: { + moduleId: "studio-fixture", + Component: StudioFallbackFixture, + }, + }, + { + PUBLIC: , + STUDIO: ( +
+ +
+ ), + }, + "test-build", + ); + const router = createMemoryRouter(routes, { + initialEntries: ["/studio/unknown/path"], + }); + + render( + + + + + + + + + , + ); + + expect(await screen.findByTestId("studio-layout")).toBeVisible(); + expect( + await screen.findByRole("heading", { name: "unknown/path" }), + ).toBeVisible(); + expect(router.state.location.pathname).toBe("/studio/unknown/path"); + }); + + it("applies authorization from a non-installed route definition", async () => { + const routes = createGroupedRouteObjects( + { + PROTECTED_FIXTURE: { + ...ROUTE_REGISTRY.APP_HOME, + routeId: "PROTECTED_FIXTURE", + path: "/private-fixture", + access: "session-required", + }, + }, + { + PROTECTED_FIXTURE: { + moduleId: "protected-fixture", + Component: StudioFallbackFixture, + }, + }, + { + PUBLIC: , + STUDIO: , + }, + "test-build", + ); + const router = createMemoryRouter(routes, { + initialEntries: ["/private-fixture"], + }); + + render( + + + + + + + + + , + ); + + expect( + await screen.findByRole("heading", { name: "세션이 필요합니다." }), + ).toBeVisible(); + }); + it("reaches the platform overview from the home starter actions", async () => { const user = userEvent.setup(); window.history.pushState({}, "", "/"); @@ -58,6 +233,16 @@ describe("generic application router", () => { expect(screen.getByRole("main")).toBeVisible(); }); + it("preserves authorization around grouped registered leaves", async () => { + window.history.pushState({}, "", "/examples/reference-resources"); + renderRouter(); + + expect( + await screen.findByRole("heading", { name: "세션이 필요합니다." }), + ).toBeVisible(); + expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible(); + }); + it("navigates between registry-backed platform routes", async () => { const user = userEvent.setup(); window.history.pushState({}, "", "/"); diff --git a/tests/features/tech-log/route-contract.test.ts b/tests/features/tech-log/route-contract.test.ts new file mode 100644 index 0000000..31d379b --- /dev/null +++ b/tests/features/tech-log/route-contract.test.ts @@ -0,0 +1,205 @@ +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_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_NOT_FOUND: "Studio 화면을 찾을 수 없습니다", + NOT_FOUND: "페이지를 찾을 수 없습니다.", +} as const; + +describe("TechLog route boundary contract", () => { + it("freezes the standalone 27-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( + expectedRoutes.map(([routeId]) => [ + routeId, + "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> = + TECH_LOG_MESSAGE_CATALOGS[locale]; + expect(Object.keys(catalog)).toHaveLength(54); + 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("does not install unfinished TechLog route or runtime entries", () => { + expect(Object.keys(ROUTE_REGISTRY).some((routeId) => routeId.startsWith("TECH_LOG_"))).toBe(false); + expect(Object.keys(ROUTE_RUNTIME).some((routeId) => routeId.startsWith("TECH_LOG_"))).toBe(false); + expect(Object.keys(ROUTE_REGISTRY)).toEqual(Object.keys(ROUTE_RUNTIME)); + }); +});