sdlc-hub/web/src/modules/jira/issues/IssueModal.test.tsx
Andrey Epifancev 2cb454af8b refactor(web): модуль jira и оболочка из списка модулей (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:05:02 +04:00

172 lines
12 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, 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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
await userEvent.click(dialog()); // цель — сам <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(<App router={router} />);
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 — на <dialog>
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
});
it("с доски: окно поверх доски, раздел «Доска связей» активен, после закрытия доска та же", async () => {
const router = at(["/app/board"]);
render(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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<Response>(() => {});
if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" });
return json(200, {});
});
const r1 = render(<App router={at(["/app/issues/AURORA-1"])} />);
expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument();
r1.unmount();
render(<App router={at(["/app/issues/AURORA-2"])} />);
expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
});
});