sdlc-hub/web/src/modules/jira/agile/SprintPage.test.tsx

334 lines
20 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 { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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 type { AgileIssue, AgileResponse, AgileSprint } from "../api/types.ts";
const A = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null };
const iss = (key: string, over: Partial<AgileIssue> = {}): AgileIssue => ({
key, summary: `Тема ${key}`, issue_type: "Задача", status: "Новая", priority: null, assignee: null,
epic_key: null, story_points: null, rank: "0|a", done: false, ...over,
});
const iso = (offsetDays: number) => {
const d = new Date(Date.now() + offsetDays * 86_400_000);
const p = (n: number) => String(n).padStart(2, "0");
d.setHours(12, 0, 0, 0); // полдень местного времени и местное смещение: результат не зависит от пояса и часа суток
const off = -d.getTimezoneOffset(), a = Math.abs(off);
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000${off < 0 ? "-" : "+"}${p(Math.floor(a / 60))}${p(a % 60)}`;
};
const sprint = (over: Partial<AgileSprint>): AgileSprint => ({
id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: iso(3),
complete_date: null, goal: null, count: 0, count_done: 0, unestimated: 0, points: 0, points_done: 0, issues: [], ...over,
});
const s102 = (over: Partial<AgileSprint> = {}) => sprint({
id: 102, name: "AWPCA_Sprint_3", goal: "Закрыть свод", count: 3, count_done: 1, points: 25, points_done: 8,
issues: [iss("AURORA-1", { story_points: 5 }), iss("AURORA-2", { done: true, status: "Готово" }), iss("AURORA-3", { status: "Странный" })], ...over,
});
const agile = (sprints: AgileSprint[] = [s102()], over: Partial<AgileResponse> = {}): AgileResponse => ({
board: { id: 4216, type: "scrum", columns: [{ name: "К выполнению", statuses: ["Новая"] }, { name: "Готово", statuses: ["Готово"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" },
sprints, backlog: [], board_issues: null, ...over,
});
function hub(data: AgileResponse) {
mockFetch((method, url) => {
const k = `${method} ${url.pathname}`;
if (k === "GET /agile") return json(200, data);
if (k === "GET /projects") return json(200, { projects: [A] });
if (k === "GET /settings") return json(200, settings);
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" });
});
}
const open = (path: string) => {
const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
render(<App router={router} />);
return router;
};
const page = () => screen.findByTestId("page-sprint");
const jiraGroup = () => screen.getByRole("group", { name: "Jira" });
const colNames = (p: HTMLElement) => Array.from(p.querySelectorAll(".agile-col__name")).map((e) => e.textContent);
describe("«Спринт»", () => {
beforeEach(() => { setToken("tok"); localStorage.clear(); });
it("меню scrum-проекта: «Связи», «Бэклог», «Спринт», «Задачи», «Лента»; «Kanban» нет", async () => {
hub(agile());
open("/app/jira/links?project=AWPCA");
await within(jiraGroup()).findByRole("link", { name: "Спринт" });
expect(within(jiraGroup()).getAllByRole("link").map((l) => l.textContent)).toEqual(["Связи", "Бэклог", "Спринт", "Задачи", "Лента"]);
});
it("колонки доски с числом задач и «Без колонки»", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
const cols = Array.from(p.querySelectorAll(".agile-col"));
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1", "1"]);
expect(within(cols[2] as HTMLElement).getByText("Тема AURORA-3")).toBeInTheDocument();
expect(p.querySelector(".agile-columns")).not.toBeNull();
const h = within(p).getByRole("heading", { name: "AWPCA_Sprint_3" });
expect(h.parentElement).toHaveTextContent("Закрыть свод");
expect(h.parentElement).toHaveTextContent("1 из 3 задач");
expect(h.parentElement).toHaveTextContent("8 из 25 SP");
});
it("«осталось 3 дн.» и «срок прошёл»", async () => {
hub(agile([s102({ end_date: iso(3) })]));
open("/app/jira/sprint?project=AWPCA");
expect(await within(await page()).findByText("осталось 3 дн.")).toBeInTheDocument();
});
it("срок прошёл", async () => {
hub(agile([s102({ end_date: iso(-1) })]));
open("/app/jira/sprint?project=AWPCA");
expect(await within(await page()).findByText("срок прошёл")).toBeInTheDocument();
});
const s104 = () => sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-77")], count: 1 });
const s103 = () => sprint({ id: 103, name: "AWPCA_Sprint_4", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-88")], count: 1 });
const picker = (p: HTMLElement) => p.querySelector<HTMLButtonElement>("button[aria-haspopup]")!;
const sprintGroup = () => screen.getByRole("group", { name: "Спринты" });
it("по умолчанию выбраны все активные: два активных — два заголовка и задачи обоих", async () => {
hub(agile([s102(), s104()]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(within(p).getByText("Тема AURORA-77")).toBeInTheDocument();
expect(within(p).getAllByRole("heading", { level: 2 }).map((h) => h.textContent)).toEqual(
expect.arrayContaining(["AWPCA_Sprint_3", "AWPCA_Sprint_5"]));
expect(picker(p)).toHaveTextContent("Спринты: 2");
});
it("один активный: на кнопке «Спринты» его имя", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(picker(p)).toHaveTextContent("AWPCA_Sprint_3");
});
it("«Будущий спринт на доске»: отметить будущий — ?sprint=101,102 через replace, «осталось» только у активного", async () => {
hub(agile([sprint({ id: 101, name: "Активный", issues: [iss("AURORA-5")], count: 1 }), sprint({ id: 102, name: "Будущий", state: "future", start_date: iso(5), end_date: iso(15), issues: [iss("AURORA-6")], count: 1 })]));
const router = open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-5");
expect(within(p).queryByText("Тема AURORA-6")).toBeNull();
await userEvent.click(picker(p));
await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /Будущий/ }));
expect(await within(p).findByText("Тема AURORA-6")).toBeInTheDocument();
expect(router.state.location.search).toContain("sprint=101%2C102");
expect(router.state.location.search).toContain("project=AWPCA");
expect(router.state.historyAction).toBe("REPLACE");
expect(within(p).getAllByRole("heading", { level: 2, name: /^(Активный|Будущий)$/ })).toHaveLength(2);
expect(within(p).getAllByText(/осталось \d+ дн\./)).toHaveLength(1);
});
it("в списке спринтов подписи «активный»/«будущий»; последнюю отметку снять нельзя", async () => {
hub(agile([s102(), s103()]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
await userEvent.click(picker(p));
const g = within(sprintGroup());
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3.*активный/ })).toBeChecked();
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_4.*будущий/ })).not.toBeChecked();
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeDisabled();
await userEvent.click(g.getByRole("checkbox", { name: /AWPCA_Sprint_4/ }));
expect(g.getByRole("checkbox", { name: /AWPCA_Sprint_3/ })).toBeEnabled();
});
it("задача в обоих выбранных спринтах — одна карточка", async () => {
hub(agile([s102(), sprint({ id: 104, name: "AWPCA_Sprint_5", issues: [iss("AURORA-1", { story_points: 5 })], count: 1 })]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(within(p).getAllByText("Тема AURORA-1")).toHaveLength(1);
});
it("?sprint=999 и ?sprint=abc — все активные", async () => {
hub(agile([s102(), s103()]));
open("/app/jira/sprint?project=AWPCA&sprint=999");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
expect(screen.queryByText("Тема AURORA-88")).toBeNull();
});
it("мусорный ?sprint=abc — все активные", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA&sprint=abc");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
});
it("?sprint=103 (будущий) — только он", async () => {
hub(agile([s102(), s103()]));
open("/app/jira/sprint?project=AWPCA&sprint=103");
const p = await page();
expect(await within(p).findByText("Тема AURORA-88")).toBeInTheDocument();
expect(within(p).queryByText("Тема AURORA-1")).toBeNull();
});
it("устаревший ?sprint= (закрытый) — все активные", async () => {
hub(agile([s102(), sprint({ id: 101, name: "Закрытый", state: "closed", issues: [iss("AURORA-99")] })]));
open("/app/jira/sprint?project=AWPCA&sprint=101");
expect(await within(await page()).findByText("Тема AURORA-1")).toBeInTheDocument();
expect(screen.queryByText("Тема AURORA-99")).toBeNull();
});
it("нет активного, есть будущий — «Нет активного спринта», «Бэклог», выбор доступен; выбор будущего показывает доску", async () => {
hub(agile([s103()]));
open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
await userEvent.click(picker(await page()));
await userEvent.click(within(sprintGroup()).getByRole("checkbox", { name: /AWPCA_Sprint_4/ }));
expect(await p.findByText("Тема AURORA-88")).toBeInTheDocument();
expect(p.queryByText("Нет активного спринта")).toBeNull();
});
it("«Исполнитель на доске спринта»: только его карточки, числа колонок по видимым; на «Бэклоге» «все»", async () => {
const sp = s102({ issues: [iss("AURORA-1", { assignee: "ivanov" }), iss("AURORA-2", { assignee: "petrov" }), iss("AURORA-3", { assignee: "ivanov", status: "Готово", done: true })] });
hub(agile([sp]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-2");
await userEvent.selectOptions(within(p).getByLabelText("Исполнитель"), "ivanov");
await waitFor(() => expect(within(p).queryByText("Тема AURORA-2")).toBeNull());
expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument();
const cols = Array.from(p.querySelectorAll(".agile-col"));
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "1"]);
cleanup();
hub(agile([sp]));
open("/app/jira/backlog?project=AWPCA");
const b = await screen.findByTestId("page-backlog");
expect(await within(b).findByLabelText("Исполнитель")).toHaveValue("");
});
it("«Скрыть выполненные» убирает выполненные карточки", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-2");
await userEvent.click(within(p).getByRole("checkbox", { name: "Скрыть выполненные" }));
expect(within(p).queryByText("Тема AURORA-2")).toBeNull();
expect(within(p).getByText("Тема AURORA-1")).toBeInTheDocument();
const cols = Array.from(p.querySelectorAll(".agile-col"));
expect(cols.map((c) => c.querySelector(".agile-col__count")!.textContent)).toEqual(["1", "0", "1"]);
});
it("заголовки колонок — h3 (T10.3)", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(within(p).getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(["К выполнению", "Готово", "Без колонки"]);
});
it("«Скрытая колонка»: снять «Готово» — колонки нет, на «Колонки» — 1; после повторного монтажа так же", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
let p = await page();
await within(p).findByText("Тема AURORA-1");
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
await userEvent.click(within(screen.getByRole("group", { name: "Колонки" })).getByRole("checkbox", { name: "Готово" }));
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
cleanup();
open("/app/jira/sprint?project=AWPCA");
p = await page();
await within(p).findByText("Тема AURORA-1");
expect(colNames(p)).toEqual(["К выполнению", "Без колонки"]);
expect(within(p).getByRole("button", { name: /^Колонки/ })).toHaveTextContent("Колонки1");
});
it("последнюю видимую колонку снять нельзя", async () => {
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
await userEvent.click(within(p).getByRole("button", { name: /^Колонки/ }));
const g = within(screen.getByRole("group", { name: "Колонки" }));
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
await userEvent.click(g.getByRole("checkbox", { name: "Без колонки" }));
expect(colNames(p)).toEqual(["К выполнению"]);
expect(g.getByRole("checkbox", { name: "К выполнению" })).toBeDisabled();
});
it("скрытая колонка, которой больше нет, не ломает меню и не считается", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Удалённая"] } } }));
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = await page();
await within(p).findByText("Тема AURORA-1");
expect(colNames(p)).toEqual(["К выполнению", "Готово", "Без колонки"]);
const btn = within(p).getByRole("button", { name: /^Колонки/ });
expect(btn).toHaveTextContent(/^Колонки$/);
await userEvent.click(btn);
expect(within(screen.getByRole("group", { name: "Колонки" })).getAllByRole("checkbox")).toHaveLength(3);
});
it("эпик на доске спринта — карточка, учитывается в числе колонки", async () => {
hub(agile([s102({ issues: [iss("AURORA-1"), iss("AURORA-E", { issue_type: "Эпик" })] })]));
open("/app/jira/sprint?project=AWPCA");
const p = await page();
expect(await within(p).findByText("Тема AURORA-E")).toBeInTheDocument();
const cols = Array.from(p.querySelectorAll(".agile-col"));
expect(cols[0].querySelector(".agile-col__count")!.textContent).toBe("2");
});
it("нет активного — «Нет активного спринта» и ссылка «Бэклог»", async () => {
hub(agile([sprint({ id: 105, name: "C", state: "closed" })]));
open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
expect(await p.findByText("Нет активного спринта")).toBeInTheDocument();
expect(p.getByRole("link", { name: "Бэклог" })).toHaveAttribute("href", "/app/jira/backlog?project=AWPCA");
expect(p.queryByRole("button", { name: /^Колонки/ })).toBeNull(); // M-7: доски нет — меню не нужно
});
it("T7.2: меню «Колонки» строится по задачам без фильтров — «Без колонки» не пропадает из меню вместе с поиском", async () => {
localStorage.setItem("sdlc-hub-agile", JSON.stringify({ AWPCA: { sprint: { hiddenColumns: ["Без колонки"] } } }));
hub(agile());
open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
await p.findByText("Тема AURORA-1");
await userEvent.type(p.getByRole("searchbox", { name: "Поиск по ключу или теме" }), "AURORA-1");
expect(colNames(await page())).toEqual(["К выполнению", "Готово"]);
const btn = p.getByRole("button", { name: "Колонки, скрыто 1" });
await userEvent.click(btn);
const g = within(screen.getByRole("group", { name: "Колонки" }));
expect(g.getByRole("checkbox", { name: "Без колонки" })).not.toBeChecked();
await userEvent.click(g.getByRole("checkbox", { name: "Готово" }));
expect(JSON.parse(localStorage.getItem("sdlc-hub-agile")!).AWPCA.sprint.hiddenColumns).toEqual(["Готово", "Без колонки"]);
});
it("агент без досок — «Нужен агент v15 и обновление из Jira» и «Обновить из Jira»", async () => {
hub({ board: null, sprints: [], backlog: null, board_issues: null });
open("/app/jira/sprint?project=AWPCA");
expect(await screen.findByText("Нужен агент v15 и обновление из Jira")).toBeInTheDocument();
expect(within(screen.getByRole("main")).getAllByRole("button", { name: "Обновить из Jira" }).length).toBeGreaterThan(0);
});
it("щелчок по карточке открывает окно задачи поверх «Спринта»", async () => {
hub(agile());
const router = open("/app/jira/sprint?project=AWPCA");
const p = within(await page());
await userEvent.click(await p.findByText("Тема AURORA-1"));
await screen.findByRole("dialog");
expect(router.state.location.pathname).toBe("/app/jira/issues/AURORA-1");
await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Закрыть" }));
await waitFor(() => expect(router.state.location.pathname).toBe("/app/jira/sprint"));
});
});