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 = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; function CardAt() { const { key = "" } = useParams(); const navigate = useNavigate(); // key — как в IssueModal: другая задача монтирует карточку заново return void navigate("/board")} />; } function open(path: string, client = makeQueryClient()) { const router = createMemoryRouter([ { path: "/jira/issues/:key", element: }, { path: "/board", element:

доска

}, ], { initialEntries: [path] }); render(); 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(); 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(); 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(); 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 => ({ 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, 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); }); });