feat: port TechLog Studio validation workflow

This commit is contained in:
DongHyeonka
2026-08-15 23:59:08 +09:00
parent 5933265975
commit 9c6906fc6f
20 changed files with 1551 additions and 35 deletions
@@ -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(
<MemoryRouter initialEntries={["/studio"]}>
<StudioProvider createGateway={() => gateway}>{child}</StudioProvider>
</MemoryRouter>,
);
}
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<StudioGateway["validateDocument"]>) {
keys.push(args[2].idempotencyKey);
return base.validateDocument(...args);
},
} satisfies StudioGateway;
renderStudio(<ValidationScreen documentId={FIXTURE_IDS.fetchJoinCase} />, 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(
<ValidationScreen documentId={FIXTURE_IDS.edgeTokenQuestion} />,
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(<DocumentEditorScreen documentId={FIXTURE_IDS.edgeTokenQuestion} />, 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<StudioGateway["getDocument"]>()
.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(
<ValidationScreen documentId={FIXTURE_IDS.fetchJoinCase} />,
gateway,
);
await waitFor(() => expect(firstSignal).toBeDefined());
view.rerender(
<MemoryRouter initialEntries={["/studio"]}>
<StudioProvider createGateway={() => gateway}>
<ValidationScreen documentId={FIXTURE_IDS.stateNonceReference} />
</StudioProvider>
</MemoryRouter>,
);
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<StudioGateway["createPreview"]>) {
keys.push(args[2].idempotencyKey);
return base.createPreview(...args);
},
} satisfies StudioGateway;
const view = renderStudio(
<PublicPreviewScreen documentId={FIXTURE_IDS.stateNonceReference} />,
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(<PublicPreviewScreen documentId={FIXTURE_IDS.fetchJoinCase} />);
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(<PublicPreviewScreen documentId={FIXTURE_IDS.expiredPreviewCase} />);
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<StudioGateway["getDocument"]>()
.mockRejectedValueOnce(new Error("offline"))
.mockImplementation((id, options) => base.getDocument(id, options));
renderStudio(
<PublicPreviewScreen documentId={FIXTURE_IDS.stateNonceReference} />,
{ ...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(<ValidationScreen documentId={FIXTURE_IDS.expiredPreviewCase} />);
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(
<ValidationScreen documentId="99999999-9999-4999-8999-999999999999" />,
);
expect(await screen.findByRole("heading", { level: 1, name: "작업본을 찾을 수 없습니다" })).toBeVisible();
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute(
"href",
"/studio/documents",
);
});
});