test(client): 刀1 — 统计页「上屏数值对不对」widget 断言(缺口①)

闭合最初诉求「统计准不准」的前端那一半:后端记账已 L4 e2e,前端此前只到
解析单测(解析对 ≠ 渲染对)。新测试把真实形态的后端响应(/v1/me·/v1/usage·
/v1/usage/devices)经真 ApiClient→AccountApi→provider 链(连带测 JSON 解析)
喂给 StatsPage,断言:
- 指标卡:本月流量 ΣgbTotal=3.0、本月时长 Σmin/60=2.0、未连接延迟=节点 ping 42
- 周柱:7 根来自 me.weekly_gb(取唯一值核对)
- 分设备:2 行,每行流量/时长正确

注入 MockClient 不打真网络;用 find.byWidgetPredicate 匹配 MetricCard/DeviceStatRow
公共字段,无需改生产代码。改坏期望值已验证转红(非永真)。自动并入 flutter-client job。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-25 22:59:05 +08:00
parent 55d368d98b
commit 60daa6b399
+158
View File
@@ -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<String?> loadAccessToken() async => 'test-token';
@override
Future<String?> loadRefreshToken() async => null;
@override
Future<void> saveTokens({required String access, required String refresh}) async {}
@override
Future<void> clear() async {}
@override
Future<void> markOnboarded() async {}
@override
Future<bool> isOnboarded() async => true;
@override
Future<void> saveLastEmail(String email) async {}
@override
Future<String?> loadLastEmail() async => null;
}
// 真实形态后端响应(字段面对照 api_contract_test.dart 的冻结样本)。
final Map<String, dynamic> _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<String, dynamic> _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<String, dynamic> _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<void> _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,
);
});
}