Publishing needs a topic and nothing could create one. The backend now owns that surface; this is its consumer — the management contract vendored, a gateway over its nine operations, and one Studio screen that lists, creates, and deletes topics and projects. The screen adds no CSS. It reuses the classes the working-copy list already uses, so it inherits Studio's spacing, type, and colour rather than growing a second visual vocabulary beside them. Scope stops at list/create/delete: renaming, phase changes, and visibility are implemented in the backend and declared in the contract, but their screens are a separate design. Two real defects surfaced while making the public port async, and both would have shipped: The search page and the header search dialog shared a query key. With an empty query, `["tech-log","search",""]` was identical for both, so react-query handed one surface the other's cache — different shapes — and the page died reading a field that was not there. Keys now name the surface. The explore filter's selects are uncontrolled and read `defaultValue`, which React applies once. Their options arrive later now, so the first render had nothing to match and the value stayed empty: a topic in the URL no longer showed as selected. The form key includes whether the catalog has arrived, so it remounts with the options present. Controlled inputs would be the other answer, but this form submits to build a URL — the URL owns the value. The route brought its own bookkeeping: a build chunk, a manual accessibility evidence file, and the CI artifact baseline that counts them. The gate pins a digest of its own shape precisely so a new route cannot slip in without that count being reviewed. Test harnesses that render public screens now assemble the query providers and await the settled paint, because the screens they render became async.
311 lines
13 KiB
TypeScript
311 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";
|
|
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.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
|
|
createManagementGateway={createManagementGatewayStub} 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
|
|
createManagementGateway={createManagementGatewayStub} 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
|
|
createManagementGateway={createManagementGatewayStub} 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
|
|
createManagementGateway={createManagementGatewayStub} 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();
|
|
});
|
|
});
|