#!/usr/bin/env python3 # -*- coding: utf-8 -*- """M5b 自测(pbl_evidence 证据聚合与查询契约)—— 纯 stdlib,无需 DB / 无需 pytest。 覆盖设计 docs/01-design/modules/pbl_evidence.md §3.2 的 M5b 需求点: 1. **四类证据幂等采集**:``pbl_evidence_collect_idempotent`` 首次 ``created=True``, 重复(同 idempotency_key / 同唯一索引三元组)``created=False`` 且不产生第二行; 2. **idempotency_key 归一**:显式键原样透传;缺省键由 ``tenant|source_event_id| evidence_type`` 派生且稳定(重放同键);非四类 evidence_type 报 ``PBL_E_VALIDATION``; 3. **聚合契约**:``pbl_evidence_aggregate`` 四类计数 + 覆盖度 + 分组 + 时间水位; 4. **列表查询契约**:``pbl_evidence_query`` 分页 total/rows + payload 解析 + 水位; 5. **主体档案契约**:``pbl_evidence_subject_summary`` 学生 / 团队四类分组明细; 6. **Q-OPEN-10**:预览 / Playtest 数据(payload 标记、显式预览会话、STUDENT 私有可见性) 默认剔除,``include_preview=True`` 才纳入,且 ``preview_filter`` 回显剔除口径; 7. **租户 fail-closed**:无 tenant_id 直接 ``PBL_E_TENANT_MISSING``,不落到 SQL。 内存版 DB 会**校验拼出的 SQL**(列清单、LIMIT 占位、无 ``None`` 泄漏)并按 params 过滤, 因此不只是「函数不报错」,而是验证了查询参数与 SQL 的对应关系。 运行: cd modules/pbl_evidence && python3 scripts/selftest_m5b.py # 或: python3 -m pytest scripts/selftest_m5b.py -q (test_* 函数可直接被收集) """ import asyncio import json import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) for sib in ('pbl_common',): p = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(__file__)))), sib) if os.path.isdir(p) and p not in sys.path: sys.path.insert(0, p) from pbl_common.api import PblError # noqa: E402 import pbl_evidence.aggregation as A # noqa: E402 TENANT = '1' EVIDENCE_COLS = ['id', 'tenant_id', 'artifact_id', 'evidence_type', 'source_event_id', 'session_id', 'learner_id', 'blueprint_id', 'payload_json', 'occurred_at', 'dedup_key', 'collect_batch_id', 'created_at', 'updated_at'] ARTIFACT_COLS = ['id', 'tenant_id', 'artifact_uid', 'session_id', 'blueprint_id', 'team_id', 'creator_id', 'title', 'artifact_type', 'content_json', 'version_no', 'status', 'submitted_at', 'created_at', 'updated_at'] _CAPTURED = {'sql': [], 'params': []} def _row(idx, etype, learner='S01', session='101', blueprint='9', artifact=0, occurred='2026-09-20 10:00:0%d' % (idx % 9), payload=None, source=None): return { 'id': idx, 'tenant_id': TENANT, 'artifact_id': artifact, 'evidence_type': etype, 'source_event_id': source or ('EV%03d' % idx), 'session_id': session, 'learner_id': learner, 'blueprint_id': blueprint, 'payload_json': json.dumps(payload or {'event_type': 'mission.completed'}, ensure_ascii=False), 'occurred_at': occurred, 'dedup_key': 'K%03d' % idx, } def seed_rows(): """正式环境证据 5 条(四类齐)+ 预览/Playtest 证据 4 条(应被默认过滤)。""" return [ _row(1, 'decision'), _row(2, 'action'), _row(3, 'observation', learner='S02'), _row(4, 'artifact_version', artifact=77, learner='S02'), _row(5, 'decision', learner='S03', occurred='2026-09-21 08:00:00'), # ── 预览 / Playtest 族(Q-OPEN-10 默认剔除)──────────────── _row(6, 'action', payload={'pbl_preview': True, 'event_type': 'mission.completed'}), _row(7, 'observation', payload={'env_mode': 'playtest'}), _row(8, 'decision', session='999', payload={'event_type': 'team.decision'}), # 显式预览会话 999 _row(9, 'artifact_version', artifact=88, payload={'visibility': 'student', 'content_ref': 'draft'}), # STUDENT 私有 ] class FakeDB: """内存版证据库:按 params 过滤,同时校验 SQL 文本合法性。""" def __init__(self, rows): self.rows = rows async def table_columns(self, table): if table == 'pbl_evidence': return list(EVIDENCE_COLS) if table == 'pbl_artifact': return list(ARTIFACT_COLS) return [] @staticmethod def _match(row, params): for key, val in (params or {}).items(): if key in ('scan_limit', 'time_from', 'time_to') or key.startswith('etype'): continue if key in row and str(row[key]) != str(val): return False if 'time_from' in (params or {}) and row.get('occurred_at'): if str(row['occurred_at']) < str(params['time_from']): return False if 'time_to' in (params or {}) and row.get('occurred_at'): if str(row['occurred_at']) > str(params['time_to']): return False types = [v for k, v in (params or {}).items() if k.startswith('etype')] if types and str(row.get('evidence_type')) not in [str(t) for t in types]: return False return True async def q_all(self, sql, params=None): _CAPTURED['sql'].append(sql) _CAPTURED['params'].append(dict(params or {})) low = sql.lower() assert 'none' not in low.replace('is null', ''), 'SQL 泄漏 None:%s' % sql if 'from `pbl_evidence`' in low: assert 'limit ${scan_limit}$' in low, '缺少扫描上限(防全表拖库):%s' % sql assert 'tenant_id' in low, '证据查询必须带租户条件:%s' % sql limit = int((params or {}).get('scan_limit') or A.MAX_SCAN_ROWS) rows = [r for r in self.rows if self._match(r, params)] rows.sort(key=lambda r: (str(r.get('occurred_at') or ''), int(r.get('id') or 0)), reverse=True) return rows[:limit] if 'count(*)' in low and 'pbl_artifact' in low: return [{'c': 2}] return [] async def q_one(self, sql, params=None): rows = await self.q_all(sql, params) return rows[0] if rows else None def install_fake(rows, tenant=TENANT): """把 aggregation 的 IO 依赖换成内存实现(不碰真实 DB)。""" db = FakeDB(rows) A.table_columns = db.table_columns A.q_all = db.q_all A.q_one = db.q_one A.tenant_id = lambda *a, **k: tenant return db def run(coro): return asyncio.get_event_loop().run_until_complete(coro) # ══════════════════════════════════════════════════════════════════════ # 1. 幂等采集(含 created 布尔 + 重复不产生第二行) # ══════════════════════════════════════════════════════════════════════ def test_idempotent_collect_created_flag(): rows = seed_rows() install_fake(rows) store = {} async def fake_collect(**kw): key = (str(kw.get('tenant_id') or TENANT), str(kw.get('source_event_id')), str(kw.get('evidence_type'))) if key in store: return {'ok': True, 'deduped': True, 'id': store[key], 'unique_index': 'uk_ev_dedup', 'evidence_type': key[2]} nid = 1000 + len(store) store[key] = nid rows.append(_row(nid, key[2], source=key[1])) return {'ok': True, 'deduped': False, 'id': nid, 'unique_index': 'uk_ev_dedup', 'evidence_type': key[2]} import pbl_evidence.api as api origin = api.pbl_evidence_collect api.pbl_evidence_collect = fake_collect try: first = run(A.pbl_evidence_collect_idempotent( source_event_id='EV900', evidence_type='decision', learner_id='S01', blueprint_id='9', session_id='101', payload={'choice': 'A'})) assert first['created'] is True, first assert first['deduped'] is False, first assert first['idempotency_key'].startswith('IK') and len(first['idempotency_key']) == 26 assert first['idempotency_key_derived'] is True, first second = run(A.pbl_evidence_collect_idempotent( source_event_id='EV900', evidence_type='decision', learner_id='S01', blueprint_id='9', session_id='101', payload={'choice': 'A'})) assert second['created'] is False, second assert second['deduped'] is True, second assert second['id'] == first['id'], (first, second) assert second['idempotency_key'] == first['idempotency_key'], '派生键必须稳定' assert len(store) == 1, '重复采集不得产生第二行:%s' % store # 显式 idempotency_key 原样透传 third = run(A.pbl_evidence_collect_idempotent( source_event_id='EV901', evidence_type='action', idempotency_key='IK-EXTERNAL-01', learner_id='S01')) assert third['idempotency_key'] == 'IK-EXTERNAL-01', third assert third['idempotency_key_derived'] is False, third finally: api.pbl_evidence_collect = origin def test_collect_rejects_non_fourth_type(): install_fake(seed_rows()) try: run(A.pbl_evidence_collect_idempotent(source_event_id='EV902', evidence_type='research_upload')) raise AssertionError('非四类 evidence_type 必须被拒') except PblError as exc: assert exc.code == 'PBL_E_VALIDATION', exc.code def test_tenant_fail_closed(): install_fake(seed_rows(), tenant=None) for fn, kw in ((A.pbl_evidence_aggregate, {}), (A.pbl_evidence_query, {}), (A.pbl_evidence_subject_summary, {'subject_id': 'S01'}), (A.pbl_evidence_collect_idempotent, {'source_event_id': 'EV1'})): try: run(fn(**kw)) raise AssertionError('%s 缺租户时必须 fail-closed' % fn.__name__) except PblError as exc: assert exc.code == 'PBL_E_TENANT_MISSING', (fn.__name__, exc.code) # ══════════════════════════════════════════════════════════════════════ # 2. 聚合契约 # ══════════════════════════════════════════════════════════════════════ def test_aggregate_four_types_and_coverage(): install_fake(seed_rows()) res = run(A.pbl_evidence_aggregate(group_by='learner')) assert res['ok'] is True and res['status'] == 'OK', res assert res['total'] == 5, res['total'] # 9 行中 4 行预览族被默认剔除 assert res['by_type'] == {'decision': 2, 'action': 1, 'observation': 1, 'artifact_version': 1}, res['by_type'] assert res['coverage']['covered_types'] == 4, res['coverage'] assert res['coverage']['missing_types'] == [], res['coverage'] keys = {g['group_key'] for g in res['groups']} assert keys == {'S01', 'S02', 'S03'}, keys assert res['watermark']['max_occurred_at'] == '2026-09-21 08:00:00', res['watermark'] assert res['preview_filter']['excluded_preview'] == 3, res['preview_filter'] assert res['preview_filter']['excluded_student'] == 1, res['preview_filter'] assert res['preview_filter']['applied'] is True, res['preview_filter'] assert res['truncated'] is False, res def test_aggregate_group_by_type_and_time_range(): install_fake(seed_rows()) res = run(A.pbl_evidence_aggregate(group_by='type', time_from='2026-09-21 00:00:00', time_to='2026-09-21 23:59:59')) assert res['total'] == 1, res assert res['groups'][0]['group_key'] == 'decision', res['groups'] res2 = run(A.pbl_evidence_aggregate(group_by='session', time_range={'from': '2026-09-20 00:00:00', 'to': '2026-09-20 23:59:59'})) assert res2['group_by'] == 'session_id', res2 assert res2['total'] == 3, res2 def test_aggregate_evidence_types_filter(): install_fake(seed_rows()) res = run(A.pbl_evidence_aggregate(evidence_types=['decision', 'action'])) assert set(res['by_type']) == {'decision', 'action', 'observation', 'artifact_version'} assert res['total'] == 2, res['total'] assert res['coverage']['missing_types'] == ['observation', 'artifact_version'], res['coverage'] try: run(A.pbl_evidence_aggregate(evidence_types='decision,bogus')) raise AssertionError('非法 evidence_types 必须被拒') except PblError as exc: assert exc.code == 'PBL_E_VALIDATION', exc.code # ══════════════════════════════════════════════════════════════════════ # 3. 列表查询契约(分页 + payload + 水位) # ══════════════════════════════════════════════════════════════════════ def test_query_pagination_and_payload(): install_fake(seed_rows()) res = run(A.pbl_evidence_query(page=1, rows=2)) assert res['total'] == 5, res['total'] assert len(res['rows']) == 2, len(res['rows']) assert res['rows'][0]['payload'].get('event_type'), res['rows'][0] page2 = run(A.pbl_evidence_query(page=2, rows=2)) ids = {r['id'] for r in res['rows']} ^ {r['id'] for r in page2['rows']} assert len(ids) == 4, ids # 翻页不重叠 tail = run(A.pbl_evidence_query(page=3, rows=2)) assert len(tail['rows']) == 1, tail['rows'] assert run(A.pbl_evidence_query(page=9, rows=20))['rows'] == [] def test_query_filters_by_learner_and_type(): install_fake(seed_rows()) res = run(A.pbl_evidence_query(learner_id='S02')) assert res['total'] == 2, res['total'] assert {r['learner_id'] for r in res['rows']} == {'S02'}, res['rows'] res2 = run(A.pbl_evidence_query(evidence_type='decision')) assert res2['total'] == 2, res2['total'] # ══════════════════════════════════════════════════════════════════════ # 4. 主体档案契约(学生 / 团队) # ══════════════════════════════════════════════════════════════════════ def test_subject_summary_student_and_team(): install_fake(seed_rows()) stu = run(A.pbl_evidence_subject_summary(subject_type='student', subject_id='S02')) assert stu['ok'] is True, stu assert stu['evidence_total'] == 2, stu['evidence_total'] assert stu['by_type']['action'] == 1 and stu['by_type']['artifact_version'] == 1, stu['by_type'] assert set(stu['evidence']) == {'decision', 'action', 'observation', 'artifact_version'} assert stu['artifact_count'] == 2, stu['artifact_count'] tea = run(A.pbl_evidence_subject_summary(subject_type='team', team_id='101', blueprint_id='9')) assert tea['subject_id'] == '101', tea assert tea['evidence_total'] == 2, tea['evidence_total'] # 999 预览会话已剔除 try: run(A.pbl_evidence_subject_summary(subject_type='school', subject_id='X')) raise AssertionError('非法 subject_type 必须被拒') except PblError as exc: assert exc.code == 'PBL_E_PARAM', exc.code try: run(A.pbl_evidence_subject_summary(subject_type='student')) raise AssertionError('缺 subject_id 必须被拒') except PblError as exc: assert exc.code == 'PBL_E_PARAM', exc.code # ══════════════════════════════════════════════════════════════════════ # 5. Q-OPEN-10 预览 / Playtest 默认过滤 # ══════════════════════════════════════════════════════════════════════ def test_qopen10_preview_excluded_by_default(): install_fake(seed_rows()) default = run(A.pbl_evidence_query()) withprev = run(A.pbl_evidence_query(include_preview=True, include_student_private=True, preview_session_ids=['999'])) assert default['total'] == 5, default['total'] assert withprev['total'] == 9, withprev['total'] assert withprev['preview_filter']['applied'] is False, withprev['preview_filter'] assert withprev['by_type']['observation'] == 2, withprev['by_type'] def test_qopen10_rules_pure(): prev, rule = A.is_preview_row({'payload_json': json.dumps({'pbl_preview': True}), 'session_id': '1'}) assert prev and rule == 'payload:pbl_preview', rule prev, rule = A.is_preview_row({'payload_json': json.dumps({'env_mode': 'playtest'}), 'session_id': '1'}) assert prev and rule == 'payload:env_mode', rule prev, rule = A.is_preview_row({'payload_json': json.dumps({'mode': 'demo'}), 'session_id': '1'}) assert prev and rule == 'payload:mode', rule prev, rule = A.is_preview_row({'payload_json': json.dumps({'event_type': 'x'}), 'session_id': '999'}, ['999']) assert prev and rule == 'preview_session:999', rule prev, _ = A.is_preview_row({'payload_json': '{bad json', 'session_id': '1'}) assert prev is False, '坏 JSON 不得误判为预览' priv, rule = A.is_student_private({'payload_json': json.dumps({'visibility': 'STUDENT'})}) assert priv and rule.startswith('payload:visibility'), rule priv, _ = A.is_student_private({'payload_json': json.dumps({'visibility': 'teacher'})}) assert priv is False kept, stats = A.apply_default_filters( [{'id': 1, 'payload_json': '{}', 'session_id': '1', 'learner_id': 'S01'}], {'subject_type': 'student', 'subject_id': 'S02'}) assert kept == [] and stats['excluded_subject'] == 1, stats key, derived = A.resolve_idempotency_key({'tenant_id': '1', 'source_event_id': 'EV1', 'evidence_type': 'action'}) key2, _ = A.resolve_idempotency_key({'tenant_id': '1', 'source_event_id': 'EV1', 'evidence_type': 'action'}) assert key == key2 and derived is True, (key, key2) assert A.resolve_idempotency_key({'idempotency_key': 'abc'})[0] == 'abc' def test_sql_shape_and_column_pruning(): install_fake(seed_rows()) _CAPTURED['sql'].clear() run(A.pbl_evidence_aggregate(group_by='learner')) sql = [s for s in _CAPTURED['sql'] if 'from `pbl_evidence`' in s.lower()][0] assert 'select `id`, `evidence_type`' in sql.lower(), sql assert '`source_event_id`' in sql and '`dedup_key`' in sql and '`payload_json`' in sql, sql assert 'order by `occurred_at` desc' in sql.lower(), sql assert 'limit ${scan_limit}$' in sql.lower(), sql def test_watermark_contract(): install_fake(seed_rows()) agg = run(A.pbl_evidence_aggregate(group_by='blueprint')) wm = agg['watermark'] assert wm['count'] == 5 and wm['max_id'] == 5, wm assert wm['min_occurred_at'] == '2026-09-20 10:00:01', wm q = run(A.pbl_evidence_query(learner_id='S03')) assert q['watermark']['max_occurred_at'] == '2026-09-21 08:00:00', q['watermark'] ALL_TESTS = sorted([v for k, v in list(globals().items()) if k.startswith('test_') and callable(v)]) def main(): passed, failed = 0, 0 for fn in ALL_TESTS: try: fn() except Exception as exc: failed += 1 print('FAIL %s: %s' % (fn.__name__, exc)) else: passed += 1 print('PASS %s' % fn.__name__) print('\n== M5b selftest: %d passed, %d failed, %d total ==' % (passed, failed, len(ALL_TESTS))) return 1 if failed else 0 if __name__ == '__main__': sys.exit(main())