netx/web/tests/bizCompareTemplates.browser.mjs

155 lines
13 KiB
JavaScript

// 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(); }