dbd183ee00
发现:CI flutter analyze 退出码 1(1204 个 info,0 error/warning),其中 1191 来自 第三方 vendored 包 lucide_icons_patched —— 这道闸一直红/无效。 - analysis_options.yaml:exclude packages/lucide_icons_patched/**(去 1191 噪音) - ci.yml:flutter analyze --no-fatal-infos(info 是建议级不该硬挡 merge; error/warning 仍致命)→ analyze EXIT 0、闸真正可用 - 顺手清 11 个 clean 文件 info:auth_api 删 >>>AUTHLOG 调试 print 残留(+unused sw)、 account_page/plan_card withOpacity→withValues、vpn_bridge where(is Map)→whereType、 测试文件删多余 import + final→const - 剩 2 个 info 在 stats-overhaul dirty 文件(stats_page/device_stat_row),留其合并清 - 验证:flutter analyze --no-fatal-infos EXIT 0(1204→2)、flutter test 48 全过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
264 lines
8.8 KiB
Dart
264 lines
8.8 KiB
Dart
// vpn_bridge_mock_test.dart — VpnBridgeMock 单元测试
|
||
//
|
||
// 验收条件(任务 tsk_iP5-_Ztq5THx):
|
||
// 1. start → connecting → on 事件序列完整流到 Dart 侧
|
||
// 2. stop → off 事件
|
||
// 3. stats 流在 on 状态下周期推帧(每 statsInterval)
|
||
// 4. selectOutbound 切换时产生 connecting → on 重连序列
|
||
// 5. getStatus / getActiveOutbound / setKillSwitch 返回正确值
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
|
||
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||
|
||
void main() {
|
||
// 测试用缩短延迟,避免测试耗时过长
|
||
const connectDelay = Duration(milliseconds: 80);
|
||
const statsInterval = Duration(milliseconds: 60);
|
||
|
||
VpnBridgeMock newMock() => VpnBridgeMock(
|
||
connectDelay: connectDelay,
|
||
statsInterval: statsInterval,
|
||
);
|
||
|
||
// ── 1. start → connecting → on → stop → off ─────────────────
|
||
group('status event sequence', () {
|
||
test('start emits connecting immediately', () async {
|
||
final mock = newMock();
|
||
final events = <VpnStatus>[];
|
||
final sub = mock.statusStream.listen(events.add);
|
||
|
||
await mock.start('{}');
|
||
|
||
expect(events, [VpnStatus.connecting]);
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
|
||
test('start then emits on after connectDelay', () async {
|
||
final mock = newMock();
|
||
final events = <VpnStatus>[];
|
||
final sub = mock.statusStream.listen(events.add);
|
||
|
||
await mock.start('{}');
|
||
await Future<void>.delayed(connectDelay + const Duration(milliseconds: 20));
|
||
|
||
expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on]));
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
|
||
test('stop after on emits off', () async {
|
||
final mock = newMock();
|
||
final events = <VpnStatus>[];
|
||
final sub = mock.statusStream.listen(events.add);
|
||
|
||
await mock.start('{}');
|
||
await Future<void>.delayed(connectDelay + const Duration(milliseconds: 20));
|
||
await mock.stop();
|
||
|
||
expect(events.last, VpnStatus.off);
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
|
||
test('full sequence: start→connecting→on→stop→off', () async {
|
||
final mock = newMock();
|
||
final events = <VpnStatus>[];
|
||
final sub = mock.statusStream.listen(events.add);
|
||
|
||
await mock.start('{}');
|
||
await Future<void>.delayed(connectDelay + const Duration(milliseconds: 20));
|
||
await mock.stop();
|
||
|
||
expect(events, containsAllInOrder([
|
||
VpnStatus.connecting,
|
||
VpnStatus.on,
|
||
VpnStatus.off,
|
||
]));
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
|
||
test('stop before on transitions: connecting → off (no on emitted)', () async {
|
||
final mock = newMock();
|
||
final events = <VpnStatus>[];
|
||
final sub = mock.statusStream.listen(events.add);
|
||
|
||
await mock.start('{}');
|
||
// Stop before connectDelay expires
|
||
await mock.stop();
|
||
|
||
// Should be: connecting, off — no 'on'
|
||
expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.off]));
|
||
expect(events, isNot(contains(VpnStatus.on)));
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
});
|
||
|
||
// ── 2. stats 流 ─────────────────────────────────────────────
|
||
group('stats stream', () {
|
||
test('stats stream emits frames after on transition', () async {
|
||
final mock = newMock();
|
||
final stats = <VpnStatsEvent>[];
|
||
final sub = mock.statsStream.listen(stats.add);
|
||
|
||
await mock.start('{}');
|
||
// Wait for on + at least 2 stats ticks
|
||
await Future<void>.delayed(
|
||
connectDelay + statsInterval * 2 + const Duration(milliseconds: 20),
|
||
);
|
||
|
||
expect(stats, isNotEmpty);
|
||
expect(stats.first.uploadSpeed, greaterThan(0));
|
||
expect(stats.first.downloadSpeed, greaterThan(0));
|
||
expect(stats.first.downloadBytes, greaterThan(0));
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
|
||
test('stats stream stops after stop()', () async {
|
||
final mock = newMock();
|
||
final stats = <VpnStatsEvent>[];
|
||
final sub = mock.statsStream.listen(stats.add);
|
||
|
||
await mock.start('{}');
|
||
await Future<void>.delayed(connectDelay + statsInterval * 2 + const Duration(milliseconds: 20));
|
||
final countBeforeStop = stats.length;
|
||
|
||
await mock.stop();
|
||
await Future<void>.delayed(statsInterval * 3);
|
||
final countAfterStop = stats.length;
|
||
|
||
expect(countBeforeStop, greaterThan(0));
|
||
// No new frames after stop
|
||
expect(countAfterStop, equals(countBeforeStop));
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
|
||
test('stats have urltestResults', () async {
|
||
final mock = newMock();
|
||
final stats = <VpnStatsEvent>[];
|
||
final sub = mock.statsStream.listen(stats.add);
|
||
|
||
await mock.start('{}');
|
||
await Future<void>.delayed(connectDelay + statsInterval + const Duration(milliseconds: 20));
|
||
|
||
expect(stats.first.urltestResults, isNotEmpty);
|
||
expect(stats.first.urltestResults.first.tag, isNotEmpty);
|
||
expect(stats.first.urltestResults.first.delayMs, greaterThan(0));
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
});
|
||
|
||
// ── 3. selectOutbound ────────────────────────────────────────
|
||
group('selectOutbound', () {
|
||
test('selectOutbound while on triggers reconnect sequence', () async {
|
||
final mock = newMock();
|
||
final events = <VpnStatus>[];
|
||
final sub = mock.statusStream.listen(events.add);
|
||
|
||
await mock.start('{}');
|
||
await Future<void>.delayed(connectDelay + const Duration(milliseconds: 20));
|
||
|
||
// Clear previous events for clarity
|
||
events.clear();
|
||
|
||
await mock.selectOutbound('jp-01');
|
||
await Future<void>.delayed(connectDelay + const Duration(milliseconds: 20));
|
||
|
||
expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on]));
|
||
|
||
await sub.cancel();
|
||
mock.dispose();
|
||
});
|
||
});
|
||
|
||
// ── 4. MethodChannel stub methods ────────────────────────────
|
||
group('stub methods', () {
|
||
test('getStatus returns current status', () async {
|
||
final mock = newMock();
|
||
expect(await mock.getStatus(), VpnStatus.off);
|
||
|
||
await mock.start('{}');
|
||
expect(await mock.getStatus(), VpnStatus.connecting);
|
||
|
||
await Future<void>.delayed(connectDelay + const Duration(milliseconds: 20));
|
||
expect(await mock.getStatus(), VpnStatus.on);
|
||
|
||
await mock.stop();
|
||
expect(await mock.getStatus(), VpnStatus.off);
|
||
|
||
mock.dispose();
|
||
});
|
||
|
||
test('getActiveOutbound returns default then updated tag', () async {
|
||
final mock = newMock();
|
||
expect(await mock.getActiveOutbound(), 'auto');
|
||
|
||
await mock.selectOutbound('hk-01');
|
||
expect(await mock.getActiveOutbound(), 'hk-01');
|
||
|
||
mock.dispose();
|
||
});
|
||
|
||
test('setKillSwitch completes without error', () async {
|
||
final mock = newMock();
|
||
await expectLater(mock.setKillSwitch(on: true), completes);
|
||
await expectLater(mock.setKillSwitch(on: false), completes);
|
||
mock.dispose();
|
||
});
|
||
});
|
||
|
||
// ── 5. VpnStatsEvent schema round-trip ───────────────────────
|
||
group('VpnStatsEvent schema', () {
|
||
test('fromMap / toMap round-trip', () {
|
||
const original = VpnStatsEvent(
|
||
uploadBytes: 1024,
|
||
downloadBytes: 4096,
|
||
uploadSpeed: 512.0,
|
||
downloadSpeed: 2048.0,
|
||
urltestResults: [
|
||
UrltestResult(tag: 'hk-01', delayMs: 18),
|
||
UrltestResult(tag: 'jp-01', delayMs: 32),
|
||
],
|
||
);
|
||
// toMap() returns Map<String, dynamic> — same type as fromMap expects
|
||
final decoded = VpnStatsEvent.fromMap(original.toMap());
|
||
|
||
expect(decoded.uploadBytes, original.uploadBytes);
|
||
expect(decoded.downloadBytes, original.downloadBytes);
|
||
expect(decoded.uploadSpeed, original.uploadSpeed);
|
||
expect(decoded.downloadSpeed, original.downloadSpeed);
|
||
expect(decoded.urltestResults.length, 2);
|
||
expect(decoded.urltestResults[0].tag, 'hk-01');
|
||
expect(decoded.urltestResults[1].delayMs, 32);
|
||
});
|
||
|
||
test('VpnStatus.fromString handles all known values', () {
|
||
expect(VpnStatus.fromString('off'), VpnStatus.off);
|
||
expect(VpnStatus.fromString('connecting'), VpnStatus.connecting);
|
||
expect(VpnStatus.fromString('on'), VpnStatus.on);
|
||
expect(VpnStatus.fromString('error'), VpnStatus.error);
|
||
expect(VpnStatus.fromString('unknown'), VpnStatus.error); // fallback
|
||
});
|
||
|
||
test('VpnStatus.toNativeString round-trips', () {
|
||
for (final s in VpnStatus.values) {
|
||
expect(VpnStatus.fromString(s.toNativeString()), s);
|
||
}
|
||
});
|
||
});
|
||
}
|