// stats_page_test.dart — 问题① 前端统计「上屏数值对不对」widget 级断言(重设计后)。 // // 闭合「统计准不准」的前端那一半:把一份**真实形态的后端响应**(/v1/me·/v1/usage· // /v1/usage/devices)经真 ApiClient→AccountApi→provider 链(连带测 JSON 解析)喂给 // 重设计后的 StatsPage,断言「本月/本周/今日」三张周期卡的 下行/上行/使用时长 上屏数字 // 正确。注入 MockClient 不打真网络。 import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; import 'package:pangolin_vpn/l10n/app_text.dart'; import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/models/node.dart'; import 'package:pangolin_vpn/pangolin_theme.dart'; import 'package:pangolin_vpn/screens/stats_page.dart'; import 'package:pangolin_vpn/services/api_client.dart'; import 'package:pangolin_vpn/services/token_store.dart'; import 'package:pangolin_vpn/state/account_providers.dart'; import 'package:pangolin_vpn/state/app_providers.dart'; import 'package:pangolin_vpn/state/auth_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; import 'package:pangolin_vpn/widgets/period_card.dart'; import '../helpers/harness.dart'; const int _giB = 1024 * 1024 * 1024; /// 已登录的 TokenStore 替身:让 authProvider.isLoggedIn=true, /// 否则 me/usage/deviceUsage provider 会因未登录短路返回空。 class _LoggedInTokenStore implements TokenStore { const _LoggedInTokenStore(); @override Future loadAccessToken() async => 'test-token'; @override Future loadRefreshToken() async => null; @override Future saveTokens({required String access, required String refresh}) async {} @override Future clear() async {} @override Future markOnboarded() async {} @override Future isOnboarded() async => true; @override Future saveLastEmail(String email) async {} @override Future loadLastEmail() async => null; } final Map _meJson = { 'email': 'u@test.local', 'plan': 'free', 'devices_used': 2, 'devices_max': 5, 'weekly_gb': [0.1, 0.2, 0.0, 1.0, 2.0, 0.5, 1.5], }; // 两天用量(旧→新):24日 down/up=1GiB·90min;25日(末天=今日)down/up=0.5GiB·30min。 final Map _usageJson = { 'points': [ {'date': '2026-06-24', 'bytes_up': _giB, 'bytes_down': _giB, 'minutes_used': 90}, {'date': '2026-06-25', 'bytes_up': _giB ~/ 2, 'bytes_down': _giB ~/ 2, 'minutes_used': 30}, ], }; final Map _devicesJson = { 'devices': [ {'uuid': 'd1', 'name': 'iPhone', 'platform': 'ios', 'bytes_up': _giB, 'bytes_down': _giB, 'minutes_used': 120}, ], }; MockClient _statsMockClient() => MockClient((req) async { switch (req.url.path) { case '/v1/me': return http.Response(jsonEncode(_meJson), 200); case '/v1/usage/devices': return http.Response(jsonEncode(_devicesJson), 200); case '/v1/usage': return http.Response(jsonEncode(_usageJson), 200); default: return http.Response('{}', 404); } }); Future _drain(WidgetTester tester) async { for (var i = 0; i < 8; i++) { await tester.pump(const Duration(milliseconds: 20)); } } /// 命中某周期卡的第 i 个指标(数值+单位)。 Finder _metric(String period, int i, String value, String unit) => find.byWidgetPredicate( (w) => w is PeriodCard && w.period == period && w.metrics.length > i && w.metrics[i].value == value && w.metrics[i].unit == unit, ); // localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。 class _FixedLocale extends LocaleNotifier { _FixedLocale(AppLang lang) { state = lang; } } void main() { setUpAll(disableGoogleFontsFetching); const t = StringsZh(); testWidgets('统计页:真实响应经解析后,月/周/日 周期卡 下行/上行/时长 上屏正确', (tester) async { await tester.binding.setSurfaceSize(const Size(440, 1400)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(ProviderScope( overrides: [ localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)), tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()), apiClientProvider.overrideWithValue(ApiClient( baseUrl: 'http://test.local', getToken: () => 'test-token', refresh: () async => false, client: _statsMockClient(), )), effectiveNodeProvider.overrideWithValue( const Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 42, uuid: 'hk-01'), ), vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), ], child: MaterialApp( debugShowCheckedModeBanner: false, theme: PangolinTheme.light, home: const Scaffold(body: StatsPage(isWide: true)), ), )); await _drain(tester); // 本月/本周(仅 2 天,聚合相同):下行 Σ=1.5GB、上行 Σ=1.5GB、时长 120min/60=2.0h。 expect(_metric(t.periodMonth, 0, '1.5', 'GB'), findsOneWidget, reason: '本月下行 = 1.0+0.5 GiB'); expect(_metric(t.periodMonth, 1, '1.5', 'GB'), findsOneWidget, reason: '本月上行'); expect(_metric(t.periodMonth, 2, '2.0', 'h'), findsOneWidget, reason: '本月时长 = 120min/60'); expect(_metric(t.periodWeek, 0, '1.5', 'GB'), findsOneWidget, reason: '本周下行(仅 2 天=本月)'); // 今日(末天 25日):下行 0.5GiB=512MB、时长 30min/60=0.5h。 expect(_metric(t.periodToday, 0, '512.0', 'MB'), findsOneWidget, reason: '今日下行 = 0.5 GiB → MB'); expect(_metric(t.periodToday, 1, '512.0', 'MB'), findsOneWidget, reason: '今日上行'); expect(_metric(t.periodToday, 2, '0.5', 'h'), findsOneWidget, reason: '今日时长 = 30min/60'); }); }