diff --git a/client/test/widget/stats_page_test.dart b/client/test/widget/stats_page_test.dart new file mode 100644 index 0000000..efc43bd --- /dev/null +++ b/client/test/widget/stats_page_test.dart @@ -0,0 +1,158 @@ +// 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, + ); + }); +}