wikiPlain включает WikiOptions.anyAttachment: !имя! и [^имя] дают вложение без списка; карточка флаг не передаёт.
onBack защищён closingKey; в таблице [ парна, только если ближайшая скобка — ]; {code} из пробелов в одной строке не даёт pre.
Тесты: events, plain, parse, IssueModal (двойной «Назад», «Назад» при ошибке загрузки — T3.2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
22 KiB
TypeScript
334 lines
22 KiB
TypeScript
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();
|
||
});
|
||
});
|
||
});
|