sdlc-hub/web/src/shell/Sidebar.test.tsx
2026-10-22 08:53:06 +04:00

232 lines
13 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 { 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<string, () => 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(<App router={router} />);
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();
});
});