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("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта const router = at(["/app/jira/links", "/app/jira/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/jira/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/jira/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/jira/links"); }); it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => { const router = at(["/app/jira/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/jira/issues"); }); it("щелчок по фону окна закрывает его", async () => { const router = at(["/app/jira/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/jira/issues"); }); it("I-2: выделение текста внутри окна, отпущенное над фоном, окно не закрывает", async () => { const router = at(["/app/jira/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/jira/issues/AURORA-9001"); }); it("с доски: окно поверх доски, раздел «Связи» активен, после закрытия доска та же", async () => { const router = at(["/app/jira/links"]); 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/jira/links")); expect(screen.getByTestId("page-board")).toBe(boardEl); }); it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => { const router = at(["/app/jira/links", "/app/jira/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/jira/issues")); await act(async () => { await router.navigate(-1); }); expect(router.state.location.pathname).toBe("/app/jira/links"); // не карточка }); it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => { const router = at(["/app/jira/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/jira/issues")); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); }); it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => { const router = at(["/app/jira/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/jira/issues/AURORA-9001"); expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик"); fireEvent(dialog(), new Event("cancel", { cancelable: true })); // после «Остаться» повторный Esc снова спрашивает (closingKey сброшен) expect(await within(dialog()).findByRole("button", { name: "Уйти" })).toBeInTheDocument(); }); it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => { const router = at(["/app/jira/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/jira/issues/AURORA-9001"); }); it("поиск перед открытием не закрывает окно (#42 M-1): ключ и ячейка темы", async () => { for (const open of ["key", "sum"] as const) { const router = at(["/app/jira/issues"]); const { unmount } = render(); await screen.findByRole("table"); await userEvent.type(screen.getByRole("searchbox"), "свод"); if (open === "key") await userEvent.click(screen.getByRole("link", { name: "AURORA-9002" })); else await userEvent.click(screen.getByRole("cell", { name: "API свода" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); await new Promise((r) => setTimeout(r, 400)); expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002"); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(router.state.location.pathname).toBe("/app/jira/issues"); expect(new URLSearchParams(router.state.location.search).get("q")).toBe("свод"); unmount(); } }); it("двойной щелчок по ✕ не уводит дальше назад (#42 M-2)", async () => { const router = at(["/app/jira/links", "/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); const x = within(dialog()).getByRole("button", { name: "Закрыть" }); await act(async () => { fireEvent.click(x); fireEvent.click(x); }); // оба щелчка до перерисовки await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(router.state.location.pathname).toBe("/app/jira/issues"); }); it("двойной ✕ у прямого адреса: адрес списка, дальше по истории не уходит (Review Focus 2)", async () => { const router = at(["/app/jira/issues/AURORA-9002"]); render(); await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9002/ }); await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true })); await userEvent.dblClick(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(router.state.location.pathname).toBe("/app/jira/issues"); }); it("подтверждение ухода: имя и фокус на «Остаться» (#42 M-4)", async () => { const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" })); await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" })); await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик"); fireEvent(dialog(), new Event("cancel", { cancelable: true })); const ask = await within(dialog()).findByRole("alertdialog", { name: "Уйти без сохранения заметки?" }); await waitFor(() => expect(document.activeElement).toBe(within(ask).getByRole("button", { name: "Остаться" }))); }); 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(); }); describe("задача не в зеркале после перехода (#42 M-5)", () => { const mirror = () => mockFetch((_m, url) => { const p = url.pathname; if (p === "/issues/AURORA-1999") return json(404, {}); if (p === "/issues/AURORA-9002") return json(200, issue("AURORA-9002", { links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: "Тема", status: "Новая" }], links_in: [], })); if (p === "/settings") return json(200, settings); if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] }); if (p === "/issues") return json(200, { issues: rows }); if (p === "/batches") return json(200, { batches: [] }); return json(200, {}); }); const msg = "Задачи AURORA-1999 нет в зеркале хаба."; it("«Назад» возвращает прежнюю карточку; при новом переходе «Закрыть» закрывает окно", async () => { mirror(); const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" })); expect(await within(dialog()).findByText(msg)).toBeInTheDocument(); await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" })); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-1999" })); await within(dialog()).findByText(msg); await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(router.state.location.pathname).toBe("/app/jira/issues"); }); it("прямой адрес 404 — только «Закрыть»", async () => { mirror(); render(); expect(await within(await screen.findByRole("dialog")).findByText(msg)).toBeInTheDocument(); expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument(); expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull(); }); it("«Назад» есть только после перехода: из списка — нет, после перехода из списка — есть", async () => { mirror(); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); await within(dialog()).findByRole("heading", { name: /AURORA-9002/ }); expect(within(dialog()).queryByRole("button", { name: "Назад" })).toBeNull(); await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-1999" })); await within(dialog()).findByText(msg); expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument(); }); it("двойной щелчок по «Назад» — один шаг, окно остаётся (M-1)", async () => { mirror(); const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" })); await within(dialog()).findByText(msg); const back = within(dialog()).getByRole("button", { name: "Назад" }); await act(async () => { fireEvent.click(back); fireEvent.click(back); }); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002"); }); it("двойной «Назад» из прямого адреса не уходит со страницы приложения (M-1)", async () => { mirror(); const router = at(["/app/jira/issues/AURORA-9002"]); render(); await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" })); await within(dialog()).findByText(msg); const back = within(dialog()).getByRole("button", { name: "Назад" }); await act(async () => { fireEvent.click(back); fireEvent.click(back); }); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-9002"); }); it("ошибка загрузки после перехода: «Повторить», «Назад», «Закрыть» (T3.2)", async () => { mockFetch((_m, url) => { const p = url.pathname; if (p === "/issues/AURORA-1999") return json(500, { detail: "сбой" }); if (p === "/issues/AURORA-9002") return json(200, issue("AURORA-9002", { links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: "Тема", status: "Новая" }], links_in: [], })); if (p === "/settings") return json(200, settings); if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] }); if (p === "/issues") return json(200, { issues: rows }); if (p === "/batches") return json(200, { batches: [] }); return json(200, {}); }); const router = at(["/app/jira/issues"]); render(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-1999" })); expect(await within(dialog()).findByRole("button", { name: "Повторить" })).toBeInTheDocument(); expect(within(dialog()).getByRole("button", { name: "Закрыть" })).toBeInTheDocument(); await userEvent.click(within(dialog()).getByRole("button", { name: "Назад" })); expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument(); }); it("переход из прямого адреса — «Назад» есть", async () => { mirror(); render(); await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-1999" })); await within(dialog()).findByText(msg); expect(within(dialog()).getByRole("button", { name: "Назад" })).toBeInTheDocument(); }); }); });