232 lines
13 KiB
TypeScript
232 lines
13 KiB
TypeScript
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();
|
||
});
|
||
});
|