286 lines
8.7 KiB
TypeScript
286 lines
8.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
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,
|
|
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 <h1>{String(input.params["*"])}</h1>;
|
|
}
|
|
|
|
function renderRouter() {
|
|
return render(
|
|
<ApplicationProvider
|
|
application={createTestApplication({
|
|
session: createAnonymousSessionAdapter(),
|
|
})}
|
|
>
|
|
<AppRouter />
|
|
</ApplicationProvider>,
|
|
);
|
|
}
|
|
|
|
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: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="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: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="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: <div data-layout="public" />,
|
|
STUDIO: <div data-layout="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: <Outlet />,
|
|
STUDIO: (
|
|
<section data-testid="studio-layout">
|
|
<Outlet />
|
|
</section>
|
|
),
|
|
},
|
|
"test-build",
|
|
);
|
|
const router = createMemoryRouter(routes, {
|
|
initialEntries: ["/studio/unknown/path"],
|
|
});
|
|
|
|
render(
|
|
<ApplicationProvider application={createTestApplication()}>
|
|
<LocaleProvider>
|
|
<ThemeProvider>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
</LocaleProvider>
|
|
</ApplicationProvider>,
|
|
);
|
|
|
|
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: <Outlet />,
|
|
STUDIO: <Outlet />,
|
|
},
|
|
"test-build",
|
|
);
|
|
const router = createMemoryRouter(routes, {
|
|
initialEntries: ["/private-fixture"],
|
|
});
|
|
|
|
render(
|
|
<ApplicationProvider application={createTestApplication()}>
|
|
<LocaleProvider>
|
|
<ThemeProvider>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ThemeProvider>
|
|
</LocaleProvider>
|
|
</ApplicationProvider>,
|
|
);
|
|
|
|
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({}, "", "/");
|
|
renderRouter();
|
|
|
|
// The starter action lives inside the lazily loaded home chunk, so this is
|
|
// the first wait in the file that has to outlast a chunk load rather than
|
|
// an already-mounted shell element.
|
|
await user.click(
|
|
await screen.findByRole(
|
|
"link",
|
|
{ name: "플랫폼 구성 보기" },
|
|
{ timeout: 5000 },
|
|
),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("heading", { name: "플랫폼 구성", level: 1 }),
|
|
).toHaveFocus(),
|
|
);
|
|
});
|
|
|
|
it("renders the app shell and not-found route without a feature input", async () => {
|
|
window.history.pushState({}, "", "/missing");
|
|
renderRouter();
|
|
|
|
expect(
|
|
await screen.findByRole("heading", {
|
|
name: "페이지를 찾을 수 없습니다.",
|
|
}),
|
|
).toBeVisible();
|
|
expect(screen.getByRole("navigation", { name: "주요 탐색" })).toBeVisible();
|
|
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({}, "", "/");
|
|
renderRouter();
|
|
|
|
await user.click(
|
|
await screen.findByRole("link", { name: "UI 구성요소" }),
|
|
);
|
|
|
|
expect(
|
|
await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }),
|
|
).toBeVisible();
|
|
expect(window.location.pathname).toBe("/examples/ui");
|
|
expect(document.title).toBe("UI 구성요소 · Tech Log");
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("heading", { name: "UI 구성요소", level: 1 }),
|
|
).toHaveFocus(),
|
|
);
|
|
});
|
|
|
|
it("focuses the route heading again when the lazy chunk is already cached", async () => {
|
|
// §9.7. The first visit resolves the route module asynchronously, so the
|
|
// router lifecycle and the page header commit separately. A later visit
|
|
// renders the cached module in the same commit, which is the ordering that
|
|
// must still hand focus to the heading rather than leaving it on main.
|
|
const user = userEvent.setup();
|
|
window.history.pushState({}, "", "/");
|
|
renderRouter();
|
|
|
|
for (const label of ["UI 구성요소", "화면 상태", "UI 구성요소"]) {
|
|
await user.click(await screen.findByRole("link", { name: label }));
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("heading", { name: label, level: 1 }),
|
|
).toHaveFocus(),
|
|
);
|
|
}
|
|
});
|
|
});
|