287 lines
11 KiB
Python
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()
|