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:
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user