#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ M3b 离线自测:第12章 event→condition→response → script_type=1 规则 JSON 映射 + 产物导出。 运行:cd modules/pbl_compiler && python3 scripts/test_m3b_mapping.py 不依赖数据库 / ahserver —— 纯函数层 + FakeSor 模拟 sqlor 上下文验证导出幂等性。 """ import json import os import sys import asyncio ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) # 绕过包 __init__.py(其 import ahserver,离线环境不可用):注册同名命名空间包 import types # noqa: E402 _pkg = types.ModuleType('pbl_compiler') _pkg.__path__ = [os.path.join(ROOT, 'pbl_compiler')] sys.modules['pbl_compiler'] = _pkg def run(coro): loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) finally: loop.close() from pbl_compiler.script_mapping import ( # noqa: E402 CompileError, build_rules, canonical_dumps, canonical_rules_json, diff_rules, has_errors, normalize_condition, normalize_ecr, normalize_event, normalize_responses, rules_hash, sha256_text, sort_rules, strip_volatile, build_rules_manifest, validate_rules, iter_ecr_sources, OP_ARITY, ) from pbl_compiler import exporter # noqa: E402 PASS, FAIL = [], [] def check(name, cond, extra=''): (PASS if cond else FAIL).append(name) print((' [PASS] ' if cond else ' [FAIL] ') + name + ((' -> ' + str(extra)) if (extra and not cond) else '')) CTX = {'tenant_id': 'T1', 'blueprint_id': 'bp_demo', 'blueprint_version': 'v3', 'strict': False} SNAPSHOT = { 'blueprint_id': 'bp_demo', 'version': 'v3', 'rules': [ { 'id': 'r_score_pass', 'name': '得分达标开门', 'description': '玩家得分>=60 且 已完成任务 时开启第二场景', 'priority': 200, 'event': {'type': 'onValueChange', 'source': 'player_1', 'match': {'field': 'score'}}, 'condition': {'kind': 'all', 'items': [ {'op': '>=', 'left': 'var:score', 'right': '60'}, {'field': 'state.task_done', 'value': True}, ]}, 'responses': [ {'action': 'goto_scene', 'params': {'scene': 'scene_2'}}, {'action': 'add_score', 'target': 'self', 'params': {'amount': 10}}, ], 'limits': {'cooldown_ms': 1000, 'max_triggers': 3}, 'tags': ['Main Flow', 'score'], 'created_at': '2026-09-18 10:00:00', }, { 'name': '计时器提示', 'priority': 50, 'event': {'type': 'timer', 'interval_ms': 5000}, 'condition': 'remaining <= 30', 'then': [{'action': 'show_message', 'params': {'msg': '时间不多了'}}], }, ], 'scenes': [ {'id': 'scene_1', 'events': [ {'id': 'ev_enter', 'type': 'enter', 'source': 'scene_1', 'responses': [{'action': 'play_bgm', 'params': {'track': 1}}]}, ]}, ], } def t_determinism(): print('\n[T1] 确定性:同快照两次编译逐字节一致') r1, i1 = build_rules(SNAPSHOT, CTX) r2, i2 = build_rules(json.loads(json.dumps(SNAPSHOT)), dict(CTX)) check('两次编译 rules_json 完全相同', canonical_rules_json(r1) == canonical_rules_json(r2)) check('rules_hash 相同', rules_hash(r1) == rules_hash(r2)) check('键序无关(打乱 dict 键序后 hash 不变)', rules_hash(r1) == rules_hash(json.loads(canonical_rules_json(r1)))) # 易变字段不影响产物 s2 = json.loads(json.dumps(SNAPSHOT)) s2['rules'][0]['created_at'] = '2030-01-01 00:00:00' s2['rules'][0]['updated_by'] = 'someone' r3, _ = build_rules(s2, CTX) check('created_at/updated_by 变更不影响 rules_hash', rules_hash(r1) == rules_hash(r3)) # 零空白 txt = canonical_rules_json(r1) check('产物零空白(无 ", " / ": ")', ', ' not in txt and ': ' not in txt) check('产物无换行', '\n' not in txt) def t_mapping(): print('\n[T2] 映射正确性:event / condition / response 归一') rules, issues = build_rules(SNAPSHOT, CTX) check('共映射 3 条规则(rules 2 + scenes.events 1)', len(rules) == 3, len(rules)) by_id = {r['rule_id']: r for r in rules} r = by_id['r_score_pass'] check('event.type 归一 onValueChange -> change', r['event']['type'] == 'change', r['event']) check('event.source 保留', r['event']['source'] == 'player_1') check('event.match 键归一 snake', r['event']['match'] == {'field': 'score'}, r['event']['match']) check('condition 为 all 组合子', r['condition']['kind'] == 'all') ops = [c['op'] for c in r['condition']['items']] check('操作符归一 >= -> gte, 隐式 == -> eq', ops == ['gte', 'eq'], ops) check('左操作数 var:score -> {kind:var,name:score}', r['condition']['items'][0]['left'] == {'kind': 'var', 'name': 'score'}) check("右操作数 '60' -> const 60(数字归一)", r['condition']['items'][0]['right'] == {'kind': 'const', 'value': 60}) check('嵌套 var 路径 state.task_done -> var', r['condition']['items'][1]['left'] == {'kind': 'var', 'name': 'state.task_done'}) check('responses 2 个动作', len(r['responses']) == 2) check('params 同义键归一 scene -> scene_id', r['responses'][0]['params'].get('scene_id') == 'scene_2', r['responses'][0]['params']) check('params 同义键归一 amount -> value', r['responses'][1]['params'].get('value') == 10, r['responses'][1]['params']) check("target 'self' -> kind self", r['responses'][1]['target'] == {'kind': 'self', 'id': ''}) check('limits 保留', r['limits'] == {'cooldown_ms': 1000, 'max_triggers': 3}) check('tags 归一 snake + 排序', r['tags'] == ['main_flow', 'score'], r['tags']) check('origin 可追溯', r['origin']['blueprint_version'] == 'v3' and r['origin']['sub_object_id'] == 'r_score_pass') check('schema_version = 1.0', r['schema_version'] == '1.0') # 无 id 的规则 → 内容哈希派生 rule_id timer = [x for x in rules if x['event']['type'] == 'timer'][0] check('无 id 规则派生 rule_ 前缀哈希 id', timer['rule_id'].startswith('rule_'), timer['rule_id']) check('timer interval_ms 提升到 match', timer['event']['match'].get('interval_ms') == 5000) check('字符串条件 "remaining <= 30" 解析为 compare lte', timer['condition'] == {'kind': 'compare', 'op': 'lte', 'left': {'kind': 'var', 'name': 'remaining'}, 'right': {'kind': 'const', 'value': 30}}, timer['condition']) check("'then' 别名识别为 responses", len(timer['responses']) == 1) # 排序:priority 降序 prios = [x['priority'] for x in rules] check('规则按 priority 降序稳定排序', prios == sorted(prios, reverse=True), prios) def t_shapes(): print('\n[T3] 多形态兼容 + 边界') # 形态 B:events 顶层 snap_b = {'events': [{'id': 'e1', 'type': 'click', 'source': 'btn', 'conditions': [{'field': 'hp', 'op': '<', 'value': 1}], 'actions': [{'action': 'game_over'}]}]} rb, ib = build_rules(snap_b, CTX) check('形态B events 顶层可映射', len(rb) == 1 and rb[0]['event']['type'] == 'click', rb) check('形态B conditions 别名识别', rb[0]['condition']['op'] == 'lt') check('形态B 无 error issue', not has_errors(ib), ib) # 形态 A 单 dict(非数组) snap_c = {'rule': {'id': 'r1', 'event': {'type': 'start'}, 'responses': [{'action': 'init_world'}]}} rc, ic = build_rules(snap_c, CTX) check('单 dict rule 也可映射', len(rc) == 1, rc) # 无条件 → always snap_d = {'rules': [{'id': 'r2', 'event': {'type': 'enter'}, 'responses': [{'action': 'log'}]}]} rd, _ = build_rules(snap_d, CTX) check('缺省条件归一为 always', rd[0]['condition'] == {'kind': 'always'}) # 空快照 try: build_rules({}, CTX) check('空快照抛 CompileError', False) except CompileError as e: check('空快照抛 CompileError(E_SNAPSHOT_EMPTY)', e.code == 'E_SNAPSHOT_EMPTY', e.code) # tenant 缺失 try: build_rules(SNAPSHOT, {'blueprint_id': 'b', 'blueprint_version': 'v'}) check('tenant_id 缺失抛错', False) except CompileError as e: check('tenant_id 缺失 fail-closed', e.code == 'E_TENANT_REQUIRED', e.code) # 无 ECR r_none, i_none = build_rules({'scenes': [{'id': 's'}]}, CTX) check('无 ECR 记录 → 0 规则 + W_NO_ECR', r_none == [] and any(i['code'] == 'W_NO_ECR' for i in i_none)) def t_failclosed(): print('\n[T4] fail-closed:strict 模式未登记 action / 缺 event / 缺 response') strict_ctx = dict(CTX, strict=True, known_actions={'goto_scene', 'add_score'}) snap = {'rules': [ {'id': 'ok', 'event': {'type': 'click'}, 'responses': [{'action': 'goto_scene'}]}, {'id': 'bad_action', 'event': {'type': 'click'}, 'responses': [{'action': 'launch_missile'}]}, {'id': 'no_event', 'responses': [{'action': 'goto_scene'}]}, {'id': 'no_resp', 'event': {'type': 'click'}}, ]} rules, issues = build_rules(snap, strict_ctx) codes = {i['code'] for i in issues} check('未登记 action → E_CAP_UNKNOWN', 'E_CAP_UNKNOWN' in codes, codes) check('缺 event → E_EVENT_MISSING', 'E_EVENT_MISSING' in codes) check('缺 response → E_RESP_MISSING', 'E_RESP_MISSING' in codes) check('strict 下 bad_action 规则被剔除', 'bad_action' not in {r['source_id'] for r in rules}) check('存在 error issue', has_errors(issues)) check('无效规则被剔除并记 E_RULE_DROPPED', any(i['code'] == 'E_RULE_DROPPED' for i in issues)) check('no_event / no_resp 规则不进入产物', not ({'no_event', 'no_resp'} & {r['source_id'] for r in rules})) check('存活规则自身通过 validate_rules(无半成品)', not has_errors(validate_rules(rules)), validate_rules(rules)) # 非 strict:降级 warning,规则保留 rules2, issues2 = build_rules(snap, dict(strict_ctx, strict=False)) check('非 strict 降级为 W_CAP_UNKNOWN 且保留规则', any(i['code'] == 'W_CAP_UNKNOWN' for i in issues2) and 'bad_action' in {r['source_id'] for r in rules2}) # 未知操作符 _, i3 = build_rules({'rules': [{'id': 'x', 'event': {'type': 'click'}, 'condition': {'op': '~=', 'left': 'a', 'right': 1}, 'responses': [{'action': 'log'}]}]}, CTX) check('未知操作符 → E_COND_OP_UNKNOWN', any(i['code'] == 'E_COND_OP_UNKNOWN' for i in i3)) # between 缺边界 _, i4 = build_rules({'rules': [{'id': 'y', 'event': {'type': 'click'}, 'condition': {'op': 'between', 'left': 'a', 'right': [1]}, 'responses': [{'action': 'log'}]}]}, CTX) check('between 边界不足 → E_COND_BETWEEN', any(i['code'] == 'E_COND_BETWEEN' for i in i4)) def t_manifest_diff(): print('\n[T5] manifest / diff / 幂等指纹') rules, _ = build_rules(SNAPSHOT, CTX) m = build_rules_manifest(rules) check('manifest rule_count 正确', m['rule_count'] == len(rules)) check('manifest script_type=1', m['script_type'] == 1) check('manifest rules_hash 与 rules_hash 一致', m['rules_hash'] == rules_hash(rules)) check('manifest 不含时间戳字段', not any(k in canonical_dumps(m) for k in ('created_at', 'timestamp'))) check('manifest 每条含 content_hash', all(e.get('content_hash') for e in m['rules'])) rules2, _ = build_rules(SNAPSHOT, CTX) d_same = diff_rules(rules, rules2) check('相同规则 diff 全空', d_same['added'] == [] and d_same['removed'] == [] and d_same['changed'] == [], d_same) mod = json.loads(canonical_rules_json(rules)) mod[0]['priority'] = 999 d = diff_rules(rules, mod) check('修改 priority → changed 命中', d['changed_count'] == 1, d) d2 = diff_rules(rules, mod[:1]) check('删规则 → removed 命中', d2['removed_count'] == len(rules) - 1, d2) def t_strip(): print('\n[T6] strip_volatile / 操作数归一') o = {'a': 1, 'created_at': 'x', 'nested': {'updated_by': 'y', 'b': [1, {'ts': 2, 'c': 3}]}} s = strip_volatile(o) check('顶层易变键剥离', 'created_at' not in s) check('嵌套易变键剥离', 'updated_by' not in s['nested'] and 'ts' not in s['nested']['b'][1]) check('正常键保留', s['a'] == 1 and s['nested']['b'][1]['c'] == 3) iss = [] from pbl_compiler.script_mapping import normalize_operand check("'$hp' -> var", normalize_operand('$hp', iss, 'p') == {'kind': 'var', 'name': 'hp'}) check("'{{score}}' -> var", normalize_operand('{{score}}', iss, 'p') == {'kind': 'var', 'name': 'score'}) check("'3.5' -> const float", normalize_operand('3.5', iss, 'p') == {'kind': 'const', 'value': 3.5}) check("'true' -> const bool", normalize_operand('true', iss, 'p') == {'kind': 'const', 'value': True}) check("[1,2] -> list", normalize_operand([1, 2], iss, 'p')['kind'] == 'list') check("{'kind':'entity_attr'} 保留", normalize_operand( {'kind': 'entity_attr', 'entity': 'e1', 'attr': 'hp'}, iss, 'p') == {'kind': 'entity_attr', 'entity': 'e1', 'attr': 'hp'}) # --------------------------------------------------------------------------- # FakeSor:模拟 sqlor 上下文,验证 exporter 幂等 + tenant 打头 # --------------------------------------------------------------------------- class Row(object): def __init__(self, **kw): self.__dict__.update(kw) class FakeSor(object): def __init__(self, tables=None): self.tables = tables or {} self.sqls = [] self.inserts = [] self.updates = [] async def sqlExe(self, sql, ns=None): self.sqls.append((sql, dict(ns or {}))) low = sql.strip().lower() if low.startswith('select 1 from'): t = sql.split('`')[1] if t not in self.tables: raise Exception('no table %s' % t) return [] if low.startswith('select column_name'): t = (ns or {}).get('tbl') return [Row(COLUMN_NAME=c) for c in sorted(self.tables.get(t, {}).get('cols', []))] if low.startswith('select * from'): t = sql.split('`')[1] return [Row(**r) for r in self.tables.get(t, {}).get('rows', [])] if low.startswith('delete from'): t = sql.split('`')[1] n = len(self.tables.get(t, {}).get('rows', [])) self.tables.setdefault(t, {'cols': [], 'rows': []})['rows'] = [] return [Row(deleted=n)] return [] async def R(self, table, where): rows = self.tables.get(table, {}).get('rows', []) hit = [r for r in rows if all(str(r.get(k)) == str(v) for k, v in where.items())] return [Row(**r) for r in hit] async def C(self, table, ns): row = dict(ns) row.setdefault('id', 'id_%d' % (len(self.inserts) + 1)) self.tables.setdefault(table, {'cols': [], 'rows': []})['rows'].append(row) self.inserts.append((table, dict(ns))) return row async def U(self, table, ns, where): self.updates.append((table, dict(ns), dict(where))) rows = self.tables.get(table, {}).get('rows', []) for r in rows: if all(str(r.get(k)) == str(v) for k, v in where.items()): r.update(ns) return 1 async def D(self, table, where): return await self.sqlExe('DELETE FROM `%s` WHERE 1' % table, where) SCRIPT_COLS = ['id', 'tenant_id', 'script_code', 'name', 'script_type', 'content', 'description', 'group_name', 'enabled', 'content_hash', 'origin', 'blueprint_id', 'blueprint_version', 'created_at'] GD_COLS = ['id', 'tenant_id', 'blueprint_id', 'blueprint_version', 'game_definition', 'gd_hash', 'rules_hash', 'rule_count', 'status', 'compiler_version'] VER_COLS = ['id', 'tenant_id', 'blueprint_id', 'blueprint_version', 'rules_json', 'rules_hash', 'rules_manifest', 'rules_count', 'script_ids'] def make_sor(): return FakeSor({ 'script': {'cols': SCRIPT_COLS, 'rows': []}, 'pbl_game_definition': {'cols': GD_COLS, 'rows': []}, 'pbl_compiler_version': {'cols': VER_COLS, 'rows': []}, }) def t_export(): print('\n[T7] 导出:script_type=1 落库 + 幂等 + tenant 打头') rules, _ = build_rules(SNAPSHOT, CTX) sor = make_sor() res = run( exporter.export_rules(sor, rules, CTX)) rows = sor.tables['script']['rows'] check('导出行数 == 规则数', len(rows) == len(rules), (len(rows), len(rules))) check('全部 script_type=1', all(r['script_type'] == 1 for r in rows)) check('全部带 tenant_id', all(r['tenant_id'] == 'T1' for r in rows)) check('script_code 唯一', len({r['script_code'] for r in rows}) == len(rows)) check('content 是确定性 JSON(sort_keys 零空白)', all(r['content'] == canonical_dumps(json.loads(r['content'])) for r in rows)) check('content_hash 与内容一致', all(r['content_hash'] == sha256_text(r['content']) for r in rows)) check('rules_hash 与本地一致', res['rules_hash'] == rules_hash(rules)) # 幂等:再导一次 → 0 insert / 0 update n_ins = len(sor.inserts) res2 = run( exporter.export_rules(sor, rules, CTX)) check('重复导出 skipped == 规则数', res2['skipped'] == len(rules), res2['skipped']) check('重复导出 exported == 0', res2['exported'] == 0, res2['exported']) check('重复导出无新增 insert', len(sor.inserts) == n_ins) check('重复导出无 update', len(sor.updates) == 0) check('表行数未增长', len(sor.tables['script']['rows']) == len(rules)) # 内容变化 → update 而非 insert mod = json.loads(canonical_rules_json(rules)) mod[0]['priority'] = 777 res3 = run( exporter.export_rules(sor, mod, CTX)) check('内容变更走 update', res3['exported'] == 1 and len(sor.updates) == 1, (res3['exported'], len(sor.updates))) check('内容变更不新增行', len(sor.tables['script']['rows']) == len(rules)) # 读回一致 back = run( exporter.load_exported_rules(sor, CTX)) check('load_exported_rules 读回条数一致', len(back) == len(rules), len(back)) # tenant 缺失 fail-closed try: run( exporter.export_rules(sor, rules, {'blueprint_version': 'v3'})) check('导出缺 tenant 抛错', False) except CompileError as e: check('导出缺 tenant fail-closed', e.code == 'E_TENANT_REQUIRED', e.code) # dry_run 不落库 sor_d = make_sor() res_d = run( exporter.export_rules(sor_d, rules, dict(CTX, dry_run=True))) check('dry_run 不落库', len(sor_d.tables['script']['rows']) == 0 and res_d['skipped'] == len(rules)) # 所有 SQL 都带 tenant_id check('所有 sqlExe 参数含 tenant_id 或为元数据探测', all(('tenant_id' in (ns or {})) or 'information_schema' in sql.lower() or sql.strip().lower().startswith('select 1 from') or sql.strip().lower().startswith('select * from') for sql, ns in sor.sqls), [s for s, n in sor.sqls if 'tenant_id' not in (n or {})][:3]) def t_gd(): print('\n[T8] game_definition 写入 + 版本回写 + export_all 一站式') rules, _ = build_rules(SNAPSHOT, CTX) base_gd = {'schema_version': '1.0', 'meta': {'title': 'Demo'}, 'scenes': [], 'entities': [], 'variables': [], 'ui': [], 'audio': [], 'scoring': {}, 'flow': {}, 'i18n': {}} gd, gd_text, gd_hash = exporter.build_game_definition(rules, CTX, base_gd) check('GD 含 rules 顶层键', 'rules' in gd) check('GD 保留 M3a 其它顶层键', 'scenes' in gd and 'meta' in gd) check('rules.script_type == 1', gd['rules']['script_type'] == 1) check('rules.rule_count 正确', gd['rules']['rule_count'] == len(rules)) check('rules.rules_hash 与 rules_hash 一致', gd['rules']['rules_hash'] == rules_hash(rules)) check('GD 文本确定性(重算一致)', exporter.build_game_definition(json.loads(canonical_rules_json(rules)), CTX, base_gd)[2] == gd_hash) check('GD 文本零空白', ', ' not in gd_text and '\n' not in gd_text) sor = make_sor() r1 = run( exporter.write_game_definition(sor, gd, gd_text, gd_hash, CTX)) check('首次写入 GD written=True', r1['written'] is True, r1) check('GD 表 1 行', len(sor.tables['pbl_game_definition']['rows']) == 1) r2 = run( exporter.write_game_definition(sor, gd, gd_text, gd_hash, CTX)) check('同 hash 重复写入 unchanged=True(幂等)', r2.get('unchanged') is True and r2['written'] is False, r2) check('GD 表仍 1 行', len(sor.tables['pbl_game_definition']['rows']) == 1) gd2 = json.loads(gd_text) gd2['rules']['rules'][0]['priority'] = 1 t2 = canonical_dumps(gd2) r3 = run( exporter.write_game_definition(sor, gd2, t2, sha256_text(t2), CTX)) check('hash 变化 → 覆盖更新不新增行', r3['written'] is True and len(sor.tables['pbl_game_definition']['rows']) == 1) # 版本回写 exp_res = run(exporter.export_rules(sor, rules, CTX)) vr = run( exporter.write_compiler_version_rules(sor, 'ver_001', rules, CTX, exp_res)) check('版本回写 written=True', vr['written'] is True, vr) vrow = sor.tables['pbl_compiler_version']['rows'][0] check('版本行 rules_hash 正确', vrow['rules_hash'] == rules_hash(rules)) check('版本行 rules_count 正确', vrow['rules_count'] == len(rules)) check('版本行 rules_json 可解析且确定性', json.loads(vrow['rules_json']) is not None and vrow['rules_json'] == canonical_rules_json(rules)) man = json.loads(vrow['rules_manifest']) check('版本行 manifest 含 script_ids', len(man['extra']['script_ids']) == len(rules)) # export_all 一站式(含 error → 拒绝落库) sor_a = make_sor() out = run( exporter.export_all(sor_a, SNAPSHOT, dict(CTX, strict=False), base_gd=base_gd, version_id='ver_002')) check('export_all status=ok', out['status'] == 'ok', out.get('issues')) check('export_all rule_count 正确', out['rule_count'] == len(rules)) check('export_all 落库脚本', len(sor_a.tables['script']['rows']) == len(rules)) check('export_all 写 GD', len(sor_a.tables['pbl_game_definition']['rows']) == 1) check('export_all 回写版本', len(sor_a.tables['pbl_compiler_version']['rows']) == 1) bad_snap = {'rules': [{'id': 'b1', 'responses': [{'action': 'x'}]}]} # 缺 event sor_b = make_sor() try: run( exporter.export_all(sor_b, bad_snap, CTX, base_gd=base_gd)) check('export_all 有 error 时拒绝落库', False) except CompileError as e: check('export_all fail-closed(E_RULE_INVALID)', e.code == 'E_RULE_INVALID', e.code) check('失败时脚本表为空(无半成品)', len(sor_b.tables['script']['rows']) == 0) check('失败时 GD 表为空', len(sor_b.tables['pbl_game_definition']['rows']) == 0) # rollback rb = run( exporter.rollback_export(sor_a, CTX)) check('rollback 清空规则脚本', len(sor_a.tables['script']['rows']) == 0, rb) def t_ecr_enum(): print('\n[T9] ECR 源枚举去重 + 路径可追溯') snap = {'rules': [{'id': 'r1', 'event': {'type': 'click'}, 'responses': [{'action': 'a'}]}], 'logic': [{'id': 'r1', 'event': {'type': 'click'}, 'responses': [{'action': 'a'}]}]} srcs = iter_ecr_sources(snap) check('同内容跨键重复只保留一处', len(srcs) == 1, [p for p, _ in srcs]) check('source_path 可追溯', srcs[0][0].startswith('snapshot.'), srcs[0][0]) rules, issues = build_rules(snap, CTX) check('去重后无 E_RULE_ID_DUP', not any(i['code'] == 'E_RULE_ID_DUP' for i in issues), issues) # 真重复 id 但内容不同 → 报 dup 并派生新 id snap2 = {'rules': [ {'id': 'dup', 'event': {'type': 'click'}, 'responses': [{'action': 'a'}]}, {'id': 'dup', 'event': {'type': 'enter'}, 'responses': [{'action': 'b'}]}]} r2, i2 = build_rules(snap2, CTX) check('内容不同的重复 id → E_RULE_ID_DUP', any(i['code'] == 'E_RULE_ID_DUP' for i in i2)) check('重复 id 派生唯一 rule_id', len({r['rule_id'] for r in r2}) == 2) def main(): print('=' * 72) print('M3b 自测:script_engine 规则映射与导出(第12章 ECR → script_type=1)') print('=' * 72) for fn in (t_determinism, t_mapping, t_shapes, t_failclosed, t_manifest_diff, t_strip, t_export, t_gd, t_ecr_enum): fn() print('\n' + '=' * 72) print('PASS: %d FAIL: %d' % (len(PASS), len(FAIL))) if FAIL: print('失败项: ' + ', '.join(FAIL)) print('=' * 72) return 1 if FAIL else 0 if __name__ == '__main__': sys.exit(main())