# -*- coding: utf-8 -*- """probe: demo 二级字典项录入链路(父项下拉填充 / 分组联动刷新 / 保存 / 两级树 / 编辑回显)""" import json, re, time, pathlib, sys, io, urllib.request, urllib.parse sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') from playwright.sync_api import sync_playwright ROOT = pathlib.Path(__file__).resolve().parent DEMO = ROOT / "demo" / "index.html" API = "http://localhost:8080" TS = time.strftime("%H%M%S") P_CODE, P_NAME = f"e2d_probe_p_{TS}", f"e2d-探针父组-{TS}" Q_CODE, Q_NAME = f"e2d_probe_q_{TS}", f"e2d-探针空组-{TS}" def step(n, ok, note=""): print(("PASS " if ok else "FAIL ") + n + (" | " + note if note else "")) return bool(ok) tok = json.loads(urllib.request.urlopen( f"{API}/api/auth/debug/token?userId=739564171091247104", timeout=10).read().decode())["data"] def api_get(url): req = urllib.request.Request(url) req.add_header("Authorization", "Bearer " + tok) with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read().decode()) def api_post(url, data=None): body = urllib.parse.urlencode(data or {}).encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") req.add_header("Authorization", "Bearer " + tok) with urllib.request.urlopen(req, timeout=20) as r: return json.loads(r.read().decode()) def opt_val(page, sel, pat): pairs = page.eval_on_selector_all(sel + " option", "os => os.map(o => [o.value, o.textContent])") for v, t in pairs: if re.search(pat, t): return v return "" def del_item(iid): try: api_post(f"{API}/api/dict/item/delete?id={iid}", {}) except Exception: api_post(f"{API}/api/dict/item/force-delete?id={iid}", {}) console_errors, page_errors = [], [] ok_all = True with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.set_default_timeout(20000) page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None) page.on("pageerror", lambda e: page_errors.append(str(e))) page.on("dialog", lambda d: d.accept()) page.goto(DEMO.as_uri() + "?api=" + API) page.wait_for_load_state("networkidle") page.click("button:has-text('uid 登录')") page.wait_for_selector(".toast-item:has-text('登录成功')", timeout=15000) # 建父组 P + 空组 Q for code, name in ((P_CODE, P_NAME), (Q_CODE, Q_NAME)): page.click("button:has-text('+ 新建分组')") page.wait_for_selector("#fgName") page.fill("#fgName", name); page.fill("#fgCode", code) page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('分组保存成功')", timeout=15000) page.wait_for_timeout(400) # 字典项 tab,切到父组 P page.click("nav.tabs button[data-tab='item']") page.wait_for_selector("#iGroup option", state="attached", timeout=15000) p_id = opt_val(page, "#iGroup", re.escape(P_NAME)) page.select_option("#iGroup", p_id) page.wait_for_timeout(400) # 弹窗1:组内尚无一级项 → 父项下拉只有「(无,作为一级项)」 page.click("button:has-text('+ 新建字典项')") page.wait_for_selector("#fiName") page.select_option("#fiGroup", p_id) page.wait_for_timeout(300) n0 = page.eval_on_selector_all("#fiParent option", "os => os.length") ok_all &= step("空组新建弹窗父项下拉仅「无」", n0 == 1, f"options={n0}") # 建一级项 P1(不选父项) page.fill("#fiName", "e2d-父级P1"); page.fill("#fiValue", "pv1"); page.fill("#fiCode", "p1") page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000) page.wait_for_timeout(600) # 弹窗2:父项下拉已填充同组一级项 P1 → 选中 → 建二级项 C1 page.click("button:has-text('+ 新建字典项')") page.wait_for_selector("#fiName") page.select_option("#fiGroup", p_id) page.wait_for_timeout(300) par_val = opt_val(page, "#fiParent", "e2d-父级P1") ok_all &= step("新建弹窗父项下拉填充同组一级项", bool(par_val), f"parent option id={par_val}") page.select_option("#fiParent", par_val) page.fill("#fiName", "e2d-子级C1"); page.fill("#fiValue", "cv1") page.click(".dfoot button.ok") page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000) ok_all &= step("二级项保存(parentId=同组一级项)", True, f"parent_id={par_val}") page.wait_for_timeout(600) # 两级树端点断言:顶层 P1 节点 children 含 C1 resp = api_get(f"{API}/api/dict/item/tree?groupCode={P_CODE}") nodes = resp.get("data") or [] p1node = next((n for n in nodes if n.get("code") == "p1"), None) kids = (p1node or {}).get("children") or [] hit = any(c.get("name") == "e2d-子级C1" and str(c.get("parentId")) == str(par_val) for c in kids) ok_all &= step("两级树端点 children 含二级项", hit, f"顶层{len(nodes)}个 / P1子项{len(kids)}个") # 联动:弹窗内切换分组 → 父项下拉刷新(空组 Q 仅剩「无」,切回 P 恢复 P1) page.click("button:has-text('+ 新建字典项')") page.wait_for_selector("#fiName") page.select_option("#fiGroup", p_id) page.wait_for_timeout(200) q_id = opt_val(page, "#fiGroup", re.escape(Q_NAME)) page.select_option("#fiGroup", q_id) page.wait_for_timeout(400) nq = page.eval_on_selector_all("#fiParent option", "os => os.length") ok_all &= step("切换到空组后父项下拉联动刷新", nq == 1, f"Q组options={nq}") page.select_option("#fiGroup", p_id) page.wait_for_timeout(400) back = opt_val(page, "#fiParent", "e2d-父级P1") ok_all &= step("切回原组父项下拉恢复一级项", bool(back), "") page.click(".dfoot button:has-text('取消')") page.wait_for_timeout(300) # 编辑二级项 C1:父项回显选中 P1 c_row = page.locator("#iBody tr:has-text('e2d-子级C1')") c_id = re.search(r"dlgItem\('(\d+)'", c_row.locator("td.ops button:has-text('编辑')").get_attribute("onclick")).group(1) page.click(f"#iBody button[onclick*=\"dlgItem('{c_id}')\"]") page.wait_for_selector("#fiName") page.wait_for_selector(f"#fiParent option[value='{par_val}']", state="attached", timeout=10000) sel_val = page.eval_on_selector("#fiParent", "e => e.value") ok_all &= step("编辑二级项父项回显选中", sel_val == par_val, f"selected={sel_val}") page.click(".dfoot button:has-text('取消')") page.wait_for_timeout(300) # 清理:删 C1 → P1 → 组 P/Q del_item(c_id) p1_id = par_val del_item(p1_id) for gid in (p_id, q_id): try: api_post(f"{API}/api/dict/group/delete?id={gid}", {}) except Exception as ex: print("组删除失败:", gid, ex) page.screenshot(path=str(ROOT / "shots" / f"probe-parent-{TS}.png")) browser.close() ok_all &= (len(console_errors) == 0 and len(page_errors) == 0) step("console/page error 零", len(console_errors) == 0 and len(page_errors) == 0, f"console={console_errors[:2]} page={page_errors[:2]}") print("ALL_PASS =", ok_all)