import { cleanup, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { createMemoryRouter } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../App.tsx"; import { getAuth, setToken } from "../auth/token.ts"; import settings from "../board/__fixtures__/settings.json"; import { routes } from "../routes.tsx"; import { json, mockFetch } from "../test/fetchMock.ts"; const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }; const WITH_SYNC = { ...SNAP, last_sync: { started_at: "2026-10-14T11:58:00+00:00", finished_at: "2026-10-14T12:00:00+00:00", issues_count: 9 } }; interface Call { method: string; url: URL } function hub(over: Record Response> = {}) { const calls: Call[] = []; mockFetch((method, url) => { calls.push({ method, url }); const k = `${method} ${url.pathname}`; if (over[k]) return over[k](); if (k === "GET /projects") return json(200, { projects: [A] }); if (k === "GET /settings") return json(200, settings); if (k === "GET /board") return json(200, WITH_SYNC); if (k === "GET /issues") return json(200, { issues: [] }); if (k === "GET /batches") return json(200, { batches: [] }); if (k === "GET /agent") return json(200, { online: true, last_seen_at: "2026-10-14T09:30:00+00:00" }); if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 }); if (k === "GET /batches/7") return json(200, { id: 7, author: "viewer", status: "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: "running" }] }); return json(404, { error: "not_found" }); }); return { count: (m: string, p: string) => calls.filter((c) => c.method === m && c.url.pathname === p).length }; } const open = (path: string) => { const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); render(); return router; }; const bar = () => screen.getByRole("complementary", { name: "Панель приложения" }); describe("боковая панель", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); it("разделы — ссылки «Доска связей» и «Задачи», активная с aria-current", async () => { hub(); open("/app/issues?project=AWPCA"); await screen.findByRole("link", { name: "Доска связей" }); await waitFor(() => expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board?project=AWPCA")); expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page"); expect(screen.getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); }); it("на странице «не найдено» ни один раздел не активен; ссылки в сообщении ведут на разделы", async () => { hub(); open("/app/nowhere?project=AWPCA"); expect(await screen.findByRole("heading", { name: "Страница не найдена" })).toBeInTheDocument(); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); expect(within(nav).getByRole("link", { name: "Доска связей" })).not.toHaveAttribute("aria-current"); expect(within(nav).getByRole("link", { name: "Задачи" })).not.toHaveAttribute("aria-current"); const main = within(screen.getByRole("main")); expect(main.getByRole("link", { name: "Доска связей" })).toHaveAttribute("href", "/app/board"); expect(main.getByRole("link", { name: "Задачи" })).toHaveAttribute("href", "/app/issues"); expect(screen.getByRole("alert")).toHaveTextContent("Страница не найдена"); }); it("«Обновить из Jira» на /issues ставит пакет, недоступна, по done — «пакет #7 выполнен» и GET /issues повторно", async () => { let done = false; const h = hub({ "GET /batches/7": () => json(200, { id: 7, author: "viewer", status: done ? "done" : "running", remaining_seconds: 3, counts: {}, commands: [{ type: "refresh", status: done ? "done" : "running" }] }) }); open("/app/issues?project=AWPCA"); await screen.findByTestId("page-issues"); await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(0)); const issuesBefore = h.count("GET", "/issues"); const btn = within(bar()).getByRole("button", { name: "Обновить из Jira" }); await userEvent.click(btn); await waitFor(() => expect(btn).toBeDisabled()); expect(btn).toHaveAttribute("aria-busy", "true"); expect(h.count("POST", "/refresh")).toBe(1); expect(await within(bar()).findByRole("status")).toHaveTextContent(/пакет #7 выполняется/); done = true; await waitFor(() => expect(within(bar()).getByRole("status")).toHaveTextContent("пакет #7 выполнен"), { timeout: 6000 }); await waitFor(() => expect(h.count("GET", "/issues")).toBeGreaterThan(issuesBefore)); expect(btn).toBeEnabled(); }); it("агент офлайн — «агент офлайн»; на связи — «агент на связи»", async () => { hub({ "GET /agent": () => json(200, { online: false, last_seen_at: "2026-10-14T09:30:00+00:00" }) }); open("/app/board?project=AWPCA"); expect(await within(bar()).findByText("агент офлайн")).toBeInTheDocument(); }); it("агент на связи", async () => { hub(); open("/app/board?project=AWPCA"); expect(await within(bar()).findByText("агент на связи")).toBeInTheDocument(); }); it("«данные на ДД.ММ ЧЧ:ММ» берутся из last_sync снимка", async () => { hub(); open("/app/issues?project=AWPCA"); expect(await within(bar()).findByText(/^данные на \d\d\.\d\d \d\d:\d\d$/)).toBeInTheDocument(); }); it("полного обновления не было", async () => { hub({ "GET /board": () => json(200, { ...SNAP, last_sync: null }) }); open("/app/issues?project=AWPCA"); expect(await within(bar()).findByText("полного обновления не было")).toBeInTheDocument(); }); it("меню «Ещё» → «Сменить токен» показывает экран входа", async () => { hub(); open("/app/board?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Сменить токен" })); expect(getAuth().token).toBe(""); expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument(); }); it("переключатель темы — в панели", async () => { hub(); open("/app/board?project=AWPCA"); expect(await within(bar()).findByRole("switch", { name: "Тёмная тема" })).toBeInTheDocument(); }); it("доска не рисует свою кнопку «Обновить из Jira»: в приложении она одна", async () => { hub(); open("/app/board?project=AWPCA"); await screen.findByTestId("page-board"); await screen.findByText(/задач из/); expect(screen.getAllByRole("button", { name: "Обновить из Jira" })).toHaveLength(1); }); it("разделы — в навигации «Разделы» боковой панели", async () => { hub(); open("/app/board?project=AWPCA"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); }); it("«Свернуть панель» сворачивает и запоминает в jira-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => { hub(); open("/app/board?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" }); expect(btn).toHaveAttribute("aria-expanded", "true"); await userEvent.click(btn); expect(localStorage.getItem("jira-hub-sidebar")).toBe("collapsed"); expect(bar()).toHaveClass("is-collapsed"); cleanup(); open("/app/board?project=AWPCA"); expect(bar()).toHaveClass("is-collapsed"); expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false"); expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи"); await userEvent.click(within(bar()).getByRole("button", { name: "Развернуть панель" })); expect(localStorage.getItem("jira-hub-sidebar")).toBeNull(); expect(bar()).not.toHaveClass("is-collapsed"); }); it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => { localStorage.setItem("jira-hub-sidebar", "collapsed"); const h = hub(); open("/app/issues?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: "Обновить из Jira" }); await userEvent.click(btn); await waitFor(() => expect(btn).toBeDisabled()); expect(h.count("POST", "/refresh")).toBe(1); expect(await within(bar()).findByRole("status")).toHaveTextContent(/пакет #7 выполняется/); expect(btn.getAttribute("title")).toMatch(/пакет #7 выполняется/); }); it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => { localStorage.setItem("jira-hub-sidebar", "collapsed"); hub(); open("/app/board?project=AWPCA"); const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" }); await userEvent.click(tag); expect(bar()).not.toHaveClass("is-collapsed"); }); }); describe("боковая панель на узком экране", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); vi.stubGlobal("matchMedia", (q: string) => ({ matches: q.includes("max-width: 639px"), media: q, addEventListener() {}, removeEventListener() {} })); }); afterEach(() => { vi.unstubAllGlobals(); }); it("сверху строка с «Меню», названием и «Обновить из Jira»; панели нет", async () => { hub(); open("/app/board?project=AWPCA"); const top = await screen.findByRole("banner", { name: "Строка приложения" }); expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false"); expect(within(top).getByText("Хаб Jira")).toBeInTheDocument(); expect(within(top).getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument(); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); it("«Меню» открывает панель с фокусом на первом разделе; Escape закрывает и возвращает фокус", async () => { hub(); open("/app/board?project=AWPCA"); const menu = await screen.findByRole("button", { name: "Меню" }); await userEvent.click(menu); expect(menu).toHaveAttribute("aria-expanded", "true"); const nav = within(bar()).getByRole("navigation", { name: "Разделы" }); expect(within(nav).getByRole("link", { name: "Доска связей" })).toHaveFocus(); expect(within(bar()).queryByRole("button", { name: "Свернуть панель" })).toBeNull(); await userEvent.keyboard("{Escape}"); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); expect(menu).toHaveFocus(); }); it("выбор раздела закрывает панель и открывает раздел", async () => { hub(); const router = open("/app/board?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(within(bar()).getByRole("link", { name: "Задачи" })); expect(router.state.location.pathname).toBe("/app/issues"); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); it("щелчок по подложке и «Закрыть меню» закрывают панель", async () => { hub(); open("/app/board?project=AWPCA"); await userEvent.click(await screen.findByRole("button", { name: "Меню" })); await userEvent.click(screen.getByTestId("sidebar-backdrop")); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); await userEvent.click(screen.getByRole("button", { name: "Меню" })); await userEvent.click(within(bar()).getByRole("button", { name: "Закрыть меню" })); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); });