632 lines
43 KiB
TypeScript
632 lines
43 KiB
TypeScript
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);
|
||
});
|
||
});
|