diff --git a/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-10-report.md b/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-10-report.md new file mode 100644 index 0000000..1a817d8 --- /dev/null +++ b/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-10-report.md @@ -0,0 +1,30 @@ +# Task 10 report + +## Mapping + +- Source Studio provider/runtime shell and header → application-input-created, provider-scoped gateway; React Router navigation; native Public link; persisted `pageshow` generation reset. +- Source dashboard → exact workspace heading, totals, workflow sections, row labels, links, loading and error copy. +- Source document list → exact search/filter/list/empty surfaces plus cursor pagination, retry, and abort of obsolete requests. +- Source new-document form → exact type cards/copy, session gateway creation, announcement, and editor redirect. +- Source Studio not-found → in-shell 404 surface; Studio routes remain public with no auth UI. + +## TDD evidence + +- RED: `corepack pnpm exec vitest run tests/features/tech-log/studio-shell-smoke.test.tsx tests/features/tech-log/studio-screens-smoke.test.tsx` failed both suites at missing Studio presentation imports (exit 1). +- GREEN: the same command passed 2 files / 8 tests. +- Focused regression: both Studio suites plus `tests/features/tech-log/runtime-composition.test.ts` passed 3 files / 10 tests. + +## Files + +- Added the 12 Task 10 Studio provider/runtime/shell/component/page files under `src/features/tech-log/presentation/studio/`. +- Added `studio-shell-smoke.test.tsx` and `studio-screens-smoke.test.tsx`. + +## SHA + +- Base: `2b6fa42620136c3edb1506f907ce79c2251d1316`. +- Implementation: the commit containing this report, titled `feat: port TechLog Studio shell and indexes` (final SHA recorded in the Task 10 handoff). + +## Deferred + +- Task 11 editor screens and Task 12 dirty-leave/save/validation dialogs remain intentionally deferred. +- Broad architecture, type, lint, build, security, and visual gates remain deferred to Task 14 by user direction. diff --git a/src/features/tech-log/presentation/studio/components/document-list.tsx b/src/features/tech-log/presentation/studio/components/document-list.tsx new file mode 100644 index 0000000..bc47d93 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/document-list.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Link } from "react-router-dom"; + +import type { ListDocumentsQuery } from "../../../application/ports/studio-gateway.ts"; +import type { DocumentPage } from "../../../contracts/studio/contract.ts"; +import { useStudio } from "../use-studio.ts"; + +const kindLabel = { + CASE: "Case", + REFERENCE: "Reference", + QUESTION: "Question", +} as const; +const publicationLabel = { + NEVER_PUBLISHED: "게시 전", + PUBLISHED: "게시 중", + UNPUBLISHED: "게시 취소", +} as const; +const nextLabel = { + CONTINUE_EDITING: "작성 계속", + VALIDATE: "검증하기", + FIX_VALIDATION: "오류 수정", + CREATE_PREVIEW: "미리보기", + PUBLISH: "게시하기", + NONE: "완료", +} as const; + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +export function DocumentList() { + const { gateway } = useStudio(); + const [searchDraft, setSearchDraft] = useState(""); + const [q, setQ] = useState(""); + const [kind, setKind] = useState(); + const [status, setStatus] = + useState(); + const [cursor, setCursor] = useState(); + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [retryGeneration, setRetryGeneration] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + void gateway + .listDocuments( + { + ...(q ? { q } : {}), + ...(kind ? { kind } : {}), + ...(status ? { publicationStatus: status } : {}), + ...(cursor ? { cursor } : {}), + limit: 20, + }, + { signal: controller.signal }, + ) + .then( + (value) => { + setPage(value); + setLoading(false); + }, + (reason: unknown) => { + if (isAbortError(reason)) return; + setError("작업본을 불러오지 못했습니다."); + setLoading(false); + }, + ); + return () => controller.abort(); + }, [cursor, gateway, kind, q, retryGeneration, status]); + + const changeFilter = (next: { + kind?: ListDocumentsQuery["kind"]; + status?: ListDocumentsQuery["publicationStatus"]; + }) => { + if ("kind" in next) setKind(next.kind); + if ("status" in next) setStatus(next.status); + setCursor(undefined); + }; + const submitSearch = (event: FormEvent) => { + event.preventDefault(); + setQ(searchDraft.trim()); + setCursor(undefined); + }; + + return ( +
+
+
+

