diff --git a/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-12-report.md b/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-12-report.md new file mode 100644 index 0000000..72fc6f4 --- /dev/null +++ b/.superpowers/sdd/2026-08-15-techlog-ui-migration/task-12-report.md @@ -0,0 +1,33 @@ +# Task 12 report + +## Mapping + +- Source editor save workflow → pending/success announcements, fresh per-command idempotency keys, retry after request failure, and revision-conflict state without replacing the local draft. +- Source guarded Studio links/provider/dialog → all internal Studio anchors use the guarded `` DOM; dirty navigation offers stay, discard, and save-then-navigate with modal focus/trigger restoration and native `beforeunload` protection. +- Source validation report/screen → saved-version validation gates, current/stale freshness copy, error-before-warning issue order, exact JSON-pointer editor anchors, retry/not-found surfaces, and abortable route reads. +- Source Public Preview screen → missing/current/stale/expired states, exact next-action labels, idempotent preview creation, retry/not-found surfaces, and the shared typed `PublicRecordRenderer`. +- Route pages → existing route-input codecs supply the validation/preview document ID; no publish/history/snapshot behavior was pulled forward. +- Task 10/11 seam → provider gained only dirty-navigation/time state, editor gained the save callback, and existing Studio links were switched to the newly available source guarded-link component. + +## TDD evidence + +- RED: `corepack pnpm exec vitest run tests/features/tech-log/studio-save-navigation.test.tsx tests/features/tech-log/studio-validation-preview.test.tsx` exited 1 before collection at the intentionally missing `guarded-studio-link.tsx` and `public-preview-screen.tsx` imports (2 failed files, 0 tests). +- GREEN: the two workflow suites plus `tests/features/tech-log/mock-studio-gateway.test.ts` passed 3 files / 22 tests. +- Focused seam regression: those three files plus the existing Studio shell, screen, and editor suites passed 6 files / 35 tests. +- Scope check: `git diff --check` passed. + +## Files + +- Added guarded link, unsaved dialog, beforeunload hook, validation report/screen, Public Preview screen, and validation/preview route pages under `src/features/tech-log/presentation/studio/`. +- Extended the Task 10 provider/context and Task 11 editor/status rail; updated existing Studio internal link consumers to use the source guard. +- Added `studio-save-navigation.test.tsx` and `studio-validation-preview.test.tsx`. + +## SHA + +- Base: `59332659752ee17c095471d06e7f0fc8b00c89b4`. +- Implementation: the commit containing this report, titled `feat: port TechLog Studio validation workflow` (final SHA recorded in the Task 12 handoff). + +## Deferred + +- Publish, republish, unpublish, publication history, warning acknowledgement, and immutable publication snapshots remain deferred to Task 13. +- Broad browser-security, app/test types, lint, build, architecture, visual, and full-suite gates remain deferred to integrated Task 14 by user direction. diff --git a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx index 28a6b29..1279db1 100644 --- a/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx +++ b/src/features/tech-log/presentation/studio/components/document-editor-screen.tsx @@ -1,5 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { isStudioGatewayError } from "../../../application/ports/studio-gateway-error.ts"; import type { components } from "../../../contracts/studio/generated.ts"; @@ -7,7 +6,9 @@ import type { WorkingCopy, WorkingCopyInput, } from "../../../contracts/studio/contract.ts"; +import { createLocalId } from "../../../domain/studio/local-id.ts"; import { DocumentEditor, type DocumentEditorController } from "./document-editor.tsx"; +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { useStudio, useStudioEditorSession } from "../use-studio.ts"; type CatalogEntry = components["schemas"]["CatalogEntry"]; @@ -24,7 +25,7 @@ function inputOf(document: WorkingCopy): WorkingCopyInput { export function DocumentEditorScreen({ documentId }: { documentId: string }) { const studio = useStudio(); const session = useStudioEditorSession(); - const { begin, clear, editor, updateDraft } = session; + const { begin, clear, editor, setStatus, updateDraft } = session; const [retryKey, setRetryKey] = useState(0); const requestKey = `${documentId}:${retryKey}`; const [result, setResult] = useState<{ @@ -66,6 +67,40 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) { return () => request.abort(); }, [begin, clear, documentId, requestKey, studio.gateway]); + const save = useCallback(async () => { + const current = editor; + if ( + !current || + current.status === "CLEAN" || + current.status === "SAVING" || + current.status === "CONFLICT" + ) return; + setStatus("SAVING"); + try { + const detail = await studio.gateway.saveDocument( + current.documentId, + { + expectedVersion: current.saved.version, + document: current.draft, + }, + { idempotencyKey: createLocalId("studio-editor-save") }, + ); + begin(detail.document, inputOf(detail.document)); + studio.setRequestAnnouncement( + `버전 ${detail.document.version}으로 저장했습니다.`, + ); + } catch (error) { + const conflict = isStudioGatewayError(error) && + error.code === "VERSION_CONFLICT"; + setStatus(conflict ? "CONFLICT" : "DIRTY"); + studio.setRequestAnnouncement( + isStudioGatewayError(error) + ? error.problem.detail + : "저장하지 못했습니다.", + ); + } + }, [begin, editor, setStatus, studio]); + const controller = useMemo(() => { if (!editor || editor.documentId !== documentId) return null; return { @@ -78,8 +113,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) { replace(draft) { if (draft.kind === editor.draft.kind) updateDraft(draft); }, + save, }; - }, [documentId, editor, updateDraft]); + }, [documentId, editor, save, updateDraft]); const currentResult = result?.key === requestKey ? result : null; const problem = currentResult?.problem ?? null; @@ -92,7 +128,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {

404

Studio 화면을 찾을 수 없습니다

이 작업본은 현재 Studio 세션에 없습니다.

