253 lines
14 KiB
TypeScript
253 lines
14 KiB
TypeScript
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<string, () => 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(<BoardPage project={project} />, { 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(<StrictMode><BoardPage project="AWPCA" /></StrictMode>, { 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();
|
||
});
|
||
});
|