feat: port TechLog Studio shell and indexes

This commit is contained in:
DongHyeonka
2026-08-15 23:29:32 +09:00
parent 2b6fa42620
commit 887f5e6eb1
15 changed files with 1187 additions and 0 deletions
@@ -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.
@@ -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<ListDocumentsQuery["kind"]>();
const [status, setStatus] =
useState<ListDocumentsQuery["publicationStatus"]>();
const [cursor, setCursor] = useState<string>();
const [page, setPage] = useState<DocumentPage | null>(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 (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">WORKING COPIES</p>
<h1></h1>
<p>
Case, Reference, Question을 .
</p>
</div>
<Link className="studio-primary-action" to="/studio/documents/new">
</Link>
</header>
<section className="studio-document-tools" aria-label="작업본 검색과 필터">
<form role="search" onSubmit={submitSearch}>
<label htmlFor="studio-search"></label>
<div>
<input
id="studio-search"
type="search"
value={searchDraft}
placeholder="제목, 요약, slug"
onChange={(event) => setSearchDraft(event.target.value)}
/>
<button type="submit"></button>
</div>
</form>
<label>
<select
value={kind ?? ""}
onChange={(event) =>
changeFilter({
kind: (event.target.value || undefined) as ListDocumentsQuery["kind"],
})
}
>
<option value=""></option>
<option value="CASE">Case</option>
<option value="REFERENCE">Reference</option>
<option value="QUESTION">Question</option>
</select>
</label>
<label>
<select
value={status ?? ""}
onChange={(event) =>
changeFilter({
status: (event.target.value || undefined) as ListDocumentsQuery["publicationStatus"],
})
}
>
<option value=""></option>
<option value="NEVER_PUBLISHED"> </option>
<option value="PUBLISHED"> </option>
<option value="UNPUBLISHED"> </option>
</select>
</label>
</section>
{loading ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{error ? (
<>
<p className="studio-screen-error" role="alert">
{error}
</p>
<button
className="studio-secondary-button"
type="button"
onClick={() => setRetryGeneration((current) => current + 1)}
>
</button>
</>
) : null}
{page && !loading && !error ? (
<>
<p className="studio-result-count">{page.items.length} </p>
{page.items.length ? (
<div className="studio-document-list">
{page.items.map((item) => (
<article className="studio-document-row" key={item.id}>
<p className="studio-row-label">{kindLabel[item.kind]}</p>
<div className="studio-document-title">
<h2>
<Link to={`/studio/documents/${item.id}/edit`}>
{item.title || "제목 없는 작업본"}
</Link>
</h2>
<p>{item.project?.label ?? "프로젝트 미지정"}</p>
</div>
<dl>
<div>
<dt></dt>
<dd>{publicationLabel[item.publicationStatus]}</dd>
</div>
<div>
<dt></dt>
<dd>{nextLabel[item.nextAction]}</dd>
</div>
<div>
<dt></dt>
<dd>
<time dateTime={item.updatedAt}>
{new Date(item.updatedAt).toLocaleDateString("ko-KR", {
timeZone: "Asia/Seoul",
})}
</time>
</dd>
</div>
</dl>
</article>
))}
</div>
) : (
<section className="studio-empty-state">
<h2> </h2>
<p> .</p>
<Link to="/studio/documents/new"> </Link>
</section>
)}
{page.nextCursor ? (
<button
className="studio-secondary-button"
type="button"
onClick={() => setCursor(page.nextCursor ?? undefined)}
>
</button>
) : null}
</>
) : null}
</div>
);
}
@@ -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<RecordKind>("CASE");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const locked = useRef(false);
const activeRequest = useRef<AbortController | null>(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 (
<div className="studio-page studio-new-page">
<header className="studio-page-heading">
<p className="studio-eyebrow">NEW WORKING COPY</p>
<h1> </h1>
<p>
.
</p>
</header>
<form
onSubmit={(event) => {
void submit(event);
}}
>
<fieldset className="studio-type-list">
<legend> </legend>
{types.map((type) => (
<label key={type.kind}>
<input
type="radio"
name="kind"
value={type.kind}
checked={kind === type.kind}
onChange={() => setKind(type.kind)}
/>
<strong>{type.title}</strong>
<span>{type.description}</span>
<small>{type.fields}</small>
</label>
))}
</fieldset>
<div className="studio-create-footer">
<button className="studio-primary-button" type="submit" disabled={pending}>
{pending ? "만드는 중…" : "작업본 만들기"}
</button>
<p> Studio .</p>
</div>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
</form>
</div>
);
}
@@ -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 (
<article className="studio-work-row">
<p className="studio-row-label">{kindLabel[item.kind]}</p>
<div>
<h3>
<Link to={`/studio/documents/${item.id}/edit`}>
{item.title || "제목 없는 작업본"}
</Link>
</h3>
<p>
{item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]}
</p>
</div>
<time dateTime={item.updatedAt}>
{new Date(item.updatedAt).toLocaleDateString("ko-KR", {
timeZone: "Asia/Seoul",
})}
</time>
</article>
);
}
function WorkSection({
title,
items,
href,
empty,
}: Readonly<{
title: string;
items: DocumentSummary[];
href: string;
empty: string;
}>) {
return (
<section className="studio-work-section">
<div className="studio-section-title">
<h2>{title}</h2>
<Link to={href}> </Link>
</div>
<div className="studio-work-list">
{items.length ? (
items.map((item) => <WorkRow key={item.id} item={item} />)
) : (
<p className="studio-empty-inline">{empty}</p>
)}
</div>
</section>
);
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function StudioDashboard() {
const { gateway } = useStudio();
const [dashboard, setDashboard] = useState<StudioDashboardData | null>(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 (
<div className="studio-page studio-dashboard-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">WORKSPACE</p>
<h1> </h1>
<p> · .</p>
</div>
<Link className="studio-primary-action" to="/studio/documents/new">
</Link>
</header>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!dashboard && !error ? (
<p className="studio-loading" role="status">
Studio .
</p>
) : null}
{dashboard ? (
<>
<section className="studio-summary-grid" aria-label="Studio 요약">
<div>
<span> </span>
<strong>{dashboard.totals.documents}</strong>
</div>
<div>
<span> </span>
<strong>{validationItems.length}</strong>
</div>
<div>
<span> </span>
<strong>{dashboard.totals.readyToPublish}</strong>
</div>
<div>
<span> </span>
<strong>{dashboard.totals.publications}</strong>
</div>
</section>
<WorkSection
title="이어서 작성"
items={dashboard.continueWriting}
href="/studio/documents"
empty="이어서 작성할 문서가 없습니다."
/>
<WorkSection
title="검증과 미리보기"
items={validationItems}
href="/studio/documents"
empty="현재 검증할 문서가 없습니다."
/>
<WorkSection
title="게시 준비"
items={dashboard.readyToPublish}
href="/studio/documents"
empty="게시 준비가 끝난 문서가 없습니다."
/>
<section className="studio-work-section">
<div className="studio-section-title">
<h2> </h2>
<Link to="/studio/publications"> </Link>
</div>
<div className="studio-work-list">
{dashboard.recentPublications.length ? (
dashboard.recentPublications.map((item) => (
<article
className="studio-work-row"
key={item.event.publicationEventId}
>
<p className="studio-row-label">
{item.event.type === "UNPUBLISHED"
? "게시 취소"
: item.event.type === "REPUBLISHED"
? "재게시"
: "게시"}
</p>
<div>
<h3>{item.document.title}</h3>
<p>{item.document.project?.label ?? "프로젝트 미지정"}</p>
</div>
<time dateTime={item.event.occurredAt}>
{new Date(item.event.occurredAt).toLocaleDateString("ko-KR", {
timeZone: "Asia/Seoul",
})}
</time>
</article>
))
) : (
<p className="studio-empty-inline"> .</p>
)}
</div>
</section>
</>
) : null}
</div>
);
}
@@ -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 (
<nav aria-label={label}>
{navigation.map((item) => (
<Link
key={item.href}
to={item.href}
aria-current={item.active(currentPath) ? "page" : undefined}
>
{item.label}
</Link>
))}
<a href="/"> </a>
</nav>
);
}
export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) {
const [open, setOpen] = useState(false);
const mobileId = useId();
return (
<header className="studio-header">
<div className="studio-header-inner">
<Link
className="studio-wordmark"
to="/studio"
aria-label="TechLog Studio"
>
TechLog <span>Studio</span>
</Link>
<div className="studio-desktop-navigation">
<StudioNavigation currentPath={currentPath} label="Studio 주 탐색" />
</div>
<button
className="studio-menu-trigger"
type="button"
aria-expanded={open}
aria-controls={mobileId}
onClick={() => setOpen((current) => !current)}
>
{open ? "Studio 메뉴 닫기" : "Studio 메뉴 열기"}
</button>
</div>
<div id={mobileId} className="studio-mobile-navigation" hidden={!open}>
<StudioNavigation currentPath={currentPath} label="Studio 모바일 탐색" />
</div>
</header>
);
}
@@ -0,0 +1,5 @@
import { DocumentList } from "../components/document-list.tsx";
export function DocumentsPage() {
return <DocumentList />;
}
@@ -0,0 +1,5 @@
import { NewDocumentForm } from "../components/new-document-form.tsx";
export function NewDocumentPage() {
return <NewDocumentForm />;
}
@@ -0,0 +1,5 @@
import { StudioDashboard } from "../components/studio-dashboard.tsx";
export function StudioHomePage() {
return <StudioDashboard />;
}
@@ -0,0 +1,12 @@
import { Link } from "react-router-dom";
export function StudioNotFoundPage() {
return (
<section className="studio-route-state">
<p className="studio-eyebrow">404</p>
<h1>Studio </h1>
<p> .</p>
<Link to="/studio/documents"> </Link>
</section>
);
}
@@ -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<StudioGateway>(() => createGateway());
const [requestAnnouncement, setRequestAnnouncement] = useState("");
const navigateInternal = useCallback((href: string) => navigate(href), [navigate]);
const value = useMemo<StudioContextValue>(
() => ({
gateway,
requestAnnouncement,
setRequestAnnouncement,
navigateInternal,
}),
[gateway, navigateInternal, requestAnnouncement],
);
return (
<StudioContext.Provider value={value}>
<div className="studio-app">{children}</div>
</StudioContext.Provider>
);
}
@@ -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 (
<div className="studio-app">
<header className="studio-header">
<div className="studio-header-inner">
<a className="studio-wordmark" href="/studio">
TechLog <span>Studio</span>
</a>
</div>
</header>
<main id="main-content" className="studio-main">
<section className="studio-route-state" role="alert">
<p className="studio-eyebrow">STUDIO ERROR</p>
<h1>Studio </h1>
<p> .</p>
<button type="button" onClick={this.retry}>
</button>
</section>
</main>
</div>
);
}
return <Fragment key={this.state.resetKey}>{this.props.children}</Fragment>;
}
}
@@ -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 (
<>
<a className="studio-skip-link" href="#main-content">
</a>
<StudioHeader currentPath={location.pathname} />
<main id="main-content" className="studio-main" tabIndex={-1}>
{children}
</main>
<p
className="studio-visually-hidden"
aria-live="polite"
aria-atomic="true"
>
{requestAnnouncement}
</p>
</>
);
}
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 (
<StudioRuntimeBoundary key={generation}>
<StudioProvider
key={generation}
createGateway={createGateway}
navigate={navigateInternal}
>
<StudioFrame>{children}</StudioFrame>
</StudioProvider>
</StudioRuntimeBoundary>
);
}
@@ -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<StudioContextValue | null>(null);
export function useStudio(): StudioContextValue {
const context = useContext(StudioContext);
if (!context) throw new Error("useStudio must be used within StudioProvider");
return context;
}
@@ -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();
});
});