From 425d09eb454b5834cc44a111db47a39d66577e47 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 15:14:51 +0400 Subject: [PATCH] =?UTF-8?q?fix(web-modules):=20I-2=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=BE=D0=BA=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=BA=D0=B5,=20M-6,=20T2.3=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=20jira=5Fsynced=5Fat=20=D0=B2=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8C,=20M-4=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D0=B4,=20M-10=20=D0=B4=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D1=8B=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/api/types.ts | 7 +- web/src/boundary.test.ts | 76 ++++++++++++++------- web/src/modules/jira/projectFields.ts | 7 +- web/src/modules/jira/status.ts | 3 +- web/src/modules/wiki/RootForm.test.tsx | 2 +- web/src/projects/projects.test.tsx | 2 +- web/src/shell/NewProject.test.tsx | 3 +- web/src/shell/activity/ActivityProvider.tsx | 18 ++--- web/src/shell/activity/activity.test.tsx | 24 ++++++- web/src/shell/activity/activity.ts | 1 - 10 files changed, 96 insertions(+), 47 deletions(-) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b0f0213..0590df7 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1,6 +1,6 @@ // ответы HTTP API хаба, общие для ядра (openspec/specs/hub-api); типы модулей — в modules/<модуль>/api/types.ts -// настройки хаба: ядро знает только метку проекта; словари модулей (jira) +// настройки хаба: ядро знает только метку проекта; словари модулей // типизирует сам модуль export interface HubSettings { project_key?: string; @@ -24,11 +24,10 @@ export interface AgentView { } export interface CreatedBatch { id: number; estimate_seconds: number; count: number } -// проект-метка (GET/POST /projects): ядро знает метку, название, дату, время обновления из Jira и последние пакеты; -// остальные поля модулей (jira: board_id, board_type, issues_count) типизирует сам модуль — modules/jira/projectFields.ts +// проект-метка (GET/POST /projects): ядро знает метку, название и дату создания; поля модулей +// (свежесть данных, доска, число задач) типизирует сам модуль — modules/<модуль>/ export type Project = { label: string; name: string; created_at: string; - jira_synced_at: string | null; batches?: Batch[]; } & Record; // пакет, поставленный хабом проекту: module — модуль, чей это пакет (отслеживает его трекер этого модуля) export interface ProjectBatch { module: string; id: number } diff --git a/web/src/boundary.test.ts b/web/src/boundary.test.ts index d9a153d..4d94b8a 100644 --- a/web/src/boundary.test.ts +++ b/web/src/boundary.test.ts @@ -23,6 +23,34 @@ function resolve(from: string, spec: string): string { return parts.join("/"); } +// имена полей и типов модулей, которых в ядре быть не должно (типизирует сам модуль) +const MODULE_TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow|jira_synced_at|wiki_synced_at|wiki_sync_error|wiki_root|board_id|board_type|issues_count)\b/; + +// классы, которые «определяет» CSS-текст: только селекторы; значения свойств, атрибуты и :not(...) не считаются +const classesOf = (css: string): Set => { + const out = new Set(); + const noComments = css.replace(/\/\*[\s\S]*?\*\//g, ""); + for (const m of noComments.matchAll(/([^{}]+)\{/g)) { + if (/^\s*@(?!container|media|supports)/.test(m[1])) continue; + const sel = m[1].replace(/@(container|media|supports)[^{]*$/, "").replace(/\[[^\]]*\]/g, "") + .replace(/:not\([^)]*\)/g, "").replace(/:(is|where|has)\(/g, " ").replace(/\([^)]*\)/g, ""); + for (const c of sel.matchAll(/\.([a-zA-Z_][\w-]*)/g)) out.add(c[1]); + } + return out; +}; +const cssClashes = (files: Record, ownerOf: (f: string) => string | null): string[] => { + const seen = new Map>(); // класс -> владелец -> файл + for (const f of Object.keys(files)) { + const o = ownerOf(f); + if (!o) continue; + for (const c of classesOf(files[f])) { + if (!seen.has(c)) seen.set(c, new Map()); + if (!seen.get(c)!.has(o)) seen.get(c)!.set(o, f); + } + } + return [...seen].filter(([, m]) => m.size > 1).map(([c, m]) => `.${c}: ${[...m.values()].join(", ")}`); +}; + describe("граница ядра и модулей", () => { it("core does not import modules", () => { const bad: string[] = []; @@ -52,34 +80,32 @@ describe("граница ядра и модулей", () => { }); it("css classes do not collide", () => { - const classesOf = (css: string): Set => { - const out = new Set(); - const noComments = css.replace(/\/\*[\s\S]*?\*\//g, ""); - // только селекторы: текст перед «{»; значения свойств (0.5, 1.5em) не трогаем - for (const m of noComments.matchAll(/([^{}]+)\{/g)) { - if (/^\s*@(?!container|media|supports)/.test(m[1])) continue; - const sel = m[1].replace(/@(container|media|supports)[^{]*$/, "").replace(/\[[^\]]*\]/g, "").replace(/\([^)]*\)/g, ""); - for (const c of sel.matchAll(/\.([a-zA-Z_][\w-]*)/g)) out.add(c[1]); - } - return out; - }; - const owner = (f: string): string | null => moduleOf(f) ?? (CORE.test(f) ? "core" : null); - const seen = new Map>(); // класс -> владелец -> файл - for (const f of Object.keys(CSS)) { - const o = owner(f); - if (!o) continue; - for (const c of classesOf(CSS[f])) { - if (!seen.has(c)) seen.set(c, new Map()); - if (!seen.get(c)!.has(o)) seen.get(c)!.set(o, f); - } - } - const clashes = [...seen].filter(([, m]) => m.size > 1).map(([c, m]) => `.${c}: ${[...m.values()].join(", ")}`); - expect(clashes).toEqual([]); + expect(cssClashes(CSS, (f) => moduleOf(f) ?? (CORE.test(f) ? "core" : null))).toEqual([]); }); it("core has no module terms", () => { - const TERMS = /\b(fact_status|closed_statuses|kind_labels|link_types|BoardIssue|IssueRow)\b/; - const bad = coreFiles.filter((f) => TERMS.test(SRC[f])).map((f) => `${f}: ${SRC[f].match(TERMS)![1]}`); + const bad = coreFiles.filter((f) => MODULE_TERMS.test(SRC[f])).map((f) => `${f}: ${SRC[f].match(MODULE_TERMS)![1]}`); expect(bad).toEqual([]); }); }); + +// детекторы вынесены в функции, чтобы проверять их на синтетике (M-10) +describe("детекторы границы", () => { + it("classesOf: составной селектор даёт класс, значения свойств — нет, :not(.x) не определяет класс", () => { + expect([...classesOf("div.wiki h1 { margin: 0.5em }")]).toEqual(["wiki"]); + expect([...classesOf(".a:not(.b) .c[data-x='.d'] { top: 1.5px }")].sort()).toEqual(["a", "c"]); + expect([...classesOf("@media (min-width: 600px) { .m:is(.n) { color: red } } /* .skip */")].sort()).toEqual(["m", "n"]); + }); + it("cssClashes: один класс у модуля и ядра — столкновение; у одного владельца — нет", () => { + const owner = (f: string) => (f.startsWith("core/") ? "core" : f.split("/")[0]); + expect(cssClashes({ "core/a.css": ".wiki{}", "wiki/b.css": "div.wiki h1{}" }, owner)).toEqual([".wiki: core/a.css, wiki/b.css"]); + expect(cssClashes({ "wiki/a.css": ".x{}", "wiki/b.css": ".x{}", "jira/c.css": ".y{}" }, owner)).toEqual([]); + }); + it("MODULE_TERMS ловит поля модулей в ядре (jira_synced_at, board_type), но не слова из обычного текста", () => { + expect(MODULE_TERMS.test("jira_synced_at: string | null")).toBe(true); + expect(MODULE_TERMS.test("p.wiki_root")).toBe(true); + expect(MODULE_TERMS.test("board_type")).toBe(true); + expect(MODULE_TERMS.test("fact_status")).toBe(true); + expect(MODULE_TERMS.test("метка задач в Jira")).toBe(false); + }); +}); diff --git a/web/src/modules/jira/projectFields.ts b/web/src/modules/jira/projectFields.ts index f0310c9..42b3a16 100644 --- a/web/src/modules/jira/projectFields.ts +++ b/web/src/modules/jira/projectFields.ts @@ -1,4 +1,9 @@ import type { Project } from "../../api/types.ts"; // поля проекта, которые добавляет модуль jira (GET /projects); board_type хаб определяет по отчёту агента, до него — null -export type JiraProject = Project & { board_id: number | null; board_type: "scrum" | "kanban" | null; issues_count: number }; +export type JiraProject = Project & { + board_id: number | null; board_type: "scrum" | "kanban" | null; issues_count: number; + jira_synced_at: string | null; // время последнего успешного полного обновления из Jira; null — ещё не было +}; +// у проекта в ответе хаба поле модуля есть всегда (null — «ещё не было»); без него (старый ответ, тестовая фикстура) — не проект Jira +export const hasJiraSync = (p: Project): p is JiraProject => "jira_synced_at" in p; diff --git a/web/src/modules/jira/status.ts b/web/src/modules/jira/status.ts index 96c0200..54c53ec 100644 --- a/web/src/modules/jira/status.ts +++ b/web/src/modules/jira/status.ts @@ -2,12 +2,13 @@ import { api } from "../../api/client.ts"; import type { CreatedBatch } from "../../api/types.ts"; import type { ModuleStatus } from "../types.ts"; import { qk } from "./api/queries.ts"; +import { hasJiraSync } from "./projectFields.ts"; // состояние модуля jira (контракт ModuleStatus): свежесть — по всем проектам, обновление — полное по базовой JQL export const jiraStatus: ModuleStatus = { agent: "jira", scope: "all", - freshness: (p) => ({ available: true, at: (p.jira_synced_at as string | null | undefined) ?? null, error: null }), + freshness: (p) => ({ available: true, at: hasJiraSync(p) ? p.jira_synced_at : null, error: null }), refresh: { label: "Обновить из Jira", start: () => api.post("/refresh", { full: true, author: "viewer" }), diff --git a/web/src/modules/wiki/RootForm.test.tsx b/web/src/modules/wiki/RootForm.test.tsx index 88cd68d..a56ddcd 100644 --- a/web/src/modules/wiki/RootForm.test.tsx +++ b/web/src/modules/wiki/RootForm.test.tsx @@ -17,7 +17,7 @@ describe("RootForm: пакет привязки отслеживает моду ? json(200, { project: { label: "P", name: "x", created_at: "" }, batch_ids: [5, 6], batches: [{ module: "wiki", id: 5 }, { module: "jira", id: 6 }] }) : json(404, {})); const modules = { jira: act(), wiki: act() }; - const value: ActivityValue = { agents: [], modules, summary: "online", trackBatch: vi.fn(async () => {}) }; + const value: ActivityValue = { agents: [], modules, summary: "online" }; render( ); await userEvent.type(screen.getByLabelText("Корень вики (DOC-… или ссылка)"), "DOC-1"); diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index 8575ac0..2616092 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -112,7 +112,7 @@ describe("проект в приложении", () => { await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); - // пакет проекта отслеживается по batch_id (trackBatch): панель показывает ход + // пакет проекта отслеживается по batch_id (track модуля): панель показывает ход expect(await within(screen.getByRole("complementary", { name: "Панель приложения" })).findByRole("status")).toHaveTextContent(/пакет #7 в очереди/); expect(h.get("/batches").length).toBeGreaterThanOrEqual(listed); const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!; diff --git a/web/src/shell/NewProject.test.tsx b/web/src/shell/NewProject.test.tsx index 0bfd1e9..792d032 100644 --- a/web/src/shell/NewProject.test.tsx +++ b/web/src/shell/NewProject.test.tsx @@ -17,7 +17,7 @@ describe("NewProject: пакеты каждому своему модулю", () ? json(201, { project: { label: "P", name: "Пэ", created_at: "" }, batch_id: 3, batches: [{ module: "jira", id: 3 }, { module: "wiki", id: 4 }] }) : json(404, {})); const modules = { jira: act(), wiki: act() }; - const value: ActivityValue = { agents: [], modules, summary: "online", trackBatch: vi.fn(async () => {}) }; + const value: ActivityValue = { agents: [], modules, summary: "online" }; const onClose = vi.fn(), select = vi.fn(); render( ); @@ -27,6 +27,5 @@ describe("NewProject: пакеты каждому своему модулю", () await waitFor(() => expect(select).toHaveBeenCalledWith("P")); expect(modules.jira.track).toHaveBeenCalledExactlyOnceWith(3); expect(modules.wiki.track).toHaveBeenCalledExactlyOnceWith(4); - expect(value.trackBatch).not.toHaveBeenCalled(); }); }); diff --git a/web/src/shell/activity/ActivityProvider.tsx b/web/src/shell/activity/ActivityProvider.tsx index 8807f06..7e13e46 100644 --- a/web/src/shell/activity/ActivityProvider.tsx +++ b/web/src/shell/activity/ActivityProvider.tsx @@ -4,6 +4,7 @@ // смене проекта, раз в 60 с и после 409 на постановке; пакет любого автора (design web-modules, решение 4) import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useProjects } from "../../api/queries.ts"; import { api, ApiError, AuthError, dropTokenOn, HubDownError } from "../../api/client.ts"; import type { AgentView, Batch } from "../../api/types.ts"; import { MODULES } from "../../modules/index.ts"; @@ -216,17 +217,16 @@ export function ActivityProvider({ children, project, pollMs = POLL_MS }: { chil const modules = useMemo(() => Object.fromEntries(TRACKED.map((m, i) => [m.name, trackers[i].view])), // eslint-disable-next-line react-hooks/exhaustive-deps -- представления модулей мемоизированы в трекерах trackers.map((t) => t.view)); + // сводная точка (spec «Оболочка и адреса»): ошибка у любого модуля → пакет в работе → агенты на связи или нет. + // Офлайн-агент модуля без данных у проекта (у вики без корня) сводку не красит: ему нечего обновлять + const projectData = useProjects().data?.find((x) => x.label === project); + const agentMatters = (m: (typeof TRACKED)[number]) => + m.status.scope === "all" || !projectData || m.status.freshness(projectData).available; const summary: ActivityValue["summary"] = TRACKED.some((m) => isError(modules[m.name].event)) ? "error" - : TRACKED.some((m) => !!m.status.agent && modules[m.name].agent?.online === false) ? "offline" - : trackers.some((t) => t.tracking) ? "busy" : "online"; + : trackers.some((t) => t.tracking) ? "busy" + : TRACKED.some((m) => !!m.status.agent && modules[m.name].agent?.online === false && agentMatters(m)) ? "offline" : "online"; - const trackBatch = useCallback(async (id: number) => { - const b = await api.get(`/batches/${id}`); - const m = TRACKED.find((x) => x.name === b.module) ?? TRACKED.find((x) => x.status.refresh?.isOwn(b)); - if (m) modules[m.name].track(id); - }, [modules]); - - const value = useMemo(() => ({ agents: agents ?? [], modules, summary, trackBatch }), [agents, modules, summary, trackBatch]); + const value = useMemo(() => ({ agents: agents ?? [], modules, summary }), [agents, modules, summary]); return {children}; } diff --git a/web/src/shell/activity/activity.test.tsx b/web/src/shell/activity/activity.test.tsx index 159406f..89598ab 100644 --- a/web/src/shell/activity/activity.test.tsx +++ b/web/src/shell/activity/activity.test.tsx @@ -442,11 +442,31 @@ describe("ActivityProvider", () => { expect(m.now().summary).toBe("error"); // ошибка важнее офлайна }); - it("пакет вики в очереди и агент офлайн — сводка offline", async () => { - hub({ "GET /agents": () => json(200, { agents: [agentView("jira"), agentView("wiki", { online: false })] }) }); + const proj = (o: object = {}) => ({ label: "AWPCA", name: "АРМ КА", created_at: "2026-10-01T00:00:00+00:00", jira_synced_at: null, ...o }); + const offlineWiki = { "GET /agents": () => json(200, { agents: [agentView("jira"), agentView("wiki", { online: false })] }) }; + + it("I-2: агент вики офлайн, у проекта есть корень вики — сводка offline", async () => { + hub({ ...offlineWiki, "GET /projects": () => json(200, { projects: [proj({ wiki_root: "DOC-1" })] }) }); const m = mount(); await waitFor(() => expect(m.now().summary).toBe("offline")); }); + + it("M-6: агент вики офлайн, но у проекта нет корня вики — сводка online", async () => { + hub({ ...offlineWiki, "GET /projects": () => json(200, { projects: [proj()] }) }); + const m = mount(); + await waitFor(() => expect(m.now().wiki.agent?.online).toBe(false)); + await waitFor(() => expect(m.client.getQueryData(["projects"])).toBeTruthy()); + expect(m.now().summary).toBe("online"); + }); + + it("I-2 (спека): пакет в работе важнее офлайна агента — сводка busy", async () => { + hub({ ...offlineWiki, "GET /projects": () => json(200, { projects: [proj({ wiki_root: "DOC-1" })] }), + ...byId({ 7: "refresh" }, () => "running") }); + const m = mount(); + await waitFor(() => expect(m.now().summary).toBe("offline")); + await act(() => m.now().jira.start()); + expect(m.now().summary).toBe("busy"); + }); }); describe("eventText", () => { diff --git a/web/src/shell/activity/activity.ts b/web/src/shell/activity/activity.ts index 9c22779..acfe89f 100644 --- a/web/src/shell/activity/activity.ts +++ b/web/src/shell/activity/activity.ts @@ -16,7 +16,6 @@ export interface ActivityValue { agents: AgentView[]; modules: Record; summary: "error" | "busy" | "online" | "offline"; - trackBatch(batchId: number): Promise; // пакет без известного модуля: модуль берётся из ответа хаба } export const AGENTS_KEY = ["agents"] as const;