sdlc-hub/web/src/shell/feed/FeedPage.test.tsx
2026-10-28 12:48:40 +04:00

227 lines
14 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 { 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>): 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(<App router={router} />);
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");
});
});