Adds TECH_LOG_STUDIO_SOURCE (MOCK | HTTP, default MOCK) to the V2 runtime
config schema so a build can switch createTechLogFeatureInstalledInput
between the mock and HTTP Studio gateways without a rebuild. V1 documents
predate the key and always normalize to MOCK. The HTTP gateway is
constructed with only { operations } per Task 4's actual signature -
no CSRF provider is wired here; that lands with attachCredentials at a
later composition-root task.
Updates every existing Studio test call site to the new required
createTechLogFeatureInstalledInput(context) signature via a shared
tests/helpers/studio-install-context.ts MOCK fixture, so the whole
existing Studio suite keeps exercising the mock adapter unchanged.
240 lines
8.6 KiB
TypeScript
240 lines
8.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { createElement } from "react";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { afterAll, beforeAll, describe, expect, it } 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 { FatalErrorState } from "../../../src/features/tech-log/presentation/public/components/fatal-error-state.tsx";
|
|
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
|
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
|
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
|
|
|
let styleElement: HTMLStyleElement;
|
|
|
|
beforeAll(() => {
|
|
styleElement = document.createElement("style");
|
|
styleElement.textContent = readFileSync(
|
|
resolve(
|
|
process.cwd(),
|
|
"src/features/tech-log/presentation/styles/globals.css",
|
|
),
|
|
"utf8",
|
|
);
|
|
document.head.append(styleElement);
|
|
});
|
|
|
|
afterAll(() => {
|
|
styleElement.remove();
|
|
});
|
|
|
|
function renderPublicSurface(node: React.ReactNode) {
|
|
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
|
const shell = createElement(PublicShell, { children: node });
|
|
const router = createElement(
|
|
MemoryRouter,
|
|
{ initialEntries: ["/projects"] },
|
|
shell,
|
|
);
|
|
return render(
|
|
createElement(ApplicationProvider, {
|
|
application: createTestApplication({
|
|
featureInputs: { "tech-log": techLog },
|
|
}),
|
|
children: router,
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe("TechLog consumer-visible style contract", () => {
|
|
it("applies the source typography, palette, shell width, and header spacing", () => {
|
|
const view = renderPublicSurface(
|
|
createElement(
|
|
"main",
|
|
{ id: "main-content", className: "shell" },
|
|
"공개 본문",
|
|
),
|
|
);
|
|
|
|
const rootStyle = getComputedStyle(document.documentElement);
|
|
const bodyStyle = getComputedStyle(document.body);
|
|
const headerInner = view.container.querySelector<HTMLElement>(
|
|
".header-inner",
|
|
);
|
|
const main = view.getByRole("main");
|
|
const wordmark = view.getByRole("link", { name: "TechLog 홈" });
|
|
|
|
expect(rootStyle.getPropertyValue("--canvas")).toBe("#f7f7f5");
|
|
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
|
|
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
|
|
expect(rootStyle.getPropertyValue("--shell")).toBe("1180px");
|
|
expect(rootStyle.getPropertyValue("--body-copy")).toBe("42rem");
|
|
expect(rootStyle.color).toBe("var(--ink)");
|
|
expect(rootStyle.background).toBe("var(--canvas)");
|
|
expect(bodyStyle.color).toBe("var(--ink)");
|
|
expect(bodyStyle.background).toContain(
|
|
"linear-gradient(to bottom, rgba(255, 255, 255, 0.45), transparent 420px)",
|
|
);
|
|
expect(bodyStyle.background).toContain("var(--canvas)");
|
|
expect(bodyStyle.fontFamily).toContain("Pretendard Variable");
|
|
expect(bodyStyle.fontSize).toBe("16px");
|
|
expect(bodyStyle.fontWeight).toBe("400");
|
|
expect(bodyStyle.lineHeight).toBe("1.6");
|
|
expect(bodyStyle.minWidth).toBe("320px");
|
|
expect(headerInner).not.toBeNull();
|
|
expect(getComputedStyle(headerInner!).display).toBe("grid");
|
|
expect(getComputedStyle(headerInner!).minHeight).toBe("76px");
|
|
expect(getComputedStyle(headerInner!).gap).toBe("48px");
|
|
expect(getComputedStyle(main).width).toBe(
|
|
"min(var(--shell), calc(100% - 80px))",
|
|
);
|
|
expect(getComputedStyle(wordmark).fontSize).toBe("17px");
|
|
expect(getComputedStyle(wordmark).fontWeight).toBe("680");
|
|
});
|
|
|
|
it("aligns code, table, and evidence widths with the article body", () => {
|
|
const view = renderPublicSurface(
|
|
createElement(
|
|
"main",
|
|
{ id: "main-content", className: "shell" },
|
|
createElement(
|
|
"article",
|
|
{ className: "document-body" },
|
|
createElement(
|
|
"section",
|
|
null,
|
|
createElement("p", null, "본문"),
|
|
createElement(
|
|
"figure",
|
|
{ className: "code-block" },
|
|
createElement("pre", null, createElement("code", null, "code")),
|
|
),
|
|
createElement(
|
|
"div",
|
|
{ className: "data-table-wrap" },
|
|
createElement("table", null, createElement("tbody")),
|
|
),
|
|
createElement(
|
|
"figure",
|
|
{ className: "evidence-figure" },
|
|
createElement("img", { alt: "근거 자료" }),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
const article = view.container.querySelector<HTMLElement>(".document-body");
|
|
const code = view.container.querySelector<HTMLElement>(".code-block");
|
|
const codeScroller = view.container.querySelector<HTMLElement>(
|
|
".code-block pre",
|
|
);
|
|
const table = view.container.querySelector<HTMLElement>(".data-table-wrap");
|
|
const evidence = view.container.querySelector<HTMLElement>(
|
|
".evidence-figure",
|
|
);
|
|
|
|
expect(getComputedStyle(article!).width).toBe("min(100%, var(--body-copy))");
|
|
for (const material of [code, table, evidence]) {
|
|
expect(getComputedStyle(material!).width).toBe(
|
|
"min(var(--body-copy), 100%)",
|
|
);
|
|
expect(getComputedStyle(material!).transform).toBe("none");
|
|
}
|
|
expect(getComputedStyle(codeScroller!).overflowX).toBe("auto");
|
|
expect(getComputedStyle(table!).overflowX).toBe("auto");
|
|
});
|
|
|
|
it("preserves keyboard focus and the real search-field focus style", async () => {
|
|
const user = userEvent.setup();
|
|
const view = renderPublicSurface(
|
|
createElement(
|
|
"main",
|
|
{ id: "main-content", className: "shell" },
|
|
"공개 본문",
|
|
),
|
|
);
|
|
const wordmark = view.getByRole("link", { name: "TechLog 홈" });
|
|
const searchInput = view.container.querySelector<HTMLInputElement>(
|
|
".search-field input",
|
|
);
|
|
const searchField = view.container.querySelector<HTMLElement>(
|
|
".search-field",
|
|
);
|
|
|
|
await user.tab();
|
|
await user.tab();
|
|
expect(wordmark).toHaveFocus();
|
|
|
|
searchInput!.focus();
|
|
|
|
expect(searchInput).toHaveFocus();
|
|
expect(searchField).not.toBeNull();
|
|
expect(searchField!.matches(":focus-within")).toBe(true);
|
|
expect(getComputedStyle(searchField!).boxShadow).toBe(
|
|
"0 0 0 3px var(--signal-soft)",
|
|
);
|
|
});
|
|
|
|
it("keeps every header and dialog control at the source minimum target size", () => {
|
|
const view = renderPublicSurface(
|
|
createElement(
|
|
"main",
|
|
{ id: "main-content", className: "shell" },
|
|
"공개 본문",
|
|
),
|
|
);
|
|
|
|
const wordmark = view.getByRole("link", { name: "TechLog 홈" });
|
|
const navLink = view.container.querySelector<HTMLElement>(".nav-link");
|
|
const searchButton = view.getByRole("button", {
|
|
name: "TechLog 검색 열기",
|
|
});
|
|
const closeButton = view.container.querySelector<HTMLElement>(
|
|
".dialog-close",
|
|
);
|
|
const searchField = view.container.querySelector<HTMLElement>(
|
|
".search-field",
|
|
);
|
|
const dialogInner = view.container.querySelector<HTMLElement>(
|
|
".search-dialog-inner",
|
|
);
|
|
|
|
expect(getComputedStyle(wordmark).minHeight).toBe("44px");
|
|
expect(getComputedStyle(navLink!).minHeight).toBe("44px");
|
|
expect(getComputedStyle(searchButton).minHeight).toBe("44px");
|
|
expect(getComputedStyle(closeButton!).minWidth).toBe("52px");
|
|
expect(getComputedStyle(closeButton!).minHeight).toBe("44px");
|
|
expect(getComputedStyle(searchField!).minHeight).toBe("56px");
|
|
expect(getComputedStyle(searchField!).marginTop).toBe("24px");
|
|
expect(getComputedStyle(searchField!).backgroundColor).toBe(
|
|
"rgb(255, 255, 255)",
|
|
);
|
|
expect(getComputedStyle(dialogInner!).padding).toBe("27px 28px 22px");
|
|
});
|
|
|
|
it("preserves footer spacing and fatal-action sizing", () => {
|
|
const view = renderPublicSurface(
|
|
createElement(FatalErrorState, {
|
|
traceId: "PUBLIC-500",
|
|
retryHref: "/",
|
|
}),
|
|
);
|
|
|
|
const footerInner = view.container.querySelector<HTMLElement>(
|
|
".footer-inner",
|
|
);
|
|
const retry = view.getByRole("link", { name: "다시 시도" });
|
|
|
|
expect(getComputedStyle(footerInner!).minHeight).toBe("172px");
|
|
expect(getComputedStyle(footerInner!).gap).toBe("40px");
|
|
expect(getComputedStyle(retry).minHeight).toBe("44px");
|
|
expect(getComputedStyle(retry).marginTop).toBe("34px");
|
|
});
|
|
});
|