pbl_compiler/scripts/test_m3b_mapping.py
2026-09-18 19:27:22 +08:00

533 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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] 多形态兼容 + 边界')
# 形态 Bevents 顶层
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-closedstrict 模式未登记 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 是确定性 JSONsort_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())