feat: port TechLog Studio validation workflow
This commit is contained in:
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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 <p data-testid="announcement" aria-live="polite">{requestAnnouncement}</p>;
|
||||
}
|
||||
|
||||
function renderEditor(documentId: string, gateway: StudioGateway) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway}>
|
||||
<DocumentEditorScreen documentId={documentId} />
|
||||
<Announcement />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function DirtyNavigationProbe({ saved }: { saved: WorkingCopy }) {
|
||||
const { begin, updateDraft } = useStudioEditorSession();
|
||||
useEffect(() => {
|
||||
begin(saved, inputOf(saved));
|
||||
updateDraft({ ...inputOf(saved), title: "바뀐 제목" });
|
||||
}, [begin, saved, updateDraft]);
|
||||
return <GuardedStudioLink href="/studio/documents">작업본</GuardedStudioLink>;
|
||||
}
|
||||
|
||||
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<void>();
|
||||
const keys: string[] = [];
|
||||
let calls = 0;
|
||||
const gateway = {
|
||||
...base,
|
||||
saveDocument(...args: Parameters<StudioGateway["saveDocument"]>) {
|
||||
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<StudioGateway["saveDocument"]>()
|
||||
.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(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
|
||||
<DirtyNavigationProbe saved={saved} />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
|
||||
<DirtyNavigationProbe saved={saved} />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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<WorkingCopyDetail>();
|
||||
let key = "";
|
||||
const gateway = {
|
||||
...base,
|
||||
saveDocument(...args: Parameters<StudioGateway["saveDocument"]>) {
|
||||
key = args[2].idempotencyKey;
|
||||
return pending.promise;
|
||||
},
|
||||
} satisfies StudioGateway;
|
||||
const destinations: string[] = [];
|
||||
const saved = await getSavedDocument(base);
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/studio/documents/${saved.id}/edit`]}>
|
||||
<StudioProvider createGateway={() => gateway} navigate={(href) => destinations.push(href)}>
|
||||
<DirtyNavigationProbe saved={saved} />
|
||||
</StudioProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user