// stats_page_test.dart — 问题① 前端统计「上屏数值对不对」widget 级断言。 // // 闭合最初诉求「统计准不准」的前端那一半:后端记账已 L4 e2e,前端此前只到 // 解析单测(device_usage_test/format_test)——解析对 ≠ 渲染对。本测试把一份 // **真实形态的后端响应**(/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/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/auth_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; import 'package:pangolin_vpn/widgets/device_stat_row.dart'; import 'package:pangolin_vpn/widgets/metric_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; } // 真实形态后端响应(字段面对照 api_contract_test.dart 的冻结样本)。 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], }; // 两天用量:总 3.0 GB、总 120 分钟(2.0 h)。 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}, ], }; // 两台设备:iPhone 2.0GB/2.0h、MacBook 1.0GB/1.0h。 final Map _devicesJson = { 'devices': [ {'uuid': 'd1', 'name': 'iPhone', 'platform': 'ios', 'bytes_up': _giB, 'bytes_down': _giB, 'minutes_used': 120}, {'uuid': 'd2', 'name': 'MacBook', 'platform': 'macos', 'bytes_up': _giB ~/ 2, 'bytes_down': _giB ~/ 2, 'minutes_used': 60}, ], }; 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); } }); /// 排空 token 加载 → provider 取数(MockClient 异步)的链路。 Future _drain(WidgetTester tester) async { for (var i = 0; i < 8; i++) { await tester.pump(const Duration(milliseconds: 20)); } } Finder _metricCard(String label, String value) => find.byWidgetPredicate( (w) => w is MetricCard && w.label == label && w.value == value, ); 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: [ tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()), apiClientProvider.overrideWithValue(ApiClient( baseUrl: 'http://test.local', getToken: () => 'test-token', refresh: () async => false, client: _statsMockClient(), )), // 生效节点 ping=42;未连接 → 延迟取节点 ping。 effectiveNodeProvider.overrideWithValue( const Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 42, uuid: 'hk-01'), ), // 假桥:让 connection/vpnStats provider 不触原生(且不自动连接 → 未连接态)。 vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), ], child: MaterialApp( debugShowCheckedModeBanner: false, theme: PangolinTheme.light, home: const Scaffold(body: StatsPage(isWide: true)), ), )); await _drain(tester); // ── 指标卡:本月流量 ΣgbTotal=3.0、本月时长 Σmin/60=2.0、延迟=节点 ping 42 ── expect(_metricCard(t.trafficMonth, '3.0'), findsOneWidget, reason: '本月流量 = 2.0+1.0 GB'); expect(_metricCard(t.durMonth, '2.0'), findsOneWidget, reason: '本月时长 = 120min/60'); expect(_metricCard(t.avgPing, '42'), findsOneWidget, reason: '未连接 → 延迟取节点 ping'); // ── 周柱:7 根来自 me.weekly_gb,取两个唯一值核对(普通 Text 可 find.text)── expect(find.text('0.1'), findsOneWidget, reason: 'weekly_gb[0]'); expect(find.text('1.5'), findsOneWidget, reason: 'weekly_gb[6]'); // ── 分设备:2 行,每行流量/时长正确 ── expect(find.byType(DeviceStatRow), findsNWidgets(2)); expect( find.byWidgetPredicate((w) => w is DeviceStatRow && w.name == 'iPhone' && w.gbText == '2.0' && w.hoursText == '2.0'), findsOneWidget, ); expect( find.byWidgetPredicate((w) => w is DeviceStatRow && w.name == 'MacBook' && w.gbText == '1.0' && w.hoursText == '1.0'), findsOneWidget, ); }); }