mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 22:20:50 +08:00
Optimize cutover compare/monitor per the 2026-10-12 plan.
Correctness (A): normalize port-map names with the template rules (snapshot v2, v1 replays unchanged); protect mappings/templates that are referenced; stop dropping edited mapping text; accept pulled-but-not-shutdown ports as migrated; validate baseline ownership on project create; one canonical key form; fix built-in template tolerances and upgrade old packaged values in place. Cross-vendor identity (B): single identity pipeline for every path, per-side and per-vendor interface rules, value maps, filters after normalization, mapping pre-flight for compare and cutover projects, SQL eligibility fixes. Data quality (V): no_data / suspect_parse / not_collected sheet states, command slice coverage, directional compare modes, value-sorted duplicate pairing, before-side raw names in results and CSV. Robustness (H): bounded auto-run history, background evaluate/finish with polling, manual route tables out of the acceptance gate, baseline row cache, scoped duplicate-key failures, merged HF tasks with a per-NE session cap, same-device cutover, explicit scope_by_port_map, read-only GETs, resolvable red tickets. Frontend: pre-flight report, mapping table view, template editor support for the new rules, data-state banners. Docs: canonical field contract. Not included: Huawei status parsers (no real samples), SQL-path route compare. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fff7d63b9b
commit
315539e0fd
59 changed files with 6483 additions and 487 deletions
|
|
@ -40,6 +40,8 @@ const diffs = [
|
|||
{ kind: "added", key: { neighbor: "10.2.1.3", vrf: "core" }, before: null, after: row("10.2.1.3", "Established"), changes: {} },
|
||||
{ kind: "unchanged", key: { neighbor: "10.2.1.4", vrf: "core" }, before: row("10.2.1.4", "Established"), after: row("10.2.1.4", "Established"), changes: {} },
|
||||
];
|
||||
const mappingPosts = [];
|
||||
const preflightPosts = [];
|
||||
let failDiffs = false;
|
||||
let active = false;
|
||||
let legacy = false;
|
||||
|
|
@ -79,6 +81,18 @@ await page.route("**/v1/**", async (route) => {
|
|||
} else if (p.endsWith("/runs/run-1")) {
|
||||
pollCounts.detail++;
|
||||
data = { ...run, config_snapshot_version: legacy ? null : 1, status: active ? "running" : "success" };
|
||||
} else if (p.endsWith("/compare/preflight") && route.request().method() === "POST") {
|
||||
preflightPosts.push(route.request().postDataJSON());
|
||||
data = {
|
||||
verdict: "risk", risk_reasons: ["suspect_unmapped"], coverage: { mapped: 2, suspect_unmapped: 1, identity_same_name: 0, rate: 66.7 },
|
||||
mapping: { pairs: 2, unused_map_keys: [], map_targets_missing_on_after: [], n_to_1_targets: [] },
|
||||
sheets: [{ sheet_id: "peer", title: "BGP 邻居", metric_id: "bgp_peer", before_count: 4, after_count: 4, data_state: "ok",
|
||||
counts: { mapped_exact: 2, mapped_by_parent: 0, identity_same_name: 0, suspect_unmapped: 1 }, suspect_unmapped: ["gei-7"], suspect_unmapped_total: 1 }],
|
||||
command_coverage: { only_before: [], only_after: [] },
|
||||
};
|
||||
} else if (p.endsWith("/mappings") && route.request().method() === "POST") {
|
||||
mappingPosts.push(route.request().postDataJSON());
|
||||
data = { id: "map-new", name: "端口映射", rows: route.request().postDataJSON().rows };
|
||||
} else if (p.endsWith("/mappings")) data = { items: [] };
|
||||
else data = { items: [] };
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) });
|
||||
|
|
@ -108,6 +122,49 @@ try {
|
|||
assert.deepEqual(body.enabled_sheet_ids, ["peer"]);
|
||||
assert.equal(body.before_batch_id, "b");
|
||||
assert.equal(body.after_batch_id, "a");
|
||||
// A3: mapping text typed but never "saved" must not be dropped silently.
|
||||
const mapEditor = page.locator(".bs-cmp-mapping__editor");
|
||||
const mapStatus = page.getByTestId("map-parse-status");
|
||||
await mapEditor.fill("GE0/1/0/1 gei0/2/0/1\nxge1\tgei2\nbroken line here");
|
||||
await mapStatus.getByText("已解析 2 对", { exact: false }).waitFor();
|
||||
assert.match(await mapStatus.innerText(), /无效 1 行(第 3 行)/);
|
||||
assert.match(await mapStatus.innerText(), /未保存/);
|
||||
let patched = false;
|
||||
const onPatch = (req) => { if (req.method() === "PATCH" && req.url().endsWith("/compare/jobs/job-1")) patched = true; };
|
||||
page.on("request", onPatch);
|
||||
await page.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
await page.waitForTimeout(400);
|
||||
assert.equal(patched, false, "invalid mapping text blocks saving the job");
|
||||
assert.equal(mappingPosts.length, 0);
|
||||
await mapEditor.fill("GE0/1/0/1 gei0/2/0/1\nxge1\tgei2");
|
||||
const mappedJob = page.waitForRequest((req) => req.method() === "PATCH" && req.url().endsWith("/compare/jobs/job-1"));
|
||||
await page.getByRole("button", { name: "保存配置", exact: true }).click();
|
||||
assert.equal((await mappedJob).postDataJSON().mapping_id, "map-new");
|
||||
page.off("request", onPatch);
|
||||
assert.deepEqual(mappingPosts[0].rows, [
|
||||
{ before_if: "GE0/1/0/1", after_if: "gei0/2/0/1" }, { before_if: "xge1", after_if: "gei2" }]);
|
||||
// B5: the pre-flight checks the editor text (saved or not) against the two batches.
|
||||
await page.getByRole("button", { name: "校验映射", exact: true }).click();
|
||||
const pf = page.getByTestId("preflight-report");
|
||||
await pf.waitFor();
|
||||
assert.deepEqual(preflightPosts.at(-1).mapping_rows, [
|
||||
{ before_if: "GE0/1/0/1", after_if: "gei0/2/0/1" }, { before_if: "xge1", after_if: "gei2" }]);
|
||||
assert.equal(preflightPosts.at(-1).before_batch_id, "b");
|
||||
assert.equal(preflightPosts.at(-1).after_batch_id, "a");
|
||||
assert.match(await pf.textContent(), /疑似漏配/);
|
||||
await pf.getByRole("button", { name: "加入映射编辑区(目标待填)", exact: true }).click();
|
||||
assert.match(await mapEditor.inputValue(), /gei-7,/);
|
||||
// F1: table view edits the same text.
|
||||
await mapEditor.fill("a1,b1\na1,b2\nc1,");
|
||||
await page.getByRole("button", { name: "表格", exact: true }).click();
|
||||
const tbl = page.getByTestId("mapping-table");
|
||||
await tbl.waitFor();
|
||||
assert.equal(await tbl.locator("tbody tr.is-duplicate").count(), 2);
|
||||
assert.equal(await tbl.locator("tbody tr.is-todo").count(), 1);
|
||||
await tbl.getByLabel("操作后接口 3", { exact: true }).fill("d1");
|
||||
await page.getByRole("button", { name: "文本", exact: true }).click();
|
||||
assert.equal(await mapEditor.inputValue(), "a1,b1\na1,b2\nc1,d1");
|
||||
await mapEditor.fill("");
|
||||
await page.getByLabel("模式", { exact: true }).selectOption("auto");
|
||||
assert.equal(await page.getByLabel("操作后批次", { exact: true }).count(), 0);
|
||||
await page.getByText("保持操作前批次固定;操作后任务采集完成时,自动取最新批次进行对比。", { exact: true }).waitFor();
|
||||
|
|
@ -209,6 +266,25 @@ try {
|
|||
await page.getByLabel("接口对应关系", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
|
||||
// V1/V5: a side that parsed nothing is flagged, and the before-side name is shown as printed.
|
||||
const card = run.summary.sheet_cards[0];
|
||||
Object.assign(card, { data_state: "suspect_parse", data_state_reasons: ["after:parsed_0_of_40_lines"] });
|
||||
diffs[0].raw_key_before = { neighbor: "GE0/1", vrf: "core" };
|
||||
diffs[0].key = { neighbor: "gei0/1", vrf: "core" };
|
||||
await page.reload();
|
||||
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
await page.getByRole("tab", { name: "比对结果", exact: true }).click();
|
||||
await page.locator(".bs-cmp-row").first().waitFor();
|
||||
const banner = page.getByTestId("data-state-banner");
|
||||
await banner.waitFor();
|
||||
assert.match(await banner.textContent(), /疑似解析失败/);
|
||||
assert.match(await banner.textContent(), /parsed_0_of_40_lines/);
|
||||
assert.match(await page.locator(".bs-cmp-nav__item").first().textContent(), /疑似解析失败/);
|
||||
assert.match(await page.locator(".cx-rawkey").first().textContent(), /GE0\/1/);
|
||||
Object.assign(card, { data_state: "ok", data_state_reasons: [] });
|
||||
delete diffs[0].raw_key_before;
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
|
||||
active = true;
|
||||
await page.reload();
|
||||
await page.getByRole("row").filter({ hasText: "割接前后 · 业务验收" }).getByRole("button", { name: "详情", exact: true }).click();
|
||||
|
|
@ -218,7 +294,7 @@ try {
|
|||
assert.ok(pollCounts.runs <= 3 && pollCounts.detail <= 3, JSON.stringify(pollCounts));
|
||||
assert.ok(pollCounts.runs >= 2 && pollCounts.detail >= 2, "active run should keep polling");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ passed: ["job list labels", "four config sections", "source pairing", "last sheet locked", "configuration save payload", "automatic mode", "390px configuration", "keyboard tabs", "wizard source selection", "same-task batch selection", "source change clears batch", "history counts", "execution and verdict separation", "queued progress semantics", "history loading", "history error retry", "390px history", "result layout", "saved execution settings", "legacy settings warning", "error clears stale rows", "retry", "clear filters", "768px toolbar", "old job response ignored", "single poller", "no runtime errors"], pollCounts, output }));
|
||||
console.log(JSON.stringify({ passed: ["job list labels", "four config sections", "source pairing", "last sheet locked", "configuration save payload", "automatic mode", "390px configuration", "keyboard tabs", "wizard source selection", "same-task batch selection", "source change clears batch", "history counts", "execution and verdict separation", "queued progress semantics", "history loading", "history error retry", "390px history", "result layout", "saved execution settings", "legacy settings warning", "error clears stale rows", "retry", "clear filters", "768px toolbar", "old job response ignored", "single poller", "mapping pre-flight", "mapping table view", "data state banner", "raw before key", "no runtime errors"], pollCounts, output }));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(output, "biz-compare-test-failure.png"), fullPage: true });
|
||||
console.error(JSON.stringify(await page.locator(".bs-cmp-source select").evaluateAll((els) => els.map((el) => ({ value: el.value, html: el.parentElement.outerHTML })))));
|
||||
|
|
|
|||
|
|
@ -149,6 +149,45 @@ try {
|
|||
await savedJob;
|
||||
assert.deepEqual(writes.at(-1).enabled_sheet_ids, []);
|
||||
passed.push("missing sheets block save", "incomplete sources disabled", "explicit scope repair");
|
||||
// Cross-vendor identity rules: value maps, directional compare, per-side aliases and sheet options
|
||||
// must reach the API exactly as edited.
|
||||
await page.goto(`${base}/network/cutover/compare-templates`);
|
||||
await templateRow().waitFor();
|
||||
await openEditor();
|
||||
await editor().locator("details.ct-editor__norm summary").click();
|
||||
await editor().getByLabel("接口归一化", { exact: true }).fill("GE,gei\nEth-Trunk,ae,after,huawei");
|
||||
await editor().getByRole("tab", { name: /^字段规则/ }).click();
|
||||
const stateRow = editor().locator(".ct-field-table tbody tr").filter({ has: page.getByText("state", { exact: true }) });
|
||||
await stateRow.locator('input[type="checkbox"]').nth(2).check();
|
||||
await editor().getByLabel("state 方式", { exact: true }).selectOption("not_worse");
|
||||
await editor().getByLabel("state 好→坏顺序", { exact: true }).fill("up,down");
|
||||
await editor().getByLabel("state 值映射", { exact: true }).fill("estab,established\n*down,down");
|
||||
await editor().getByLabel("state 值映射", { exact: true }).blur();
|
||||
await editor().getByLabel("state 值映射范围", { exact: true }).selectOption("before");
|
||||
await editor().getByLabel("state 值映射", { exact: true }).fill("up/up,up");
|
||||
await editor().getByLabel("state 值映射", { exact: true }).blur();
|
||||
await editor().getByLabel("未映射端口", { exact: true }).selectOption("ignore_port");
|
||||
await editor().getByLabel("同键多行配对", { exact: true }).selectOption("ordered");
|
||||
await editor().getByRole("checkbox", { name: /内置厂商接口名称统一/ }).uncheck();
|
||||
await editor().getByRole("button", { name: "保存模板", exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
const saved = writes.at(-1);
|
||||
assert.deepEqual(saved.iface_normalize_rules, [{ from: "GE", to: "gei" }, { from: "Eth-Trunk", to: "ae", side: "after", vendor: "huawei" }]);
|
||||
const sheetS = saved.metrics.find((m) => m.sheet_id === "s");
|
||||
const stateRules = sheetS.field_rules.filter((r) => r.field === "state");
|
||||
assert.deepEqual(stateRules.find((r) => !r.side), { field: "state", compare: "not_worse", rank: ["up", "down"], value_map: { estab: "established", "*down": "down" } });
|
||||
assert.deepEqual(stateRules.find((r) => r.side === "before"), { field: "state", side: "before", value_map: { "up/up": "up" } });
|
||||
assert.equal(sheetS.unmapped_port_policy, "ignore_port");
|
||||
assert.equal(sheetS.duplicate_pairing, "ordered");
|
||||
assert.equal(sheetS.builtin_vendor_canonical, false);
|
||||
assert.deepEqual(sheetS.row_filters, complex, "opaque filters survive the same save");
|
||||
passed.push("value map and rank saved", "per-side value map saved", "per-side alias saved", "sheet identity options saved");
|
||||
// ... and come back unchanged when the template is opened again.
|
||||
await openEditor();
|
||||
await editor().getByRole("tab", { name: /^字段规则/ }).click();
|
||||
assert.equal(await editor().getByLabel("state 值映射", { exact: true }).inputValue(), "estab,established\n*down,down");
|
||||
assert.equal(await editor().getByLabel("未映射端口", { exact: true }).inputValue(), "ignore_port");
|
||||
await page.keyboard.press("Escape");
|
||||
assert.deepEqual(errors, []);
|
||||
passed.push("no runtime errors");
|
||||
console.log(JSON.stringify({ passed, output }));
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const browser = await chromium.launch({ headless: true, channel: process.env.NET
|
|||
const page = await browser.newPage({ viewport: { width: 1440, height: 1080 } });
|
||||
const errors = [], passed = [], requests = [], writes = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "", sampleAgeSec = 0;
|
||||
let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "", sampleAgeSec = 0, richCards = false, evalPolls = 0;
|
||||
const now = new Date().toISOString();
|
||||
const project = { id: "p", name: "核心 PE 替换", old_task_id: "op", new_task_id: "np", old_hf_task_id: "oh", new_hf_task_id: "nh", old_baseline_batch_id: "ob", new_baseline_batch_id: "nb", monitor_template_id: "mt", hf_interval_sec: 60, status: "active", created_at: now,
|
||||
collect_metric_ids_effective: ["arp"], old_task: { id: "op", ne_name: "PE-OLD", ne_ip: "10.0.0.1" }, new_task: { id: "np", ne_name: "PE-NEW", ne_ip: "10.0.0.2" } };
|
||||
|
|
@ -25,7 +25,14 @@ function run(rid, pid = "p") {
|
|||
const sampleTime = sampleAgeSec ? new Date(Date.now() - sampleAgeSec * 1000).toISOString() : now;
|
||||
return { id: rid, purpose: rid === "hist" ? "manual" : "auto", created_at: now,
|
||||
summary: { progress: { ok: pid === "p2" ? 3 : 99, total: pid === "p2" ? 3 : 101 }, anomaly: 1, anomaly_outside_expect: 1, coverage_complete: !uncertain, duplicate_keys: uncertain ? 2 : 0,
|
||||
sheet_cards: sheets.map((s) => ({ ...s, progress_ok: 49, progress_total: 50, anomaly: 0, old_collected_at: sampleTime, new_collected_at: sampleTime, age_limit_sec: 180, sample_issues: uncertain ? ["sample_skew"] : [] })) } };
|
||||
duplicate_warnings: richCards ? [{ sheet_id: "isis", title: "ISIS 邻居", count: 2, examples: ["a|b"] }] : [],
|
||||
sheet_cards: [
|
||||
...sheets.map((s) => ({ ...s, progress_ok: 49, progress_total: 50, anomaly: 0, old_collected_at: sampleTime, new_collected_at: sampleTime, age_limit_sec: 180, sample_issues: uncertain ? ["sample_skew"] : [] })),
|
||||
...(richCards ? [
|
||||
{ sheet_id: "routes", metric_id: "bgp_route", title: "BGP 路由", manual_pending: true, progress_ok: 0, progress_total: 0, anomaly: 0 },
|
||||
{ sheet_id: "v6", metric_id: "bgp_peer", title: "BGP IPv6", not_collected: true, data_state: "not_collected", data_state_reasons: ["new:slice_not_collected"], progress_ok: 0, progress_total: 0, anomaly: 0 },
|
||||
] : []),
|
||||
] } };
|
||||
}
|
||||
await page.route("**/v1/**", async (route) => {
|
||||
const req = route.request(), url = new URL(req.url()), p = url.pathname;
|
||||
|
|
@ -38,6 +45,20 @@ await page.route("**/v1/**", async (route) => {
|
|||
project.collect_metric_ids_effective = project.collect_metric_ids;
|
||||
data = project;
|
||||
}
|
||||
else if (req.method() === "POST" && p.endsWith("/batches/wave/evaluate")) { evalPolls = 0; data = { id: "async-run", status: "running", purpose: "manual", summary: { progress: { phase: "queued" } } }; }
|
||||
else if (p.endsWith("/runs/async-run")) {
|
||||
evalPolls += 1;
|
||||
data = evalPolls < 3
|
||||
? { id: "async-run", status: "running", summary: { progress: { phase: "evaluating", sheet_index: 1, sheet_total: 2, sheet_title: "ARP · 公网" } } }
|
||||
: { ...run("async-run"), status: "success" };
|
||||
}
|
||||
else if (p.endsWith("/p/preflight")) data = {
|
||||
verdict: "risk", risk_reasons: ["suspect_unmapped"], coverage: { mapped: 2, suspect_unmapped: 1, identity_same_name: 0, rate: 66.7 },
|
||||
mapping: { pairs: 2, unused_map_keys: ["gei-9"], map_targets_missing_on_after: [], n_to_1_targets: [] },
|
||||
sheets: [{ sheet_id: "ifb", title: "端口状态", metric_id: "interface_brief", before_count: 3, after_count: 2, data_state: "ok",
|
||||
counts: { mapped_exact: 2, mapped_by_parent: 0, identity_same_name: 0, suspect_unmapped: 1 }, suspect_unmapped: ["gei-7"], suspect_unmapped_total: 1 }],
|
||||
command_coverage: { only_before: ["bgp_peer{afi=ipv4}"], only_after: [] },
|
||||
};
|
||||
else if (p.endsWith("/p/ensure-highfreq")) data = { project, hf_status: "running", collect: { old: [], new: [] } };
|
||||
else if (req.method() === "POST" && p.endsWith("/p/batches")) { writes.push(req.postDataJSON()); data = { ...wave, id: "created-wave", ...req.postDataJSON() }; }
|
||||
else if (p.endsWith("/biz-migration/projects")) data = { items: [project, second] };
|
||||
|
|
@ -197,6 +218,23 @@ try {
|
|||
assert.equal(await table().locator("tbody tr").count(), 100);
|
||||
passed.push("refresh failure keeps snapshot with explicit error");
|
||||
failBoard = false;
|
||||
// H2: evaluation is computed in the background; the page polls the run and shows progress.
|
||||
const evalPost = page.waitForRequest((r) => r.method() === "POST" && r.url().endsWith("/batches/wave/evaluate"));
|
||||
await dialog().getByRole("button", { name: "立即判定", exact: true }).first().click();
|
||||
await evalPost;
|
||||
await dialog().getByTestId("eval-progress").waitFor();
|
||||
assert.match(await dialog().getByTestId("eval-progress").textContent(), /1\/2|排队/);
|
||||
await dialog().getByTestId("eval-progress").waitFor({ state: "detached", timeout: 15000 });
|
||||
assert.ok(requests.filter((r) => r.p.endsWith("/runs/async-run")).length >= 3, "run polled until it settled");
|
||||
passed.push("evaluation polls a background run", "progress shown while the run computes");
|
||||
// H3/V2/H5 states on the board.
|
||||
richCards = true;
|
||||
await dialog().getByRole("button", { name: "刷新看板", exact: true }).click();
|
||||
await dialog().getByTestId("manual-pending-banner").waitFor();
|
||||
assert.match(await dialog().getByTestId("not-collected-banner").textContent(), /BGP IPv6/);
|
||||
assert.match(await dialog().getByTestId("duplicate-warning-banner").textContent(), /ISIS 邻居/);
|
||||
richCards = false;
|
||||
passed.push("manual route table is pending, not failed", "uncollected slice is not judged", "out-of-scope duplicate keys are warnings");
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
assert.ok(await dialog().locator(".bm-live-kpis").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
assert.ok(await dialog().locator(".bm-board").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
|
|
@ -215,6 +253,14 @@ try {
|
|||
await page.waitForTimeout(500);
|
||||
assert.deepEqual(writes.at(-1).collect_metric_ids, ['arp']);
|
||||
passed.push("route can be selected from comparison items", "route has manual mode instead of periodic interval", "dynamic metric additions saved explicitly", "removed metric no longer selected");
|
||||
// B5: mapping pre-flight on the portrait baselines.
|
||||
await dialog().getByRole("button", { name: "映射预检", exact: true }).click();
|
||||
const report = dialog().getByTestId("preflight-report");
|
||||
await report.waitFor();
|
||||
assert.match(await report.textContent(), /疑似漏配/);
|
||||
assert.match(await report.textContent(), /gei-7/);
|
||||
assert.match(await report.textContent(), /bgp_peer\{afi=ipv4\}/);
|
||||
passed.push("pre-flight lists suspected unmapped ports and collection differences");
|
||||
await dialog().getByRole("button", { name: "割接批次", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.bm-expect-table tbody tr').length === 100);
|
||||
await dialog().getByRole("checkbox", { name: "ARP · 公网 业务 0", exact: true }).check();
|
||||
|
|
@ -243,6 +289,7 @@ try {
|
|||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ passed, errors }, null, 2));
|
||||
} catch (error) {
|
||||
console.log("FAILED:", String(error.message).split("\n").slice(0, 6).join(" | "));
|
||||
await page.screenshot({ path: path.join(output, "cutover-monitor-failure.png"), fullPage: true });
|
||||
console.log(JSON.stringify(await page.locator('.bm-board').evaluate((el) => {
|
||||
const layout = [];
|
||||
|
|
|
|||
|
|
@ -57,3 +57,53 @@ test("invalid tolerances, modes, duplicate sheets and scalar set membership bloc
|
|||
assert.throws(() => validateTemplateDraft([sheet(), sheet()]), /duplicate_sheet/);
|
||||
assert.throws(() => validateTemplateDraft([{ ...sheet(), row_filters: [{ field: "id", op: "in", value: "a,b" }] }]), /value_list/);
|
||||
});
|
||||
|
||||
import { sheetPayload, formatIfaceNormText, parseValueMapText, formatValueMapText } from "../src/features/cutover/lib/compareTemplateModel.ts";
|
||||
|
||||
test("sheet options survive import and the save payload", () => {
|
||||
const parsed = parseTemplateImport(template([{ ...sheet(), unmapped_port_policy: "ignore_port", duplicate_pairing: "ordered", builtin_vendor_canonical: false }]));
|
||||
const s = parsed.metrics[0];
|
||||
assert.equal(s.unmapped_port_policy, "ignore_port");
|
||||
assert.equal(s.duplicate_pairing, "ordered");
|
||||
assert.equal(s.builtin_vendor_canonical, false);
|
||||
const body = sheetPayload(s);
|
||||
assert.equal(body.unmapped_port_policy, "ignore_port");
|
||||
assert.equal(body.builtin_vendor_canonical, false);
|
||||
assert.equal("duplicate_pairing" in sheetPayload({ ...s, duplicate_pairing: "" }), false);
|
||||
});
|
||||
|
||||
test("alias rules keep side and vendor and may repeat per scope", () => {
|
||||
const rules = parseIfaceNormText("GE,gei\nGE,GigabitEthernet,before\nEth-Trunk,ae,after,huawei");
|
||||
assert.deepEqual(rules, [{ from: "GE", to: "gei" }, { from: "GE", to: "GigabitEthernet", side: "before" }, { from: "Eth-Trunk", to: "ae", side: "after", vendor: "huawei" }]);
|
||||
assert.deepEqual(parseIfaceNormText(formatIfaceNormText(rules)), rules);
|
||||
assert.throws(() => parseIfaceNormText("GE,gei\nge,x"), /duplicate_alias/);
|
||||
assert.throws(() => parseIfaceNormText("GE,gei,middle"), /unknown_alias_side/);
|
||||
});
|
||||
|
||||
test("a vendor-scoped alias with no side survives the text round trip", () => {
|
||||
const rules = parseIfaceNormText("Eth-Trunk,ae,both,huawei");
|
||||
assert.deepEqual(rules, [{ from: "Eth-Trunk", to: "ae", vendor: "huawei" }]);
|
||||
assert.equal(formatIfaceNormText(rules), "Eth-Trunk,ae,both,huawei");
|
||||
});
|
||||
|
||||
test("value maps parse, format and are bounded", () => {
|
||||
const map = parseValueMapText("estab,established\n# note\n*down,down");
|
||||
assert.deepEqual(map, { estab: "established", "*down": "down" });
|
||||
assert.deepEqual(parseValueMapText(formatValueMapText(map)), map);
|
||||
assert.throws(() => parseValueMapText("lonely"), /pair_required/);
|
||||
assert.throws(() => parseValueMapText(Array.from({ length: 501 }, (_, i) => `a${i},b`).join("\n")), /too_large/);
|
||||
});
|
||||
|
||||
test("directional compare modes need a rank and a field can carry one rule per side", () => {
|
||||
const base = sheet();
|
||||
assert.throws(() => validateTemplateDraft([{ ...base, field_rules: [{ field: "state", compare: "not_worse" }] }]), /rank_list_required/);
|
||||
validateTemplateDraft([{ ...base, field_rules: [{ field: "state", compare: "not_worse", rank: ["up", "down"] }, { field: "count", compare: "numeric_min", tolerance: 5 }] }]);
|
||||
validateTemplateDraft([{ ...base, field_rules: [{ field: "state", side: "before", value_map: { a: "b" } }, { field: "state", side: "after", value_map: { c: "d" } }] }]);
|
||||
assert.throws(() => validateTemplateDraft([{ ...base, field_rules: [{ field: "state", value_map: { a: "b" } }, { field: "state", value_map: { c: "d" } }] }]), /duplicate_field/);
|
||||
assert.throws(() => validateTemplateDraft([{ ...base, field_rules: [{ field: "state", value_map: { a: 1 } }] }]), /string_pairs/);
|
||||
});
|
||||
|
||||
test("filter side survives the editor round trip", () => {
|
||||
const groups = toOrGroups([{ field: "type", op: "eq", value: "dynamic", side: "before" }]);
|
||||
assert.deepEqual(fromOrGroups(groups), [{ field: "type", op: "eq", value: "dynamic", side: "before" }]);
|
||||
});
|
||||
|
|
|
|||
30
web/tests/mappingText.test.mjs
Normal file
30
web/tests/mappingText.test.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { parseMapText, mapTextDirty } from "../src/features/cutover/lib/mappingText.ts";
|
||||
|
||||
test("comma, pipe, tab and exactly-two-token space lines are all accepted", () => {
|
||||
const r = parseMapText("a1,b1\na2|b2\na3\tb3\na4 b4\n# note\n\nbefore_if,after_if");
|
||||
assert.deepEqual(r.rows.map((x) => [x.before_if, x.after_if]), [["a1", "b1"], ["a2", "b2"], ["a3", "b3"], ["a4", "b4"]]);
|
||||
assert.deepEqual(r.invalidLines, []);
|
||||
});
|
||||
|
||||
test("unreadable lines are reported with their line numbers instead of dropped", () => {
|
||||
const r = parseMapText("a1,b1\nonlyone\na,b,c\na5 b5 c5\nok1,ok2");
|
||||
assert.deepEqual(r.invalidLines, [2, 3, 4]);
|
||||
assert.equal(r.rows.length, 2);
|
||||
});
|
||||
|
||||
test("repeated before port is flagged and the first pair is kept", () => {
|
||||
const r = parseMapText("a,b\na,c");
|
||||
assert.deepEqual(r.duplicateBefore, ["a"]);
|
||||
assert.deepEqual(r.rows, [{ before_if: "a", after_if: "b" }]);
|
||||
});
|
||||
|
||||
test("dirty check ignores order and spacing but sees edits and invalid text", () => {
|
||||
const saved = [{ before_if: "a", after_if: "b" }, { before_if: "c", after_if: "d" }];
|
||||
assert.equal(mapTextDirty("c , d\na,b", saved), false);
|
||||
assert.equal(mapTextDirty("a,b", saved), true);
|
||||
assert.equal(mapTextDirty("a,b\nc,d\nbroken", saved), true);
|
||||
assert.equal(mapTextDirty("", undefined), false);
|
||||
assert.equal(mapTextDirty("x,y", undefined), true);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue