import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { setToken } from "../../auth/token.ts"; import { json, mockFetch } from "../../test/fetchMock.ts"; import { renderWithProviders } from "../../test/render.tsx"; import { fmtStamp } from "../../ui/format.ts"; import { WikiPage } from "./WikiPage.tsx"; const STATUS = { root: "DOC-1", root_title: "Корень", synced_at: "2026-10-20T10:00:00+00:00", pages: 3, error: null, batch: null }; const TREE = { pages: [ { code: "DOC-1", title: "Корень", depth: 0, parent_code: null, size: 10 }, { code: "DOC-2", title: "Модель данных", depth: 1, parent_code: "DOC-1", size: 10 }, { code: "DOC-3", title: "Регламент", depth: 1, parent_code: "DOC-1", size: 10 }, ] }; const PAGES: Record = { "DOC-2": "Текст модели данных", "DOC-3": "Смотри [Модель данных](page:DOC-2) и [скачать](attachment:7)", }; const page = (code: string, markdown = PAGES[code] ?? "") => ({ code, title: TREE.pages.find((p) => p.code === code)?.title ?? code, path: code, url: `https://eva.test/${code}`, synced_at: "2026-10-20T10:00:00+00:00", markdown, attachments: [], }); type Calls = { method: string; path: string; search: string; body: unknown }[]; function hub(over: (method: string, url: URL, body: unknown) => Response | undefined = () => undefined, status: object = STATUS) { const calls: Calls = []; mockFetch((method, url, body) => { calls.push({ method, path: url.pathname, search: url.search, body }); const o = over(method, url, body); if (o) return o; if (url.pathname === "/wiki/status") return json(200, status); if (url.pathname === "/wiki/tree") return json(200, TREE); const m = /^\/wiki\/pages\/([^/]+)$/.exec(url.pathname); if (m) return json(200, page(decodeURIComponent(m[1]))); return json(404, { error: "not_found" }); }); return calls; } const open = (route: string) => renderWithProviders(, { route, path: "/wiki" }); describe("страница «Вики»", () => { beforeEach(() => setToken("tok")); it("шапка: h1 «Вики», подзаголовок с корнем, числом страниц и временем, вторичная кнопка «Обновить вики»", async () => { hub(); open("/wiki?project=AWPCA"); expect(await screen.findByRole("heading", { level: 1, name: "Вики" })).toBeInTheDocument(); expect(screen.getByText(`Корень · 3 страниц · обновлено ${fmtStamp(STATUS.synced_at)}`)).toBeInTheDocument(); const btn = screen.getByRole("button", { name: "Обновить вики" }); expect(btn).toHaveClass("ui-btn--secondary"); expect(btn).not.toHaveClass("ui-btn--primary"); }); it("без page открыт корень и выделен в дереве", async () => { const calls = hub(); open("/wiki?project=AWPCA"); expect(await screen.findByRole("treeitem", { name: /Корень/ })).toHaveAttribute("aria-selected", "true"); expect(await screen.findByRole("article")).toBeInTheDocument(); expect(calls.some((c) => c.path === "/wiki/pages/DOC-1")).toBe(true); }); it("T10.2: предки открытой страницы раскрыты и остаются раскрытыми после очистки поиска", async () => { hub((_m, url) => url.pathname === "/wiki/search" ? json(200, { results: [{ code: "DOC-3", title: "Регламент", path: "p", snippet: "порядок" }] }) : undefined); open("/wiki?project=AWPCA&page=DOC-2"); expect(await screen.findByRole("treeitem", { name: /Модель данных/ })).toHaveAttribute("aria-selected", "true"); await userEvent.click(screen.getByRole("treeitem", { name: /Корень/ }).querySelector(".wiki-tree__chevron")!); // свернуть корень вручную await userEvent.click(screen.getByRole("treeitem", { name: /Корень/ }).querySelector(".wiki-tree__chevron")!); // и раскрыть await userEvent.type(screen.getByRole("searchbox", { name: "Поиск по вики" }), "поряд"); expect(await screen.findByText("порядок")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Очистить" })); expect(await screen.findByRole("treeitem", { name: /Модель данных/ })).toBeInTheDocument(); expect(screen.getByRole("treeitem", { name: /Корень/ })).toHaveAttribute("aria-expanded", "true"); }); it("T10.6: ручное сворачивание сохраняется после очистки поиска", async () => { hub((_m, url) => url.pathname === "/wiki/search" ? json(200, { results: [] }) : undefined); open("/wiki?project=AWPCA"); await userEvent.click((await screen.findByRole("treeitem", { name: /Корень/ })).querySelector(".wiki-tree__chevron")!); expect(screen.queryByRole("treeitem", { name: /Регламент/ })).toBeNull(); await userEvent.type(screen.getByRole("searchbox", { name: "Поиск по вики" }), "яяя"); expect(await screen.findByText("Ничего не найдено")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Очистить" })); expect(await screen.findByRole("treeitem", { name: /Корень/ })).toHaveAttribute("aria-expanded", "false"); expect(screen.queryByRole("treeitem", { name: /Регламент/ })).toBeNull(); }); it("T10.2: у узлов дерева aria-setsize и aria-posinset", async () => { hub(); open("/wiki?project=AWPCA"); await screen.findByRole("tree"); const root = screen.getByRole("treeitem", { name: /Корень/ }); expect(root).toHaveAttribute("aria-setsize", "1"); expect(root).toHaveAttribute("aria-posinset", "1"); expect(screen.getByRole("treeitem", { name: /Модель данных/ })).toHaveAttribute("aria-setsize", "2"); expect(screen.getByRole("treeitem", { name: /Регламент/ })).toHaveAttribute("aria-posinset", "2"); }); it("T10.3: заголовки идут по порядку h1 → h2 без скачков, заголовки текста страницы ниже заголовка страницы", async () => { hub((_m, url) => url.pathname === "/wiki/pages/DOC-2" ? json(200, page("DOC-2", "# Верх\n\n## Раздел\n\n### Подраздел")) : undefined); open("/wiki?project=AWPCA&page=DOC-2"); const article = await screen.findByRole("article"); await within(article).findByText("Подраздел"); const levels = screen.getAllByRole("heading").map((h) => Number(h.tagName[1])); expect(levels[0]).toBe(1); expect(levels[1]).toBe(2); levels.slice(1).forEach((l, i) => { expect(l).toBeGreaterThan(1); expect(l - (levels[i] ?? 0)).toBeLessThanOrEqual(1); }); }); // jsdom без вёрстки: ResizeObserver подменён, ширину страницы сообщает тест const withWidth = (width: number) => { class RO { cb: (e: unknown[]) => void; constructor(cb: (e: unknown[]) => void) { this.cb = cb; } observe() { this.cb([{ contentRect: { width } }]); } disconnect() {} unobserve() {} } vi.stubGlobal("ResizeObserver", RO); }; it("на узкой странице (ширина контейнера ≤ 759.98) страница выше дерева", async () => { withWidth(700); try { hub(); open("/wiki?project=AWPCA&page=DOC-2"); const article = await screen.findByRole("article"); const tree = await screen.findByRole("tree"); await waitFor(() => expect(article.compareDocumentPosition(tree) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()); } finally { vi.unstubAllGlobals(); } }); it("на странице шире порога дерево выше страницы", async () => { withWidth(900); try { hub(); open("/wiki?project=AWPCA&page=DOC-2"); const article = await screen.findByRole("article"); const tree = await screen.findByRole("tree"); expect(tree.compareDocumentPosition(article) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); } finally { vi.unstubAllGlobals(); } }); it("на широком экране дерево выше страницы", async () => { hub(); open("/wiki?project=AWPCA&page=DOC-2"); const article = await screen.findByRole("article"); const tree = await screen.findByRole("tree"); expect(tree.compareDocumentPosition(article) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it("параметр q заполняет поиск и запускает его", async () => { const calls = hub((_m, url) => url.pathname === "/wiki/search" ? json(200, { results: [{ code: "DOC-3", title: "Регламент", path: "p", snippet: "порядок согласования" }] }) : undefined); open("/wiki?project=AWPCA&q=согласов"); expect(await screen.findByRole("searchbox", { name: "Поиск по вики" })).toHaveValue("согласов"); expect(await screen.findByText("порядок согласования")).toBeInTheDocument(); expect(new URLSearchParams(calls.find((c) => c.path === "/wiki/search")!.search).get("q")).toBe("согласов"); }); it("M-15: ошибка вложения в состоянии failed видна сразу рядом со ссылкой", async () => { hub((_m, url) => url.pathname === "/wiki/pages/DOC-3" ? json(200, { ...page("DOC-3"), attachments: [{ id: 7, name: "a.txt", state: "failed", size: null, error: "Eva: 404" }] }) : undefined); open("/wiki?project=AWPCA&page=DOC-3"); expect(await screen.findByText(/Eva: 404/)).toBeInTheDocument(); }); it("ссылка page: открывает страницу", async () => { hub(); const { router } = open("/wiki?project=AWPCA&page=DOC-3"); const article = await screen.findByRole("article"); await userEvent.click(await within(article).findByRole("link", { name: "Модель данных" })); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&page=DOC-2")); expect(await within(screen.getByRole("article")).findByText("Текст модели данных")).toBeInTheDocument(); expect(screen.getByRole("treeitem", { name: /Модель данных/ })).toHaveAttribute("aria-selected", "true"); }); it("без корня — форма привязки", async () => { const calls = hub((method, url) => { if (url.pathname === "/wiki/status") return json(409, { error: "no_root", detail: "у проекта sfm_kedr нет корня вики" }); if (method === "PATCH") return json(200, { project: { label: "sfm_kedr", name: "x", created_at: "" }, batch_ids: [5] }); }); renderWithProviders(, { route: "/wiki?project=sfm_kedr", path: "/wiki" }); await userEvent.type(await screen.findByLabelText("Корень вики (DOC-… или ссылка)"), "DOC-001234"); await userEvent.click(screen.getByRole("button", { name: "Привязать" })); await waitFor(() => expect(calls.find((c) => c.method === "PATCH")).toBeTruthy()); const patch = calls.find((c) => c.method === "PATCH")!; expect(patch.path).toBe("/projects/sfm_kedr"); expect(patch.body).toMatchObject({ wiki_root: "DOC-001234" }); }); it("422 при привязке корня — текст ошибки у поля", async () => { hub((method, url) => { if (url.pathname === "/wiki/status") return json(409, { error: "no_root", detail: "нет корня" }); if (method === "PATCH") return json(422, { error: "bad_request", detail: "ожидается код DOC-… или ссылка на документ Eva" }); }); renderWithProviders(, { route: "/wiki?project=sfm_kedr", path: "/wiki" }); await userEvent.type(await screen.findByLabelText("Корень вики (DOC-… или ссылка)"), "ерунда"); await userEvent.click(screen.getByRole("button", { name: "Привязать" })); expect(await screen.findByText("ожидается код DOC-… или ссылка на документ Eva")).toBeInTheDocument(); expect(screen.getByLabelText("Корень вики (DOC-… или ссылка)")).toHaveAttribute("aria-invalid", "true"); }); it("404 страницы — сообщение об ошибке, дерево на месте", async () => { hub((_m, url) => url.pathname === "/wiki/pages/DOC-9" ? json(404, { error: "not_found", detail: "страница DOC-9 не найдена" }) : undefined); open("/wiki?project=AWPCA&page=DOC-9"); expect(await screen.findByText(/страница DOC-9 не найдена/)).toBeInTheDocument(); expect(screen.getByRole("tree")).toBeInTheDocument(); }); it("ссылки //host не уводят на внешний сайт, PAGE: открывается как page:", async () => { hub((_m, url) => url.pathname === "/wiki/pages/DOC-2" ? json(200, page("DOC-2", "[внешняя](//evil.example/x) [вторая](PAGE:DOC-3)")) : undefined); open("/wiki?project=AWPCA&page=DOC-2"); const article = await screen.findByRole("article"); await within(article).findByText("внешняя"); expect(article.querySelector('a[href^="//"]')).toBeNull(); expect(within(article).getByRole("link", { name: "вторая" })).toHaveAttribute("href", "/wiki?project=AWPCA&page=DOC-3"); }); it("поиск заменяет дерево", async () => { const calls = hub((_m, url) => url.pathname === "/wiki/search" ? json(200, { results: [{ code: "DOC-3", title: "Регламент", path: "p", snippet: "порядок согласования" }] }) : undefined); open("/wiki?project=AWPCA"); expect(await screen.findByRole("tree")).toBeInTheDocument(); await userEvent.type(screen.getByRole("searchbox", { name: "Поиск по вики" }), "согласов"); expect(await screen.findByText("порядок согласования")).toBeInTheDocument(); expect(screen.queryByRole("tree")).toBeNull(); const s = calls.find((c) => c.path === "/wiki/search")!; expect(new URLSearchParams(s.search).get("q")).toBe("согласов"); expect(new URLSearchParams(s.search).get("project")).toBe("AWPCA"); await userEvent.click(screen.getByRole("button", { name: "Очистить" })); expect(await screen.findByRole("tree")).toBeInTheDocument(); }); it("сырой HTML не исполняется", async () => { hub((_m, url) => url.pathname === "/wiki/pages/DOC-2" ? json(200, page("DOC-2", "до после\n\n[плохая](javascript:alert(1))")) : undefined); const { container } = open("/wiki?project=AWPCA&page=DOC-2"); const article = await screen.findByRole("article"); await within(article).findByText(/до/); expect(container.querySelector("script")).toBeNull(); expect(article.textContent).toContain("