// stats_contract_test.dart — 跨进程契约快照(支柱 2:契约单源)。 // // 冻结 pangolin/vpn/stats 与 pangolin/vpn/status 两条 EventChannel 的「字段面」。 // 任何字段增删改名 / 状态值变动都会让本测试失败,逼开发者显式更新基线 + 同步另一端。 // 这正是「mac 实时统计恒为 0」一类 bug 的守门:原生产出端与 Dart 消费端各写、悄悄 // 分叉时,没有任何东西报警。 // // ⚠️ 改下面的冻结集 = 改跨进程契约,必须同步三处(否则静默失效): // 1. 原生产出端:client/ios|macos/Runner/StatsClient.swift、 // client/android/.../PangolinVpnService.kt、桌面 client/lib/bridge/kernel_process.dart // 2. 消费端:client/lib/bridge/vpn_bridge.dart(VpnStatsEvent.fromMap / VpnStatus) // 3. 文档:vpn_bridge.dart 头注 + docs/dev-conventions.html 支柱 2 import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; // ── 契约冻结集 ──────────────────────────────────────────────────── const _frozenStatsKeys = { 'uploadBytes', 'downloadBytes', 'uploadSpeed', 'downloadSpeed', 'urltestResults', }; const _frozenUrltestKeys = {'tag', 'delayMs'}; const _frozenStatusValues = {'off', 'connecting', 'on', 'error'}; /// canonical 契约样本 —— 原生侧应当产出的形状(全字段)。 Map _canonicalStats() => { 'uploadBytes': 1024, 'downloadBytes': 2048, 'uploadSpeed': 128.0, 'downloadSpeed': 256.0, 'urltestResults': [ {'tag': 'auto', 'delayMs': 42}, ], }; void main() { group('stats EventChannel 契约快照', () { test('payload 字段面冻结(增删字段须同步原生 + 文档)', () { expect(_canonicalStats().keys.toSet(), _frozenStatsKeys); final urltest = (_canonicalStats()['urltestResults'] as List).first as Map; expect(urltest.keys.cast().toSet(), _frozenUrltestKeys); }); test('fromMap 正确解析全部字段与类型', () { final e = VpnStatsEvent.fromMap(_canonicalStats()); expect(e.uploadBytes, 1024); expect(e.downloadBytes, 2048); expect(e.uploadSpeed, 128.0); expect(e.downloadSpeed, 256.0); expect(e.urltestResults.single.tag, 'auto'); expect(e.urltestResults.single.delayMs, 42); }); test('整数速率也能解析(原生可能传 int 而非 double)', () { final e = VpnStatsEvent.fromMap({ ..._canonicalStats(), 'uploadSpeed': 128, // num → double 'downloadSpeed': 256, }); expect(e.uploadSpeed, 128.0); expect(e.downloadSpeed, 256.0); }); test('缺字段降级为 0/空,不抛(鲁棒性,避免半截 payload 崩溃)', () { final e = VpnStatsEvent.fromMap(const {}); expect(e.uploadBytes, 0); expect(e.downloadBytes, 0); expect(e.uploadSpeed, 0.0); expect(e.downloadSpeed, 0.0); expect(e.urltestResults, isEmpty); }); test('fromNativeMap 接受平台通道原始 Map', () { final Map raw = { 'uploadBytes': 10, 'downloadBytes': 20, 'uploadSpeed': 1.0, 'downloadSpeed': 2.0, 'urltestResults': const [], }; final e = VpnStatsEvent.fromNativeMap(raw); expect(e.uploadBytes, 10); expect(e.downloadBytes, 20); }); }); group('status EventChannel 契约快照', () { test('状态值集合冻结', () { expect(_frozenStatusValues, {'off', 'connecting', 'on', 'error'}); }); test('每个冻结值 round-trip 一致(fromString → toNativeString)', () { for (final s in _frozenStatusValues) { expect(VpnStatus.fromString(s).toNativeString(), s); } }); test('未知值降级为 error(不静默成 off 误导 UI)', () { expect(VpnStatus.fromString('garbage'), VpnStatus.error); expect(VpnStatus.fromString(''), VpnStatus.error); }); }); }