import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createMemoryRouter } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; import { App } from "../../../App.tsx"; import { setToken } from "../../../auth/token.ts"; import board from "../board/__fixtures__/hub-basic.board.json"; import settings from "../board/__fixtures__/settings.json"; import { routes } from "../../../routes.tsx"; import { json, mockFetch } from "../../../test/fetchMock.ts"; import { issue } from "../../../test/issueFixtures.ts"; const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, reporter: null, created: null, jira_updated: null, fetched_at: "" })); function hub() { return mockFetch((_m, url) => { const p = url.pathname; if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] }); if (p === "/settings") return json(200, settings); if (p === "/board") return json(200, board); if (p === "/issues") return json(200, { issues: rows }); if (p.startsWith("/issues/")) return json(200, issue(decodeURIComponent(p.slice("/issues/".length)))); if (p === "/batches") return json(200, { batches: [] }); return json(200, {}); }); } const at = (entries: string[], index = entries.length - 1) => createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" }); const dialog = () => screen.getByRole("dialog"); describe("карточка окном", () => { beforeEach(() => { setToken("tok"); hub(); }); it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => { sessionStorage.setItem("jira-hub-issue-filters", "1"); // панель «Фильтры» раскрыта const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]); render(); const table = await screen.findByRole("table"); expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" })); expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002"); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса expect(screen.getByRole("link", { name: "Задачи" })).toHaveAttribute("aria-current", "page"); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(router.state.location.pathname).toBe("/app/issues"); expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2"); expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main await act(async () => { await router.navigate(-1); }); expect(router.state.location.pathname).toBe("/app/board"); }); it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => { const router = at(["/app/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" })); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); await act(async () => { await router.navigate(-1); }); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); fireEvent(dialog(), new Event("cancel", { cancelable: true })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(router.state.location.pathname).toBe("/app/issues"); }); it("щелчок по фону окна закрывает его", async () => { const router = at(["/app/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await within(dialog()).findByRole("heading", { name: /AURORA-9001/ }); await userEvent.click(dialog()); // цель — сам , не содержимое await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(router.state.location.pathname).toBe("/app/issues"); }); it("I-2: выделение текста внутри окна, отпущенное над фоном, окно не закрывает", async () => { const router = at(["/app/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); const title = await within(dialog()).findByRole("heading", { name: /AURORA-9001/ }); fireEvent.pointerDown(title); // нажали на тексте карточки… fireEvent.pointerUp(dialog()); fireEvent.click(dialog()); // …отпустили над фоном: click — на expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); }); it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => { const router = at(["/app/board"]); render(); const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" }); const boardEl = screen.getByTestId("page-board"); await userEvent.click(ilink); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(screen.getByTestId("page-board")).toBe(boardEl); expect(screen.getByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(router.state.location.pathname).toBe("/app/board")); expect(screen.getByTestId("page-board")).toBe(boardEl); }); it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => { const router = at(["/app/board", "/app/issues/AURORA-9001"]); render(); expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true })); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); await act(async () => { await router.navigate(-1); }); expect(router.state.location.pathname).toBe("/app/board"); // не карточка }); it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => { const router = at(["/app/issues/AURORA-9001"]); render(); await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); fireEvent(dialog(), new Event("cancel", { cancelable: true })); await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues")); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => { const router = at(["/app/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); const box = await within(dialog()).findByLabelText("Заметка владельца"); await userEvent.type(box, "черновик"); fireEvent(dialog(), new Event("cancel", { cancelable: true })); expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument(); await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" })); expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); }); it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => { const router = at(["/app/issues/AURORA-9001"]); render(); await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик"); fireEvent(dialog(), new Event("cancel", { cancelable: true })); await userEvent.click(await within(dialog()).findByRole("button", { name: "Остаться" })); await userEvent.click(within(dialog()).getByRole("button", { name: "Отмена" })); await userEvent.click(await within(dialog()).findByRole("button", { name: "Отменить правки" })); await act(async () => { await router.navigate(-1); }); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); }); it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => { mockFetch((_m, url) => { if (url.pathname === "/settings") return json(200, settings); if (url.pathname === "/issues/AURORA-1") return new Promise(() => {}); if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" }); return json(200, {}); }); const r1 = render(); expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument(); r1.unmount(); render(); expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument(); }); });