195 lines
12 KiB
TypeScript
195 lines
12 KiB
TypeScript
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 = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((r) => { resolve = r; }); return { promise, resolve }; };
|
||
|
||
function open(path: string, client = makeQueryClient()) {
|
||
const router = createMemoryRouter([
|
||
{ path: "/issues/:key", element: <IssuePage /> },
|
||
{ path: "/board", element: <p>доска</p> },
|
||
], { initialEntries: [path] });
|
||
render(<QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider>);
|
||
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<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.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<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.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");
|
||
});
|
||
});
|