sdlc-hub/web/src/projects/projects.test.tsx
Andrey Epifancev 2a472cacbf style(web): вход, новый проект, служебные экраны; старые стили убраны (#65)
Экраны входа, «Новый проект», ProjectGate, «не найдено» и ошибка отрисовки
переведены на компоненты системы. controls.css удалён, псевдонимы токенов
убраны, глобальные .row/.spacer/.hint/.warn/.login разобраны. Разделы в
верхней панели не активны вне доски и задач. Тонкая настройка дорожек доски,
тест на ошибку списка, стабилизирован тест проектов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 01:19:09 +04:00

198 lines
13 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 { 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();
});
});