Files
DongHyeonka 3754269118 feat: add the Studio release editor and point the footer at the changelog
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.
2026-08-21 03:08:41 +09:00

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