import { fireEvent, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { StrictMode } from "react"; 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 settings from "./__fixtures__/settings.json"; import { BoardPage } from "./BoardPage.tsx"; const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8"); const SNAP = JSON.parse(fx("hub-basic.board.json")); function hub(over: Record Response> = {}) { return mockFetch((method, url) => { const k = `${method} ${url.pathname}`; if (over[k]) return over[k](); if (k === "GET /settings") return json(200, settings); if (k === "GET /board") return json(200, SNAP); if (k === "GET /batches") return json(200, { batches: [] }); if (k === "GET /agents") return json(200, { agents: [] }); 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: {} }); return json(404, { error: "not_found" }); }); } const page = (project = "AWPCA") => renderWithProviders(, { route: "/board", path: "/board" }); // канбан-проект: спринтов нет ни у одной своей задачи (соседи — из другого проекта — могут иметь) const withSprints = (keep: (key: string) => boolean) => ({ ...SNAP, issues: SNAP.issues.map((i: { key: string; sprint: string | null }) => ({ ...i, sprint: keep(i.key) ? i.sprint : null })) }); const KANBAN = withSprints(() => false); const NEIGHBOR_ONLY = withSprints((k) => k === "AURORA-9007"); const NO_SPRINT_TEXT = "колонки «Спринт» нет — показаны все задачи"; describe("страница доски", () => { beforeEach(() => setToken("tok")); it("шапка: вторичная кнопка «Обновить из Jira», primary нет; пустого блока .board-src под шапкой нет", async () => { hub(); const { container } = page(); await screen.findByText(/задач из/); expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary"); expect(container.querySelector(".ui-btn--primary")).toBeNull(); expect(container.querySelector(".board-src")).toBeNull(); }); it("строит доску из хаба: источник, статистика, выбор самого наполненного спринта", async () => { hub(); page(); expect(await screen.findByText(/задач из/)).toBeInTheDocument(); expect(screen.getByText(/задач из 9/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument(); }); it("снимок запрашивается по проекту страницы", async () => { const fetch = hub(); page(); await screen.findByText(/задач из/); const urls = fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/board"); expect(urls[0].searchParams.get("project")).toBe("AWPCA"); }); it("пустые словари в настройках — сообщение, а не пустая доска", async () => { hub({ "GET /settings": () => json(200, { kind_labels: {}, link_types: {} }) }); page(); expect(await screen.findByText(/В настройках хаба нет kind_labels и link_types/)).toBeInTheDocument(); expect(document.querySelector(".board")).toBeNull(); }); it("переключатели и легенда сохраняются под прежними ключами", async () => { hub(); page(); await screen.findByText(/задач из 9/); const nb = screen.getByRole("button", { name: "Соседи по связям" }); await userEvent.click(nb); expect(nb).toHaveAttribute("aria-pressed", "true"); expect(localStorage.getItem("graph-neighbors")).toBe("1"); await userEvent.click(within(document.querySelector(".legend") as HTMLElement).getByRole("button", { name: /разработка/ })); expect(localStorage.getItem("graph-hidekind")).toBe('["dev"]'); }); it("«Проблемы» открывают ящик, счётчик — сроки, исполнители, статусы", async () => { hub(); page(); await screen.findByText(/задач из 9/); await userEvent.click(screen.getByRole("button", { name: /Проблемы/ })); expect(screen.getByText("Проблемы на доске")).toBeInTheDocument(); expect(localStorage.getItem("graph-prob")).toBe("1"); expect(screen.getByRole("button", { name: /^Проблемы/ })).toHaveAttribute("aria-pressed", "true"); }); it("кнопка «Проблемы» несёт счётчик и aria-pressed", async () => { hub(); page(); await screen.findByText(/задач из 9/); const b = screen.getByRole("button", { name: /^Проблемы/ }); expect(b).toHaveAttribute("aria-pressed", "false"); expect(within(b).getByText(/^\d+$/)).toBeInTheDocument(); }); it("экспорт JSON показывает выгрузку arm-ka-links/1 с раскладкой", async () => { hub(); page(); await screen.findByText(/задач из 9/); await userEvent.click(screen.getByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Экспорт JSON" })); const text = (screen.getByRole("textbox") as HTMLTextAreaElement).value; expect(JSON.parse(text)).toMatchObject({ format: "arm-ka-links/1", source: "Хаб" }); expect(JSON.parse(text).nodes.find((n: { key: string }) => n.key === "AURORA-9001").layout).not.toBeNull(); }); it("сброс раскладки — с подтверждением в меню", async () => { hub(); localStorage.setItem("graph-pos:board", '{"AURORA-9001":{"x":1,"ry":40}}'); page(); await screen.findByText(/задач из 9/); await userEvent.click(screen.getByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Сбросить раскладку" })); await userEvent.click(screen.getByRole("button", { name: "Да, сбросить" })); expect(localStorage.getItem("graph-pos:board")).toBeNull(); }); it("загрузка CSV — источник «из файла»", async () => { hub(); page(); await screen.findByText(/задач из 9/); const input = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(input, { target: { files: [new File([fx("jira-export.csv")], "jira-export.csv", { type: "text/csv" })] } }); expect(await screen.findByText("из файла: jira-export.csv")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Вернуться к данным хаба" })); expect(await screen.findByText(/задач из/)).toBeInTheDocument(); expect(screen.queryByText(/^из файла:/)).not.toBeInTheDocument(); }); it("«Загрузить файл…» в меню «Ещё» открывает выбор файла", async () => { hub(); page(); await screen.findByText(/задач из 9/); const input = document.querySelector('input[type="file"]') as HTMLInputElement; const click = vi.spyOn(input, "click"); await userEvent.click(screen.getByRole("button", { name: "Ещё" })); await userEvent.click(screen.getByRole("button", { name: "Загрузить файл…" })); expect(click).toHaveBeenCalled(); }); it("свёрнутая легенда: скрыт «тестирование», «Легенда» свёрнута, после перемонтирования свёрнута, «скрыто: 1»", async () => { hub(); const r = page(); await screen.findByText(/задач из 9/); const legend = () => document.querySelector(".legend"); await userEvent.click(within(legend() as HTMLElement).getByRole("button", { name: /тестирование/ })); expect(screen.getByText("скрыто: 1")).toBeInTheDocument(); const toggle = screen.getByRole("button", { name: /^Легенда/ }); expect(toggle).toHaveAttribute("aria-expanded", "true"); await userEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "false"); expect(legend()).toBeNull(); expect(localStorage.getItem("sdlc-hub-legend")).toBe("0"); r.unmount(); page(); await screen.findByText(/задач из 9/); expect(screen.getByRole("button", { name: /^Легенда/ })).toHaveAttribute("aria-expanded", "false"); expect(legend()).toBeNull(); expect(screen.getByText("скрыто: 1")).toBeInTheDocument(); }); it("кнопка «Обновить из Jira» в шапке есть, хода пакета и «Тема» на доске нет", async () => { hub(); page(); await screen.findByText(/задач из 9/); expect(screen.getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Ещё" })); expect(screen.getByRole("button", { name: "Ещё" })).toHaveAttribute("aria-expanded", "true"); expect(screen.getByRole("button", { name: "Перечитать" })).toBeInTheDocument(); // меню действительно открыто expect(screen.queryByRole("button", { name: /^Тема/ })).not.toBeInTheDocument(); }); it("заголовок страницы — h1, в шапке label; строка состояния — только события доски", async () => { hub(); page(); await screen.findByText(/задач из 9/); expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); expect(screen.queryByText(/^Хаб · данные на/)).not.toBeInTheDocument(); // данные хаба пометки не имеют (спека, решение 13) expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); it("пустой link_types при заполненном kind_labels — называет только недостающий словарь", async () => { hub({ "GET /settings": () => json(200, { kind_labels: { dev: "разработка" }, link_types: {} }) }); page(); expect(await screen.findByText(/В настройках хаба нет link_types —/)).toBeInTheDocument(); }); it("T12.3: --head-h снимается при уходе со страницы", async () => { hub(); const r = page(); await screen.findByText(/задач из/); r.unmount(); expect(document.documentElement.style.getPropertyValue("--head-h")).toBe(""); }); it("T12.2: переключатель пишет флаг в хранилище один раз, в том числе в StrictMode", async () => { hub(); renderWithProviders(, { route: "/board", path: "/board" }); await screen.findByText(/задач из/); const set = vi.spyOn(Storage.prototype, "setItem"); fireEvent.click(screen.getByRole("button", { name: "Критический путь" })); expect(set.mock.calls.filter(([k]) => k === "graph-crit")).toEqual([["graph-crit", "1"]]); }); it("T12.4: список исполнителей — группа флажков с подписью", async () => { hub(); page(); await userEvent.click(await screen.findByRole("button", { name: /Все исполнители/ })); expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument(); expect(screen.queryByRole("listbox")).toBeNull(); }); it("T9: старая раскладка переносится только в проект AWPCA", async () => { hub(); localStorage.setItem("graph-pos:old.json", '{"AURORA-9001":{"x":1,"ry":40}}'); const r = page("STOCKS_DRRK"); await screen.findByText(/задач из/); expect(localStorage.getItem("graph-pos:board@STOCKS_DRRK")).toBeNull(); expect(localStorage.getItem("graph-pos:board")).toBeNull(); r.unmount(); page("AWPCA"); await screen.findByText(/задач из/); expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-9001":{"x":1,"ry":40}}'); }); it("T9: канбан-проект — без спринтов в снимке: надпись вместо фильтра спринтов", async () => { hub({ "GET /board": () => json(200, KANBAN) }); page("STOCKS_DRRK"); expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull(); }); it("T9: канбан-проект, спринт только у соседа — надпись, карточка соседа на доске", async () => { hub({ "GET /board": () => json(200, NEIGHBOR_ONLY) }); page("STOCKS_DRRK"); expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull(); expect(screen.getAllByText("AURORA-9007").length).toBeGreaterThan(0); }); it("T9: проект AWPCA с фикстурой hub-basic — выбор спринта есть", async () => { hub(); page("AWPCA"); expect(await screen.findByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument(); expect(screen.queryByText(NO_SPRINT_TEXT)).toBeNull(); }); });