import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClientProvider } from "@tanstack/react-query"; import { createMemoryRouter, RouterProvider } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; import { qk } from "../api/queries.ts"; import { makeQueryClient } from "../api/queryClient.ts"; import { setToken } from "../auth/token.ts"; import settings from "../board/__fixtures__/settings.json"; import { json, mockFetch } from "../test/fetchMock.ts"; import { issue, note } from "../test/issueFixtures.ts"; import { IssuePage } from "./IssuePage.tsx"; const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; function open(path: string, client = makeQueryClient()) { const router = createMemoryRouter([ { path: "/issues/:key", element: }, { path: "/board", element:

доска

}, ], { initialEntries: [path] }); render(); return router; } describe("карточка задачи", () => { beforeEach(() => setToken("tok")); it("поля, описание с переносами, связи с подписями, команды", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument(); expect(screen.getByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description"); expect(await screen.findByText("блокирует начало")).toBeInTheDocument(); expect(screen.getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002"); expect(screen.getByText("Duplicate")).toBeInTheDocument(); // неизвестный тип — имя Jira expect(screen.getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001"); }); it("заголовок страницы — h1 (бренд в оболочке — span)", 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: 1, name: /AURORA-9001/ })).toBeInTheDocument(); }); it("задачи нет в зеркале — сообщение и ссылка на список", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" })); open("/issues/AURORA-1"); expect(await screen.findByText(/Задачи AURORA-1 нет в зеркале/)).toBeInTheDocument(); expect(screen.getByRole("link", { name: "к списку задач" })).toHaveAttribute("href", "/issues"); }); 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"); 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: "Сохранить" })).toBeDisabled(); }); 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"); 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.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.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.getByLabelText("Заметка владельца")).toHaveValue(""); expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); }); 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.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 waitFor(() => expect(box).toBeEnabled()); expect(box).toHaveValue("раз"); }); 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-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" })); open("/issues/AURORA-1"); const link = await screen.findByRole("link", { name: "к списку задач" }); expect(link.closest(".issue")).not.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("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => { mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001"))); open("/issues/AURORA-9001"); const input = await screen.findByLabelText("Статус по факту"); const id = input.getAttribute("list")!; expect(id).not.toBe("fact-statuses"); expect(document.getElementById(id)?.tagName).toBe("DATALIST"); }); });