fix(web-modules): I-2 порядок сводной точки по спеке, M-6, T2.3 поле jira_synced_at в модуль, M-4 мёртвый код, M-10 детекторы границы
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
47bba63d8b
commit
425d09eb45
@ -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<string, unknown>;
|
||||
// пакет, поставленный хабом проекту: module — модуль, чей это пакет (отслеживает его трекер этого модуля)
|
||||
export interface ProjectBatch { module: string; id: number }
|
||||
|
||||
@ -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<string> => {
|
||||
const out = new Set<string>();
|
||||
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<string, string>, ownerOf: (f: string) => string | null): string[] => {
|
||||
const seen = new Map<string, Map<string, string>>(); // класс -> владелец -> файл
|
||||
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<string> => {
|
||||
const out = new Set<string>();
|
||||
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<string, Map<string, string>>(); // класс -> владелец -> файл
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<CreatedBatch>("/refresh", { full: true, author: "viewer" }),
|
||||
|
||||
@ -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(<QueryClientProvider client={new QueryClient()}><ActivityContext.Provider value={value}>
|
||||
<RootForm project="P" /></ActivityContext.Provider></QueryClientProvider>);
|
||||
await userEvent.type(screen.getByLabelText("Корень вики (DOC-… или ссылка)"), "DOC-1");
|
||||
|
||||
@ -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")!;
|
||||
|
||||
@ -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(<QueryClientProvider client={new QueryClient()}><ActivityContext.Provider value={value}>
|
||||
<NewProject onClose={onClose} select={select} /></ActivityContext.Provider></QueryClientProvider>);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<Batch>(`/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<ActivityValue>(() => ({ agents: agents ?? [], modules, summary, trackBatch }), [agents, modules, summary, trackBatch]);
|
||||
const value = useMemo<ActivityValue>(() => ({ agents: agents ?? [], modules, summary }), [agents, modules, summary]);
|
||||
return <ActivityContext.Provider value={value}>{children}</ActivityContext.Provider>;
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -16,7 +16,6 @@ export interface ActivityValue {
|
||||
agents: AgentView[];
|
||||
modules: Record<string, ModuleActivity>;
|
||||
summary: "error" | "busy" | "online" | "offline";
|
||||
trackBatch(batchId: number): Promise<void>; // пакет без известного модуля: модуль берётся из ответа хаба
|
||||
}
|
||||
|
||||
export const AGENTS_KEY = ["agents"] as const;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user