The public site shipped a Releases page and a footer link to a release, and neither could ever have content: the read path existed, the write path did not. This adds the seven release operations to the contract contribution and the gateway, and a Studio screen that can actually write one. The editor is six markdown fields rather than one, because that is what the contract models and what a release note is — why, what, what a reader notices, what it leaves in the code, how it was verified, what is still open. Publishing is separate from saving: a draft saves in any state, but the public query keys on PUBLISHED alone, so publish is where completeness is demanded. No new CSS. The screen reuses the document editor's field classes and the working-copy list's row classes, so it inherits Studio's spacing and type instead of introducing a second look. The footer previously linked `/releases/0.1.0` — a version that did not exist, so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now points at the changelog index, which is the only place that knows what the latest release is and which reads correctly when there are none. Route inventory, navigation order, message catalog, manual accessibility evidence, artifact baseline, and the pinned gate-shape digest all move with the new route. The digest was recomputed by first reproducing the previous constant from the previous gates.json, so the computation is known to be the one it was pinned under.
154 lines
7.3 KiB
TypeScript
154 lines
7.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { Outlet, RouterProvider, createMemoryRouter } from "react-router-dom";
|
|
import { afterEach, 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 type { StudioGateway } from "../../../src/features/tech-log/application/ports/studio-gateway.ts";
|
|
import { StudioHomePage } from "../../../src/features/tech-log/presentation/studio/pages/studio-home-page.tsx";
|
|
import { StudioNotFoundPage } from "../../../src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx";
|
|
import { StudioShell } from "../../../src/features/tech-log/presentation/studio/studio-shell.tsx";
|
|
import { createExternalAuthSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
|
import { SessionProvider } from "../../../src/presentation/providers/session-provider.tsx";
|
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
|
import { renderWithQueryProviders } from "../../helpers/query-providers.tsx";
|
|
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
async function renderStudio(
|
|
initialEntry: string,
|
|
createStudioGateway: () => StudioGateway,
|
|
) {
|
|
const router = createMemoryRouter(
|
|
[
|
|
{
|
|
path: "/studio",
|
|
element: (
|
|
<StudioShell>
|
|
<Outlet />
|
|
</StudioShell>
|
|
),
|
|
children: [
|
|
{ index: true, element: <StudioHomePage /> },
|
|
{ path: "documents", element: <p>작업본 라우트</p> },
|
|
{ path: "documents/new", element: <p>새 문서 라우트</p> },
|
|
{ path: "publications", element: <p>게시 기록 라우트</p> },
|
|
{ path: "*", element: <StudioNotFoundPage /> },
|
|
],
|
|
},
|
|
],
|
|
{ initialEntries: [initialEntry] },
|
|
);
|
|
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const application = createTestApplication({
|
|
// The Studio shell draws its chrome only for an authenticated session — a
|
|
// signed-out visitor gets the sign-in surface and nothing else, which is
|
|
// what these tests are not about. In the real app AppRouter supplies both
|
|
// the session state and the provider; here the harness does.
|
|
session: createExternalAuthSessionAdapter({
|
|
readState: () => "authenticated" as const,
|
|
subscribe: () => () => {},
|
|
beginSignIn: async () => {},
|
|
signOut: async () => {},
|
|
attachCredential: async () => ({ headers: {} }),
|
|
recoverSession: async () => "restored" as const,
|
|
notifyUnauthenticated: () => {},
|
|
}),
|
|
featureInputs: {
|
|
"tech-log": { ...installed, createStudioGateway },
|
|
},
|
|
});
|
|
const view = render(
|
|
renderWithQueryProviders(
|
|
<ApplicationProvider application={application}>
|
|
<SessionProvider>
|
|
<RouterProvider router={router} />
|
|
</SessionProvider>
|
|
</ApplicationProvider>,
|
|
));
|
|
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
|
|
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
|
|
await screen.findByRole("main");
|
|
return { ...view, router };
|
|
}
|
|
|
|
describe("TechLog Studio shell", () => {
|
|
it("creates one application-provided gateway for child navigation and exposes the source header", async () => {
|
|
const user = userEvent.setup();
|
|
const gateway = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const createGateway = vi.fn(() => gateway);
|
|
|
|
const { container, router } = await renderStudio("/studio", createGateway);
|
|
|
|
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
|
expect(createGateway).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
Array.from(
|
|
container.querySelectorAll<HTMLAnchorElement>(".studio-desktop-navigation a"),
|
|
(link) => [link.textContent, link.getAttribute("href")],
|
|
),
|
|
).toEqual([
|
|
["작업본", "/studio/documents"],
|
|
["게시 기록", "/studio/publications"],
|
|
["새 문서", "/studio/documents/new"],
|
|
["주제·프로젝트", "/studio/taxonomy"],
|
|
["릴리즈", "/studio/releases"],
|
|
["공개 사이트 보기", "/"],
|
|
]);
|
|
expect(screen.getByRole("link", { name: "공개 사이트 보기" })).toHaveAttribute("href", "/");
|
|
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
|
|
|
await user.click(within(screen.getByRole("navigation", { name: "Studio 주 탐색" })).getByRole("link", { name: "작업본" }));
|
|
|
|
expect(router.state.location.pathname).toBe("/studio/documents");
|
|
expect(screen.getByText("작업본 라우트")).toBeVisible();
|
|
expect(createGateway).toHaveBeenCalledTimes(1);
|
|
expect(screen.getAllByRole("link", { name: "작업본" })[0]).toHaveAttribute("aria-current", "page");
|
|
});
|
|
|
|
it("recreates the provider generation and cancels obsolete work after a persisted pageshow", async () => {
|
|
const user = userEvent.setup();
|
|
let firstSignal: AbortSignal | undefined;
|
|
const first = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
const second = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway();
|
|
vi.spyOn(first, "getDashboard").mockImplementation(({ signal } = {}) => {
|
|
firstSignal = signal;
|
|
return new Promise(() => {});
|
|
});
|
|
const secondDashboard = vi.spyOn(second, "getDashboard");
|
|
const createGateway = vi.fn()
|
|
.mockReturnValueOnce(first)
|
|
.mockReturnValueOnce(second);
|
|
|
|
await renderStudio("/studio", createGateway);
|
|
await waitFor(() => expect(firstSignal).toBeDefined());
|
|
await user.click(screen.getByRole("button", { name: "Studio 메뉴 열기" }));
|
|
expect(screen.getByRole("button", { name: "Studio 메뉴 닫기" })).toBeVisible();
|
|
|
|
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
|
|
|
|
await waitFor(() => expect(createGateway).toHaveBeenCalledTimes(2));
|
|
expect(firstSignal?.aborted).toBe(true);
|
|
expect(screen.getByRole("button", { name: "Studio 메뉴 열기" })).toHaveAttribute("aria-expanded", "false");
|
|
expect(await screen.findByRole("heading", { level: 1, name: "작업 흐름" })).toBeVisible();
|
|
expect(secondDashboard).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps unknown Studio routes inside the Studio shell without authentication UI", async () => {
|
|
const createGateway = vi.fn(
|
|
() => createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input.createStudioGateway(),
|
|
);
|
|
|
|
await renderStudio("/studio/does-not-exist", createGateway);
|
|
|
|
expect(screen.getByRole("banner")).toHaveClass("studio-header");
|
|
expect(screen.getByRole("heading", { level: 1, name: "Studio 화면을 찾을 수 없습니다" })).toBeVisible();
|
|
expect(screen.getByRole("link", { name: "작업본으로 돌아가기" })).toHaveAttribute("href", "/studio/documents");
|
|
expect(screen.queryByText(/로그인|sign in/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|