Files
quantux_mcp/quantux_verify.py

287 lines
11 KiB
Python

"""
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 <app_id> [--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"<script>\s*(window\.QUX_MODEL.*?)</script>", 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()