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 Response | Promise> = {}) { 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(); 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((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(); }); });