// @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 {useLocation().pathname}; } function RouterStudioProvider({ children, gateway, }: Readonly<{ children: ReactNode; gateway: StudioGateway }>) { const navigate = useNavigate(); return ( gateway} navigate={(href) => { void navigate(href); }} > {children} ); } function renderScreen(children: ReactNode, gateway: StudioGateway) { const input = createTechLogFeatureInstalledInput().input; return render( gateway } }, })} > {children} , ); } 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(, 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(, 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() .mockImplementationOnce((_query, { signal } = {}) => { obsoleteSignal = signal; return new Promise(() => {}); }) .mockResolvedValueOnce(firstPage) .mockResolvedValueOnce(secondPage); const gateway = { ...base, listDocuments } satisfies StudioGateway; renderScreen(, 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() .mockRejectedValueOnce(new Error("offline")) .mockResolvedValueOnce(recovered), } satisfies StudioGateway; renderScreen(, 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( <> , 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); }); });