sdlc-hub/web/src/modules/jira/issues/IssueCard.test.tsx

632 lines
43 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, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider } from "@tanstack/react-query";
import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { qk } from "../api/queries.ts";
import { makeQueryClient } from "../../../api/queryClient.ts";
import type { IssueAttachment, IssueDetail } from "../api/types.ts";
import { setToken } from "../../../auth/token.ts";
import baseSettings from "../board/__fixtures__/settings.json";
import { json, mockFetch } from "../../../test/fetchMock.ts";
import { issue, note } from "../../../test/issueFixtures.ts";
import { IssueCard } from "./IssueCard.tsx";
const settings = { ...baseSettings, project_key: "AURORA" };
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
function CardAt() {
const { key = "" } = useParams();
const navigate = useNavigate();
// key — как в IssueModal: другая задача монтирует карточку заново
return <IssueCard key={key} issueKey={key} onClose={() => void navigate("/board")} />;
}
function open(path: string, client = makeQueryClient()) {
const router = createMemoryRouter([
{ path: "/jira/issues/:key", element: <CardAt /> },
{ path: "/board", element: <p>доска</p> },
], { initialEntries: [path] });
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
return router;
}
// высоты элементов в jsdom нулевые: для проверок свёртки по измерению — подмена
function overflow(scroll: number, client: number) {
const s = vi.spyOn(Element.prototype, "scrollHeight", "get").mockReturnValue(scroll);
const c = vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(client);
return () => { s.mockRestore(); c.mockRestore(); };
}
describe("карточка задачи", () => {
beforeEach(() => setToken("tok"));
it("шапка: ключ, статус Jira, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" })));
open("/jira/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument();
const head = screen.getByTestId("issue-head");
expect(within(head).getByText("AURORA-9001")).toBeInTheDocument();
expect(within(head).getByText("В работе", { selector: ".ui-badge" })).toBeInTheDocument();
expect(head.querySelector(".badge.fact")).toBeNull(); // статуса по факту в карточке нет (hide-closed)
expect(within(head).queryByText(/факт:/)).toBeNull();
expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01.2020 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик");
expect(within(head).getByText("просрочено")).toHaveClass("ui-badge--error");
expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
});
it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", {
links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" },
{ target_key: "lower-1", link_type: "ASUPR_Связанные", summary: null, status: null }],
links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" },
{ source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }],
})));
open("/jira/issues/AURORA-9001");
const depends = await screen.findByRole("group", { name: "Зависит от" });
expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово");
const blocks = screen.getByRole("group", { name: "Блокирует" });
expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/jira/issues/AURORA-9002");
expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая");
expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале");
expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008");
expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы
});
it("связь на ключ вне зеркала ведёт в Jira, а не в карточку (#103)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", {
links_out: [{ target_key: "AURORA-1999", link_type: "ASUPR_Связанные", summary: null, status: null }], links_in: [],
})));
open("/jira/issues/AURORA-9001");
const link = await screen.findByRole("link", { name: /AURORA-1999/ });
expect(link).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-1999");
expect(link).toHaveAttribute("target", "_blank");
expect(link.closest("li")).toHaveTextContent("нет в зеркале");
});
it("«Прочие поля»: вид работы отдельно от меток (#42 M-5)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { labels: ["AWPCA", "kind-dev"] })));
open("/jira/issues/AURORA-9001");
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
const other = screen.getByRole("region", { name: "Прочие поля" });
expect(within(other).getByText("Вид работы в Jira").nextSibling).toHaveTextContent("разработка");
expect(within(other).getByText("Метки").nextSibling).toHaveTextContent("AWPCA");
expect(within(other).queryByText(/kind-dev/)).toBeNull();
});
it("пустое описание (одни пробелы) — без раздела «Описание» (#56 M-4)", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: " \n\n" })));
open("/jira/issues/AURORA-9001");
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull();
});
it("без связей — «связей нет»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] })));
open("/jira/issues/AURORA-9001");
expect(await screen.findByText("связей нет")).toBeInTheDocument();
});
it("длинное описание в рендере свёрнуто по высоте, «развернуть»/«свернуть»; «исходник» не сбрасывает «развёрнуто»", async () => {
const restore = overflow(500, 180);
const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n");
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
open("/jira/issues/AURORA-9001");
const body = (await screen.findByText("абзац 1")).closest(".description")!;
expect(body.tagName).toBe("DIV");
expect(body).toHaveClass("clamped");
await userEvent.click(screen.getByRole("button", { name: "развернуть" }));
expect(screen.getByText("абзац 1").closest(".description")).not.toHaveClass("clamped");
await userEvent.click(screen.getByRole("button", { name: "исходник" }));
const pre = document.querySelector("pre.description")!;
expect(pre).not.toHaveClass("clamped");
expect(screen.getByRole("button", { name: "свернуть" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "свернуть" }));
expect(document.querySelector("pre.description")).toHaveClass("clamped");
restore();
});
it("C-1: описание смонтировано скрытым (диалог ещё закрыт) — «развернуть» появляется, когда размеры изменились", async () => {
const observers: (() => void)[] = [];
vi.stubGlobal("ResizeObserver", class { constructor(cb: () => void) { observers.push(cb); } observe() {} disconnect() {} });
let restore = overflow(0, 0);
const long = Array.from({ length: 20 }, (_, n) => `абзац ${n + 1}`).join("\n\n");
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
open("/jira/issues/AURORA-9001");
await screen.findByText("абзац 1");
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
restore();
restore = overflow(500, 180);
act(() => observers.forEach((cb) => cb()));
expect(screen.getByRole("button", { name: "развернуть" })).toBeInTheDocument();
restore();
vi.unstubAllGlobals();
});
it("исходник длиннее 8 строк свёрнут построчно", async () => {
const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n");
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
open("/jira/issues/AURORA-9001");
await userEvent.click(await screen.findByRole("button", { name: "исходник" }));
const pre = screen.getByText(long, { normalizer: (s) => s });
expect(pre).toHaveClass("description", "clamped");
expect(screen.getByRole("button", { name: "развернуть" })).toBeInTheDocument();
});
it("список через пустые строки, который помещается, — в рендере без «развернуть» (design #8)", async () => {
const restore = overflow(120, 180);
const d = " * один\n\n * два\n\n * три\n\n * четыре\n\n * пять";
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
open("/jira/issues/AURORA-9001");
expect(await screen.findByText("пять")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
restore();
});
it("описание отрендерено; «исходник» — исходник только у описания, «текст» — обратно", async () => {
const d = "h3. Контекст\n# Загрузить ОСВ\n# Открыть документ";
const comments = [{ comment_id: "1", author: "Иванов Иван", created: null, updated: null, body: "*важно*" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d, comments })));
open("/jira/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument();
const section = screen.getByRole("heading", { name: "Описание" }).closest("section")!;
await userEvent.click(within(section).getByRole("button", { name: "исходник" }));
expect(section.querySelector("pre.description")).toHaveTextContent("h3. Контекст");
expect(screen.queryByRole("heading", { level: 3, name: "Контекст" })).toBeNull();
expect(screen.getByText("важно").tagName).toBe("STRONG");
await userEvent.click(within(section).getByRole("button", { name: "текст" }));
expect(screen.getByRole("heading", { level: 3, name: "Контекст" })).toBeInTheDocument();
});
it("сценарий «Исходник у одного комментария»: переключается только он, после перехода всё отрендерено", async () => {
const comments = [
{ comment_id: "2", author: "Петров", created: null, updated: null, body: "*b*" },
{ comment_id: "1", author: "Иванов", created: null, updated: null, body: "*a*" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments })));
const router = open("/jira/issues/AURORA-9001");
await screen.findByRole("heading", { name: "Комментарии (2)" });
const items = () => within(screen.getByTestId("comments")).getAllByRole("listitem");
await userEvent.click(within(items()[1]).getByRole("button", { name: "исходник" }));
expect(items()[1].querySelector("pre.body")).toHaveTextContent("*a*");
expect(within(items()[0]).getByText("b").tagName).toBe("STRONG");
await act(() => router.navigate("/jira/issues/AURORA-9002"));
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9002" });
await act(() => router.navigate(-1));
await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" });
await waitFor(() => expect(within(items()[1]).getByText("a").tagName).toBe("STRONG"));
expect(document.querySelector("pre.body")).toBeNull();
});
it("ключ задачи проекта в комментарии — ссылка на карточку", async () => {
const comments = [{ comment_id: "1", author: "Иванов", created: null, updated: null, body: "П. 1 ведётся в AURORA-9002, не UTF-8" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue(url.pathname.split("/").pop()!, { comments })));
const router = open("/jira/issues/AURORA-9001");
const comment = within(await screen.findByTestId("comments"));
expect(comment.queryByRole("link", { name: "UTF-8" })).toBeNull();
await userEvent.click(comment.getByRole("link", { name: "AURORA-9002" }));
expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9002");
});
it("короткое описание — без кнопки; нет описания — нет раздела", async () => {
let d: string | null = "строка 1\nстрока 2";
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
open("/jira/issues/AURORA-9001");
const body = (await screen.findByText(/строка 1/)).closest(".description")!;
expect(body).toHaveTextContent("строка 1строка 2");
expect(body.querySelector("br")).not.toBeNull();
expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
d = null;
cleanup();
open("/jira/issues/AURORA-9001");
await screen.findByRole("heading", { level: 2 });
expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull();
});
it("комментарии новые сверху; «Скрыть» запоминается для следующей карточки", async () => {
const comments = [
{ comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" },
{ comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments })));
open("/jira/issues/AURORA-9001");
expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument();
const items = within(screen.getByTestId("comments")).getAllByRole("listitem");
expect(items[0]).toHaveTextContent("Петров Пётр");
expect(items[0].querySelector(".body")).toHaveTextContent("новыйвторая строка");
expect(items[0].querySelector(".body br")).not.toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Скрыть" }));
expect(screen.queryByText("старый")).toBeNull();
expect(localStorage.getItem("sdlc-hub-comments-hidden")).toBe("1");
cleanup();
open("/jira/issues/AURORA-9001");
expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument();
expect(screen.queryByText("старый")).toBeNull();
});
it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => {
let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }];
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments })));
open("/jira/issues/AURORA-9001");
expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument();
expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—");
comments = [];
cleanup();
open("/jira/issues/AURORA-9001");
expect(await screen.findByText("комментариев нет")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull();
});
it("прочие поля и команды хаба свёрнуты", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/jira/issues/AURORA-9001");
const other = await screen.findByRole("region", { name: "Прочие поля" });
expect(other).toHaveTextContent("Приоритет");
expect(other).toHaveTextContent("Высокий");
const details = screen.getByText("Команды хаба (1)").closest("details")!;
expect(details).not.toHaveAttribute("open");
expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
});
it("заголовок карточки — h2 с id для aria-labelledby окна", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/jira/issues/AURORA-9001");
expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title");
});
it("задачи нет в зеркале — сообщение и «Закрыть»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
const router = open("/jira/issues/AURORA-1");
expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Закрыть" }));
expect(router.state.location.pathname).toBe("/board");
});
it("сохраняются только изменённые поля, очищенное — null", async () => {
let put: unknown = null;
mockFetch((m, url, body) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
return json(200, issue("AURORA-9001"));
});
open("/jira/issues/AURORA-9001");
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
const rest = await screen.findByLabelText("Осталось");
await userEvent.clear(rest); await userEvent.type(rest, "тесты");
await userEvent.clear(screen.getByLabelText("Сделано"));
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
expect(await screen.findByText(/изменено .*web/)).toBeInTheDocument(); // снова режим чтения
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull();
});
it("422 — текст ошибки у формы, введённое на месте", async () => {
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
return json(200, issue("AURORA-9001"));
});
open("/jira/issues/AURORA-9001");
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
const owner = await screen.findByLabelText("Заметка владельца");
await userEvent.type(owner, "проверить");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
expect(owner).toHaveValue("проверить");
});
it("уход с несохранёнными правками требует подтверждения", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
const router = open("/jira/issues/AURORA-9001");
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
await act(async () => { await router.navigate("/board"); });
expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
expect(router.state.location.pathname).toBe("/jira/issues/AURORA-9001");
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
});
it("поздний ответ прежней карточки не подменяет новую", async () => {
const client = makeQueryClient();
const slow = deferred<Response>();
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (url.pathname === "/issues/AURORA-9001") return slow.promise;
return json(200, issue("AURORA-9002"));
});
const router = open("/jira/issues/AURORA-9001", client);
await act(async () => { await router.navigate("/jira/issues/AURORA-9002"); });
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
slow.resolve(json(200, issue("AURORA-9001")));
await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
});
it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
return json(200, issue("AURORA-9001"));
});
// 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002"));
const router = open("/jira/issues/AURORA-9001", client);
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await act(async () => { await router.navigate("/jira/issues/AURORA-9002"); });
await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" })));
await waitFor(() => expect(client.isMutating()).toBe(0));
expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения
expect(screen.queryByText("черновик")).toBeNull();
});
it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
const slowPut = deferred<Response>();
mockFetch((m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (m === "PUT") return slowPut.promise;
return json(200, issue("AURORA-9001"));
});
open("/jira/issues/AURORA-9001");
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз");
await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
const box = screen.getByLabelText("Заметка владельца");
await waitFor(() => expect(box).toBeDisabled());
slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" })));
await screen.findByRole("button", { name: "Править" }); // режим чтения (findByText("раз") ловил бы ещё живой textarea)
expect(screen.getByText("раз")).toBeInTheDocument(); // с сохранённым значением
expect(screen.queryByLabelText("Заметка владельца")).toBeNull(); // форма закрыта
});
it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
const client = makeQueryClient();
client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
let down = true;
mockFetch((_m, url) => {
if (url.pathname === "/settings") return json(200, settings);
if (down) throw new TypeError("Failed to fetch");
return json(200, issue("AURORA-9001"));
});
open("/jira/issues/AURORA-9001", client);
expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
down = false;
await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
});
it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
open("/jira/issues/AURORA-9001");
expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
});
it("закрытая задача со сроком в прошлом — без пометки «просрочено»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10", status: "Готово" })));
open("/jira/issues/AURORA-9001");
const facts = await screen.findByTestId("issue-facts");
expect(facts).toHaveTextContent("срок 10.01.2020");
expect(within(facts).queryByText("просрочено")).toBeNull();
});
it("статуса по факту нет ни в чтении заметок, ни в форме", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/jira/issues/AURORA-9001");
const view = await screen.findByRole("region", { name: "Служебные заметки" });
expect(view).not.toHaveTextContent("Статус по факту");
expect(view).toHaveTextContent("Вид работы");
await userEvent.click(screen.getByRole("button", { name: "Править" }));
await screen.findByLabelText("Осталось");
expect(screen.queryByLabelText("Статус по факту")).toBeNull();
expect(document.querySelector("datalist")).toBeNull();
});
it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/jira/issues/AURORA-9001");
const view = await screen.findByRole("region", { name: "Служебные заметки" });
expect(view).toHaveTextContent("Сделано");
expect(view).toHaveTextContent("схема");
expect(within(view).getByText("Заметка владельца").nextElementSibling).toHaveTextContent("—");
expect(view).toHaveTextContent(/изменено .*chat/);
await userEvent.click(within(view).getByRole("button", { name: "Править" }));
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
});
it("«Отмена» с правками спрашивает; «Отменить правки» возвращает чтение без изменений", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
open("/jira/issues/AURORA-9001");
await userEvent.click(await screen.findByRole("button", { name: "Править" }));
await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик");
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
expect(screen.getByText("Отменить правки заметки?")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Продолжить правку" }));
expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
await userEvent.click(screen.getByRole("button", { name: "Отменить правки" }));
expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
expect(screen.queryByText("черновик")).toBeNull();
});
it("заметки ещё нет — режим чтения с «—» и «Править»", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null })));
open("/jira/issues/AURORA-9001");
const view = await screen.findByRole("region", { name: "Служебные заметки" });
expect(within(view).getAllByText("—")).toHaveLength(4); // вид работы, сделано, осталось, заметка владельца
expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument();
});
it("T5.3: одинаковые связи не дают предупреждения о повторяющихся key", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const dup = { target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API", status: "Новая" };
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [dup, dup] })));
open("/jira/issues/AURORA-9001");
await screen.findAllByRole("link", { name: "AURORA-9002" });
expect(err.mock.calls.flat().join(" ")).not.toContain("same key");
err.mockRestore();
});
});
describe("история в карточке", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
const hist = (n: number) => Array.from({ length: n }, (_, i) => ({
at: new Date(2026, 9, 20, 9, i).toISOString(), kind: "status" as const, field: "status", author: "Петров",
from: "a", to: `s${i}`, from_value: null, to_value: null,
}));
it("12 событий: видны 10 последних и «Показать все (12)», после нажатия — все", async () => {
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { changes: hist(12) })));
open("/jira/issues/AURORA-9001");
const sec = await screen.findByTestId("history");
expect(within(sec).getByRole("heading", { name: "История (12)" })).toBeInTheDocument();
expect(within(sec).getAllByRole("listitem")).toHaveLength(10);
expect(within(sec).getAllByRole("listitem")[0]).toHaveTextContent(/^20\.10 09:00/); // с датой всегда
await userEvent.click(within(sec).getByRole("button", { name: "Показать все (12)" }));
expect(within(sec).getAllByRole("listitem")).toHaveLength(12);
});
it("пустая история — «изменений нет»; без поля changes (старый хаб) — блока нет", async () => {
let body = issue("AURORA-9001", { changes: [] });
mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, body));
open("/jira/issues/AURORA-9001");
const sec = await screen.findByTestId("history");
expect(within(sec).getByText("изменений нет")).toBeInTheDocument();
cleanup();
body = issue("AURORA-9002");
open("/jira/issues/AURORA-9002");
await screen.findByRole("heading", { name: "Тема AURORA-9002" });
expect(screen.queryByTestId("history")).toBeNull();
});
});
describe("вложения в карточке", () => {
beforeEach(() => setToken("tok"));
const att = (id: string, filename: string, over: Partial<IssueAttachment> = {}): IssueAttachment => ({
attachment_id: id, filename, size: 240000, mime_type: "image/png", author: "Иванов Иван", created: "2026-10-15T12:00:00.000+0300",
state: "none", error: null, ...over,
});
const comment = { comment_id: "101", author: "Петров", created: null, updated: null, body: "см. !скрин 1.png|thumbnail!", attachment_ids: ["31337"] };
const base = { description: "текст", comments: [comment], attachments: [att("31337", "скрин 1.png"), att("31338", "ОСВ 60.xlsx", { mime_type: null, size: 1024, created: "2026-10-16T12:00:00.000+0300" })] };
function hub(over: Partial<IssueDetail>, extra?: (m: string, url: URL) => Response | undefined) {
return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over }))));
}
it("раздел «Вложения (2)» после «Описание», порядок хаба; размер, автор · дата", async () => {
hub({});
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
expect(sec.getByRole("heading", { level: 3, name: "Вложения (2)" })).toBeInTheDocument();
const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent);
expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1);
const rows = sec.getAllByRole("listitem");
expect(rows[1]).toHaveTextContent("ОСВ 60.xlsx");
expect(rows[1]).toHaveTextContent("1,0 КБ");
expect(rows[0]).toHaveTextContent("скрин 1.png");
expect(rows[0]).toHaveTextContent("234,4 КБ");
expect(rows[0]).toHaveTextContent("Иванов Иван · 15.10");
expect(within(rows[0]).getByRole("button", { name: "Скачать" })).toBeInTheDocument();
});
it("порядок вложений — как у хаба, без пересортировки по дате", async () => {
hub({ attachments: [att("1", "b.bin", { created: null }), att("2", "a.bin", { created: "2026-10-20T10:00:00.000+0300" })], comments: [] });
open("/jira/issues/AURORA-1650");
const rows = within(await screen.findByTestId("attachments")).getAllByRole("listitem");
expect(rows[0]).toHaveTextContent("b.bin");
expect(rows[1]).toHaveTextContent("a.bin");
});
it("повтор после ошибки: успех в карточке скрывает прежний failed из несвежего ответа хаба", async () => {
let fetched = false;
hub({ attachments: [att("31337", "скрин 1.png", { state: "failed", error: "сбой" })], comments: [] }, (_m, url) => {
if (url.pathname === "/attachments/31337/fetch") { fetched = true; return json(200, { attachment_id: "31337", state: "ready", error: null, command_id: 1, batch_id: 1 }); }
if (url.pathname === "/attachments/31337/content") return new Response("PNG", { status: 200 });
return undefined;
});
URL.createObjectURL = vi.fn(() => "blob:fake");
URL.revokeObjectURL = vi.fn();
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
expect(await sec.findByText("сбой")).toBeInTheDocument();
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(fetched).toBe(true));
await waitFor(() => expect(sec.queryByText("сбой")).toBeNull());
expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument();
});
it("без поля attachments (старый хаб) раздела нет", async () => {
hub({ attachments: undefined, comments: [] });
open("/jira/issues/AURORA-1650");
await screen.findByText("Описание");
expect(screen.queryByTestId("attachments")).toBeNull();
});
it("состояние ready — отметка «у хаба»", async () => {
hub({ attachments: [att("31337", "скрин 1.png", { state: "ready" })], comments: [] });
open("/jira/issues/AURORA-1650");
expect(within(await screen.findByTestId("attachments")).getByText("у хаба")).toBeInTheDocument();
});
it("25 МБ — ссылка «Открыть в Jira» с подсказкой вместо кнопки", async () => {
hub({ attachments: [att("77", "запись.mp4", { size: 25 * 1024 ** 2, mime_type: "video/mp4" })], comments: [] });
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
const a = sec.getByRole("link", { name: "Открыть в Jira" });
expect(a).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/secure/attachment/77/%D0%B7%D0%B0%D0%BF%D0%B8%D1%81%D1%8C.mp4");
expect(a).toHaveAttribute("target", "_blank");
expect(a).toHaveAttribute("title", "больше 10 МБ — только в Jira");
expect(sec.queryByRole("button", { name: "Скачать" })).toBeNull();
});
it("размер неизвестен — подпись и обычная кнопка", async () => {
hub({ attachments: [att("5", "x.bin", { size: null })], comments: [] });
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
expect(sec.getByText(/размер неизвестен/)).toBeInTheDocument();
expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument();
});
it("плашка под комментарием и кнопка в тексте", async () => {
hub({});
open("/jira/issues/AURORA-1650");
const c = within(await screen.findByTestId("comments"));
expect(c.getByRole("button", { name: "скрин 1.png · 234,4 КБ" })).toBeInTheDocument();
expect(c.getByRole("button", { name: "скрин 1.png" })).toBeInTheDocument();
expect(c.queryByText(/thumbnail/)).toBeNull();
});
it("«Загружается…» на всех кнопках одного вложения; чужие не затронуты", async () => {
hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" || url.pathname === "/attachments/31337"
? json(200, { attachment_id: "31337", state: "fetching", error: null, command_id: 1, batch_id: 1 }) : undefined);
open("/jira/issues/AURORA-1650");
await userEvent.click(within(await screen.findByTestId("comments")).getByRole("button", { name: "скрин 1.png · 234,4 КБ" }));
expect(await screen.findAllByText("Загружается…")).toHaveLength(3);
for (const b of screen.getAllByText("Загружается…")) expect(b.closest("button")).toBeDisabled();
expect(within(screen.getByTestId("attachments")).getByRole("button", { name: "Скачать" })).toBeEnabled(); // ОСВ 60.xlsx
});
it("ошибка хаба — текст и «Повторить» под вложением", async () => {
const f = hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch"
? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined);
open("/jira/issues/AURORA-1650");
const sec = within(await screen.findByTestId("attachments"));
await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[0]);
expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument();
await userEvent.click(sec.getByRole("button", { name: "Повторить" }));
expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2);
});
});