sdlc-hub/web/src/issues/IssueCard.test.tsx
2026-10-22 01:04:18 +04:00

453 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider } from "@tanstack/react-query";
import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { qk } from "../api/queries.ts";
import { makeQueryClient } from "../api/queryClient.ts";
import { 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: "/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("/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<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("/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<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("/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<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("/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();
});
});