mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 13:00:57 +08:00
feat(biz-compare): preserve templates and refine industrial editor
This commit is contained in:
parent
90cf1907b4
commit
355bcfc4c0
19 changed files with 719 additions and 219 deletions
155
web/tests/bizCompareTemplates.browser.mjs
Normal file
155
web/tests/bizCompareTemplates.browser.mjs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// Local Vite + fixture APIs only. Never connects to devices or a production DB.
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(process.env.NETX_PLAYWRIGHT_MODULE || "playwright");
|
||||
const base = process.env.NETX_TEST_URL || "http://127.0.0.1:5179";
|
||||
const output = path.resolve(process.env.NETX_TEST_OUTPUT || "../docs/reviews/assets");
|
||||
await mkdir(output, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, channel: process.env.NETX_TEST_BROWSER || "chrome" });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const errors = [], passed = [], requests = [], writes = [];
|
||||
let templateWriteDelay = 0;
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const complex = [{ any: [{ all: [{ any: [{ field: "id", op: "eq", value: 0 }, { field: "id", op: "eq", value: false }] }, { field: "state", op: "not_empty" }] }] }];
|
||||
const sheet = { sheet_id: "s", title: "业务条目", metric_id: "custom", key_fields: ["id"], iface_fields: ["port"], compare_fields: ["count"], display_fields: ["id", "custom_context"], field_rules: [{ field: "count", compare: "numeric", tolerance: 2 }], row_filters: complex, ignore_port_changes: false };
|
||||
const simple = { ...sheet, sheet_id: "simple", title: "简单过滤", row_filters: [{ field: "external_state", op: "eq", value: "up,down" }], field_rules: [{ field: "count", compare: "ignore", ignore: true }], compare_fields: [] };
|
||||
let templates = [{ id: "tpl", name: "验收模板", job_count: 1, metrics: [sheet, simple], iface_normalize_rules: [{ from: "GE", to: "gei" }] }, ...Array.from({ length: 119 }, (_, i) => ({ id: `t-${i}`, name: `备用模板 ${i}`, job_count: 0, metrics: [sheet] }))];
|
||||
const job = { id: "job", name: "检查范围失效任务", template_id: "tpl", enabled_sheet_ids: ["removed-sheet"], mode: "manual", status: "ready", before_task_id: "before", after_task_id: "after", before_batch_id: "b", after_batch_id: "a" };
|
||||
await page.route("**/v1/**", async (route) => {
|
||||
const req = route.request(), p = new URL(req.url()).pathname;
|
||||
requests.push(p);
|
||||
let data = { items: [] };
|
||||
if (p === "/v1/auth/me") data = { user: { id: "fixture", username: "fixture", role: "admin", is_active: true } };
|
||||
else if (req.method() === "POST" && p.endsWith("/compare/templates")) {
|
||||
writes.push(req.postDataJSON());
|
||||
const created = { ...req.postDataJSON(), id: `import-${writes.length}`, job_count: 0 };
|
||||
templates = [created, ...templates]; data = created;
|
||||
} else if (req.method() === "PATCH" && p.includes("/compare/templates/")) {
|
||||
writes.push(req.postDataJSON()); data = { ...req.postDataJSON(), id: "tpl" };
|
||||
if (templateWriteDelay) await new Promise((resolve) => setTimeout(resolve, templateWriteDelay));
|
||||
templates = templates.map((t) => t.id === "tpl" ? { ...t, ...data } : t);
|
||||
} else if (req.method() === "PATCH" && p.endsWith("/compare/jobs/job")) { writes.push(req.postDataJSON()); data = { ...job, ...req.postDataJSON() }; }
|
||||
else if (p.endsWith("/compare/templates")) data = { items: templates };
|
||||
else if (p.endsWith("/compare/metrics")) data = { items: [{ metric_id: "custom", fields: ["id", "port", "count", "state"].map((name) => ({ name, display_name: name, dtype: "string", is_key: name === "id" })) }] };
|
||||
else if (p.endsWith("/compare/jobs")) data = { items: [job] };
|
||||
else if (p.endsWith("/tasks")) data = { items: [{ id: "before", ne_name: "PE-OLD" }, { id: "after", ne_name: "PE-NEW" }] };
|
||||
else if (p.endsWith("/batches")) data = { items: [{ id: p.includes("before") ? "b" : "a", status: "success", started_at: "2026-10-11T00:00:00Z" }, { id: "partial", status: "partial" }, { id: "running", status: "running" }] };
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) });
|
||||
});
|
||||
const templateRow = () => page.locator(".bs-template-workspace table tbody tr").filter({ has: page.getByText("验收模板", { exact: true }) });
|
||||
const editor = () => page.getByRole("dialog", { name: "编辑模板", exact: true });
|
||||
const openEditor = async () => { await templateRow().getByRole("button", { name: "编辑", exact: true }).click(); await editor().waitFor(); await page.waitForTimeout(350); };
|
||||
try {
|
||||
await page.goto(`${base}/network/cutover/compare-templates`);
|
||||
await templateRow().waitFor();
|
||||
assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 50);
|
||||
assert.ok(await templateRow().getByRole("button", { name: "删除", exact: true }).isDisabled());
|
||||
await templateRow().getByText("已被 1 个任务引用", { exact: true }).waitFor();
|
||||
assert.deepEqual(requests.filter((p) => p.includes("biz-state") && /\/(tasks|mappings|jobs)$/.test(p)), []);
|
||||
passed.push("50 row render bound", "referenced template protected", "template page skips jobs/tasks/mappings");
|
||||
await page.getByRole("button", { name: "下一页", exact: true }).click();
|
||||
assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 50);
|
||||
await page.getByRole("button", { name: "下一页", exact: true }).click();
|
||||
assert.equal(await page.locator(".bs-template-workspace table tbody tr").count(), 20);
|
||||
await page.getByRole("textbox", { name: "筛选模板名 / metric", exact: true }).fill("验收模板");
|
||||
await page.waitForFunction(() => document.querySelectorAll(".bs-template-workspace table tbody tr").length === 1);
|
||||
passed.push("pagination", "search resets page");
|
||||
const download = page.waitForEvent("download");
|
||||
await templateRow().getByRole("button", { name: "导出", exact: true }).click();
|
||||
const exported = JSON.parse(await readFile(await (await download).path(), "utf8"));
|
||||
assert.equal(exported.metrics[0].ignore_port_changes, false);
|
||||
assert.deepEqual(exported.metrics[0].row_filters, complex);
|
||||
passed.push("export keeps false port policy", "export keeps complex filters");
|
||||
await openEditor();
|
||||
assert.equal(await editor().getByLabel("端口变化策略", { exact: true }).inputValue(), "strict");
|
||||
await editor().getByText("custom_context", { exact: true }).waitFor();
|
||||
assert.equal(await editor().locator(".ct-editor__norm").getAttribute("open"), null);
|
||||
await page.screenshot({ path: path.join(output, "compare-template-editor.png"), fullPage: true });
|
||||
await editor().getByRole("tab", { name: /^行过滤/ }).click();
|
||||
assert.deepEqual(JSON.parse(await editor().locator(".ct-complex-filters pre").textContent()), complex);
|
||||
assert.equal(await editor().getByRole("button", { name: "+ 或组", exact: true }).count(), 0);
|
||||
templateWriteDelay = 1000;
|
||||
const saving = page.waitForRequest((req) => req.method() === "PATCH" && req.url().endsWith("/compare/templates/tpl"));
|
||||
await editor().getByRole("button", { name: "保存模板", exact: true }).click();
|
||||
await saving;
|
||||
assert.ok(await editor().locator("[inert]").count());
|
||||
assert.ok(await editor().getByRole("button", { name: "Close", exact: true }).isDisabled());
|
||||
await page.keyboard.press("Escape");
|
||||
assert.ok(await editor().isVisible());
|
||||
await editor().waitFor({ state: "hidden" });
|
||||
templateWriteDelay = 0;
|
||||
assert.deepEqual(writes.at(-1).metrics[0].row_filters, complex);
|
||||
assert.equal(writes.at(-1).metrics[0].ignore_port_changes, false);
|
||||
passed.push("strict policy loaded", "custom fields editable", "aliases collapsed", "opaque filters cannot be partially edited", "saving locks editor and dismissal", "save preserves complex filters and port policy");
|
||||
await openEditor();
|
||||
await editor().getByLabel("count 容差", { exact: true }).fill("-1");
|
||||
const writeCount = writes.length;
|
||||
await editor().getByRole("button", { name: "保存模板", exact: true }).click();
|
||||
await editor().getByRole("alert").waitFor();
|
||||
assert.equal(writes.length, writeCount);
|
||||
await editor().getByLabel("count 容差", { exact: true }).fill("3");
|
||||
assert.equal(await editor().getByRole("alert").count(), 0);
|
||||
await editor().getByRole("tab", { name: /^字段规则/ }).focus();
|
||||
await page.keyboard.press("End");
|
||||
assert.equal(await editor().getByRole("tab", { name: /^行过滤/ }).getAttribute("aria-selected"), "true");
|
||||
// Use the sheet nav directly: locale wording is not part of this assertion.
|
||||
await editor().locator(".ct-editor__nav [role=tab]").first().focus();
|
||||
await page.keyboard.press("End");
|
||||
assert.equal(await editor().locator(".ct-editor__nav [role=tab]").last().getAttribute("aria-selected"), "true");
|
||||
await editor().getByRole("tab", { name: /^字段规则/ }).click();
|
||||
const countRow = editor().locator(".ct-field-table tbody tr").filter({ has: page.getByText("count", { exact: true }) });
|
||||
await countRow.locator('input[type="checkbox"]').nth(2).check();
|
||||
assert.equal(await editor().getByLabel("count 方式", { exact: true }).inputValue(), "eq");
|
||||
await editor().getByRole("tab", { name: /^行过滤/ }).click();
|
||||
assert.equal(await editor().getByLabel("字段 1.1", { exact: true }).inputValue(), "external_state");
|
||||
await editor().getByLabel("过滤运算符 1.1", { exact: true }).selectOption("in");
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
await page.getByText(/^metrics\[0\].count:/).waitFor({ state: "hidden" });
|
||||
assert.ok(await editor().locator(".ct-editor__main").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
assert.ok(await editor().locator(".ct-filter-row").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
const titleBounds = await editor().getByLabel("表标题", { exact: true }).boundingBox();
|
||||
const actionsBounds = await editor().locator(".ct-editor__pane-head > .btn-row").boundingBox();
|
||||
assert.ok(titleBounds.y + titleBounds.height <= actionsBounds.y, "mobile actions must not overlap title input");
|
||||
const saveBounds = await editor().getByRole("button", { name: "保存模板", exact: true }).boundingBox();
|
||||
assert.ok(saveBounds.x >= 0 && saveBounds.x + saveBounds.width <= 391);
|
||||
await page.screenshot({ path: path.join(output, "compare-template-editor-mobile.png"), fullPage: true });
|
||||
await editor().getByRole("button", { name: "保存模板", exact: true }).click();
|
||||
await editor().waitFor({ state: "hidden" });
|
||||
const updated = writes.at(-1).metrics[1];
|
||||
assert.deepEqual(updated.row_filters[0].value, ["up", "down"]);
|
||||
assert.ok(updated.compare_fields.includes("count"));
|
||||
assert.ok(!updated.field_rules.some((r) => r.field === "count" && (r.ignore || ["ignore", "skip", "off"].includes(r.compare))));
|
||||
passed.push("negative tolerance blocks write", "keyboard rule tabs", "keyboard sheet tabs", "re-enable ignored field", "filter-only field visible", "membership converts to array", "390px layout and footer");
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
const bad = { name: "无效导入", metrics: [sheet, { ...sheet, sheet_id: "bad", key_fields: [] }] };
|
||||
const beforeImport = writes.length;
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "invalid.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(bad)) });
|
||||
await page.getByText(/metrics\[1\]/).waitFor();
|
||||
assert.equal(writes.length, beforeImport);
|
||||
const imported = { ...exported, name: "导入验收" };
|
||||
await page.locator('input[type="file"]').setInputFiles({ name: "valid.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(imported)) });
|
||||
await page.getByText("模板已导入", { exact: true }).waitFor();
|
||||
assert.equal(writes.at(-1).metrics[0].ignore_port_changes, false);
|
||||
assert.deepEqual(writes.at(-1).metrics[0].row_filters, complex);
|
||||
passed.push("invalid import never partially writes", "valid import roundtrip");
|
||||
await page.goto(`${base}/network/cutover/biz-compare`);
|
||||
await page.getByRole("row").filter({ hasText: job.name }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
await page.getByRole("tab", { name: "任务配置", exact: true }).click();
|
||||
await page.getByText(/配置中的检查项已被移除或改名,请重新选择检查范围/).waitFor();
|
||||
assert.ok(await page.getByRole("button", { name: "保存配置", exact: true }).isDisabled());
|
||||
await page.getByLabel("操作前批次", { exact: true }).locator('option[value="partial"]').waitFor({ state: "attached" });
|
||||
assert.ok(await page.getByLabel("操作前批次", { exact: true }).locator('option[value="partial"]').isDisabled());
|
||||
assert.ok(await page.getByLabel("操作后批次", { exact: true }).locator('option[value="running"]').isDisabled());
|
||||
await page.getByRole("button", { name: "启用当前全部检查项", exact: true }).click();
|
||||
const savedJob = page.waitForResponse((res) => res.request().method() === "PATCH" && res.url().endsWith("/compare/jobs/job"));
|
||||
await page.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await savedJob;
|
||||
assert.deepEqual(writes.at(-1).enabled_sheet_ids, []);
|
||||
passed.push("missing sheets block save", "incomplete sources disabled", "explicit scope repair");
|
||||
assert.deepEqual(errors, []);
|
||||
passed.push("no runtime errors");
|
||||
console.log(JSON.stringify({ passed, output }));
|
||||
} finally { await browser.close(); }
|
||||
59
web/tests/compareTemplateModel.test.mjs
Normal file
59
web/tests/compareTemplateModel.test.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { parseTemplateImport, parseIfaceNormText, toOrGroups, fromOrGroups, validateTemplateDraft } from "../src/pages/network/compareTemplateModel.ts";
|
||||
|
||||
const sheet = () => ({ metric_id: "custom", sheet_id: "s", key_fields: ["id"], iface_fields: ["port"], compare_fields: ["count"], field_rules: [], row_filters: [] });
|
||||
const template = (metrics = [sheet()]) => ({ name: "验收", metrics });
|
||||
|
||||
test("invalid second sheet rejects the complete import", () => {
|
||||
assert.throws(() => parseTemplateImport(template([sheet(), { ...sheet(), sheet_id: "s2", key_fields: [] }])), /metrics\[1\]/);
|
||||
});
|
||||
|
||||
test("all three port policies and custom fields survive import", () => {
|
||||
for (const policy of [true, false, null]) {
|
||||
const parsed = parseTemplateImport(template([{ ...sheet(), ignore_port_changes: policy }]));
|
||||
assert.equal(parsed.metrics[0].ignore_port_changes, policy);
|
||||
assert.deepEqual(parsed.metrics[0].compare_fields, ["count"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy single metric import preserves explicit false and ignored fields without mutation", () => {
|
||||
const raw = { name: "legacy", ...sheet(), ignore_port_changes: false, ignore_fields: ["count"], field_rules: [{ field: "count", compare: "numeric" }] };
|
||||
const original = structuredClone(raw);
|
||||
const parsed = parseTemplateImport(raw);
|
||||
assert.equal(parsed.metrics[0].ignore_port_changes, false);
|
||||
assert.deepEqual(parsed.metrics[0].compare_fields, []);
|
||||
assert.equal(parsed.metrics[0].field_rules[0].ignore, true);
|
||||
assert.deepEqual(raw, original);
|
||||
});
|
||||
|
||||
test("complex conditions remain intact and cannot become partial editable groups", () => {
|
||||
const filters = [{ any: [{ all: [{ any: [{ field: "a", op: "eq", value: 0 }, { field: "b", op: "eq", value: false }] }, { field: "c", op: "empty" }] }] }];
|
||||
assert.equal(toOrGroups(filters), null);
|
||||
assert.deepEqual(parseTemplateImport(template([{ ...sheet(), row_filters: filters }])).metrics[0].row_filters, filters);
|
||||
});
|
||||
|
||||
test("supported OR/AND editor roundtrip preserves false, zero and list values", () => {
|
||||
const groups = [[{ field: "a", op: "eq", value: 0 }, { field: "b", op: "eq", value: false }], [{ field: "c", op: "in", value: [0, false, "up"] }]];
|
||||
assert.deepEqual(toOrGroups(fromOrGroups(groups)), groups);
|
||||
});
|
||||
|
||||
test("import rejects unsupported format/version and malformed aliases", () => {
|
||||
assert.throws(() => parseTemplateImport({ ...template(), format: "other" }), /unsupported/);
|
||||
assert.throws(() => parseTemplateImport({ ...template(), version: 2 }), /unsupported/);
|
||||
assert.throws(() => parseTemplateImport({ ...template(), iface_normalize_rules: [{ from: "GE", to: "a" }, { from: "ge", to: "b" }] }), /duplicate_alias/);
|
||||
assert.throws(() => parseTemplateImport({ ...template(), iface_normalize_rules: [{ from: "GE" }] }), /alias_pair/);
|
||||
});
|
||||
|
||||
test("alias editor rejects bad lines instead of silently discarding them", () => {
|
||||
assert.throws(() => parseIfaceNormText("GE, xe\nbroken"), /:2:/);
|
||||
assert.throws(() => parseIfaceNormText("GE, xe\nge\tother"), /duplicate_alias/);
|
||||
assert.deepEqual(parseIfaceNormText("# aliases\nGE, xe\nEth\tport"), [{ from: "GE", to: "xe" }, { from: "Eth", to: "port" }]);
|
||||
});
|
||||
|
||||
test("invalid tolerances, modes, duplicate sheets and scalar set membership block saving", () => {
|
||||
for (const tolerance of [-1, Infinity, NaN]) assert.throws(() => validateTemplateDraft([{ ...sheet(), field_rules: [{ field: "count", compare: "numeric", tolerance }] }]), /tolerance/);
|
||||
assert.throws(() => validateTemplateDraft([{ ...sheet(), field_rules: [{ field: "count", compare: "typo" }] }]), /unknown_compare/);
|
||||
assert.throws(() => validateTemplateDraft([sheet(), sheet()]), /duplicate_sheet/);
|
||||
assert.throws(() => validateTemplateDraft([{ ...sheet(), row_filters: [{ field: "id", op: "in", value: "a,b" }] }]), /value_list/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue