Экраны входа, «Новый проект», ProjectGate, «не найдено» и ошибка отрисовки переведены на компоненты системы. controls.css удалён, псевдонимы токенов убраны, глобальные .row/.spacer/.hint/.warn/.login разобраны. Разделы в верхней панели не активны вне доски и задач. Тонкая настройка дорожек доски, тест на ошибку списка, стабилизирован тест проектов. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
13 KiB
TypeScript
198 lines
13 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { readFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { createMemoryRouter } from "react-router";
|
||
import { beforeEach, describe, expect, it } from "vitest";
|
||
import { App } from "../App.tsx";
|
||
import { setToken } from "../auth/token.ts";
|
||
import settings from "../board/__fixtures__/settings.json";
|
||
import { routes } from "../routes.tsx";
|
||
import { json, mockFetch } from "../test/fetchMock.ts";
|
||
import { issue } from "../test/issueFixtures.ts";
|
||
import { REMEMBER_KEY } from "./current.ts";
|
||
|
||
const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8"));
|
||
const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "2026-10-01T00:00:00+00:00", issues_count: 9 };
|
||
const S = { label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: null, board_type: null, created_at: "2026-10-02T00:00:00+00:00", issues_count: 0 };
|
||
const ROW = { ...SNAP.issues[1], fetched_at: "" };
|
||
|
||
interface Call { method: string; url: URL; body: unknown }
|
||
function hub(projects: unknown[] = [A, S], over: Record<string, (body: unknown) => Response | Promise<Response>> = {}) {
|
||
const calls: Call[] = [];
|
||
const fetch = mockFetch((method, url, body) => {
|
||
calls.push({ method, url, body });
|
||
const k = `${method} ${url.pathname}`;
|
||
if (over[k]) return over[k](body);
|
||
if (k === "GET /projects") return json(200, { projects });
|
||
if (k === "GET /settings") return json(200, settings);
|
||
if (k === "GET /board") return json(200, SNAP);
|
||
if (k === "GET /issues") return json(200, { issues: [ROW] });
|
||
if (k === "GET /issues/AURORA-9001") return json(200, issue("AURORA-9001"));
|
||
if (k === "GET /batches") return json(200, { batches: [] });
|
||
if (k === "GET /agent") return json(200, { online: true });
|
||
return json(404, { error: "not_found" });
|
||
});
|
||
const get = (path: string) => calls.filter((c) => c.method === "GET" && c.url.pathname === path);
|
||
return { fetch, calls, get };
|
||
}
|
||
const open = (path: string) => {
|
||
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
|
||
render(<App router={router} />);
|
||
return router;
|
||
};
|
||
|
||
describe("проект в приложении", () => {
|
||
beforeEach(() => { setToken("tok"); localStorage.clear(); });
|
||
|
||
it("первый вход после выкатки: проект подставляется в адрес и запоминается", async () => {
|
||
const h = hub();
|
||
const router = open("/app/board");
|
||
await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA"));
|
||
expect(router.state.historyAction).toBe("REPLACE");
|
||
await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0));
|
||
expect(h.get("/board")[0].url.searchParams.get("project")).toBe("AWPCA");
|
||
expect(localStorage.getItem(REMEMBER_KEY)).toBe("AWPCA");
|
||
});
|
||
|
||
it("переключение проекта: доска, ссылка «Задачи», список и варианты фильтров", async () => {
|
||
const h = hub();
|
||
const router = open("/app/board?project=AWPCA");
|
||
await screen.findByTestId("page-board");
|
||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK");
|
||
expect(router.state.historyAction).toBe("PUSH");
|
||
await waitFor(() => expect(h.get("/board").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true));
|
||
const link = screen.getByRole("link", { name: "Задачи" });
|
||
expect(link).toHaveAttribute("href", "/app/issues?project=STOCKS_DRRK");
|
||
await userEvent.click(link);
|
||
expect(router.state.location.pathname + router.state.location.search).toBe("/app/issues?project=STOCKS_DRRK");
|
||
await screen.findByTestId("page-issues");
|
||
await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true));
|
||
expect(h.get("/board").filter((c) => c.url.searchParams.get("project") === "STOCKS_DRRK").length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("запомненный проект подставляется в адрес", async () => {
|
||
localStorage.setItem(REMEMBER_KEY, "STOCKS_DRRK");
|
||
hub();
|
||
const router = open("/app/issues");
|
||
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
|
||
expect(router.state.historyAction).toBe("REPLACE");
|
||
});
|
||
|
||
it("неизвестный проект в адресе: сообщение и список проектов, без GET /issues", async () => {
|
||
const h = hub();
|
||
open("/app/issues?project=NOPE");
|
||
expect(await screen.findByText(/проект NOPE не найден/)).toBeInTheDocument();
|
||
expect(screen.getByTestId("project-unknown")).toHaveTextContent("Выберите проект в списке «Проект» вверху.");
|
||
expect(screen.getByRole("combobox", { name: "Проект" })).toBeInTheDocument();
|
||
expect(h.get("/issues")).toHaveLength(0);
|
||
});
|
||
|
||
it("проектов нет: приглашение создать, без GET /board; щелчок открывает окно", async () => {
|
||
const h = hub([]);
|
||
open("/app/board");
|
||
await userEvent.click(await screen.findByRole("button", { name: "+ Новый проект…" }));
|
||
expect(screen.getByRole("dialog", { name: "Новый проект" })).toHaveAttribute("open");
|
||
expect(h.get("/board")).toHaveLength(0);
|
||
});
|
||
|
||
it("создание проекта из формы", async () => {
|
||
const h = hub([A], {
|
||
"POST /projects": () => json(201, { project: S, batch_id: 7 }),
|
||
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
|
||
});
|
||
const router = open("/app/board?project=AWPCA");
|
||
await screen.findByTestId("page-board");
|
||
const listed = h.get("/batches").length;
|
||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
|
||
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
|
||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
|
||
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
|
||
// пакет проекта отслеживается по batch_id (track): верхняя панель показывает ход, список пакетов не запрашивается
|
||
expect(await within(screen.getByRole("banner")).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/);
|
||
expect(h.get("/batches").length).toBe(listed);
|
||
const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!;
|
||
expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" });
|
||
expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("с номером доски board_id уходит числом", async () => {
|
||
const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7 }) });
|
||
open("/app/board?project=AWPCA");
|
||
await screen.findByTestId("page-board");
|
||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
|
||
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
|
||
await userEvent.type(within(dlg).getByLabelText("Номер доски Jira"), "5120");
|
||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
|
||
await waitFor(() => expect(h.calls.some((c) => c.method === "POST" && c.url.pathname === "/projects")).toBe(true));
|
||
expect(h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!.body)
|
||
.toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer" });
|
||
});
|
||
|
||
it("метка занята: окно открыто, текст ошибки хаба рядом с «Создать»", async () => {
|
||
hub([A], { "POST /projects": () => json(409, { error: "project_exists", detail: "label: проект с такой меткой уже есть: AWPCA" }) });
|
||
open("/app/board?project=AWPCA");
|
||
await screen.findByTestId("page-board");
|
||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||
await userEvent.type(within(dlg).getByLabelText("Метка"), "awpca");
|
||
await userEvent.type(within(dlg).getByLabelText("Название"), "Дубль");
|
||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
|
||
expect(await within(dlg).findByRole("alert")).toHaveTextContent("label: проект с такой меткой уже есть: AWPCA");
|
||
expect(dlg).toHaveAttribute("open");
|
||
});
|
||
|
||
it("ошибка фонового обновления проектов не сносит страницу: остаётся последний известный список", async () => {
|
||
let n = 0;
|
||
const h = hub([A, S], {
|
||
"GET /projects": () => (++n === 1 ? json(200, { projects: [A, S] }) : json(500, { error: "boom" })),
|
||
"POST /projects": () => json(201, { project: S, batch_id: 7 }),
|
||
"GET /batches/7": () => json(200, { id: 7, author: "viewer", status: "queued", remaining_seconds: 5, counts: {}, commands: [{ type: "refresh", status: "queued" }] }),
|
||
});
|
||
const router = open("/app/board?project=AWPCA");
|
||
await screen.findByTestId("page-board");
|
||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
|
||
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
|
||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); // инвалидация: повторный GET /projects падает
|
||
await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK"));
|
||
expect(h.get("/projects").length).toBeGreaterThan(1);
|
||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK")); // O-4: адрес меняется раньше отрисовки
|
||
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
|
||
});
|
||
|
||
it("окно «Новый проект» во время создания не закрывается: «Отмена» заблокирована, Esc гасится", async () => {
|
||
let release!: () => void;
|
||
const gate = new Promise<void>((r) => { release = r; });
|
||
hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7 }); } });
|
||
open("/app/board?project=AWPCA");
|
||
await screen.findByTestId("page-board");
|
||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…");
|
||
const dlg = screen.getByRole("dialog", { name: "Новый проект" });
|
||
await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK");
|
||
await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК");
|
||
await userEvent.click(within(dlg).getByRole("button", { name: "Создать" }));
|
||
await waitFor(() => expect(within(dlg).getByRole("button", { name: "Создать" })).toBeDisabled());
|
||
expect(within(dlg).getByRole("button", { name: "Отмена" })).toBeDisabled();
|
||
fireEvent(dlg, new Event("cancel", { cancelable: true }));
|
||
expect(screen.getByRole("dialog", { name: "Новый проект" })).toBeInTheDocument();
|
||
release();
|
||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument());
|
||
});
|
||
|
||
it("карточка по прямому адресу: выбор проекта не переписывает адрес и не закрывает окно", async () => {
|
||
const h = hub();
|
||
const router = open("/app/issues/AURORA-9001");
|
||
expect(await screen.findByTestId("issue-card")).toBeInTheDocument();
|
||
await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "AWPCA")).toBe(true));
|
||
expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
|
||
expect(router.state.location.search).toBe("");
|
||
expect(screen.getByTestId("issue-card")).toBeInTheDocument();
|
||
});
|
||
});
|