""" Quant-UX export consistency verifier. Runs the exported HTML's embedded rendering engine inside a QuickJS context (with a minimal DOM stub) and checks that every interactive behavior that exists in Quant-UX online is present and working: * runtime: engine executes without errors * render: the start screen renders its widgets * wiring: click/dblclick/mouseover flow lines are attached * navigation: clicking a wired widget changes screen * controls: inputs editable, checkbox/radio/switch toggle, dropdown options * charts/table: SVG chart + HTML table rendered * repeater: template repeated > 1 times * animation: screen transition applied when configured * coverage: widget types classified supported / unsupported CLI: python quantux_verify.py --file export.html python quantux_verify.py --app [--base URL] [--email X] [--password Y] """ import argparse import json import os import re from quickjs import Context from quantux_client import QuantUXClient # Widget types the renderer handles (incomplete support listed separately). SUPPORTED_TYPES = { "Box", "Label", "Button", "TextBox", "Password", "TextArea", "Image", "Icon", "HotSpot", "CheckBox", "RadioBox", "Switch", "ToggleButton", "SegmentButton", "SegmentPicker", "DropDown", "BarChart", "LineChart", "PieChart", "RingChart", "MultiRingChart", "StackedRingChart", "Table", "DataTable", "RadioTable", "ProgressBar", "Rating", "Stepper", "HSlider", "VolumeSlider", "LockSlider", "IFrameWidget", "QDate", "QDateDropDown", "NavBar", "NavMenu", "VerticalNavigation", "Tree", "SortableList", "Repeater", "DataList", } PARTIAL_TYPES = { "MultiRingChart": "近似渲染为环形图", "StackedRingChart": "近似渲染为环形图", "Repeater": "静态行×列重复(数据绑定需后端)", "DataList": "静态行×列重复(数据绑定需后端)", "Tree": "基础列表渲染", "SortableList": "基础列表渲染", } # --------------------------------------------------------------------------- # DOM stub + verification harness (plain JS, no external deps). # The engine runs inside this environment; results are collected into # window.__VERIFY__. # --------------------------------------------------------------------------- HARNESS = r""" var __els = []; var __nsEls = []; var __errors = []; function makeClassList() { var set = {}; return { add: function (c) { set[c] = true; }, remove: function (c) { delete set[c]; }, toggle: function (c) { if (set[c]) { delete set[c]; } else { set[c] = true; } }, contains: function (c) { return !!set[c]; }, _set: set }; } function makeEl() { var el = { style: {}, dataset: {}, children: [], _listeners: {}, _attrs: {}, classList: makeClassList(), appendChild: function (c) { el.children.push(c); if (!el.firstChild) el.firstChild = c; return c; }, addEventListener: function (t, fn) { (el._listeners[t] = el._listeners[t] || []).push(fn); }, setAttribute: function (k, v) { el._attrs[k] = v; if (k === "class") el.className = v; }, getAttribute: function (k) { return el._attrs[k]; }, set textContent(v) { el._text = v; }, get textContent() { return el._text || ""; }, set innerHTML(v) { el.children = []; el.firstChild = null; el._html = v; }, get innerHTML() { return el._html || ""; }, set className(v) { el.classList._set[v] = true; el._cls = v; }, get className() { return el._cls || ""; } }; return el; } var stage = makeEl(); var nameEl = makeEl(); var window = { innerWidth: 1920, innerHeight: 1080, addEventListener: function () {}, __VERIFY__: null }; var document = { getElementById: function (id) { return id === "stage" ? stage : (id === "screenName" ? nameEl : makeEl()); }, createElement: function () { var el = makeEl(); __els.push(el); return el; }, createElementNS: function () { var el = makeEl(); __nsEls.push(el); return el; } }; function requestAnimationFrame(fn) { fn(); } function collect(root, out) { out = out || []; root.children.forEach(function (c) { out.push(c); collect(c, out); }); return out; } var verify = {}; function fakeEvent() { return { stopPropagation: function () {}, preventDefault: function () {}, stopImmediatePropagation: function () {} }; } var __runVerify = function () { var model = window.QUX_MODEL || {}; var errors = __errors; try { var all = collect(stage); var widgets = all.filter(function (el) { return el.style && el.style.left !== undefined && el.style.width !== undefined; }); verify.widgetCount = widgets.length; verify.layerOk = stage.children.length === 1 && !!stage.firstChild && !!stage.firstChild.firstChild && stage.firstChild.firstChild.className.indexOf("qux-screen") >= 0; verify.hasLines = !!model.lines && Object.keys(model.lines).length > 0; // 只点击"真正由连线驱动"的元素(跳过 CheckBox/Switch 等自交互控件) var lineFrom = {}; if (verify.hasLines) { for (var k in model.lines) { lineFrom[model.lines[k].from] = true; } } var navWired = all.filter(function (el) { return el.dataset && el.dataset.id && lineFrom[el.dataset.id]; }); verify.wiredCount = navWired.length; if (verify.hasLines && navWired.length) { var before = nameEl.textContent; navWired[0]._listeners.click[0](fakeEvent()); var after = nameEl.textContent; verify.navigation = before !== after; verify.navFrom = before; verify.navTo = after; } else { verify.navigation = !verify.hasLines; } var inputs = all.filter(function (el) { return el.type === "text" || el.type === "password" || el.type === "date"; }); var editable = inputs.filter(function (el) { return !el.readOnly; }); verify.inputs = inputs.length; verify.inputsEditable = editable.length === inputs.length; verify.checks = all.filter(function (el) { return el.classList.contains("qux-check"); }).length; verify.radios = all.filter(function (el) { return el.classList.contains("qux-radio"); }).length; verify.switches = all.filter(function (el) { return el.classList.contains("qux-switch"); }).length; verify.selects = all.filter(function (el) { return el.classList.contains("qux-select"); }).length; verify.charts = __nsEls.filter(function (el) { return el._attrs.class === "qchart" || el._attrs["class"] === "qchart"; }).length; verify.tables = all.filter(function (el) { return el.classList.contains("qtable"); }).length; verify.progress = all.filter(function (el) { return el.classList.contains("qprogress"); }).length; verify.rating = all.filter(function (el) { return el.classList.contains("qstars"); }).length; verify.stepper = all.filter(function (el) { return el.classList.contains("qstepper"); }).length; verify.repeater = widgets.length; // raw widget count (repeater repeats included) var animLayer = stage.firstChild; verify.animation = !!(animLayer && animLayer.style && animLayer.style.transition && animLayer.style.transition.indexOf("transform") >= 0); var chk = all.filter(function (el) { return el.classList.contains("qux-check"); })[0]; if (chk) { chk._listeners.click[0](fakeEvent()); verify.toggleWorks = chk.classList.contains("qux-on"); } else { verify.toggleWorks = true; } verify.ok = true; } catch (e) { errors.push(String(e)); verify.ok = false; } verify.runtimeErrors = errors; window.__VERIFY__ = verify; }; globalThis.__runVerify = __runVerify; """ def _extract_model(html): m = re.search(r"window\.QUX_MODEL = (\{.*?\});\s*window\.QUX_IMAGES", html, re.S) if not m: raise ValueError("QUX_MODEL not found in HTML") return json.loads(m.group(1)) def _extract_engine(html): m = re.search(r"", html, re.S) if not m: raise ValueError("engine script not found in HTML") return m.group(1) def summarize_model(model): types = {} for w in (model.get("widgets") or {}).values(): t = w.get("type") types[t] = types.get(t, 0) + 1 supported = {t: c for t, c in types.items() if t in SUPPORTED_TYPES} partial = {t: (c, PARTIAL_TYPES.get(t)) for t, c in types.items() if t in PARTIAL_TYPES} unsupported = {t: c for t, c in types.items() if t not in SUPPORTED_TYPES} return { "name": model.get("name"), "screens": len(model.get("screens") or {}), "widgets": len(model.get("widgets") or {}), "lines": len(model.get("lines") or {}), "types": types, "supported": supported, "partial": partial, "unsupported": unsupported, } def verify_html(html): """Run the consistency checks against exported HTML content.""" model = summarize_model(_extract_model(html)) engine = _extract_engine(html) try: ctx = Context() ctx.eval(HARNESS) ctx.eval(engine) ctx.eval("__runVerify();") out = ctx.eval("JSON.stringify(window.__VERIFY__)") except Exception as exc: # noqa: BLE001 return { "status": "error", "error": str(exc)[:400], "model": model, } report = json.loads(out) report["model"] = model # overall verdict checks = [ report.get("ok"), report.get("layerOk"), report.get("widgetCount", 0) > 0, report.get("navigation") if report.get("hasLines") else True, report.get("inputsEditable", True), report.get("toggleWorks", True), len(report.get("runtimeErrors") or []) == 0, ] if report.get("hasLines"): checks.append(report.get("wiredCount", 0) > 0) report["verdict"] = "PASS" if all(checks) else "FAIL" return report # ---------------------------------------------------------------------- CLI def main(): parser = argparse.ArgumentParser(description="Verify a Quant-UX export HTML") parser.add_argument("--file", default="", help="path to exported html") parser.add_argument("--app", default="", help="app id to export+verify") parser.add_argument("--base", default=os.environ.get("QUX_BASE_URL", "http://127.0.0.1:8082")) parser.add_argument("--email", default=os.environ.get("QUX_ADMIN_EMAIL", "")) parser.add_argument("--password", default=os.environ.get("QUX_ADMIN_PASSWORD", "")) parser.add_argument("--out", default="", help="temp html output when verifying an app") args = parser.parse_args() if args.file: with open(args.file, encoding="utf-8") as fh: report = verify_html(fh.read()) elif args.app: from quantux_export import QuantUXExporter client = QuantUXClient(args.base) if args.email and args.password: client.login(args.email, args.password) path = args.out or "/tmp/qux_verify.html" QuantUXExporter(client).export(args.app, out_path=path) with open(path, encoding="utf-8") as fh: report = verify_html(fh.read()) else: raise SystemExit("pass --file or --app") print(json.dumps(report, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()