# -*- coding: utf-8 -*- """拖拽编程画布——图校验器。 校验项(错误码见 ERR_*): - 无入口(没有任何事件块)/多入口(多个事件块,v1 允许,各自为独立分支) - 孤立块(无任何连线)/不可达块(有连线但不在任何入口链上) —— 可达性根 = 事件块 + 函数定义块(函数体经函数定义可达) —— 豁免孤立检查:声明类块(var_create / logic_function_def,编译时提升/独立成区) - 连线引用不存在的块或端口 / 自环 / 循环引用 / 重复连线 - 事件块被作为连线目标 - 块参数校验:必填缺失 / 类型错误(number、boolean、select)/ 数值越界 / 非法标识符 - 变量引用校验(@var 引用未创建变量)/ 函数调用引用未定义的函数 返回统一结构:{valid, errors: [{code, message, block_id, field}], warnings: [...]} """ import re from .blocks import BLOCK_DEFS, get_dynamic_outputs # 错误码 ERR_MISSING_ID = "BLOCK_MISSING_ID" ERR_DUP_ID = "BLOCK_DUP_ID" ERR_UNKNOWN_TYPE = "BLOCK_UNKNOWN_TYPE" ERR_NO_ENTRY = "NO_ENTRY" ERR_ORPHAN = "ORPHAN_BLOCK" ERR_UNREACHABLE = "UNREACHABLE_BLOCK" ERR_CONN_BLOCK_NOT_FOUND = "CONN_BLOCK_NOT_FOUND" ERR_CONN_PORT_NOT_FOUND = "CONN_PORT_NOT_FOUND" ERR_CONN_DUP = "CONN_DUPLICATE" ERR_CONN_SELF = "CONN_SELF_LOOP" ERR_CONN_CYCLE = "CONN_CYCLE" ERR_EVENT_AS_TARGET = "EVENT_AS_TARGET" ERR_PARAM_REQUIRED = "PARAM_REQUIRED" ERR_PARAM_TYPE = "PARAM_TYPE" ERR_PARAM_RANGE = "PARAM_RANGE" ERR_PARAM_OPTION = "PARAM_OPTION" ERR_IDENTIFIER = "INVALID_IDENTIFIER" ERR_VAR_UNDEFINED = "VAR_UNDEFINED" ERR_FUNC_UNDEFINED = "FUNC_UNDEFINED" ERR_BAD_GRAPH = "BAD_GRAPH" _IDENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') # 声明类块:编译时被提升(变量)或独立成区(函数定义),不需要参与孤立检查 _DECLARATION_TYPES = ('var_create', 'logic_function_def') def _err(code, message, block_id=None, field=None): e = {"code": code, "message": message} if block_id is not None: e["block_id"] = block_id if field is not None: e["field"] = field return e def _is_number(s): try: float(s) return True except (TypeError, ValueError): return False def _is_bool(s): return s in (True, False, 'true', 'false', '1', '0', 1, 0) def _block_outputs(btype, params): """块的全部输出端口名(含动态端口)。""" bdef = BLOCK_DEFS.get(btype) or {} names = [p["name"] for p in (bdef.get("ports") or {}).get("outputs", [])] names += get_dynamic_outputs(btype, params) return names def _block_inputs(btype): bdef = BLOCK_DEFS.get(btype) or {} return [p["name"] for p in (bdef.get("ports") or {}).get("inputs", [])] def _block_category(btype): bdef = BLOCK_DEFS.get(btype) or {} return bdef.get("category") def validate_graph(graph): """graph: {blocks: [...], connections: [...]} → {valid, errors, warnings}""" errors, warnings = [], [] if not isinstance(graph, dict): return {"valid": False, "errors": [_err(ERR_BAD_GRAPH, "画布结构非法:必须是对象")], "warnings": []} blocks = graph.get("blocks") or [] conns = graph.get("connections") or [] if not isinstance(blocks, list) or not isinstance(conns, list): return {"valid": False, "errors": [_err(ERR_BAD_GRAPH, "画布结构非法:blocks/connections 必须是数组")], "warnings": []} bmap = {} for b in blocks: bid = b.get("id") if not bid: errors.append(_err(ERR_MISSING_ID, "块缺少 id")) continue if bid in bmap: errors.append(_err(ERR_DUP_ID, "块 id 重复:%s" % bid, bid)) continue bmap[bid] = b btype = b.get("type") if btype not in BLOCK_DEFS: errors.append(_err(ERR_UNKNOWN_TYPE, "未知块类型:%s" % btype, bid)) if not blocks: return {"valid": False, "errors": [_err(ERR_NO_ENTRY, "画布为空:没有块")], "warnings": []} # ── 连线基础校验 ── seen = set() for c in conns: frm, to = c.get("from"), c.get("to") fp, tp = c.get("fromPort"), c.get("toPort") key = "%s:%s->%s:%s" % (frm, fp, to, tp) if frm == to: errors.append(_err(ERR_CONN_SELF, "自环连线:%s" % frm, frm)) if key in seen: errors.append(_err(ERR_CONN_DUP, "重复连线:%s -> %s" % (frm, to), frm)) seen.add(key) if frm not in bmap: errors.append(_err(ERR_CONN_BLOCK_NOT_FOUND, "连线起点块不存在:%s" % frm, None)) continue if to not in bmap: errors.append(_err(ERR_CONN_BLOCK_NOT_FOUND, "连线终点块不存在:%s" % to, None)) continue fb, tb = bmap[frm], bmap[to] fbtype, tbtype = fb.get("type"), tb.get("type") # 事件块不能作为目标 if _block_category(tbtype) == "event": errors.append(_err(ERR_EVENT_AS_TARGET, "事件块不能作为连线目标:%s" % to, to)) # 端口存在性 if fp not in _block_outputs(fbtype, fb.get("params") or {}): errors.append(_err(ERR_CONN_PORT_NOT_FOUND, "起点端口不存在:%s.%s" % (frm, fp), frm)) if tp not in _block_inputs(tbtype): errors.append(_err(ERR_CONN_PORT_NOT_FOUND, "终点端口不存在:%s.%s" % (to, tp), to)) # ── 入口检查 ── events = [b for b in blocks if _block_category(b.get("type")) == "event"] if not events: errors.append(_err(ERR_NO_ENTRY, "没有入口:画布必须包含至少一个事件块(开始/点击/悬停/定时/碰撞/键盘)")) # ── 孤立块 / 可达性 ── connected_ids = set() for c in conns: connected_ids.add(c.get("from")) connected_ids.add(c.get("to")) def _exempt_orphan(b): """孤立检查豁免:事件入口 + 声明类块。""" btype = b.get("type") return _block_category(btype) == "event" or btype in _DECLARATION_TYPES for b in blocks: if b.get("id") not in connected_ids and not _exempt_orphan(b): errors.append(_err(ERR_ORPHAN, "孤立块(没有任何连线):%s [%s]" % (b.get("id"), b.get("type")), b.get("id"))) # BFS 可达性:根 = 事件块 + 函数定义块(函数体经函数定义可达) adj = {} for c in conns: adj.setdefault(c.get("from"), []).append(c) roots = [b.get("id") for b in events if b.get("id")] + \ [b.get("id") for b in blocks if b.get("type") == "logic_function_def" and b.get("id")] visited = set() stack = list(roots) while stack: bid = stack.pop() if bid in visited: continue visited.add(bid) for c in adj.get(bid, []): if c.get("to") not in visited: stack.append(c.get("to")) for b in blocks: bid = b.get("id") if bid and bid not in visited and not _exempt_orphan(b): errors.append(_err(ERR_UNREACHABLE, "不可达块(不在任何事件/函数链上):%s [%s]" % (bid, b.get("type")), bid)) # ── 环检测(DFS 三色) ── WHITE, GRAY, BLACK = 0, 1, 2 color = {bid: WHITE for bid in bmap} def dfs(bid): color[bid] = GRAY for c in adj.get(bid, []): nxt = c.get("to") if nxt in color and color[nxt] == GRAY: return True if nxt in color and color[nxt] == WHITE: if dfs(nxt): return True color[bid] = BLACK return False for bid in list(bmap): if color.get(bid) == WHITE and dfs(bid): errors.append(_err(ERR_CONN_CYCLE, "检测到循环引用(环形连线),无法编译", bid)) break # ── 参数校验 ── defined_vars = set() defined_funcs = set() for b in blocks: btype = b.get("type") if btype == "var_create": nm = (b.get("params") or {}).get("name") if nm: defined_vars.add(str(nm).strip()) if btype == "logic_function_def": nm = (b.get("params") or {}).get("name") if nm: defined_funcs.add(str(nm).strip()) for b in blocks: bid = b.get("id") btype = b.get("type") bdef = BLOCK_DEFS.get(btype) if not bdef: continue params = b.get("params") or {} if not isinstance(params, dict): errors.append(_err(ERR_PARAM_TYPE, "块参数必须是对象:%s" % bid, bid)) params = {} for pdef in bdef.get("params", []): pname = pdef["name"] val = params.get(pname) # 必填 if pdef.get("required") and (val is None or str(val).strip() == ""): errors.append(_err(ERR_PARAM_REQUIRED, "参数「%s」必填" % pdef["label"], bid, pname)) continue if val is None or str(val).strip() == "": continue val = str(val).strip() # 类型 ut = pdef.get("uitype") if ut == "number": if not _is_number(val): errors.append(_err(ERR_PARAM_TYPE, "参数「%s」必须是数字" % pdef["label"], bid, pname)) else: f = float(val) if "min" in pdef and f < pdef["min"]: errors.append(_err(ERR_PARAM_RANGE, "参数「%s」不能小于 %s" % (pdef["label"], pdef["min"]), bid, pname)) if "max" in pdef and f > pdef["max"]: errors.append(_err(ERR_PARAM_RANGE, "参数「%s」不能大于 %s" % (pdef["label"], pdef["max"]), bid, pname)) elif ut == "boolean": if not _is_bool(val): errors.append(_err(ERR_PARAM_TYPE, "参数「%s」必须是布尔值" % pdef["label"], bid, pname)) elif ut == "select": opts = pdef.get("options") or [] if opts and val not in [o["value"] for o in opts]: errors.append(_err(ERR_PARAM_OPTION, "参数「%s」取值非法:%s" % (pdef["label"], val), bid, pname)) # 标识符类参数 if pname in ("name", "result") and ut in ("text", "variable"): if not _IDENT_RE.match(val): errors.append(_err(ERR_IDENTIFIER, "参数「%s」必须是合法标识符(字母/数字/下划线,不能以数字开头):%s" % (pdef["label"], val), bid, pname)) # 变量引用校验 if ut in ("expression", "variable"): for token in re.findall(r'@([A-Za-z_][A-Za-z0-9_]*)', val): if token not in defined_vars: errors.append(_err(ERR_VAR_UNDEFINED, "引用了未创建的变量:@%s" % token, bid, pname)) # 函数调用校验 if btype == "logic_function_call": fname = (params.get("name") or "").strip() if fname and fname not in defined_funcs: errors.append(_err(ERR_FUNC_UNDEFINED, "调用了未定义的函数:%s" % fname, bid, "name")) if btype == "logic_function_def": fname = (params.get("name") or "").strip() if not fname: errors.append(_err(ERR_PARAM_REQUIRED, "函数名必填", bid, "name")) return {"valid": not errors, "errors": errors, "warnings": warnings}