// @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( ".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(".document-body"); const code = view.container.querySelector(".code-block"); const codeScroller = view.container.querySelector( ".code-block pre", ); const table = view.container.querySelector(".data-table-wrap"); const evidence = view.container.querySelector( ".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( ".search-field input", ); const searchField = view.container.querySelector( ".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(".nav-link"); const searchButton = view.getByRole("button", { name: "TechLog 검색 열기", }); const closeButton = view.container.querySelector( ".dialog-close", ); const searchField = view.container.querySelector( ".search-field", ); const dialogInner = view.container.querySelector( ".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( ".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"); }); });