import { 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 { focusManager } from "@tanstack/react-query"; import { act } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "../../App.tsx"; import type { ChangeEvent } from "../../modules/jira/api/types.ts"; import { setToken } from "../../auth/token.ts"; import settings from "../../modules/jira/board/__fixtures__/settings.json"; import { routes } from "../../routes.tsx"; import { json, mockFetch } from "../../test/fetchMock.ts"; import { issue } from "../../test/issueFixtures.ts"; const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "modules", "jira", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9, wiki_root: "DOC-1" }; const NOWIKI = { label: "sfm_kedr", name: "СФМ КЭДР", board_id: null, board_type: null, created_at: "", issues_count: 2, wiki_root: null }; const B = { label: "STOCKS", name: "Запасы", board_id: null, board_type: null, created_at: "", issues_count: 1 }; const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString(); const ev = (over: Partial): ChangeEvent => ({ at: minutesAgo(30), key: "AURORA-9002", summary: "API свода", status: "Готово", kind: "status", field: "status", author: "Петров Пётр", from: "В работе", to: "Готово", from_value: null, to_value: null, ...over, }); type Changes = { since?: string; until?: string; truncated?: boolean; changes: ChangeEvent[] } | Response; function hub(changes: () => Changes = () => ({ changes: [] })) { const calls: URL[] = []; const wikiCalls: URL[] = []; mockFetch((method, url) => { const k = `${method} ${url.pathname}`; if (k === "GET /changes") { calls.push(url); const r = changes(); return r instanceof Response ? r : json(200, { since: "", until: "", truncated: false, ...r }); } if (k === "GET /wiki/changes") { wikiCalls.push(url); return json(200, { since: "", until: "", synced_at: null, history_at: null, truncated: false, changes: [] }); } if (k === "GET /projects") return json(200, { projects: [A, B, NOWIKI] }); if (k === "GET /settings") return json(200, settings); if (k === "GET /board") return json(200, SNAP); if (k === "GET /issues") return json(200, { issues: [] }); if (k === "GET /batches") return json(200, { batches: [] }); if (k === "GET /agents") return json(200, { agents: [] }); if (k.startsWith("GET /issues/")) return json(200, issue(decodeURIComponent(url.pathname.split("/").pop()!))); return json(404, { error: "not_found" }); }); return Object.assign(calls, { wiki: wikiCalls }); } const open = (path: string) => { const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); render(); return router; }; const page = () => screen.findByTestId("page-feed"); describe("лента изменений", () => { beforeEach(() => { setToken("tok"); localStorage.clear(); }); afterEach(() => { vi.useRealTimers(); focusManager.setFocused(undefined); }); it("шапка: вторичная кнопка «Обновить из Jira», primary нет", async () => { hub(); open("/app/feed?project=AWPCA"); await page(); expect(screen.getByRole("button", { name: "Обновить из Jira" })).toHaveClass("ui-btn--secondary"); expect(document.querySelector(".ui-btn--primary")).toBeNull(); }); it.todo("шапка у проекта с вики: и «Обновить вики» (П-10) — источник вики появляется в задаче 6"); it("«Проект без вики»: флажков источников нет, GET /wiki/changes нет", async () => { const calls = hub(() => ({ changes: [ev({})] })); open("/app/feed?project=sfm_kedr"); expect(await screen.findAllByTestId("feed-group")).toHaveLength(1); expect(screen.queryByRole("group", { name: "Источники" })).toBeNull(); expect(screen.queryByRole("checkbox", { name: "Вики" })).toBeNull(); expect(calls.wiki).toHaveLength(0); }); it.todo("сняты все источники — «выберите хотя бы один источник», запросов нет, адрес sources=none — флажки источников появляются в задаче 6"); it.todo("«Что закрылось за два часа» с источником «Вики»: sources=jira в адресе, GET /wiki/changes после снятия не было — задача 6"); it.todo("«Ошибка одного источника»: /wiki/changes 500 — события Jira показаны, на месте вики — ошибка с «Повторить» — задача 6"); it("прежний /app/jira/feed → /app/feed с тем же search, replace (прежние закладки)", async () => { hub(); const router = open("/app/jira/feed?project=AWPCA&period=7d"); await page(); expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d"); expect(router.state.historyAction).toBe("REPLACE"); }); it("C-1/C-2: возврат на вкладку перечитывает ленту с границей «до» на момент запроса", async () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date(2026, 9, 22, 10, 0)); const calls = hub(); open("/app/feed?project=AWPCA"); await page(); await waitFor(() => expect(calls.length).toBe(1)); expect(new Date(calls[0].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 10, 0).getTime()); vi.setSystemTime(new Date(2026, 9, 22, 11, 30)); act(() => { focusManager.setFocused(false); focusManager.setFocused(true); }); await waitFor(() => expect(calls.length).toBe(2)); expect(new Date(calls[1].searchParams.get("until")!).getTime()).toBe(new Date(2026, 9, 22, 11, 30).getTime()); expect(new Date(calls[1].searchParams.get("since")!).getTime()).toBe(new Date(2026, 9, 22, 0, 0).getTime()); }); it("по умолчанию — «Сегодня», виды без «Прочих полей», события сгруппированы по задаче", async () => { const calls = hub(() => ({ changes: [ ev({}), ev({ key: "AURORA-9001", summary: "Схема", status: "В работе", kind: "comment", field: "comment", from: null, to: "Готово к проверке" }), ev({ at: minutesAgo(40), kind: "description", field: "description" }), ] })); open("/app/feed?project=AWPCA"); await page(); const groups = await screen.findAllByTestId("feed-group"); expect(groups.map((g) => within(g).getByRole("link").textContent)).toEqual(["AURORA-9002", "AURORA-9001"]); expect(within(groups[0]).getByText("API свода")).toBeInTheDocument(); expect(within(groups[0]).getByText(/статус «В работе» → «Готово»/)).toBeInTheDocument(); expect(within(groups[0]).getByText("описание изменено")).toBeInTheDocument(); expect(within(groups[1]).getByText("комментарий: Готово к проверке")).toBeInTheDocument(); const q = calls[0].searchParams; expect(q.get("project")).toBe("AWPCA"); expect(q.get("kinds")).toBe("status,description,dates,comment"); const midnight = new Date(); midnight.setHours(0, 0, 0, 0); expect(new Date(q.get("since")!).getTime()).toBe(midnight.getTime()); expect(q.get("limit")).toBe("2000"); expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true"); expect(screen.getByText(/3 события/)).toBeInTheDocument(); }); it("«Что закрылось за два часа»: период и виды в адресе и в запросе", async () => { const calls = hub(); const router = open("/app/feed?project=AWPCA"); await page(); await userEvent.click(screen.getByRole("radio", { name: "2 часа" })); for (const name of ["Описание", "Сроки", "Комментарии"]) await userEvent.click(screen.getByRole("checkbox", { name })); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA&period=2h&kinds=status")); // источник один (вики — в задаче 6): sources в адрес не пишется expect(router.state.location.pathname).toBe("/app/feed"); expect(calls.wiki).toHaveLength(0); await waitFor(() => expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status")); const since = new Date(calls.at(-1)!.searchParams.get("since")!).getTime(); expect(Math.abs(Date.now() - 2 * 3600_000 - since)).toBeLessThan(60_000); }); it("карточка из ленты — окно поверх, закрытие возвращает ленту с тем же периодом", async () => { hub(() => ({ changes: [ev({})] })); const router = open("/app/feed?project=AWPCA&period=7d"); await page(); await userEvent.click(await screen.findByRole("link", { name: "AURORA-9002" })); const d = await screen.findByRole("dialog"); expect(await within(d).findByRole("heading", { name: "Тема AURORA-9002" })).toBeInTheDocument(); expect(screen.getByTestId("page-feed")).toBeInTheDocument(); await userEvent.click(within(d).getAllByRole("button", { name: "Закрыть" })[0]); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); expect(router.state.location.pathname + router.state.location.search).toBe("/app/feed?project=AWPCA&period=7d"); }); it("M-1: /app/feed без project — проект дописывается в адрес", async () => { hub(); const router = open("/app/feed"); await page(); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); }); it("M-3/M-6/M-7: подсказка truncated над группами, автор без имени — «—», группы не регионы", async () => { hub(() => ({ truncated: true, changes: [ev({ author: null })] })); open("/app/feed?project=AWPCA"); const notice = await screen.findByText("Jira: показаны последние 1 событий — выберите период короче"); const group = screen.getByTestId("feed-group"); expect(notice.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(within(group).getByText("—", { selector: ".feed-event__author" })).toBeInTheDocument(); expect(within(screen.getByTestId("page-feed")).queryAllByRole("region")).toHaveLength(0); }); it("пустой период, truncated, ошибка с «Повторить»", async () => { let mode: "empty" | "truncated" | "error" = "empty"; hub(() => mode === "error" ? json(500, { error: "boom", detail: "сбой" }) : mode === "truncated" ? { truncated: true, changes: [ev({})] } : { changes: [] }); open("/app/feed?project=AWPCA"); expect(await screen.findByText("за период изменений нет")).toBeInTheDocument(); mode = "truncated"; await userEvent.click(screen.getByRole("radio", { name: "Вчера" })); expect(await screen.findByText("Jira: показаны последние 1 событий — выберите период короче")).toBeInTheDocument(); mode = "error"; await userEvent.click(screen.getByRole("radio", { name: "7 дней" })); expect(await screen.findByText("Jira: хаб ответил 500")).toBeInTheDocument(); const retry = await screen.findByRole("button", { name: "Повторить" }); mode = "empty"; await userEvent.click(retry); expect(await screen.findByText("за период изменений нет")).toBeInTheDocument(); }); it("все флажки сняты — подсказка, запроса нет", async () => { const calls = hub(); open("/app/feed?project=AWPCA&kinds=status"); await page(); await waitFor(() => expect(calls.length).toBe(1)); await userEvent.click(screen.getByRole("checkbox", { name: "Статусы" })); expect(await screen.findByText("выберите хотя бы один вид")).toBeInTheDocument(); expect(calls.length).toBe(1); }); it("мусор в адресе: период по умолчанию, неизвестные виды отброшены", async () => { const calls = hub(); open("/app/feed?project=AWPCA&period=foo&kinds=status,foo"); await page(); await waitFor(() => expect(calls.length).toBeGreaterThan(0)); expect(screen.getByRole("radio", { name: "Сегодня" })).toHaveAttribute("aria-checked", "true"); expect(calls[0].searchParams.get("kinds")).toBe("status"); expect(screen.getByRole("checkbox", { name: "Статусы" })).toBeChecked(); expect(screen.getByRole("checkbox", { name: "Сроки" })).not.toBeChecked(); }); it("смена проекта: запрос по новому проекту, period, sources и kinds сохраняются (keepSearch)", async () => { const calls = hub(); const router = open("/app/feed?project=AWPCA&period=2h&sources=jira&kinds=status"); await page(); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS"); await waitFor(() => expect(calls.at(-1)!.searchParams.get("project")).toBe("STOCKS")); expect(calls.at(-1)!.searchParams.get("kinds")).toBe("status"); expect(router.state.location.pathname).toBe("/app/feed"); expect(router.state.location.search).toBe("?project=STOCKS&period=2h&sources=jira&kinds=status"); }); });