From f21dea39b6a5022955eaf2390fcea94e7552ef94 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 21:04:57 +0400 Subject: [PATCH] =?UTF-8?q?test(web):=20e2e=20=D1=88=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B9=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=B2,=20=D1=81?= =?UTF-8?q?=D0=B8=D0=B4=20WIDE=5FE2E,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20A?= =?UTF-8?q?gileGate=20(#112,=20T9.4,=20T12.2=E2=80=93T12.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Также: шеврон свёртки секции «Бэклог» — свёрнуто вниз, развёрнуто вверх (T4.5). Co-Authored-By: Claude Sonnet 5.5 --- web/e2e/agile.spec.ts | 123 +++++++++++++++--- web/scripts/seed-hub.py | 5 +- web/src/modules/jira/agile/AgileGate.test.tsx | 68 ++++++++++ web/src/modules/jira/agile/BacklogPage.tsx | 2 +- 4 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 web/src/modules/jira/agile/AgileGate.test.tsx diff --git a/web/e2e/agile.spec.ts b/web/e2e/agile.spec.ts index 5c03720..5738c0f 100644 --- a/web/e2e/agile.spec.ts +++ b/web/e2e/agile.spec.ts @@ -2,18 +2,23 @@ import { expect, test } from "@playwright/test"; import { login } from "./hub.ts"; // экраны доски: «Бэклог», «Спринт», «Kanban» (spec web-app «Спринты и бэклог»); данные — сид тестового хаба (R-27) -const side = (page: import("@playwright/test").Page) => page.getByRole("complementary", { name: "Панель приложения" }); +type Page = import("@playwright/test").Page; +const side = (page: Page) => page.getByRole("complementary", { name: "Панель приложения" }); +// строка списка / карточка колонки по ключу задачи (ссылка с точным именем), без привязки к порядку в DOM +const itemOf = (page: Page, key: string) => page.getByRole("listitem").filter({ has: page.getByRole("link", { name: key, exact: true }) }); +const colOf = (page: Page, name: string) => page.getByRole("region", { name, exact: true }); test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", async ({ page }) => { await login(page, "/app/jira/backlog?project=AWPCA"); await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Бэклог", exact: true, level: 2 })).toBeVisible(); - const past = page.getByText("Прошлые спринты (1)"); + const past = page.getByText("Прошлые спринты (1)", { exact: true }); await expect(past).toBeVisible(); await past.click(); - await page.getByText("AWPCA_Спринт 1").first().click(); - const row = page.locator(".agile-row").filter({ hasText: "AURORA-9001" }).last(); + const pastSprint = page.locator("details.agile-past__sprint").filter({ has: page.locator("summary", { hasText: "AWPCA_Спринт 1" }) }); + await pastSprint.locator("summary").click(); + const row = pastSprint.getByRole("listitem").filter({ has: page.getByRole("link", { name: "AURORA-9001", exact: true }) }); await expect(row).toBeVisible(); await row.click(); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/); @@ -28,9 +33,9 @@ test("«Спринт» AWPCA: колонки доски, «осталось 7 д await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible(); for (const name of ["К выполнению", "В работе", "Готово"]) { - await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible(); + await expect(page.getByRole("heading", { name, exact: true, level: 3 })).toBeVisible(); } - await page.locator(".agile-card").filter({ hasText: "AURORA-9001" }).click(); + await itemOf(page, "AURORA-9001").click(); await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); @@ -39,19 +44,19 @@ test("«Спринт» AWPCA: колонки доски, «осталось 7 д test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", async ({ page }) => { await login(page, "/app/jira/kanban?project=KANBAN_E2E"); - await expect(page.getByText("Бэклог (1)")).toBeVisible(); - await expect(page.locator(".agile-col__name", { hasText: "В работе" })).toBeVisible(); - await expect(page.locator(".agile-col__name", { hasText: "Готово" })).toBeVisible(); - await expect(page.locator(".agile-col__name", { hasText: "Бэклог" })).toHaveCount(0); - await expect(page.getByText("за 14 дней")).toBeVisible(); - await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9102" })).toBeVisible(); - await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9103" })).toBeVisible(); + await expect(page.getByText("Бэклог (1)", { exact: true })).toBeVisible(); + await expect(colOf(page, "В работе")).toBeVisible(); + await expect(colOf(page, "Готово")).toBeVisible(); + await expect(colOf(page, "Бэклог")).toHaveCount(0); + await expect(page.getByText("за 14 дней", { exact: true })).toBeVisible(); + await expect(colOf(page, "В работе").getByRole("link", { name: "AURORA-9102", exact: true })).toBeVisible(); + await expect(colOf(page, "Готово").getByRole("link", { name: "AURORA-9103", exact: true })).toBeVisible(); }); test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", async ({ page }) => { await login(page, "/app/jira/backlog?project=KANBAN_E2E"); await expect(page.getByText("У проекта нет scrum-доски")).toBeVisible(); - await page.getByRole("link", { name: "Связи", exact: true }).last().click(); + await page.getByRole("main").getByRole("link", { name: "Связи", exact: true }).click(); await expect(page).toHaveURL(/\/app\/jira\/links\?project=KANBAN_E2E/); }); @@ -72,10 +77,98 @@ test("900 px: колонки «Спринта» прокручиваются в await expect(page.locator(".agile-col").first()).toBeVisible(); const m = await page.evaluate(() => { const d = document.documentElement, c = document.querySelector(".agile-columns")!; - return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth], + return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth], bottom: c.getBoundingClientRect().bottom, gutter: c.getBoundingClientRect().left - document.querySelector(".agile")!.getBoundingClientRect().left }; }); expect(m.gutter, "колонки не получают второй отступ внутри страницы (T10.1)").toBe(0); expect(m.doc[0]).toBeLessThanOrEqual(m.doc[1]); expect(m.cols[0]).toBeGreaterThan(m.cols[1]); + expect(m.bottom, "нижний край доски в окне").toBeLessThanOrEqual(900); +}); + +test("«Широкая доска» 2560×1300: колонки одной высоты, доска в окне, горизонтальная прокрутка внутри, «Без колонки» достижима", async ({ page }) => { + await page.setViewportSize({ width: 2560, height: 1300 }); + await login(page, "/app/jira/sprint?project=WIDE_E2E"); + await expect(colOf(page, "Колонка 1")).toBeVisible(); + await expect(colOf(page, "Без колонки")).toHaveCount(1); + const m = await page.evaluate(() => { + const board = document.querySelector(".agile-columns")!; + const heights = [...document.querySelectorAll(".agile-col")].map((c) => c.getBoundingClientRect().height); + return { heights, bottom: board.getBoundingClientRect().bottom, inner: window.innerHeight, width: board.getBoundingClientRect().width, + sw: board.scrollWidth, cw: board.clientWidth, count: heights.length }; + }); + expect(m.count, "11 колонок и «Без колонки»").toBe(12); + expect(Math.max(...m.heights) - Math.min(...m.heights), "высоты колонок равны").toBeLessThanOrEqual(1); + expect(m.bottom, "нижний край доски в окне").toBeLessThanOrEqual(m.inner); + expect(m.sw, "доска прокручивается по горизонтали").toBeGreaterThan(m.cw); + expect(m.width, "доска шире 1440 px (нет max-width)").toBeGreaterThan(1440); + await page.evaluate(() => { const b = document.querySelector(".agile-columns")!; b.scrollLeft = b.scrollWidth; }); + const extra = colOf(page, "Без колонки"); + await expect(extra).toBeInViewport({ ratio: 1 }); + const doc = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]); + expect(doc[0], "страница без горизонтальной прокрутки").toBeLessThanOrEqual(doc[1]); +}); + +test("узкий контейнер: на 900 px вместо панели «Эпики» — выбор «Эпик», на 1600 px — панель и кнопка", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 900 }); + await login(page, "/app/jira/backlog?project=AWPCA"); + await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); + await expect(page.getByRole("complementary", { name: "Эпики" })).toBeHidden(); + await expect(page.getByRole("button", { name: "Эпики", exact: true })).toBeHidden(); + await expect(page.getByRole("combobox", { name: "Эпик" })).toBeVisible(); + await page.setViewportSize({ width: 1600, height: 900 }); + await expect(page.getByRole("complementary", { name: "Эпики" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Эпики", exact: true })).toBeVisible(); + await expect(page.getByRole("combobox", { name: "Эпик" })).toBeHidden(); +}); + +test("«Бэклог» AWPCA: панель «Эпики», выбор эпика оставляет только его задачи, свёртка секции переживает перезагрузку", async ({ page }) => { + await login(page, "/app/jira/backlog?project=AWPCA"); + const panel = page.getByRole("complementary", { name: "Эпики" }); + await expect(panel).toBeVisible(); + await expect(itemOf(page, "AURORA-9004")).toBeVisible(); + await panel.getByRole("button", { name: /AURORA-9100/ }).click(); + await expect(itemOf(page, "AURORA-9001")).toBeVisible(); + await expect(itemOf(page, "AURORA-9004")).toHaveCount(0); + await expect(itemOf(page, "AURORA-9008")).toHaveCount(0); + await panel.getByRole("button", { name: "Все задачи" }).click(); + await expect(itemOf(page, "AURORA-9004")).toBeVisible(); + const toggle = page.getByRole("button", { name: "AWPCA_Спринт 3" }); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(itemOf(page, "AURORA-9004")).toHaveCount(0); + await page.reload(); + await expect(page.getByRole("button", { name: "AWPCA_Спринт 3" })).toHaveAttribute("aria-expanded", "false"); + await expect(itemOf(page, "AURORA-9004")).toHaveCount(0); +}); + +test("«Спринт» AWPCA: выбор двух спринтов пишет sprint=102,103 в адрес, скрытая колонка «Готово» переживает перезагрузку", async ({ page }) => { + await login(page, "/app/jira/sprint?project=AWPCA"); + await expect(colOf(page, "Готово")).toBeVisible(); + await page.getByRole("button", { name: "AWPCA_Спринт 2" }).click(); + await page.getByRole("checkbox", { name: /AWPCA_Спринт 3/ }).click(); + await expect(page).toHaveURL(/sprint=102(,|%2C)103/); + await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Колонки" }).click(); + await page.getByRole("checkbox", { name: "Готово", exact: true }).click(); + await expect(colOf(page, "Готово")).toHaveCount(0); + await page.reload(); + await expect(colOf(page, "В работе")).toBeVisible(); + await expect(colOf(page, "Готово")).toHaveCount(0); +}); + +test("390×800: страница доски без горизонтальной прокрутки документа, полоса прокрутки доски в окне", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await login(page, "/app/jira/sprint?project=WIDE_E2E"); + await expect(colOf(page, "Колонка 1")).toBeVisible(); + const m = await page.evaluate(() => { + const d = document.documentElement, b = document.querySelector(".agile-columns")!; + return { doc: [d.scrollWidth, d.clientWidth], bottom: b.getBoundingClientRect().bottom, inner: window.innerHeight, sw: b.scrollWidth, cw: b.clientWidth }; + }); + expect(m.doc[0], JSON.stringify(m)).toBeLessThanOrEqual(m.doc[1]); + // нижний край доски на 390×800 (находка T8.1, замер): аннотация, а не утверждение — см. отчёт Task 8 + test.info().annotations.push({ type: "замер 390×800", description: JSON.stringify(m) }); + expect(m.sw).toBeGreaterThan(m.cw); }); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index 5982c6f..37d529e 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -36,9 +36,10 @@ for n, i in enumerate(issues, 1): i["story_points"] = float(n % 5 + 1) today = date.today() iso = lambda d, hhmm: f"{d.isoformat()}T{hhmm}:00.000+03:00" -now_iso = iso(today, "12:00") +# фиксированная дата «Готово» — вчера в полдень: «за 14 дней» от «сегодня 12:00» не зависит (T12.2) +done_iso = iso(today - timedelta(days=1), "12:00") issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний", - "labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": now_iso} if st == "Готово" else {})} + "labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": done_iso} if st == "Готово" else {})} for n, (k, st) in enumerate([("AURORA-9101", "Backlog"), ("AURORA-9102", "В работе"), ("AURORA-9103", "Готово")], 1)] jira_projects.create(conn, "KANBAN_E2E", "Канбан e2e", 5120) # WIDE_E2E: широкая scrum-доска (11 колонок, 40 задач в одной, статус вне колонок) — воспроизводит баг прокрутки (#112, T10.3) diff --git a/web/src/modules/jira/agile/AgileGate.test.tsx b/web/src/modules/jira/agile/AgileGate.test.tsx new file mode 100644 index 0000000..65a0b0a --- /dev/null +++ b/web/src/modules/jira/agile/AgileGate.test.tsx @@ -0,0 +1,68 @@ +import { 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 type { AgileResponse } from "../api/types.ts"; + +const S = { label: "AWPCA", name: "АРМ КА", board_id: 4216, board_type: "scrum", created_at: "", issues_count: 9, jira_synced_at: null }; +const K = { label: "KAN", name: "Канбан", board_id: 5120, board_type: "kanban", created_at: "", issues_count: 4, jira_synced_at: null }; +const data: AgileResponse = { + board: { id: 4216, type: "scrum", columns: [{ name: "Новая", statuses: ["Новая"] }], kanban_backlog: null, fetched_at: "2026-10-24T10:00:00+00:00" }, + sprints: [], backlog: [], board_issues: [], +}; + +const open = (path: string) => { + const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + render(); + return router; +}; +const base = (method: string, pathname: string, projects: unknown[]) => { + const k = `${method} ${pathname}`; + if (k === "GET /projects") return json(200, { projects }); + 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: [] }); + return null; +}; + +describe("AgileGate (T9.4)", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + + it("пока /agile не ответил — «Загрузка…»", async () => { + mockFetch((method, url) => { + if (`${method} ${url.pathname}` === "GET /agile") return new Promise(() => {}); + return base(method, url.pathname, [S]) ?? json(404, { error: "not_found" }); + }); + open("/app/jira/sprint?project=AWPCA"); + expect(await screen.findByText("Загрузка…")).toBeInTheDocument(); + }); + + it("ошибка /agile — текст ошибки и «Повторить», щелчок повторяет запрос", async () => { + let calls = 0; + mockFetch((method, url) => { + if (`${method} ${url.pathname}` === "GET /agile") { calls += 1; return calls === 1 ? json(500, { error: "hub_down" }) : json(200, data); } + return base(method, url.pathname, [S]) ?? json(404, { error: "not_found" }); + }); + open("/app/jira/sprint?project=AWPCA"); + const retry = await screen.findByRole("button", { name: "Повторить" }); + expect(screen.queryByText("Загрузка…")).not.toBeInTheDocument(); + await userEvent.click(retry); + await waitFor(() => expect(calls).toBe(2)); + await waitFor(() => expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument()); + }); + + it("меню kanban-проекта: «Kanban» есть, «Бэклог» и «Спринт» нет", async () => { + mockFetch((method, url) => base(method, url.pathname, [K]) ?? json(200, { ...data, board: { ...data.board, type: "kanban" }, sprints: [], backlog: null })); + open("/app/jira/kanban?project=KAN"); + const menu = await screen.findByRole("group", { name: "Jira" }); + expect(await within(menu).findByRole("link", { name: "Kanban" })).toBeInTheDocument(); + expect(within(menu).queryByRole("link", { name: "Бэклог" })).not.toBeInTheDocument(); + expect(within(menu).queryByRole("link", { name: "Спринт" })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/modules/jira/agile/BacklogPage.tsx b/web/src/modules/jira/agile/BacklogPage.tsx index 174b8fb..26e7cc2 100644 --- a/web/src/modules/jira/agile/BacklogPage.tsx +++ b/web/src/modules/jira/agile/BacklogPage.tsx @@ -29,7 +29,7 @@ function Toggle({ titleId, bodyId, collapsed, onToggle, children }: {

);