Files
tech-log-frontend/tests/features/tech-log/studio-validation-preview.test.tsx
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

244 lines
11 KiB
TypeScript

// @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 { MOCK_STUDIO_INSTALL_CONTEXT } from "../../helpers/studio-install-context.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";
import { createManagementGatewayStub } from "../../helpers/management-gateway-stub.ts";
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(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
) {
return render(
<MemoryRouter initialEntries={["/studio"]}>
<StudioProvider
createManagementGateway={createManagementGatewayStub} 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(MOCK_STUDIO_INSTALL_CONTEXT).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(MOCK_STUDIO_INSTALL_CONTEXT).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(MOCK_STUDIO_INSTALL_CONTEXT).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
createManagementGateway={createManagementGatewayStub} 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(MOCK_STUDIO_INSTALL_CONTEXT).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(MOCK_STUDIO_INSTALL_CONTEXT).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",
);
});
});