sdlc-hub/web/src/modules/wiki/WikiPage.test.tsx
2026-10-24 15:38:46 +04:00

271 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string> = {
"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(<WikiPage project="AWPCA" />, { 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(<WikiPage project="sfm_kedr" />, { 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(<WikiPage project="sfm_kedr" />, { 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", "до <script>window.__x = 1</script> после\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("<script>");
expect(container.querySelector('a[href^="javascript"]')).toBeNull();
});
it("ошибка синхронизации видна", async () => {
hub(undefined, { ...STATUS, error: "Eva не ответила: 502" });
open("/wiki?project=AWPCA&page=DOC-2");
expect(await screen.findByText(/Eva не ответила: 502/)).toBeInTheDocument();
});
it("I-1: пакет wiki_sync завершён — подзаголовок «обновлено …» и дерево обновляются без перезагрузки", async () => {
let synced = false;
const fresh = { ...STATUS, synced_at: "2026-10-24T12:00:00+00:00" };
const empty = { ...STATUS, synced_at: null, pages: 0 };
mockFetch((method, url) => {
if (url.pathname === "/wiki/status") return json(200, synced ? fresh : empty);
if (url.pathname === "/wiki/sync" && method === "POST") { synced = true; return json(202, { batch_id: 5 }); }
if (url.pathname === "/batches/5") return json(200, { id: 5, status: "done", module: "wiki", commands: [{ type: "wiki_sync", status: "done" }], author: "viewer", title: "t", created_at: "2026-10-24T12:00:00+00:00" });
if (url.pathname === "/wiki/tree") return json(200, TREE);
if (url.pathname === "/agents") return json(200, { agents: [] });
const m = /^\/wiki\/pages\/([^/]+)$/.exec(url.pathname);
if (m) return json(200, page(decodeURIComponent(m[1])));
return json(404, { error: "not_found" });
});
open("/wiki?project=AWPCA");
await screen.findByText("Вики ещё не синхронизирована. Нажмите «Обновить вики».");
await userEvent.click(screen.getByRole("button", { name: "Обновить вики" }));
expect(await screen.findByText(`Корень · 3 страниц · обновлено ${fmtStamp(fresh.synced_at)}`, {}, { timeout: 8000 })).toBeInTheDocument();
expect(await screen.findByRole("treeitem", { name: /Модель данных/ })).toBeInTheDocument();
}, 15000);
});