sdlc-hub/web/src/modules/jira/issues/IssueModal.test.tsx
Andrey Epifancev fbc14f6366 fix(web): вложения в ленте именем (I-1), один шаг при двойном «Назад» (M-1), разбор таблиц и {code} (T1.1, T1.2)
wikiPlain включает WikiOptions.anyAttachment: !имя! и [^имя] дают вложение без списка; карточка флаг не передаёт.
onBack защищён closingKey; в таблице [ парна, только если ближайшая скобка — ]; {code} из пробелов в одной строке не даёт pre.
Тесты: events, plain, parse, IssueModal (двойной «Назад», «Назад» при ошибке загрузки — T3.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:32:16 +04:00

334 lines
22 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("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта
const router = at(["/app/jira/links", "/app/jira/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/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(<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/jira/issues");
});
it("щелчок по фону окна закрывает его", async () => {
const router = at(["/app/jira/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/jira/issues");
});
it("I-2: выделение текста внутри окна, отпущенное над фоном, окно не закрывает", async () => {
const router = at(["/app/jira/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/jira/issues/AURORA-9001");
});
it("с доски: окно поверх доски, раздел «Связи» активен, после закрытия доска та же", async () => {
const router = at(["/app/jira/links"]);
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/jira/links"));
expect(screen.getByTestId("page-board")).toBe(boardEl);
});
it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => {
const router = at(["/app/jira/links", "/app/jira/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/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(<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/jira/issues"));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => {
const router = at(["/app/jira/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/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(<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/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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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<Response>(() => {});
if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" });
return json(200, {});
});
const r1 = render(<App router={at(["/app/jira/issues/AURORA-1"])} />);
expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument();
r1.unmount();
render(<App router={at(["/app/jira/issues/AURORA-2"])} />);
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(<App router={router} />);
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(<App router={at(["/app/jira/issues/AURORA-1999"])} />);
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(<App router={at(["/app/jira/issues"])} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={router} />);
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(<App router={at(["/app/jira/issues/AURORA-9002"])} />);
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();
});
});
});