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 { 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: "/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("/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 просрочено · 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("/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", "/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("без связей — «связей нет»", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] }))); open("/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("/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("/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("/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("/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("/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("/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("/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("/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("/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("/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("/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("/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("jira-hub-comments-hidden")).toBe("1"); cleanup(); open("/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("/issues/AURORA-9001"); expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument(); expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—"); comments = []; cleanup(); open("/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("/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("/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("/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("/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("/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("/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("/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("/issues/AURORA-9001", client); await act(async () => { await router.navigate("/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("/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("/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("/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("/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("/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("/issues/AURORA-9001"); const facts = await screen.findByTestId("issue-facts"); expect(facts).toHaveTextContent("срок 10.01"); expect(within(facts).queryByText("просрочено")).toBeNull(); }); it("статуса по факту нет ни в чтении заметок, ни в форме", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/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("/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("/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("/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("/issues/AURORA-9001"); await screen.findAllByRole("link", { name: "AURORA-9002" }); expect(err.mock.calls.flat().join(" ")).not.toContain("same key"); err.mockRestore(); }); });