sdlc-hub/web/src/issues/IssuesPage.test.tsx
Andrey Epifancev bb3ddd1770 fix: волна по финальному ревью — I-2 fromFile при обновлении из файла, тесты, комментарии, спека (#29)
I-2, M-4..M-8, M-11, M-13, M-16, M-18, T7.3, M-1..M-3, M-17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:40:33 +04:00

154 lines
9.1 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, 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 } 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"));
it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" });
expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001");
const q = issuesCalls(fetch)[0].searchParams;
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("выбор исполнителя пишет его в адрес и в запрос", async () => {
const fetch = hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage />, { 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("«Показать ещё» до неполной порции", 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 />, { 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 />, { 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 />, { 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 />, { route: "/issues", path: "/issues" });
expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument();
});
it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage />, { 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 />, { 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 () => {
hub(() => [row(1)]);
renderWithProviders(<IssuesPage />, { 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 () => {
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 />, { 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)).toBeUndefined();
board.resolve(json(200, SNAP));
await waitFor(() => expect(client.getQueryData(qk.board)).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 () => {
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 />, { 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();
});
});