Adds an Asset Picker and upload dialog to the CASE editor so authors can insert `:::evidence` directives that reference backend assets, and opens projectWorkingCopy's two evidence gates so Instant Preview accepts a key backed by a freshly loaded READY asset instead of only the one hardcoded legacy key. The editor screen now owns the loaded Asset list so the Picker, the upload dialog, and Instant Preview all read the same array, and a freshly uploaded asset appears in the preview without a refetch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
306 lines
13 KiB
TypeScript
306 lines
13 KiB
TypeScript
// @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 { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.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) {
|
|
// CASE editors mount the Asset Picker (Task 10), which uses the throwing
|
|
// `useStudioAssetGateway()` accessor -- a test harness that renders it must
|
|
// supply `createAssetGateway`, the same as `StudioShell` always does.
|
|
const assetGateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT)
|
|
.input.createStudioAssetGateway();
|
|
return render(
|
|
<MemoryRouter initialEntries={[`/studio/documents/${documentId}/edit`]}>
|
|
<StudioProvider createGateway={() => gateway} createAssetGateway={() => assetGateway}>
|
|
<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(MOCK_STUDIO_INSTALL_CONTEXT).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(MOCK_STUDIO_INSTALL_CONTEXT).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(MOCK_STUDIO_INSTALL_CONTEXT).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(MOCK_STUDIO_INSTALL_CONTEXT).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(MOCK_STUDIO_INSTALL_CONTEXT).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(MOCK_STUDIO_INSTALL_CONTEXT).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();
|
|
});
|
|
});
|