import { QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { makeQueryClient } from "../api/queryClient.ts";
import { setToken } from "../auth/token.ts";
import { json, mockFetch } from "../test/fetchMock.ts";
import { dictsFromSettings } from "./dicts.ts";
import settings from "./__fixtures__/settings.json";
import { useBoardSource } from "./useBoardSource.ts";
const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures__", name), "utf8");
const SNAP = JSON.parse(fx("hub-basic.board.json"));
const CSV = fx("jira-export.csv");
const { dicts } = dictsFromSettings(settings);
const wrapper = ({ children }: { children: ReactNode }) =>
{children};
const hook = () => renderHook(() => useBoardSource(dicts, { pollMs: 10 }), { wrapper });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
// хаб в памяти: снимок, список пакетов, пакет по id; счётчики запросов
function hub(over: Partial Response | Promise>> = {}) {
const calls: string[] = [];
const inFlight: Record = {}, maxInFlight: Record = {};
const fetch = mockFetch(async (method, url, body) => {
const k = `${method} ${url.pathname}`;
calls.push(k);
inFlight[k] = (inFlight[k] ?? 0) + 1;
maxInFlight[k] = Math.max(maxInFlight[k] ?? 0, inFlight[k]);
try {
const h = over[k] ?? over[`${method} ${url.pathname.replace(/\d+$/, ":id")}`];
if (h) return await h(url, body);
if (k === "GET /board") return json(200, SNAP);
if (k === "GET /batches") return json(200, { batches: [] });
if (k === "GET /agent") return json(200, { online: true, last_seen_at: null, warning: null });
if (k === "POST /refresh") return json(201, { id: 7, estimate_seconds: 5, count: 1 });
return json(404, { error: "not_found" });
} finally { inFlight[k] -= 1; }
});
return { fetch, calls, count: (k: string) => calls.filter((c) => c === k).length, maxInFlight: (k: string) => maxInFlight[k] ?? 0 };
}
const batch = (status: string, cmd: Record = { status: "done" }) =>
json(200, { id: 7, title: "Обновление", author: "viewer", created_at: "", cancelled_at: null, status, counts: {}, remaining_seconds: 0, commands: [{ id: 1, seq: 1, type: "refresh", error: null, finished_at: null, ...cmd }] });
describe("useBoardSource", () => {
beforeEach(() => setToken("tok"));
it("строит доску из снимка хаба и подписывает источник", async () => {
hub();
const { result } = hook();
expect(result.current.status).toEqual({ kind: "loading" });
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
expect(result.current.source).toBe("hub");
expect(result.current.label).toMatch(/^Хаб · данные на /);
expect(result.current.status).toBeNull();
});
it("M-1: поздний ответ хаба не затирает загруженный CSV; «Перечитать» возвращает хаб", async () => {
const slow = deferred();
const h = hub({ "GET /board": () => slow.promise });
const { result } = hook();
act(() => { result.current.loadFile("jira-export.csv", CSV); });
expect(result.current.source).toBe("file");
slow.resolve(json(200, SNAP));
await waitFor(() => expect(h.count("GET /board")).toBe(1));
await sleep(20);
expect(result.current.source).toBe("file");
expect(result.current.label).toBe("jira-export.csv (из файла)");
act(() => result.current.reload());
await waitFor(() => expect(result.current.source).toBe("hub"));
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
});
it("M-2: двойное нажатие — один пакет и один опрос за раз", async () => {
let polls = 0;
const h = hub({ "GET /batches/:id": () => (++polls < 4 ? batch("running") : batch("done")) });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(async () => { void result.current.refreshFromJira(); await result.current.refreshFromJira(); });
expect(h.count("POST /refresh")).toBe(1);
expect(result.current.refreshing).toBe(true);
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
expect(result.current.refreshing).toBe(false);
expect(h.maxInFlight("GET /batches/7")).toBe(1);
expect(h.count("GET /board")).toBe(2); // по завершении снимок перечитан
});
it("M-3: незавершённый пакет viewer подхватывается и после «Перечитать»", async () => {
let listed: unknown[] = [];
const h = hub({ "GET /batches": () => json(200, { batches: listed }), "GET /batches/:id": () => batch("running") });
const { result } = hook();
await waitFor(() => expect(h.count("GET /batches")).toBe(1));
expect(result.current.refreshing).toBe(false);
listed = [{ id: 7, author: "viewer", status: "running" }, { id: 6, author: "chat", status: "running" }];
act(() => result.current.reload());
await waitFor(() => expect(result.current.refreshing).toBe(true));
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", id: 7, phase: "running" }));
});
it("M-4: 404 на опросе — опрос остановлен, кнопка доступна", async () => {
const h = hub({ "GET /batches/:id": () => json(404, { error: "not_found", detail: "batch" }) });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(() => result.current.refreshFromJira());
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7 }));
expect(result.current.refreshing).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
for (const status of [400, 422]) {
it(`T10.2: ${status} на опросе — опрос остановлен, batch-lost с detail, refreshing сброшен`, async () => {
const h = hub({ "GET /batches/:id": () => json(status, { error: "bad_request", detail: "плохой пакет" }) });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(() => result.current.refreshFromJira());
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", id: 7, reason: expect.stringContaining("плохой пакет") }));
expect(result.current.refreshing).toBe(false);
const n = h.count("GET /batches/7");
await sleep(50);
expect(h.count("GET /batches/7")).toBe(n);
});
}
it("M-4: 5 ошибок «хаб не отвечает» подряд — опрос остановлен", async () => {
const h = hub({ "GET /batches/:id": () => { throw new TypeError("Failed to fetch"); } });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(() => result.current.refreshFromJira());
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch-lost", reason: "хаб не отвечает" }));
expect(h.count("GET /batches/7")).toBe(5);
await sleep(50);
expect(h.count("GET /batches/7")).toBe(5);
expect(result.current.refreshing).toBe(false);
});
it("пакет с ошибкой команды — текст ошибки; агент офлайн — предупреждение в статусе", async () => {
let polls = 0;
hub({
"GET /agent": () => json(200, { online: false, last_seen_at: null, warning: null }),
"GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done", { status: "failed", error: "Jira 500" })),
});
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(() => result.current.refreshFromJira());
await waitFor(() => expect(result.current.status).toMatchObject({ kind: "batch", agentOffline: true }));
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-failed", id: 7, error: "Jira 500" }));
});
it("M-1: пакет, завершившийся при показанном файле, не заменяет файл", async () => {
let polls = 0;
hub({ "GET /batches/:id": () => (++polls < 3 ? batch("running") : batch("done")) });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
await act(() => result.current.refreshFromJira());
act(() => { result.current.loadFile("jira-export.csv", CSV); });
await waitFor(() => expect(result.current.status).toEqual({ kind: "batch-done", id: 7 }));
expect(result.current.source).toBe("file");
});
it("хаб остановлен при «Перечитать» — прежние данные и hub-down", async () => {
let down = false;
hub({ "GET /board": () => { if (down) throw new TypeError("Failed to fetch"); return json(200, SNAP); } });
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
down = true;
act(() => result.current.reload());
await waitFor(() => expect(result.current.status).toEqual({ kind: "hub-down", hasData: true }));
expect(result.current.model.nodes.length).toBe(10);
});
it("плохой файл — file-error, прежняя доска на месте", async () => {
hub();
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
act(() => { result.current.loadFile("bad.csv", "Номер,Название\n1,x\n"); });
expect(result.current.status).toMatchObject({ kind: "file-error", fileName: "bad.csv" });
expect(result.current.source).toBe("hub");
});
it("импорт JSON при живом хабе — настройки возвращаются, данные из хаба", async () => {
hub();
const { result } = hook();
await waitFor(() => expect(result.current.model.nodes.length).toBe(10));
let r: unknown = null;
act(() => { r = result.current.loadFile("old-board.json", fx("old-board.json")); });
expect(r).toMatchObject({ laneOrder: ["Гудвилл", "Тестовый эпик"] });
expect(result.current.source).toBe("hub");
expect(result.current.status).toEqual({ kind: "json-applied", fileName: "old-board.json" });
});
});