WORKING COPIES

+

작업본

+

+ 세션에 있는 Case, Reference, Question을 찾고 다음 작업으로 이동합니다. +

+
+ + 새 문서 + +
+
+
+ +
+ setSearchDraft(event.target.value)} + /> + +
+
+ + +
+ {loading ? ( +

+ 작업본을 불러오는 중입니다. +

+ ) : null} + {error ? ( + <> +

+ {error} +

+ + + ) : null} + {page && !loading && !error ? ( + <> +

{page.items.length}개의 작업본

+ {page.items.length ? ( +
+ {page.items.map((item) => ( +
+

{kindLabel[item.kind]}

+
+

+ + {item.title || "제목 없는 작업본"} + +

+

{item.project?.label ?? "프로젝트 미지정"}

+
+
+
+
상태
+
{publicationLabel[item.publicationStatus]}
+
+
+
다음
+
{nextLabel[item.nextAction]}
+
+
+
수정
+
+ +
+
+
+
+ ))} +
+ ) : ( +
+

조건에 맞는 작업본이 없습니다

+

검색어 또는 필터를 바꾸거나 새 문서를 만드세요.

+ 새 문서 만들기 +
+ )} + {page.nextCursor ? ( + + ) : null} + + ) : null} +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/new-document-form.tsx b/src/features/tech-log/presentation/studio/components/new-document-form.tsx new file mode 100644 index 0000000..226ef5f --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/new-document-form.tsx @@ -0,0 +1,165 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; + +import type { + CreateDocumentInput, + RecordKind, +} from "../../../contracts/studio/contract.ts"; +import { createLocalId } from "../../../domain/studio/local-id.ts"; +import { useStudio } from "../use-studio.ts"; + +const types = [ + { + kind: "CASE", + title: "Case", + description: "문제를 재현하고 검증한 결론을 기록합니다.", + fields: "문제 · 결론 · 환경 · 재현 · 본문", + }, + { + kind: "REFERENCE", + title: "Reference", + description: "반복해서 적용할 기술 기준을 정리합니다.", + fields: "목적 · 규칙 · 적용 조건 · 예외 · 예시", + }, + { + kind: "QUESTION", + title: "Question", + description: "아직 닫히지 않은 판단과 다음 검증을 관리합니다.", + fields: "상태 · 사실 · 가정 · 미지수 · 선택지", + }, +] as const; + +function emptyDocument(kind: RecordKind): CreateDocumentInput { + const common = { + title: "", + slug: "" as const, + summary: "", + topicId: null, + projectId: null, + relations: [], + }; + if (kind === "CASE") { + return { + ...common, + kind, + problem: "", + conclusion: "", + environment: "", + reproduction: "", + lastVerifiedOn: null, + bodyMarkdown: "", + }; + } + if (kind === "REFERENCE") { + return { + ...common, + kind, + purpose: "", + rules: [], + applyWhen: [], + exceptions: [], + examples: [], + verifiedOn: null, + }; + } + return { + ...common, + kind, + questionStatus: null, + facts: [], + assumptions: [], + unknowns: [], + constraints: [], + options: [], + nextValidation: "", + resolution: null, + }; +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +export function NewDocumentForm() { + const { gateway, navigateInternal, setRequestAnnouncement } = useStudio(); + const [kind, setKind] = useState("CASE"); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const locked = useRef(false); + const activeRequest = useRef(null); + + useEffect(() => () => activeRequest.current?.abort(), []); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (locked.current) return; + locked.current = true; + setPending(true); + setError(""); + const controller = new AbortController(); + activeRequest.current = controller; + try { + const document = await gateway.createDocument(emptyDocument(kind), { + idempotencyKey: createLocalId("studio-create"), + signal: controller.signal, + }); + setRequestAnnouncement( + `${types.find((type) => type.kind === kind)?.title} 작업본을 만들었습니다.`, + ); + navigateInternal(`/studio/documents/${document.id}/edit`); + } catch (reason) { + if (!isAbortError(reason)) { + setError("작업본을 만들지 못했습니다. 다시 시도해 주세요."); + } + } finally { + if (activeRequest.current === controller) activeRequest.current = null; + locked.current = false; + setPending(false); + } + }; + + return ( +
+
+

NEW WORKING COPY

+

새 문서

+

+ 목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다. +

+
+
{ + void submit(event); + }} + > +
+ 문서 종류 + {types.map((type) => ( + + ))} +
+
+ +

이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.

+
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/studio-dashboard.tsx b/src/features/tech-log/presentation/studio/components/studio-dashboard.tsx new file mode 100644 index 0000000..13ae06c --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/studio-dashboard.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import type { StudioDashboard as StudioDashboardData } from "../../../contracts/studio/contract.ts"; +import type { components } from "../../../contracts/studio/generated.ts"; +import { useStudio } from "../use-studio.ts"; + +type DocumentSummary = components["schemas"]["DocumentSummary"]; + +const kindLabel = { + CASE: "Case", + REFERENCE: "Reference", + QUESTION: "Question", +} as const; +const actionLabel = { + CONTINUE_EDITING: "작성 계속", + VALIDATE: "검증하기", + FIX_VALIDATION: "오류 수정", + CREATE_PREVIEW: "미리보기 만들기", + PUBLISH: "게시하기", + NONE: "게시 완료", +} as const; + +function WorkRow({ item }: Readonly<{ item: DocumentSummary }>) { + return ( +
+

{kindLabel[item.kind]}

+
+

+ + {item.title || "제목 없는 작업본"} + +

+

+ {item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]} +

+
+ +
+ ); +} + +function WorkSection({ + title, + items, + href, + empty, +}: Readonly<{ + title: string; + items: DocumentSummary[]; + href: string; + empty: string; +}>) { + return ( +
+
+

{title}

+ 전체 보기 +
+
+ {items.length ? ( + items.map((item) => ) + ) : ( +

{empty}

+ )} +
+
+ ); +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} + +export function StudioDashboard() { + const { gateway } = useStudio(); + const [dashboard, setDashboard] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + void gateway.getDashboard({ signal: controller.signal }).then( + (value) => setDashboard(value), + (reason: unknown) => { + if (!isAbortError(reason)) setError("작업 흐름을 불러오지 못했습니다."); + }, + ); + return () => controller.abort(); + }, [gateway]); + + const validationItems = + dashboard?.continueWriting.filter((item) => + ["VALIDATE", "FIX_VALIDATION", "CREATE_PREVIEW"].includes(item.nextAction), + ) ?? []; + + return ( +
+
+
+

WORKSPACE

+

작업 흐름

+

작성 중인 기록을 이어서 정리하고 검증·게시 흐름으로 연결합니다.

+
+ + 새 문서 + +
+ {error ? ( +

+ {error} +

+ ) : null} + {!dashboard && !error ? ( +

+ Studio 요약을 불러오는 중입니다. +

+ ) : null} + {dashboard ? ( + <> +
+
+ 전체 작업본 + {dashboard.totals.documents} +
+
+ 검증할 기록 + {validationItems.length} +
+
+ 게시 준비 + {dashboard.totals.readyToPublish} +
+
+ 게시 기록 + {dashboard.totals.publications} +
+
+ + + +
+
+

최근 게시

+ 게시 기록 보기 +
+
+ {dashboard.recentPublications.length ? ( + dashboard.recentPublications.map((item) => ( +
+

+ {item.event.type === "UNPUBLISHED" + ? "게시 취소" + : item.event.type === "REPUBLISHED" + ? "재게시" + : "게시"} +

+
+

{item.document.title}

+

{item.document.project?.label ?? "프로젝트 미지정"}

+
+ +
+ )) + ) : ( +

아직 게시 기록이 없습니다.

+ )} +
+
+ + ) : null} +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/studio-header.tsx b/src/features/tech-log/presentation/studio/components/studio-header.tsx new file mode 100644 index 0000000..7d58446 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/studio-header.tsx @@ -0,0 +1,74 @@ +import { useId, useState } from "react"; +import { Link } from "react-router-dom"; + +const navigation = [ + { + href: "/studio/documents", + label: "작업본", + active: (path: string) => + path.startsWith("/studio/documents") && path !== "/studio/documents/new", + }, + { + href: "/studio/publications", + label: "게시 기록", + active: (path: string) => path.startsWith("/studio/publications"), + }, + { + href: "/studio/documents/new", + label: "새 문서", + active: (path: string) => path === "/studio/documents/new", + }, +] as const; + +function StudioNavigation({ + currentPath, + label, +}: Readonly<{ currentPath: string; label: string }>) { + return ( + + ); +} + +export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) { + const [open, setOpen] = useState(false); + const mobileId = useId(); + return ( +
+
+ + TechLog Studio + +
+ +
+ +
+ +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/pages/documents-page.tsx b/src/features/tech-log/presentation/studio/pages/documents-page.tsx new file mode 100644 index 0000000..165641d --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/documents-page.tsx @@ -0,0 +1,5 @@ +import { DocumentList } from "../components/document-list.tsx"; + +export function DocumentsPage() { + return ; +} diff --git a/src/features/tech-log/presentation/studio/pages/new-document-page.tsx b/src/features/tech-log/presentation/studio/pages/new-document-page.tsx new file mode 100644 index 0000000..c8e39c5 --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/new-document-page.tsx @@ -0,0 +1,5 @@ +import { NewDocumentForm } from "../components/new-document-form.tsx"; + +export function NewDocumentPage() { + return ; +} diff --git a/src/features/tech-log/presentation/studio/pages/studio-home-page.tsx b/src/features/tech-log/presentation/studio/pages/studio-home-page.tsx new file mode 100644 index 0000000..c7412c2 --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/studio-home-page.tsx @@ -0,0 +1,5 @@ +import { StudioDashboard } from "../components/studio-dashboard.tsx"; + +export function StudioHomePage() { + return ; +} diff --git a/src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx b/src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx new file mode 100644 index 0000000..23803f5 --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx @@ -0,0 +1,12 @@ +import { Link } from "react-router-dom"; + +export function StudioNotFoundPage() { + return ( +
+

404

+

Studio 화면을 찾을 수 없습니다

+

주소를 확인하거나 작업본 목록에서 다시 시작하세요.

+ 작업본으로 돌아가기 +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/studio-provider.tsx b/src/features/tech-log/presentation/studio/studio-provider.tsx new file mode 100644 index 0000000..a79fa3f --- /dev/null +++ b/src/features/tech-log/presentation/studio/studio-provider.tsx @@ -0,0 +1,45 @@ +import { + type ReactNode, + useCallback, + useMemo, + useState, +} from "react"; + +import type { StudioGateway } from "../../application/ports/studio-gateway.ts"; +import { StudioContext, type StudioContextValue } from "./use-studio.ts"; + +type StudioProviderProps = Readonly<{ + children: ReactNode; + createGateway: () => StudioGateway; + navigate?: (href: string) => void; +}>; + +function defaultNavigate(href: string): void { + window.history.pushState({}, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function StudioProvider({ + children, + createGateway, + navigate = defaultNavigate, +}: StudioProviderProps) { + const [gateway] = useState(() => createGateway()); + const [requestAnnouncement, setRequestAnnouncement] = useState(""); + const navigateInternal = useCallback((href: string) => navigate(href), [navigate]); + const value = useMemo( + () => ({ + gateway, + requestAnnouncement, + setRequestAnnouncement, + navigateInternal, + }), + [gateway, navigateInternal, requestAnnouncement], + ); + + return ( + +
{children}
+
+ ); +} diff --git a/src/features/tech-log/presentation/studio/studio-runtime-boundary.tsx b/src/features/tech-log/presentation/studio/studio-runtime-boundary.tsx new file mode 100644 index 0000000..d3cc21c --- /dev/null +++ b/src/features/tech-log/presentation/studio/studio-runtime-boundary.tsx @@ -0,0 +1,46 @@ +import { Component, Fragment, type ReactNode } from "react"; + +export class StudioRuntimeBoundary extends Component< + Readonly<{ children: ReactNode }>, + Readonly<{ failed: boolean; resetKey: number }> +> { + state = { failed: false, resetKey: 0 }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + private retry = () => { + this.setState((current) => ({ + failed: false, + resetKey: current.resetKey + 1, + })); + }; + + render() { + if (this.state.failed) { + return ( +
+
+ +
+
+
+

STUDIO ERROR

+

Studio 화면을 불러오지 못했습니다

+

현재 세션을 초기화한 뒤 다시 시작하세요.

+ +
+
+
+ ); + } + return {this.props.children}; + } +} diff --git a/src/features/tech-log/presentation/studio/studio-shell.tsx b/src/features/tech-log/presentation/studio/studio-shell.tsx new file mode 100644 index 0000000..421ca6a --- /dev/null +++ b/src/features/tech-log/presentation/studio/studio-shell.tsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useApplication } from "../../../../presentation/providers/application-provider.tsx"; +import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts"; +import { StudioHeader } from "./components/studio-header.tsx"; +import { StudioProvider } from "./studio-provider.tsx"; +import { StudioRuntimeBoundary } from "./studio-runtime-boundary.tsx"; +import { useStudio } from "./use-studio.ts"; + +type StudioShellProps = Readonly<{ children: ReactNode }>; + +function StudioFrame({ children }: StudioShellProps) { + const location = useLocation(); + const { requestAnnouncement } = useStudio(); + return ( + <> + + 본문으로 건너뛰기 + + +
+ {children} +
+

+ {requestAnnouncement} +

+ + ); +} + +export function StudioShell({ children }: StudioShellProps) { + const application = useApplication(); + const navigate = useNavigate(); + const [generation, setGeneration] = useState(0); + const navigateInternal = useCallback( + (href: string) => { + void navigate(href); + }, + [navigate], + ); + const createGateway = useCallback( + () => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(), + [application], + ); + + useEffect(() => { + const resetPersistedSession = (event: PageTransitionEvent) => { + if (event.persisted) setGeneration((current) => current + 1); + }; + window.addEventListener("pageshow", resetPersistedSession); + return () => window.removeEventListener("pageshow", resetPersistedSession); + }, []); + + return ( + + + {children} + + + ); +} diff --git a/src/features/tech-log/presentation/studio/use-studio.ts b/src/features/tech-log/presentation/studio/use-studio.ts new file mode 100644 index 0000000..a1e382c --- /dev/null +++ b/src/features/tech-log/presentation/studio/use-studio.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +import type { StudioGateway } from "../../application/ports/studio-gateway.ts"; + +export type StudioContextValue = Readonly<{ + gateway: StudioGateway; + requestAnnouncement: string; + setRequestAnnouncement(message: string): void; + navigateInternal(href: string): void; +}>; + +export const StudioContext = createContext(null); + +export function useStudio(): StudioContextValue { + const context = useContext(StudioContext); + if (!context) throw new Error("useStudio must be used within StudioProvider"); + return context; +} diff --git a/tests/features/tech-log/studio-screens-smoke.test.tsx b/tests/features/tech-log/studio-screens-smoke.test.tsx new file mode 100644 index 0000000..38da632 --- /dev/null +++ b/tests/features/tech-log/studio-screens-smoke.test.tsx @@ -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 {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); + }); +}); diff --git a/tests/features/tech-log/studio-shell-smoke.test.tsx b/tests/features/tech-log/studio-shell-smoke.test.tsx new file mode 100644 index 0000000..db63fdb --- /dev/null +++ b/tests/features/tech-log/studio-shell-smoke.test.tsx @@ -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: ( + + + + ), + children: [ + { index: true, element: }, + { path: "documents", element:

작업본 라우트

}, + { path: "documents/new", element:

새 문서 라우트

}, + { path: "publications", element:

게시 기록 라우트

}, + { path: "*", element: }, + ], + }, + ], + { initialEntries: [initialEntry] }, + ); + const installed = createTechLogFeatureInstalledInput().input; + const application = createTestApplication({ + featureInputs: { + "tech-log": { ...installed, createStudioGateway }, + }, + }); + const view = render( + + + , + ); + 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(".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(); + }); +});