271 lines
17 KiB
TypeScript
271 lines
17 KiB
TypeScript
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);
|
||
});
|