Files
tech-log-frontend/tests/features/tech-log/studio-save-navigation.test.tsx
T
DongHyeonka 11c2713139 feat: let Studio create the topics and projects publishing requires
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.
2026-08-20 23:40:15 +09:00

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();
});
});