sdlc-hub/web/src/modules/jira/issues/IssuesPage.test.tsx

388 lines
24 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 { act, fireEvent, 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 { beforeEach, describe, expect, it, vi } from "vitest";
import { qk } from "../api/queries.ts";
import { setToken } from "../../../auth/token.ts";
import settings from "../board/__fixtures__/settings.json";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import { renderWithProviders } from "../../../test/render.tsx";
import { IssuesPage } from "./IssuesPage.tsx";
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
const row = (n: number) => ({ ...SNAP.issues[1], key: `AURORA-${9000 + n}`, summary: `Задача ${n}`, fetched_at: "" });
function hub(pages: (params: URLSearchParams) => unknown[]) {
return mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return json(200, SNAP);
if (url.pathname === "/issues") return json(200, { issues: pages(url.searchParams) });
return json(404, {});
});
}
const issuesCalls = (fetch: ReturnType<typeof hub>) =>
fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues");
describe("список задач", () => {
beforeEach(() => { setToken("tok"); sessionStorage.clear(); });
const expanded = () => sessionStorage.setItem("sdlc-hub-issue-filters", "1");
it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" });
expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/jira/issues/AURORA-9001");
const q = issuesCalls(fetch)[0].searchParams;
expect(q.get("project")).toBe("AWPCA");
expect(q.get("sprint")).toBe("AWPCA_Спринт 2");
expect(q.get("neighbor")).toBe("1");
expect(q.get("limit")).toBe("50");
expect(q.get("offset")).toBe("0");
});
it("ключ строки: href прежний, щелчок с Ctrl не открывает карточку внутри приложения (Review Focus 1)", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
const link = await screen.findByRole("link", { name: "AURORA-9001" });
expect(link).toHaveAttribute("href", "/jira/issues/AURORA-9001");
fireEvent.click(link, { ctrlKey: true });
fireEvent.click(link, { metaKey: true });
expect(router.state.location.pathname).toBe("/issues");
});
it("выбор исполнителя пишет его в адрес и в запрос", async () => {
expanded();
const fetch = hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
await userEvent.selectOptions(screen.getByLabelText("Исполнитель"), "Петров Пётр Петрович");
await waitFor(() => expect(router.state.location.search).toContain("assignee="));
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.get("assignee")).toBe("Петров Пётр Петрович"));
});
it("ошибка списка — сообщение хаба и «Повторить», повтор перезапрашивает список (T8.4)", async () => {
let fail = true;
const fetch = mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return json(200, SNAP);
if (url.pathname === "/issues") return fail ? json(500, { error: "boom" }) : json(200, { issues: [row(1)] });
return json(404, {});
});
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("хаб ответил 500");
expect(screen.queryByText("По этим фильтрам задач нет")).toBeNull();
fail = false;
await userEvent.click(within(alert).getByRole("button", { name: "Повторить" }));
expect(await screen.findByRole("link", { name: "AURORA-9001" })).toBeInTheDocument();
expect(screen.queryByRole("alert")).toBeNull();
expect(issuesCalls(fetch)).toHaveLength(2);
});
it("«Показать ещё» до неполной порции", async () => {
const fetch = hub((p) => (p.get("offset") === "0" ? Array.from({ length: 50 }, (_, i) => row(i + 1)) : Array.from({ length: 17 }, (_, i) => row(i + 51))));
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await userEvent.click(await screen.findByRole("button", { name: "Показать ещё" }));
await waitFor(() => expect(screen.getAllByRole("row")).toHaveLength(1 + 67));
expect(screen.queryByRole("button", { name: "Показать ещё" })).toBeNull();
expect(issuesCalls(fetch).at(-1)!.searchParams.get("offset")).toBe("50");
});
it("поиск — один запрос после паузы ввода", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const before = issuesCalls(fetch).length;
await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод");
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.get("q")).toBe("свод"));
expect(issuesCalls(fetch).length).toBe(before + 1);
});
it("хаб не отвечает — сообщение и «Повторить»", async () => {
let down = true;
mockFetch((_m, url) => {
if (url.pathname === "/issues" && down) throw new TypeError("Failed to fetch");
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return json(200, SNAP);
return json(200, { issues: [row(1)] });
});
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
down = false;
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByRole("link", { name: "AURORA-9001" })).toBeInTheDocument();
});
it("эпик показывается названием из снимка", async () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument();
});
it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?q=свод", path: "/issues" });
const box = await screen.findByLabelText("Поиск по ключу или теме");
expect(box).toHaveValue("свод");
await act(async () => { await router.navigate("/issues"); });
await waitFor(() => expect(box).toHaveValue(""));
await new Promise((r) => setTimeout(r, 450));
expect(router.state.location.search).toBe("");
expect(box).toHaveValue("");
});
it("T13.2: ввод поиска пишется в адрес через replace (шаг «назад» не застревает)", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const before = router.state.historyAction;
await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод");
await waitFor(() => expect(router.state.location.search).toBe("?q=%D1%81%D0%B2%D0%BE%D0%B4"));
expect(before).toBe("POP");
expect(router.state.historyAction).toBe("REPLACE");
});
it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => {
expanded();
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" });
const sel = await screen.findByLabelText("Спринт");
await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 0 (нет на доске)" })).toBeInTheDocument());
expect(sel).toHaveValue("AWPCA_Спринт 0");
});
it("значение есть на доске, снимок грузится — подпись без пометки и после загрузки", async () => {
expanded();
const board = deferred<Response>();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return board.promise;
return json(200, { issues: [row(1)] });
});
const { client } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const sel = await screen.findByLabelText("Спринт");
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
expect(client.getQueryData(qk.board("AWPCA"))).toBeUndefined();
board.resolve(json(200, SNAP));
await waitFor(() => expect(client.getQueryData(qk.board("AWPCA"))).toBeDefined()); // снимок реально применён
await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument());
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
expect(sel).toHaveValue("AWPCA_Спринт 2");
});
it("T13.1: пока снимок доски грузится, пометки «(нет на доске)» нет", async () => {
expanded();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/board") return new Promise<Response>(() => {}); // снимок не приходит до конца теста
return json(200, { issues: [row(1)] });
});
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const sel = await screen.findByLabelText("Спринт");
expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull();
expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument();
});
const openedRadio = () => screen.getByRole("radio", { name: "Открытые" });
const allRadio = () => screen.getByRole("radio", { name: "Все" });
it("закрытые скрыты по умолчанию; «Все» — closed=1 и запрос без hide_closed", async () => {
const fetch = hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(openedRadio()).toBeChecked();
let q = issuesCalls(fetch).at(-1)!.searchParams;
expect(q.get("hide_closed")).toBe("1");
expect(q.has("closed")).toBe(false);
await userEvent.click(allRadio());
await waitFor(() => expect(router.state.location.search).toBe("?closed=1"));
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.has("hide_closed")).toBe(false));
q = issuesCalls(fetch).at(-1)!.searchParams;
expect(q.has("closed")).toBe(false);
await userEvent.click(openedRadio());
await waitFor(() => expect(router.state.location.search).toBe(""));
expect(openedRadio()).toBeChecked(); // выборка с hide_closed=1 уже в кэше — повторного запроса может не быть
});
it("адрес closed=1 — выбрано «Все», запрос без hide_closed", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?closed=1", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(allRadio()).toBeChecked();
expect(issuesCalls(fetch).every((u) => !u.searchParams.has("hide_closed"))).toBe(true);
});
it("выбран закрытый статус Jira — hide_closed не отправляется", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?status=Готово", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const q = issuesCalls(fetch).at(-1)!.searchParams;
expect(q.get("status")).toBe("Готово");
expect(q.has("hide_closed")).toBe(false);
expect(openedRadio()).toBeChecked();
});
it("выбран незакрытый статус Jira — hide_closed отправляется", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?status=В работе", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(issuesCalls(fetch).at(-1)!.searchParams.get("hide_closed")).toBe("1");
});
it("настройки пришли позже — запрос по закрытому статусу без hide_closed", async () => {
const st = deferred<Response>();
const fetch = mockFetch((_m, url) => {
if (url.pathname === "/settings") return st.promise;
if (url.pathname === "/board") return json(200, SNAP);
return json(200, { issues: [row(1)] });
});
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?status=Снято", path: "/issues" });
st.resolve(json(200, settings));
await screen.findByRole("link", { name: "AURORA-9001" });
const calls = () => fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues");
await waitFor(() => expect(calls().at(-1)!.searchParams.has("hide_closed")).toBe(false));
});
it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?fact_status=В работе", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(screen.queryByLabelText("Статус по факту")).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull();
expect(issuesCalls(fetch).every((u) => !u.searchParams.has("fact_status"))).toBe(true);
});
describe("панель фильтров", () => {
const URL2 = "/issues?sprint=AWPCA_Спринт 2&assignee=Петров Пётр Петрович";
it("Фильтр в адресе: счётчик 2, плашки, панель свёрнута", async () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: URL2, path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const btn = screen.getByRole("button", { name: /Фильтры/ });
expect(btn).toHaveTextContent("2");
expect(btn).toHaveAttribute("aria-expanded", "false");
expect(screen.getByText("Спринт: AWPCA_Спринт 2")).toBeInTheDocument();
expect(screen.getByText("Исполнитель: Петров Пётр Петрович")).toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "Спринт" })).toBeNull();
});
it("кнопка «Фильтры» раскрывает панель и запоминает это в sessionStorage", async () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
await userEvent.click(screen.getByRole("button", { name: /Фильтры/ }));
expect(screen.getByRole("button", { name: /Фильтры/ })).toHaveAttribute("aria-expanded", "true");
for (const n of ["Спринт", "Исполнитель", "Вид работы", "Статус Jira", "Метка", "Эпик"]) expect(screen.getByLabelText(n)).toBeInTheDocument();
expect(sessionStorage.getItem("sdlc-hub-issue-filters")).toBe("1");
});
it("панель раскрыта по sessionStorage", async () => {
sessionStorage.setItem("sdlc-hub-issue-filters", "1");
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
expect(await screen.findByLabelText("Спринт")).toBeInTheDocument();
});
it("Снять фильтр плашкой: sprint уходит из адреса, assignee остаётся", async () => {
const fetch = hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: URL2, path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
await userEvent.click(screen.getByRole("button", { name: "Снять фильтр Спринт" }));
await waitFor(() => expect(router.state.location.search).not.toContain("sprint="));
expect(router.state.location.search).toContain("assignee=");
await waitFor(() => expect(issuesCalls(fetch).at(-1)!.searchParams.has("sprint")).toBe(false));
});
it("«Сбросить» снимает шесть списков, поиск и «Только соседи» остаются", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />,
{ route: "/issues?sprint=AWPCA_Спринт 2&assignee=Петров Пётр Петрович&status=В работе&q=свод&neighbor=1", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
await userEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(router.state.location.search).toBe("?q=%D1%81%D0%B2%D0%BE%D0%B4&neighbor=1"));
expect(screen.queryByRole("button", { name: "Сбросить" })).toBeNull();
});
it("«Только соседи» — кнопка с aria-pressed, пишет neighbor=1", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const b = screen.getByRole("button", { name: "Только соседи" });
expect(b).toHaveAttribute("aria-pressed", "false");
await userEvent.click(b);
await waitFor(() => expect(router.state.location.search).toBe("?neighbor=1"));
expect(screen.getByRole("button", { name: "Только соседи" })).toHaveAttribute("aria-pressed", "true");
});
});
it("подзаголовок: показано N", async () => {
hub(() => [row(1), row(2)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(screen.getByText("показано 2")).toBeInTheDocument();
});
it("пустая выдача — сведения «По этим фильтрам задач нет»", async () => {
hub(() => []);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
expect(await screen.findByText("По этим фильтрам задач нет")).toBeInTheDocument();
});
it("Щелчок по строке: ячейка темы открывает карточку", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await userEvent.click(await screen.findByRole("cell", { name: "Задача 1" }));
await waitFor(() => expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9001"));
});
it("выделенный текст в строке — без перехода", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
const cell = await screen.findByRole("cell", { name: "Задача 1" });
const sel = vi.spyOn(window, "getSelection").mockReturnValue({ toString: () => "Задач" } as unknown as Selection);
await userEvent.click(cell);
sel.mockRestore();
expect(router.state.location.pathname).toBe("/issues");
});
it("просрочено: класс overdue у срока незакрытой задачи с прошедшим сроком, у закрытой нет", async () => {
hub(() => [
{ ...row(1), planned_end: "2000-01-01", status: "В работе" },
{ ...row(2), planned_end: "2000-01-01", status: "Готово" },
{ ...row(3), planned_end: "2999-01-01", status: "В работе" },
]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?closed=1", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const dueCell = (n: number) => within(screen.getByRole("row", { name: new RegExp(`AURORA-${9000 + n}`) })).getByText(/^01\.01\.(2000|2999)$/);
await waitFor(() => expect(dueCell(1)).toHaveClass("overdue"));
expect(dueCell(2)).not.toHaveClass("overdue");
expect(dueCell(3)).not.toHaveClass("overdue");
});
it("шапка: вторичная кнопка «Обновить из Jira», primary нет", async () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const btn = screen.getByRole("button", { name: "Обновить из Jira" });
expect(btn).toHaveClass("ui-btn--secondary");
expect(document.querySelector(".ui-btn--primary")).toBeNull();
});
it("просроченный срок: «просрочено» для экранного чтения и title, срок через fmtDate", async () => {
hub(() => [{ ...row(1), planned_end: "2000-01-01", status: "В работе" }]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?closed=1", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
const cell = await waitFor(() => {
const c = within(screen.getByRole("row", { name: /AURORA-9001/ })).getByText(", просрочено").closest("td")!;
expect(c).toHaveClass("overdue");
return c;
});
expect(within(cell).getByText(", просрочено")).toHaveClass("visually-hidden");
expect(cell.textContent).toBe("01.01.2000, просрочено");
expect(cell).toHaveAttribute("title", "просрочено");
expect(cell).toHaveTextContent("01.01.2000");
});
});