feat: port TechLog Studio shell and indexes
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||
import type { DocumentPage } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
import { DocumentList } from "../../../src/features/tech-log/presentation/studio/components/document-list.tsx";
|
||||
import { NewDocumentForm } from "../../../src/features/tech-log/presentation/studio/components/new-document-form.tsx";
|
||||
import { StudioDashboard } from "../../../src/features/tech-log/presentation/studio/components/studio-dashboard.tsx";
|
||||
import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function LocationProbe() {
|
||||
return <output aria-label="현재 경로">{useLocation().pathname}</output>;
|
||||
}
|
||||
|
||||
function RouterStudioProvider({
|
||||
children,
|
||||
gateway,
|
||||
}: Readonly<{ children: ReactNode; gateway: StudioGateway }>) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<StudioProvider
|
||||
createGateway={() => gateway}
|
||||
navigate={(href) => {
|
||||
void navigate(href);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScreen(children: ReactNode, gateway: StudioGateway) {
|
||||
const input = createTechLogFeatureInstalledInput().input;
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
featureInputs: { "tech-log": { ...input, createStudioGateway: () => gateway } },
|
||||
})}
|
||||
>
|
||||
<MemoryRouter initialEntries={["/studio"]}>
|
||||
<RouterStudioProvider gateway={gateway}>{children}</RouterStudioProvider>
|
||||
</MemoryRouter>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TechLog Studio index screens", () => {
|
||||
it("shows the source dashboard totals, workflow sections, status links, and sample rows", async () => {
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
|
||||
const { container } = renderScreen(<StudioDashboard />, gateway);
|
||||
|
||||
const summary = await screen.findByLabelText("Studio 요약");
|
||||
expect(summary).toHaveTextContent("전체 작업본7");
|
||||
expect(summary).toHaveTextContent("검증할 기록5");
|
||||
expect(summary).toHaveTextContent("게시 준비0");
|
||||
expect(summary).toHaveTextContent("게시 기록4");
|
||||
expect(screen.getByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".studio-section-title a"), (link) => [
|
||||
link.textContent,
|
||||
link.getAttribute("href"),
|
||||
]),
|
||||
).toEqual([
|
||||
["전체 보기", "/studio/documents"],
|
||||
["전체 보기", "/studio/documents"],
|
||||
["전체 보기", "/studio/documents"],
|
||||
["게시 기록 보기", "/studio/publications"],
|
||||
]);
|
||||
expect(screen.getAllByText("컬렉션 Fetch Join과 페이징은 왜 충돌하는가").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("searches and filters documents and renders the source empty state", async () => {
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
renderScreen(<DocumentList />, gateway);
|
||||
|
||||
expect(await screen.findByText("7개의 작업본")).toBeVisible();
|
||||
fireEvent.change(screen.getByLabelText("검색"), { target: { value: "Fetch Join" } });
|
||||
fireEvent.submit(screen.getByRole("search"));
|
||||
expect(await screen.findByText("1개의 작업본")).toBeVisible();
|
||||
expect(screen.getByText("컬렉션 Fetch Join과 페이징은 왜 충돌하는가")).toBeVisible();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("종류"), { target: { value: "QUESTION" } });
|
||||
expect(await screen.findByText("0개의 작업본")).toBeVisible();
|
||||
expect(screen.getByRole("heading", { level: 2, name: "조건에 맞는 작업본이 없습니다" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("cancels an obsolete list request and advances cursor pagination", async () => {
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const all = await base.listDocuments({ limit: 20 });
|
||||
let obsoleteSignal: AbortSignal | undefined;
|
||||
const firstPage: DocumentPage = { items: all.items.slice(0, 1), nextCursor: "next-page" };
|
||||
const secondPage: DocumentPage = { items: all.items.slice(1, 2), nextCursor: null };
|
||||
const listDocuments = vi.fn<StudioGateway["listDocuments"]>()
|
||||
.mockImplementationOnce((_query, { signal } = {}) => {
|
||||
obsoleteSignal = signal;
|
||||
return new Promise(() => {});
|
||||
})
|
||||
.mockResolvedValueOnce(firstPage)
|
||||
.mockResolvedValueOnce(secondPage);
|
||||
const gateway = { ...base, listDocuments } satisfies StudioGateway;
|
||||
renderScreen(<DocumentList />, gateway);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("종류"), { target: { value: "CASE" } });
|
||||
await waitFor(() => expect(obsoleteSignal?.aborted).toBe(true));
|
||||
expect(await screen.findByText("1개의 작업본")).toBeVisible();
|
||||
await userEvent.click(screen.getByRole("button", { name: "다음 작업본" }));
|
||||
await waitFor(() => expect(listDocuments).toHaveBeenCalledTimes(3));
|
||||
expect(screen.getByText(secondPage.items[0]!.title)).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: "다음 작업본" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a retry action after a list failure and recovers", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const recovered = await base.listDocuments({ limit: 20 });
|
||||
const gateway = {
|
||||
...base,
|
||||
listDocuments: vi.fn<StudioGateway["listDocuments"]>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce(recovered),
|
||||
} satisfies StudioGateway;
|
||||
renderScreen(<DocumentList />, gateway);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("작업본을 불러오지 못했습니다.");
|
||||
await user.click(screen.getByRole("button", { name: "다시 시도" }));
|
||||
|
||||
expect(await screen.findByText("7개의 작업본")).toBeVisible();
|
||||
expect(gateway.listDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("creates the selected Question in the same session and redirects to its editor", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
renderScreen(
|
||||
<>
|
||||
<NewDocumentForm />
|
||||
<LocationProbe />
|
||||
</>,
|
||||
gateway,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("radio", { name: /Question/ }));
|
||||
await user.click(screen.getByRole("button", { name: "작업본 만들기" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("현재 경로")).toHaveTextContent(/^\/studio\/documents\/[0-9a-f-]+\/edit$/));
|
||||
expect((await gateway.listDocuments({ kind: "QUESTION" })).items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Outlet, RouterProvider, createMemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
||||
import { StudioHomePage } from "../../../src/features/tech-log/presentation/studio/pages/studio-home-page.tsx";
|
||||
import { StudioNotFoundPage } from "../../../src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx";
|
||||
import { StudioShell } from "../../../src/features/tech-log/presentation/studio/studio-shell.tsx";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function renderStudio(
|
||||
initialEntry: string,
|
||||
createStudioGateway: () => StudioGateway,
|
||||
) {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "/studio",
|
||||
element: (
|
||||
<StudioShell>
|
||||
<Outlet />
|
||||
</StudioShell>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <StudioHomePage /> },
|
||||
{ path: "documents", element: <p>작업본 라우트</p> },
|
||||
{ path: "documents/new", element: <p>새 문서 라우트</p> },
|
||||
{ path: "publications", element: <p>게시 기록 라우트</p> },
|
||||
{ path: "*", element: <StudioNotFoundPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
const installed = createTechLogFeatureInstalledInput().input;
|
||||
const application = createTestApplication({
|
||||
featureInputs: {
|
||||
"tech-log": { ...installed, createStudioGateway },
|
||||
},
|
||||
});
|
||||
const view = render(
|
||||
<ApplicationProvider application={application}>
|
||||
<RouterProvider router={router} />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
return { ...view, router };
|
||||
}
|
||||
|
||||
describe("TechLog Studio shell", () => {
|
||||
it("creates one application-provided gateway for child navigation and exposes the source header", async () => {
|
||||
const user = userEvent.setup();
|
||||
const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const createGateway = vi.fn(() => gateway);
|
||||
|
||||
const { container, router } = renderStudio("/studio", createGateway);
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
||||
expect(createGateway).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll<HTMLAnchorElement>(".studio-desktop-navigation a"),
|
||||
(link) => [link.textContent, link.getAttribute("href")],
|
||||
),
|
||||
).toEqual([
|
||||
["작업본", "/studio/documents"],
|
||||
["게시 기록", "/studio/publications"],
|
||||
["새 문서", "/studio/documents/new"],
|
||||
["공개 사이트 보기", "/"],
|
||||
]);
|
||||
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute("href", "/");
|
||||
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(screen.getByRole("navigation", { name: "Studio 주 탐색" })).getByRole("link", { name: "작업본" }));
|
||||
|
||||
expect(router.state.location.pathname).toBe("/studio/documents");
|
||||
expect(screen.getByText("작업본 라우트")).toBeVisible();
|
||||
expect(createGateway).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getAllByRole("link", { name: "작업본" })[0]).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("recreates the provider generation and cancels obsolete work after a persisted pageshow", async () => {
|
||||
const user = userEvent.setup();
|
||||
let firstSignal: AbortSignal | undefined;
|
||||
const first = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
const second = createTechLogFeatureInstalledInput().input.createStudioGateway();
|
||||
vi.spyOn(first, "getDashboard").mockImplementation(({ signal } = {}) => {
|
||||
firstSignal = signal;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
const secondDashboard = vi.spyOn(second, "getDashboard");
|
||||
const createGateway = vi.fn()
|
||||
.mockReturnValueOnce(first)
|
||||
.mockReturnValueOnce(second);
|
||||
|
||||
renderStudio("/studio", createGateway);
|
||||
await waitFor(() => expect(firstSignal).toBeDefined());
|
||||
await user.click(screen.getByRole("button", { name: "Studio 메뉴 열기" }));
|
||||
expect(screen.getByRole("button", { name: "Studio 메뉴 닫기" })).toBeVisible();
|
||||
|
||||
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
|
||||
|
||||
await waitFor(() => expect(createGateway).toHaveBeenCalledTimes(2));
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "Studio 메뉴 열기" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
||||
expect(secondDashboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps unknown Studio routes inside the Studio shell without authentication UI", () => {
|
||||
const createGateway = vi.fn(
|
||||
() => createTechLogFeatureInstalledInput().input.createStudioGateway(),
|
||||
);
|
||||
|
||||
renderStudio("/studio/does-not-exist", createGateway);
|
||||
|
||||
expect(screen.getByRole("banner")).toHaveClass("studio-header");
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Studio 화면을 찾을 수 없습니다" })).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute("href", "/studio/documents");
|
||||
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user