- 작업본으로 돌아가기 + + 작업본으로 돌아가기 + ); } diff --git a/src/features/tech-log/presentation/studio/components/document-editor.tsx b/src/features/tech-log/presentation/studio/components/document-editor.tsx index 3ce1e4a..6f79f41 100644 --- a/src/features/tech-log/presentation/studio/components/document-editor.tsx +++ b/src/features/tech-log/presentation/studio/components/document-editor.tsx @@ -21,7 +21,7 @@ export type DocumentEditorController = { status: StudioEditorStatus; update(patch: Partial): void; replace(draft: WorkingCopyInput): void; - save?: () => Promise; + save(): Promise; }; export function DocumentEditor({ controller, catalog }: { controller: DocumentEditorController; catalog: CatalogEntry[] }) { diff --git a/src/features/tech-log/presentation/studio/components/document-list.tsx b/src/features/tech-log/presentation/studio/components/document-list.tsx index bc47d93..d601c02 100644 --- a/src/features/tech-log/presentation/studio/components/document-list.tsx +++ b/src/features/tech-log/presentation/studio/components/document-list.tsx @@ -1,9 +1,9 @@ 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"; +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; const kindLabel = { CASE: "Case", @@ -94,9 +94,9 @@ export function DocumentList() { 세션에 있는 Case, Reference, Question을 찾고 다음 작업으로 이동합니다.

- + 새 문서 - +
@@ -174,9 +174,9 @@ export function DocumentList() {

{kindLabel[item.kind]}

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

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

@@ -207,7 +207,9 @@ export function DocumentList() {

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

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

- 새 문서 만들기 + + 새 문서 만들기 +
)} {page.nextCursor ? ( diff --git a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx index 7c7d1f1..299f0cd 100644 --- a/src/features/tech-log/presentation/studio/components/document-status-rail.tsx +++ b/src/features/tech-log/presentation/studio/components/document-status-rail.tsx @@ -1,6 +1,5 @@ -import { Link } from "react-router-dom"; - import type { DocumentEditorController } from "./document-editor.tsx"; +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; const labels = { CLEAN: "저장됨", @@ -16,8 +15,8 @@ export function DocumentStatusRail({ controller }: { controller: DocumentEditorC

작업 상태

{labels[controller.status]}

저장 버전
{controller.saved.version}
종류
{controller.draft.kind}
- - 저장본 검증 + + 저장본 검증 {controller.status === "CONFLICT" ?

서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.

:

불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.

} ); diff --git a/src/features/tech-log/presentation/studio/components/guarded-studio-link.tsx b/src/features/tech-log/presentation/studio/components/guarded-studio-link.tsx new file mode 100644 index 0000000..0011c72 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/guarded-studio-link.tsx @@ -0,0 +1,52 @@ +import type { + AnchorHTMLAttributes, + MouseEvent as ReactMouseEvent, +} from "react"; + +import { useStudio } from "../use-studio.ts"; + +type GuardedStudioLinkProps = AnchorHTMLAttributes & { + href: string; +}; + +function isPlainPrimaryClick(event: ReactMouseEvent): boolean { + return event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey; +} + +export function GuardedStudioLink({ + href, + onClick, + ...props +}: GuardedStudioLinkProps) { + const { navigateInternal } = useStudio(); + return ( +
{ + onClick?.(event); + if (event.defaultPrevented || !isPlainPrimaryClick(event)) return; + if ( + event.currentTarget.hasAttribute("download") || + (event.currentTarget.target && event.currentTarget.target !== "_self") + ) return; + if (href.startsWith("#")) return; + const target = new URL(href, window.location.href); + if (target.origin !== window.location.origin) return; + if ( + target.pathname !== "/studio" && + !target.pathname.startsWith("/studio/") + ) return; + event.preventDefault(); + navigateInternal( + `${target.pathname}${target.search}${target.hash}`, + event.currentTarget, + ); + }} + /> + ); +} diff --git a/src/features/tech-log/presentation/studio/components/public-preview-screen.tsx b/src/features/tech-log/presentation/studio/components/public-preview-screen.tsx new file mode 100644 index 0000000..6983a4c --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/public-preview-screen.tsx @@ -0,0 +1,303 @@ +import { useEffect, useRef, useState } from "react"; + +import { + isStudioGatewayError, + type StudioGatewayError, +} from "../../../application/ports/studio-gateway-error.ts"; +import type { + PreviewDetail, + WorkingCopyDetail, +} from "../../../contracts/studio/contract.ts"; +import { deriveValidationState } from "../../../domain/studio/document-state.ts"; +import { createLocalId } from "../../../domain/studio/local-id.ts"; +import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; +import { useStudio } from "../use-studio.ts"; +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; + +type LoadedPreview = { + detail: WorkingCopyDetail; + preview: PreviewDetail | null; +}; + +function isAbortError(error: unknown): boolean { + return typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError"; +} + +function usableValidation(detail: WorkingCopyDetail, now: Date) { + const validation = detail.currentValidation; + return validation && + deriveValidationState({ ...detail, now }).freshness === "CURRENT" && + validation.status !== "INVALID" + ? validation + : null; +} + +function formatDateTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Asia/Seoul", + }).format(date); +} + +function resolvePreviewEvidenceAsset(key: string) { + if (key !== "fetch-strategy-boundary") { + throw new Error(`Unknown local evidence asset: ${key}`); + } + return { + src: "/media/fetch-strategy-boundary.svg", + width: 1080, + height: 420, + triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기", + dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대", + }; +} + +export function PublicPreviewScreen({ documentId }: { documentId: string }) { + const studio = useStudio(); + const [retryKey, setRetryKey] = useState(0); + const [loaded, setLoaded] = useState(null); + const [problem, setProblem] = useState(null); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [feedback, setFeedback] = useState(""); + const sequence = useRef(0); + + useEffect(() => { + const request = new AbortController(); + void Promise.all([ + studio.gateway.getDocument(documentId, { signal: request.signal }), + studio.gateway.getCurrentPreview( + documentId, + { signal: request.signal }, + ).catch((error: unknown) => { + if ( + isStudioGatewayError(error) && + error.code === "PREVIEW_NOT_FOUND" + ) return null; + throw error; + }), + ]).then( + ([detail, preview]) => { + if (request.signal.aborted) return; + setLoaded({ detail, preview }); + setLoading(false); + }, + (error: unknown) => { + if (request.signal.aborted || isAbortError(error)) return; + setProblem( + error instanceof Error + ? error + : new Error("Public Preview를 불러오지 못했습니다."), + ); + setLoading(false); + }, + ); + return () => request.abort(); + }, [documentId, retryKey, studio.gateway]); + + const retry = () => { + setLoading(true); + setProblem(null); + setRetryKey((value) => value + 1); + }; + + const createPreview = async () => { + if (!loaded || creating) return; + const validation = usableValidation(loaded.detail, studio.now()); + if (!validation) return; + setCreating(true); + setFeedback(""); + try { + const preview = await studio.gateway.createPreview( + documentId, + { + expectedVersion: loaded.detail.document.version, + validationId: validation.validationId, + }, + { + idempotencyKey: createLocalId( + `studio-preview-${++sequence.current}`, + ), + }, + ); + setLoaded((current) => current + ? { + ...current, + preview: { + preview, + state: "CURRENT", + currentDocumentVersion: current.detail.document.version, + currentValidationId: validation.validationId, + }, + } + : current); + setFeedback("Public Preview를 만들었습니다."); + studio.setRequestAnnouncement( + "현재 저장 버전의 Public Preview를 만들었습니다.", + ); + } catch (error) { + if (isAbortError(error)) return; + setFeedback( + isStudioGatewayError(error) + ? error.problem.detail + : "Public Preview를 만들지 못했습니다. 다시 시도해 주세요.", + ); + } finally { + setCreating(false); + } + }; + + if (loading) { + return ( +

+ Public Preview를 확인하고 있습니다. +

+ ); + } + if ( + problem && + isStudioGatewayError(problem) && + problem.code === "DOCUMENT_NOT_FOUND" + ) { + return ( +
+

404

+

작업본을 찾을 수 없습니다

+

이 작업본은 현재 Studio 세션에 없습니다.

+ + 작업본으로 돌아가기 + +
+ ); + } + if (problem || !loaded) { + return ( +
+

PREVIEW ERROR

+

Public Preview를 불러오지 못했습니다

+

+ {problem && isStudioGatewayError(problem) + ? problem.problem.detail + : problem?.message} +

+ +
+ ); + } + + const validation = usableValidation(loaded.detail, studio.now()); + const previewDetail = loaded.preview; + const state = previewDetail?.state ?? "NONE"; + const message = state === "CURRENT" + ? "현재 저장 버전의 Public Preview입니다." + : state === "STALE" + ? "저장본보다 이전에 만든 Public Preview입니다." + : state === "EXPIRED" + ? "이 Public Preview는 만료되었습니다." + : "아직 만든 Public Preview가 없습니다"; + const canCreate = Boolean(validation) && + state !== "CURRENT" && + state !== "EXPIRED"; + + return ( +
+
+
+

+ PUBLIC PREVIEW · VERSION {loaded.detail.document.version} +

+ {previewDetail ? ( +

Public Preview

+ ) : ( +

Public Preview

+ )} +

{loaded.detail.document.title}

+
+ + 검증 결과 보기 + +
+ +
+
{state}

{message}

+
+ {canCreate ? ( + + ) : null} + {state === "CURRENT" ? ( + + 게시 준비로 이동 + + ) : null} + {!canCreate && state !== "CURRENT" ? ( + + 검증 화면으로 이동 + + ) : null} + + 편집으로 돌아가기 + +
+
+ {feedback ? ( +

{feedback}

+ ) : null} + + {previewDetail ? ( + <> +
+
+
Preview 버전
+
{previewDetail.preview.previewVersion}
+
+
+
생성
+
{formatDateTime(previewDetail.preview.createdAt)}
+
+
+
만료
+
{formatDateTime(previewDetail.preview.expiresAt)}
+
+
+
+ undefined} + /> +
+ + ) : ( +
+

공개 레이아웃을 아직 만들지 않았습니다

+

현재 검증 결과를 기준으로 Public 화면과 같은 문서를 생성합니다.

+
+ )} +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/studio-dashboard.tsx b/src/features/tech-log/presentation/studio/components/studio-dashboard.tsx index 13ae06c..a399ae9 100644 --- a/src/features/tech-log/presentation/studio/components/studio-dashboard.tsx +++ b/src/features/tech-log/presentation/studio/components/studio-dashboard.tsx @@ -1,9 +1,9 @@ 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"; +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; type DocumentSummary = components["schemas"]["DocumentSummary"]; @@ -27,9 +27,9 @@ function WorkRow({ item }: Readonly<{ item: DocumentSummary }>) {

{kindLabel[item.kind]}

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

{item.project?.label ?? "프로젝트 미지정"} · {actionLabel[item.nextAction]} @@ -59,7 +59,7 @@ function WorkSection({

{title}

- 전체 보기 + 전체 보기
{items.length ? ( @@ -105,9 +105,9 @@ export function StudioDashboard() {

작업 흐름

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

- + 새 문서 - + {error ? (

@@ -160,7 +160,9 @@ export function StudioDashboard() {

최근 게시

- 게시 기록 보기 + + 게시 기록 보기 +
{dashboard.recentPublications.length ? ( diff --git a/src/features/tech-log/presentation/studio/components/studio-header.tsx b/src/features/tech-log/presentation/studio/components/studio-header.tsx index 7d58446..4282d6c 100644 --- a/src/features/tech-log/presentation/studio/components/studio-header.tsx +++ b/src/features/tech-log/presentation/studio/components/studio-header.tsx @@ -1,5 +1,6 @@ import { useId, useState } from "react"; -import { Link } from "react-router-dom"; + +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; const navigation = [ { @@ -27,13 +28,13 @@ function StudioNavigation({ return ( @@ -46,13 +47,13 @@ export function StudioHeader({ currentPath }: Readonly<{ currentPath: string }>) return (
- TechLog Studio - +
diff --git a/src/features/tech-log/presentation/studio/components/unsaved-leave-dialog.tsx b/src/features/tech-log/presentation/studio/components/unsaved-leave-dialog.tsx new file mode 100644 index 0000000..1f49e0a --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/unsaved-leave-dialog.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef } from "react"; + +type UnsavedLeaveDialogProps = { + open: boolean; + saving: boolean; + message: string; + onStay(): void; + onDiscard(): void; + onSaveThenNavigate(): Promise; +}; + +export function UnsavedLeaveDialog({ + open, + saving, + message, + onStay, + onDiscard, + onSaveThenNavigate, +}: UnsavedLeaveDialogProps) { + const dialogRef = useRef(null); + const stayRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open && !dialog.open) { + dialog.showModal(); + stayRef.current?.focus(); + } else if (!open && dialog.open) { + dialog.close(); + } + }, [open]); + + const closeAndStay = () => { + dialogRef.current?.close(); + onStay(); + }; + const closeAndDiscard = () => { + dialogRef.current?.close(); + onDiscard(); + }; + + return ( + { + event.preventDefault(); + if (!saving) closeAndStay(); + }} + > +
+

UNSAVED CHANGES

+

저장하지 않은 변경

+

+ 현재 입력을 처리한 뒤 다른 Studio 화면으로 이동합니다. +

+ {message ? ( +

{message}

+ ) : null} +
+ + + +
+
+
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/use-before-unload.ts b/src/features/tech-log/presentation/studio/components/use-before-unload.ts new file mode 100644 index 0000000..dba0948 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/use-before-unload.ts @@ -0,0 +1,13 @@ +import { useEffect } from "react"; + +export function useBeforeUnload(enabled: boolean): void { + useEffect(() => { + if (!enabled) return; + const protectDraft = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", protectDraft); + return () => window.removeEventListener("beforeunload", protectDraft); + }, [enabled]); +} diff --git a/src/features/tech-log/presentation/studio/components/validation-report.tsx b/src/features/tech-log/presentation/studio/components/validation-report.tsx new file mode 100644 index 0000000..ec92eeb --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/validation-report.tsx @@ -0,0 +1,93 @@ +import type { ValidationReport as ValidationReportModel } from "../../../contracts/studio/contract.ts"; +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; + +const severityLabel = { ERROR: "오류", WARNING: "경고" } as const; + +function pointerSegments(path: string): string[] { + return path + .split("/") + .slice(1) + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) + .filter(Boolean); +} + +export function validationFieldTarget(path: string): string { + const segments = pointerSegments(path).map((segment) => + segment.replace(/[^a-zA-Z0-9_-]/g, "-") + ); + return segments.length + ? `studio-field-${segments.join("-")}` + : "studio-edit-panel"; +} + +function formatDateTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Asia/Seoul", + }).format(date); +} + +export function ValidationReport({ + documentId, + report, + current, +}: { + documentId: string; + report: ValidationReportModel; + current: boolean; +}) { + const issues = report.issues.toSorted((left, right) => { + const rank = { ERROR: 0, WARNING: 1 } as const; + return rank[left.severity] - rank[right.severity]; + }); + + return ( +
+
+
+

VALIDATION REPORT

+

검증 결과

+
+ + {report.status} + +
+
+
대상 버전
{report.validatedVersion}
+
상태
{current ? "현재" : "이전 결과"}
+
검증 시각
{formatDateTime(report.validatedAt)}
+
유효 시각
{formatDateTime(report.validUntil)}
+
+ {issues.length ? ( +
    + {issues.map((issue, index) => ( +
  1. + + + {severityLabel[issue.severity]} + + + {issue.message} + {issue.code} · {issue.path || "/"} + + + +
  2. + ))} +
+ ) : ( +

오류와 경고가 없습니다.

+ )} +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/components/validation-screen.tsx b/src/features/tech-log/presentation/studio/components/validation-screen.tsx new file mode 100644 index 0000000..55f0aa5 --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/validation-screen.tsx @@ -0,0 +1,225 @@ +import { useEffect, useRef, useState } from "react"; + +import { + isStudioGatewayError, + type StudioGatewayError, +} from "../../../application/ports/studio-gateway-error.ts"; +import type { + ValidationReport as ValidationReportModel, + WorkingCopyDetail, +} from "../../../contracts/studio/contract.ts"; +import { deriveValidationState } from "../../../domain/studio/document-state.ts"; +import { createLocalId } from "../../../domain/studio/local-id.ts"; +import { useStudio } from "../use-studio.ts"; +import { GuardedStudioLink } from "./guarded-studio-link.tsx"; +import { ValidationReport } from "./validation-report.tsx"; + +function isAbortError(error: unknown): boolean { + return typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError"; +} + +function isCurrent( + detail: WorkingCopyDetail, + report: ValidationReportModel | null, + now: Date, +): boolean { + if (!report) return false; + return deriveValidationState({ + ...detail, + currentValidation: report, + now, + }).freshness === "CURRENT"; +} + +export function ValidationScreen({ documentId }: { documentId: string }) { + const studio = useStudio(); + const [retryKey, setRetryKey] = useState(0); + const [detail, setDetail] = useState(null); + const [problem, setProblem] = useState(null); + const [loading, setLoading] = useState(true); + const [validating, setValidating] = useState(false); + const [completion, setCompletion] = useState(""); + const sequence = useRef(0); + + useEffect(() => { + const request = new AbortController(); + void studio.gateway.getDocument(documentId, { signal: request.signal }).then( + (next) => { + if (request.signal.aborted) return; + setDetail(next); + setLoading(false); + }, + (error: unknown) => { + if (request.signal.aborted || isAbortError(error)) return; + setProblem( + error instanceof Error + ? error + : new Error("검증 화면을 불러오지 못했습니다."), + ); + setLoading(false); + }, + ); + return () => request.abort(); + }, [documentId, retryKey, studio.gateway]); + + const retry = () => { + setLoading(true); + setProblem(null); + setRetryKey((value) => value + 1); + }; + + const validate = async () => { + if (!detail || validating) return; + setValidating(true); + setCompletion(""); + try { + const report = await studio.gateway.validateDocument( + documentId, + { expectedVersion: detail.document.version }, + { + idempotencyKey: createLocalId( + `studio-validation-${++sequence.current}`, + ), + }, + ); + setDetail((current) => current + ? { ...current, currentValidation: report } + : current); + setCompletion("검증이 완료되었습니다."); + studio.setRequestAnnouncement("저장된 문서 검증이 완료되었습니다."); + } catch (error) { + if (isAbortError(error)) return; + setCompletion( + isStudioGatewayError(error) + ? error.problem.detail + : "검증하지 못했습니다. 다시 시도해 주세요.", + ); + } finally { + setValidating(false); + } + }; + + if (loading) { + return ( +

+ 저장된 문서를 확인하고 있습니다. +

+ ); + } + if ( + problem && + isStudioGatewayError(problem) && + problem.code === "DOCUMENT_NOT_FOUND" + ) { + return ( +
+

404

+

작업본을 찾을 수 없습니다

+

이 작업본은 현재 Studio 세션에 없습니다.

+ + 작업본으로 돌아가기 + +
+ ); + } + if (problem || !detail) { + return ( +
+

VALIDATION ERROR

+

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

+

+ {problem && isStudioGatewayError(problem) + ? problem.problem.detail + : problem?.message} +

+ +
+ ); + } + + const report = detail.currentValidation; + const current = isCurrent(detail, report, studio.now()); + const ready = current && report?.status !== "INVALID"; + const gateTitle = !report + ? "아직 검증하지 않았습니다" + : !current + ? "저장 후 다시 검증해야 합니다" + : report.status === "INVALID" + ? "검증 오류를 수정해야 합니다" + : report.status === "WARNINGS" + ? "경고를 확인하고 Preview를 만들 수 있습니다" + : "현재 저장 버전의 검증을 통과했습니다"; + + return ( +
+
+
+

+ DOCUMENT VALIDATION · VERSION {detail.document.version} +

+

저장본 검증

+

{detail.document.title}

+
+ + 편집으로 돌아가기 + +
+ +
+
+

WORKFLOW GATE

+

{gateTitle}

+

+ 검증은 화면의 임시 입력이 아닌 서버에 저장된 버전{" "} + {detail.document.version}을 기준으로 실행합니다. +

+
+
+ + {ready ? ( + + Public Preview 만들기 + + ) : null} + {current && report?.status === "INVALID" ? ( + + 편집 화면에서 수정 + + ) : null} +
+
+ {completion ? ( +

{completion}

+ ) : null} + {report ? ( + + ) : ( +
+

검증 기록이 없습니다

+

+ 저장된 버전을 검증하면 공개 전 오류와 경고가 여기에 정리됩니다. +

+
+ )} +
+ ); +} diff --git a/src/features/tech-log/presentation/studio/pages/document-preview-page.tsx b/src/features/tech-log/presentation/studio/pages/document-preview-page.tsx new file mode 100644 index 0000000..279c81c --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/document-preview-page.tsx @@ -0,0 +1,7 @@ +import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx"; +import { PublicPreviewScreen } from "../components/public-preview-screen.tsx"; + +export function DocumentPreviewPage() { + const { params } = useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_PREVIEW">(); + return ; +} diff --git a/src/features/tech-log/presentation/studio/pages/document-validation-page.tsx b/src/features/tech-log/presentation/studio/pages/document-validation-page.tsx new file mode 100644 index 0000000..48efabc --- /dev/null +++ b/src/features/tech-log/presentation/studio/pages/document-validation-page.tsx @@ -0,0 +1,7 @@ +import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx"; +import { ValidationScreen } from "../components/validation-screen.tsx"; + +export function DocumentValidationPage() { + const { params } = useRouteInput<"TECH_LOG_STUDIO_DOCUMENT_VALIDATION">(); + 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 index 23803f5..860798e 100644 --- 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 @@ -1,4 +1,4 @@ -import { Link } from "react-router-dom"; +import { GuardedStudioLink } from "../components/guarded-studio-link.tsx"; export function StudioNotFoundPage() { return ( @@ -6,7 +6,9 @@ export function StudioNotFoundPage() {

404

Studio 화면을 찾을 수 없습니다

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

- 작업본으로 돌아가기 + + 작업본으로 돌아가기 +
); } diff --git a/src/features/tech-log/presentation/studio/studio-provider.tsx b/src/features/tech-log/presentation/studio/studio-provider.tsx index b197dd9..789a52b 100644 --- a/src/features/tech-log/presentation/studio/studio-provider.tsx +++ b/src/features/tech-log/presentation/studio/studio-provider.tsx @@ -2,9 +2,11 @@ import { type ReactNode, useCallback, useMemo, + useRef, useState, } from "react"; +import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; import type { StudioGateway } from "../../application/ports/studio-gateway.ts"; import type { WorkingCopy, @@ -16,13 +18,24 @@ import { type StudioEditorState, type StudioEditorStatus, } from "./use-studio.ts"; +import { UnsavedLeaveDialog } from "./components/unsaved-leave-dialog.tsx"; +import { useBeforeUnload } from "./components/use-before-unload.ts"; type StudioProviderProps = Readonly<{ children: ReactNode; createGateway: () => StudioGateway; + now?: () => Date; navigate?: (href: string) => void; }>; +function inputOf(document: WorkingCopy): WorkingCopyInput { + const input: Record = { ...document }; + delete input.id; + delete input.version; + delete input.updatedAt; + return input as WorkingCopyInput; +} + function defaultNavigate(href: string): void { window.history.pushState({}, "", href); window.dispatchEvent(new PopStateEvent("popstate")); @@ -31,12 +44,31 @@ function defaultNavigate(href: string): void { export function StudioProvider({ children, createGateway, + now = () => new Date("2026-08-14T01:00:00.000Z"), navigate = defaultNavigate, }: StudioProviderProps) { const [gateway] = useState(() => createGateway()); const [editor, setEditor] = useState(null); + const [pendingHref, setPendingHref] = useState(null); const [requestAnnouncement, setRequestAnnouncement] = useState(""); - const navigateInternal = useCallback((href: string) => navigate(href), [navigate]); + const triggerRef = useRef(null); + const saveSequence = useRef(0); + + const unsafe = editor?.status === "DIRTY" || editor?.status === "CONFLICT"; + useBeforeUnload(unsafe); + + const navigateInternal = useCallback(( + href: string, + trigger?: HTMLElement | null, + ) => { + if (!unsafe) { + navigate(href); + return; + } + triggerRef.current = trigger ?? null; + setPendingHref(href); + setRequestAnnouncement(""); + }, [navigate, unsafe]); const beginEditor = useCallback((saved: WorkingCopy, draft: WorkingCopyInput) => { setEditor({ documentId: saved.id, saved, draft, status: "CLEAN" }); }, []); @@ -47,9 +79,71 @@ export function StudioProvider({ setEditor((current) => current ? { ...current, status } : current); }, []); const clearEditor = useCallback(() => setEditor(null), []); + + const stay = useCallback(() => { + const trigger = triggerRef.current; + triggerRef.current = null; + setPendingHref(null); + setRequestAnnouncement(""); + queueMicrotask(() => trigger?.focus()); + }, []); + + const discard = useCallback(() => { + const href = pendingHref; + setEditor((current) => current + ? { ...current, draft: inputOf(current.saved), status: "CLEAN" } + : current); + setPendingHref(null); + setRequestAnnouncement(""); + triggerRef.current = null; + if (href) navigate(href); + }, [navigate, pendingHref]); + + const saveThenNavigate = useCallback(async (): Promise => { + const current = editor; + const href = pendingHref; + if (!current || !href || current.status === "SAVING") return false; + setEditor({ ...current, status: "SAVING" }); + try { + const detail = await gateway.saveDocument( + current.documentId, + { + expectedVersion: current.saved.version, + document: current.draft, + }, + { idempotencyKey: `studio-local-save-${++saveSequence.current}` }, + ); + setEditor({ + documentId: detail.document.id, + saved: detail.document, + draft: inputOf(detail.document), + status: "CLEAN", + }); + setPendingHref(null); + setRequestAnnouncement(`버전 ${detail.document.version}으로 저장했습니다.`); + triggerRef.current = null; + navigate(href); + return true; + } catch (error) { + setEditor({ + ...current, + status: isStudioGatewayError(error) && error.code === "VERSION_CONFLICT" + ? "CONFLICT" + : "DIRTY", + }); + setRequestAnnouncement( + isStudioGatewayError(error) + ? error.problem.detail + : "저장하지 못했습니다. 다시 시도해 주세요.", + ); + return false; + } + }, [editor, gateway, navigate, pendingHref]); + const value = useMemo( () => ({ gateway, + now, editor, requestAnnouncement, setRequestAnnouncement, @@ -65,6 +159,7 @@ export function StudioProvider({ editor, gateway, navigateInternal, + now, requestAnnouncement, setEditorStatus, updateEditorDraft, @@ -73,7 +168,17 @@ export function StudioProvider({ return ( -
{children}
+
+ {children} + +
); } diff --git a/src/features/tech-log/presentation/studio/use-studio.ts b/src/features/tech-log/presentation/studio/use-studio.ts index a960ebe..434e39d 100644 --- a/src/features/tech-log/presentation/studio/use-studio.ts +++ b/src/features/tech-log/presentation/studio/use-studio.ts @@ -17,10 +17,11 @@ export type StudioEditorState = Readonly<{ export type StudioContextValue = Readonly<{ gateway: StudioGateway; + now(): Date; editor: StudioEditorState | null; requestAnnouncement: string; setRequestAnnouncement(message: string): void; - navigateInternal(href: string): void; + navigateInternal(href: string, trigger?: HTMLElement | null): void; beginEditor(saved: WorkingCopy, draft: WorkingCopyInput): void; updateEditorDraft(draft: WorkingCopyInput): void; setEditorStatus(status: StudioEditorStatus): void; diff --git a/tests/features/tech-log/studio-save-navigation.test.tsx b/tests/features/tech-log/studio-save-navigation.test.tsx new file mode 100644 index 0000000..b3a449c --- /dev/null +++ b/tests/features/tech-log/studio-save-navigation.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useEffect } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; +import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; +import { StudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; +import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts"; +import type { + WorkingCopy, + WorkingCopyInput, + WorkingCopyDetail, +} from "../../../src/features/tech-log/contracts/studio/contract.ts"; +import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx"; +import { GuardedStudioLink } from "../../../src/features/tech-log/presentation/studio/components/guarded-studio-link.tsx"; +import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx"; +import { + useStudio, + useStudioEditorSession, +} from "../../../src/features/tech-log/presentation/studio/use-studio.ts"; + +const originalShowModal = HTMLDialogElement.prototype.showModal; +const originalClose = HTMLDialogElement.prototype.close; + +beforeEach(() => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value(this: HTMLDialogElement) { + this.setAttribute("open", ""); + }, + }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value(this: HTMLDialogElement) { + this.removeAttribute("open"); + this.dispatchEvent(new Event("close")); + }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value: originalShowModal, + }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { + configurable: true, + value: originalClose, + }); +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +function inputOf(document: WorkingCopy): WorkingCopyInput { + const { id, version, updatedAt, ...input } = document; + void id; + void version; + void updatedAt; + return input; +} + +function Announcement() { + const { requestAnnouncement } = useStudio(); + return

{requestAnnouncement}

; +} + +function renderEditor(documentId: string, gateway: StudioGateway) { + return render( + + gateway}> + + + + , + ); +} + +function DirtyNavigationProbe({ saved }: { saved: WorkingCopy }) { + const { begin, updateDraft } = useStudioEditorSession(); + useEffect(() => { + begin(saved, inputOf(saved)); + updateDraft({ ...inputOf(saved), title: "바뀐 제목" }); + }, [begin, saved, updateDraft]); + return 작업본; +} + +async function getSavedDocument(gateway: StudioGateway) { + return (await gateway.getDocument(FIXTURE_IDS.redisAdapterCase)).document; +} + +describe("TechLog Studio save workflow", () => { + it("shows save pending and success states and creates a fresh idempotency key for each command", async () => { + const user = userEvent.setup(); + const base = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const first = deferred(); + const keys: string[] = []; + let calls = 0; + const gateway = { + ...base, + saveDocument(...args: Parameters) { + keys.push(args[2].idempotencyKey); + calls += 1; + return calls === 1 + ? first.promise.then(() => base.saveDocument(...args)) + : base.saveDocument(...args); + }, + } satisfies StudioGateway; + renderEditor(FIXTURE_IDS.redisAdapterCase, gateway); + + await user.clear(await screen.findByLabelText("제목")); + await user.type(screen.getByLabelText("제목"), "첫 저장 제목"); + await user.click(screen.getByRole("button", { name: "저장" })); + + expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장 중…"); + expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled(); + + first.resolve(); + await waitFor(() => expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨")); + expect(screen.getByTestId("announcement")).toHaveTextContent( + "버전 5으로 저장했습니다.", + ); + + await user.clear(screen.getByLabelText("제목")); + await user.type(screen.getByLabelText("제목"), "두 번째 저장 제목"); + await user.click(screen.getByRole("button", { name: "저장" })); + await waitFor(() => expect(keys).toHaveLength(2)); + expect(keys[0]).toBeTruthy(); + expect(keys[1]).toBeTruthy(); + expect(keys[1]).not.toBe(keys[0]); + }); + + it("keeps the local draft and disables overwrite after a revision conflict", async () => { + const user = userEvent.setup(); + const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway(); + renderEditor(FIXTURE_IDS.conflictCase, gateway); + + await user.clear(await screen.findByLabelText("제목")); + await user.type(screen.getByLabelText("제목"), "내 충돌 초안"); + await user.click(screen.getByRole("button", { name: "저장" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "서버 최신본과 충돌했습니다. 이 세션에서는 다시 열어 비교해 주세요.", + ); + expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장 충돌"); + expect(screen.getByLabelText("제목")).toHaveValue("내 충돌 초안"); + expect(screen.getByRole("button", { name: "저장" })).toBeDisabled(); + }); + + it("surfaces a save error and retries with a new user-command key without losing input", async () => { + const user = userEvent.setup(); + const base = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const keys: string[] = []; + const retryable = new StudioGatewayError({ + type: "https://techlog.local/problems/studio-unavailable", + title: "STUDIO_UNAVAILABLE", + status: 503, + detail: "Studio가 잠시 응답하지 않습니다.", + code: "STUDIO_UNAVAILABLE", + retryable: true, + }); + const saveDocument = vi + .fn() + .mockImplementationOnce((_id, _command, options) => { + keys.push(options.idempotencyKey); + return Promise.reject(retryable); + }) + .mockImplementation((...args) => { + keys.push(args[2].idempotencyKey); + return base.saveDocument(...args); + }); + renderEditor(FIXTURE_IDS.redisAdapterCase, { ...base, saveDocument }); + + await user.clear(await screen.findByLabelText("제목")); + await user.type(screen.getByLabelText("제목"), "오류 뒤에도 남는 초안"); + await user.click(screen.getByRole("button", { name: "저장" })); + + await waitFor(() => expect(screen.getByTestId("announcement")).toHaveTextContent( + "Studio가 잠시 응답하지 않습니다.", + )); + expect(screen.getByLabelText("제목")).toHaveValue("오류 뒤에도 남는 초안"); + expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장되지 않음"); + + await user.click(screen.getByRole("button", { name: "저장" })); + await waitFor(() => expect(screen.getByRole("status", { name: "편집 상태" })).toHaveTextContent("저장됨")); + expect(saveDocument).toHaveBeenCalledTimes(2); + expect(keys[1]).not.toBe(keys[0]); + }); +}); + +describe("TechLog Studio dirty navigation", () => { + it("opens the source modal dialog, protects browser unload, and restores focus when staying", async () => { + const user = userEvent.setup(); + const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const destinations: string[] = []; + const saved = await getSavedDocument(gateway); + render( + + gateway} navigate={(href) => destinations.push(href)}> + + + , + ); + + await waitFor(() => { + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(true); + }); + + const trigger = screen.getByRole("link", { name: "작업본" }); + trigger.focus(); + await user.click(trigger); + const dialog = screen.getByRole("dialog", { name: "저장하지 않은 변경" }); + expect(dialog).toHaveAttribute("open"); + expect(screen.getByRole("button", { name: "이 페이지에 머무르기" })).toHaveFocus(); + expect(screen.getAllByRole("button").map((button) => button.textContent)).toEqual([ + "이 페이지에 머무르기", + "변경 버리기", + "저장 후 이동", + ]); + + await user.click(screen.getByRole("button", { name: "이 페이지에 머무르기" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(trigger).toHaveFocus(); + expect(destinations).toEqual([]); + }); + + it("discards the draft and follows the pending internal destination", async () => { + const user = userEvent.setup(); + const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const destinations: string[] = []; + const saved = await getSavedDocument(gateway); + render( + + gateway} navigate={(href) => destinations.push(href)}> + + + , + ); + + await user.click(screen.getByRole("link", { name: "작업본" })); + await user.click(screen.getByRole("button", { name: "변경 버리기" })); + expect(destinations).toEqual(["/studio/documents"]); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(false); + }); + + it("saves the draft before following the pending destination", async () => { + const user = userEvent.setup(); + const base = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const pending = deferred(); + let key = ""; + const gateway = { + ...base, + saveDocument(...args: Parameters) { + key = args[2].idempotencyKey; + return pending.promise; + }, + } satisfies StudioGateway; + const destinations: string[] = []; + const saved = await getSavedDocument(base); + render( + + gateway} navigate={(href) => destinations.push(href)}> + + + , + ); + + await user.click(screen.getByRole("link", { name: "작업본" })); + await user.click(screen.getByRole("button", { name: "저장 후 이동" })); + expect(screen.getByRole("button", { name: "저장 중" })).toBeDisabled(); + expect(destinations).toEqual([]); + + pending.resolve({ + ...(await base.getDocument(saved.id)), + document: { ...saved, version: saved.version + 1, title: "바뀐 제목" }, + }); + await waitFor(() => expect(destinations).toEqual(["/studio/documents"])); + expect(key).toBeTruthy(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/tech-log/studio-validation-preview.test.tsx b/tests/features/tech-log/studio-validation-preview.test.tsx new file mode 100644 index 0000000..b83a029 --- /dev/null +++ b/tests/features/tech-log/studio-validation-preview.test.tsx @@ -0,0 +1,239 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts"; +import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; +import type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts"; +import { DocumentEditorScreen } from "../../../src/features/tech-log/presentation/studio/components/document-editor-screen.tsx"; +import { PublicPreviewScreen } from "../../../src/features/tech-log/presentation/studio/components/public-preview-screen.tsx"; +import { ValidationScreen } from "../../../src/features/tech-log/presentation/studio/components/validation-screen.tsx"; +import { StudioProvider } from "../../../src/features/tech-log/presentation/studio/studio-provider.tsx"; + +class NoopIntersectionObserver implements IntersectionObserver { + readonly root = null; + readonly rootMargin = "0px"; + readonly scrollMargin = "0px"; + readonly thresholds = [0]; + + disconnect() {} + observe() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve() {} +} + +beforeAll(() => { + vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +afterEach(() => vi.restoreAllMocks()); + +function renderStudio( + child: React.ReactNode, + gateway: StudioGateway = createTechLogFeatureInstalledInput().input.createStudioGateway(), +) { + return render( + + gateway}>{child} + , + ); +} + +describe("TechLog Studio validation workflow", () => { + it("reruns stale saved validation, reports exact freshness copy, and creates a new key per command", async () => { + const user = userEvent.setup(); + const base = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const keys: string[] = []; + const gateway = { + ...base, + validateDocument(...args: Parameters) { + keys.push(args[2].idempotencyKey); + return base.validateDocument(...args); + }, + } satisfies StudioGateway; + renderStudio(, gateway); + + expect(await screen.findByText("저장 후 다시 검증해야 합니다")).toBeVisible(); + expect(screen.getByText("이전 결과")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "다시 검증" })); + + expect(await screen.findByText("검증이 완료되었습니다.")).toBeVisible(); + expect(screen.getByText("현재")).toBeVisible(); + expect(screen.getByRole("link", { name: "Public Preview 만들기" })).toHaveAttribute( + "href", + `/studio/documents/${FIXTURE_IDS.fetchJoinCase}/preview`, + ); + await user.click(screen.getByRole("button", { name: "다시 검증" })); + await waitFor(() => expect(keys).toHaveLength(2)); + expect(keys[0]).toBeTruthy(); + expect(keys[1]).not.toBe(keys[0]); + }); + + it("orders validation issues and links each JSON pointer to the affected editor field", async () => { + const user = userEvent.setup(); + const gateway = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const view = renderStudio( + , + gateway, + ); + + const issue = await screen.findByText("사실이 필요합니다."); + expect(issue.closest("a")).toHaveAttribute( + "href", + `/studio/documents/${FIXTURE_IDS.edgeTokenQuestion}/edit#studio-field-facts`, + ); + expect(screen.getByText("검증 오류를 수정해야 합니다")).toBeVisible(); + expect(screen.getByRole("link", { name: "편집 화면에서 수정" })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "다시 검증" })); + const warning = await screen.findByText("선택지 두 개를 권장합니다."); + expect(warning.closest("a")).toHaveAttribute( + "href", + `/studio/documents/${FIXTURE_IDS.edgeTokenQuestion}/edit#studio-field-options`, + ); + view.unmount(); + + renderStudio(, gateway); + await screen.findByRole("heading", { level: 1, name: "문서 편집" }); + expect(document.querySelector("#studio-field-facts")).toBeInTheDocument(); + expect(document.querySelector("#studio-field-options")).toBeInTheDocument(); + }); + + it("aborts a route-obsolete validation read without surfacing an abort error", async () => { + const base = createTechLogFeatureInstalledInput().input.createStudioGateway(); + let firstSignal: AbortSignal | undefined; + const getDocument = vi + .fn() + .mockImplementationOnce((_id, options) => { + firstSignal = options?.signal; + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + }); + }) + .mockImplementation((id, options) => base.getDocument(id, options)); + const gateway = { ...base, getDocument }; + const view = renderStudio( + , + gateway, + ); + await waitFor(() => expect(firstSignal).toBeDefined()); + + view.rerender( + + gateway}> + + + , + ); + + await waitFor(() => expect(firstSignal?.aborted).toBe(true)); + expect(await screen.findByText("현재 저장 버전의 검증을 통과했습니다")).toBeVisible(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); + +describe("TechLog Studio Public Preview workflow", () => { + it("creates a missing preview, shows the current state, and renders the shared Public document", async () => { + const user = userEvent.setup(); + const base = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const keys: string[] = []; + const gateway = { + ...base, + createPreview(...args: Parameters) { + keys.push(args[2].idempotencyKey); + return base.createPreview(...args); + }, + } satisfies StudioGateway; + const view = renderStudio( + , + gateway, + ); + + expect(await screen.findByText("아직 만든 Public Preview가 없습니다")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Public Preview 만들기" })); + + expect(await screen.findByText("현재 저장 버전의 Public Preview입니다.")).toBeVisible(); + expect(screen.getByRole("link", { name: "게시 준비로 이동" })).toHaveAttribute( + "href", + `/studio/documents/${FIXTURE_IDS.stateNonceReference}/publish`, + ); + expect(screen.getByRole("heading", { level: 1, name: "Authorization Code Flow에서 state와 nonce의 경계" })).toBeVisible(); + expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); + expect(view.container.querySelector("main main")).toBeNull(); + expect(view.container.querySelector(".public-record-embedded")).toBeInTheDocument(); + expect(keys).toHaveLength(1); + expect(keys[0]).toBeTruthy(); + }); + + it("keeps a stale preview inspectable and points to validation as the exact next action", async () => { + renderStudio(); + + expect(await screen.findByText("저장본보다 이전에 만든 Public Preview입니다.")).toBeVisible(); + expect(screen.getByText("STALE")).toBeVisible(); + expect(screen.getByRole("heading", { level: 1, name: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가" })).toBeVisible(); + expect(screen.getByRole("link", { name: "검증 화면으로 이동" })).toHaveAttribute( + "href", + `/studio/documents/${FIXTURE_IDS.fetchJoinCase}/validation`, + ); + expect(screen.queryByRole("button", { name: "Public Preview 다시 만들기" })).not.toBeInTheDocument(); + }); + + it("keeps an expired preview inspectable and blocks recreation until validation", async () => { + renderStudio(); + + expect(await screen.findByText("이 Public Preview는 만료되었습니다.")).toBeVisible(); + expect(screen.getByText("EXPIRED")).toBeVisible(); + expect(screen.getByRole("heading", { level: 1, name: "만료 미리보기 예시" })).toBeVisible(); + expect(screen.getByRole("link", { name: "검증 화면으로 이동" })).toHaveAttribute( + "href", + `/studio/documents/${FIXTURE_IDS.expiredPreviewCase}/validation`, + ); + }); + + it("shows retryable preview-load errors and retries the read", async () => { + const base = createTechLogFeatureInstalledInput().input.createStudioGateway(); + const getDocument = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockImplementation((id, options) => base.getDocument(id, options)); + renderStudio( + , + { ...base, getDocument }, + ); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Public Preview를 불러오지 못했습니다offline", + ); + fireEvent.click(screen.getByRole("button", { name: "다시 시도" })); + expect(await screen.findByText("아직 만든 Public Preview가 없습니다")).toBeVisible(); + expect(getDocument).toHaveBeenCalledTimes(2); + }); + + it("blocks an expired validation until validation runs again", async () => { + renderStudio(); + + expect(await screen.findByText("저장 후 다시 검증해야 합니다")).toBeVisible(); + expect(screen.queryByRole("link", { name: "Public Preview 만들기" })).not.toBeInTheDocument(); + }); + + it("renders the source not-found state for unknown document ids", async () => { + renderStudio( + , + ); + + expect(await screen.findByRole("heading", { level: 1, name: "작업본을 찾을 수 없습니다" })).toBeVisible(); + expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute( + "href", + "/studio/documents", + ); + }); +});