fix: synchronize TechLog focus and not-found runtime

This commit is contained in:
DongHyeonka
2026-08-15 22:52:42 +09:00
parent ef1d5cc548
commit 512aa4a1e9
4 changed files with 246 additions and 33 deletions
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { lazy, type ComponentType } from "react";
import {
createMemoryRouter,
Outlet,
@@ -47,9 +48,15 @@ const routeComponents = {
type DiscoveryRouteId = keyof typeof routeComponents;
type DiscoveryRenderOptions = Readonly<{
NotFoundComponent?: ComponentType;
application?: ReturnType<typeof createTestApplication>;
}>;
function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
routeId: RouteId,
initialEntry: string,
options: DiscoveryRenderOptions = {},
) {
const definition = TECH_LOG_ROUTE_REGISTRY[routeId];
const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId];
@@ -67,7 +74,7 @@ function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
},
NOT_FOUND: {
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId,
Component: NotFoundPage,
Component: options.NotFoundComponent ?? NotFoundPage,
},
},
{
@@ -86,9 +93,12 @@ function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
const techLog = createTechLogFeatureInstalledInput().input;
const view = render(
<ApplicationProvider
application={createTestApplication({
featureInputs: { "tech-log": techLog },
})}
application={
options.application ??
createTestApplication({
featureInputs: { "tech-log": techLog },
})
}
>
<RouterProvider router={router} />
</ApplicationProvider>,
@@ -183,6 +193,62 @@ describe("TechLog home discovery", () => {
expect(screen.getAllByRole("tabpanel")).toHaveLength(1);
});
it("treats external focus URL changes as authoritative without stealing focus", async () => {
const { router } = renderDiscoveryRoute(
"TECH_LOG_HOME",
"/?focus=current&state=latest-empty#latest",
);
const current = screen.getByRole("tab", { name: "현재 작업" });
current.focus();
await router.navigate("/?focus=question&state=latest-empty#latest");
await waitFor(() => {
expect(screen.getByRole("tab", { name: "열린 질문" })).toHaveAttribute(
"aria-selected",
"true",
);
});
expect(router.state.location).toMatchObject({
pathname: "/",
search: "?focus=question&state=latest-empty",
hash: "#latest",
});
expect(screen.getByRole("tab", { name: "열린 질문" })).toHaveAttribute(
"tabindex",
"0",
);
expect(current).toHaveAttribute("aria-selected", "false");
expect(current).toHaveAttribute("tabindex", "-1");
expect(current).toHaveFocus();
expect(
screen.getByRole("tabpanel", { name: "열린 질문" }),
).toBeVisible();
expect(document.getElementById("focus-panel-current")).toHaveAttribute(
"hidden",
);
await router.navigate("/?focus=decision&state=latest-empty#latest");
await waitFor(() => {
expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveAttribute(
"aria-selected",
"true",
);
});
expect(router.state.location).toMatchObject({
pathname: "/",
search: "?focus=decision&state=latest-empty",
hash: "#latest",
});
expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveAttribute(
"tabindex",
"0",
);
expect(screen.getByRole("tabpanel", { name: "최근 결정" })).toBeVisible();
expect(screen.getAllByRole("tabpanel")).toHaveLength(1);
});
it("preserves the source home empty and error states", () => {
const emptyView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty");
expect(screen.getByText("아직 공개된 기록이 없습니다.")).toHaveClass(
@@ -321,6 +387,86 @@ describe("TechLog explore discovery", () => {
).not.toBeInTheDocument();
expect(container.querySelector(".site-frame")).not.toBeNull();
});
it("recovers a rejecting registered not-found runtime under its own chunk contract", async () => {
let rejectNotFound: ((reason?: unknown) => void) | undefined;
const LazyNotFound = lazy(
() =>
new Promise<{ default: typeof NotFoundPage }>((_resolve, reject) => {
rejectNotFound = reject;
}),
);
const notFoundChunkRead = vi.fn(() => "assets/not-found.js");
const exploreKindChunkRead = vi.fn(() => "assets/explore-kind.js");
const routeChunks = {
get "route-not-found"() {
return notFoundChunkRead();
},
get "route-tech-log-explore-kind"() {
return exploreKindChunkRead();
},
};
const release = {
buildId: "task-7-test-build",
releaseId: "task-7-test-release",
configSchemaVersion: "1",
apiContractVersion: "1",
assetManifestHash: "task-7-test-hash",
routeChunks,
};
const record = vi.fn();
const techLog = createTechLogFeatureInstalledInput().input;
const application = createTestApplication({
diagnostics: { record },
releaseInfo: {
getCurrent: async () => release,
refresh: async () => release,
},
featureInputs: { "tech-log": techLog },
});
const { router, container } = renderDiscoveryRoute(
"TECH_LOG_EXPLORE_KIND",
"/explore/unknown",
{ NotFoundComponent: LazyNotFound, application },
);
expect(
screen.getByText("화면을 준비하고 있습니다.").closest("section"),
).toHaveAttribute("data-surface", "none");
await waitFor(() => expect(rejectNotFound).toBeTypeOf("function"));
await act(async () => {
rejectNotFound?.(
new TypeError("Failed to fetch dynamically imported module"),
);
});
expect(
await screen.findByRole("heading", {
name: "화면 자산을 복구하지 못했습니다.",
}),
).toBeVisible();
expect(notFoundChunkRead).toHaveBeenCalledOnce();
expect(exploreKindChunkRead).not.toHaveBeenCalled();
expect(router.state.location.pathname).toBe("/explore/unknown");
expect(container.querySelector(".site-frame")).not.toBeNull();
expect(
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
).not.toBeInTheDocument();
const routeChangedRecords = record.mock.calls
.map(([entry]) => entry)
.filter((entry) => entry.eventId === "route.changed");
expect(routeChangedRecords).toEqual([
expect.objectContaining({
context: expect.objectContaining({
route_id: "TECH_LOG_EXPLORE_KIND",
}),
}),
]);
expect(
record.mock.calls.some(([entry]) => entry.eventId === "ui.render.failed"),
).toBe(false);
});
});
describe("TechLog search discovery", () => {