Files
pangolin/client/test/contract/stats_contract_test.dart
T
wangjia 149aa90cfa test(contract): 第2刀b Dart 契约快照(stats + /v1 API 字段面冻结)
支柱 2(契约单源)守门 — 堵 mac-stats-0 一类跨进程契约悄悄分叉:
- test/contract/stats_contract_test.dart:冻结 pangolin/vpn/stats payload
  字段面 + status 四值,改契约即测试红、逼同步原生产出端 + 文档
- test/contract/api_contract_test.dart:冻结 /v1/me、/v1/usage、
  /v1/usage/devices 的 snake_case 字段面
- ci.yml:flutter job 测试纳入 test/contract;本地 15 用例全过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:32:00 +08:00

106 lines
4.0 KiB
Dart

// 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 = <String>{
'uploadBytes',
'downloadBytes',
'uploadSpeed',
'downloadSpeed',
'urltestResults',
};
const _frozenUrltestKeys = <String>{'tag', 'delayMs'};
const _frozenStatusValues = <String>{'off', 'connecting', 'on', 'error'};
/// canonical 契约样本 —— 原生侧应当产出的形状(全字段)。
Map<String, dynamic> _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<String>().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<Object?,Object?>', () {
final Map<Object?, Object?> 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);
});
});
}