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 = () => { let resolve!: (v: T) => void; const promise = new Promise((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) => 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { route: "/issues", path: "/issues" }); expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument(); }); it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => { hub(() => [row(1)]); const { router } = renderWithProviders(, { 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(, { 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(, { 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(); 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(, { 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(() => {}); // снимок не приходит до конца теста return json(200, { issues: [row(1)] }); }); renderWithProviders(, { 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(, { 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(, { 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(, { 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(, { 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(); 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(, { 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(, { 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(, { 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(, { 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(, { route: "/issues", path: "/issues" }); expect(await screen.findByLabelText("Спринт")).toBeInTheDocument(); }); it("Снять фильтр плашкой: sprint уходит из адреса, assignee остаётся", async () => { const fetch = hub(() => [row(1)]); const { router } = renderWithProviders(, { 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(, { 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(, { 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(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(screen.getByText("показано 2")).toBeInTheDocument(); }); it("пустая выдача — сведения «По этим фильтрам задач нет»", async () => { hub(() => []); renderWithProviders(, { route: "/issues", path: "/issues" }); expect(await screen.findByText("По этим фильтрам задач нет")).toBeInTheDocument(); }); it("Щелчок по строке: ячейка темы открывает карточку", async () => { hub(() => [row(1)]); const { router } = renderWithProviders(, { 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(, { 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(, { 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(, { 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(, { 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"); }); });