Files
pangolin/client/test/widget/stats_page_test.dart
T
wangjia 01ebafc05e feat(client/stats): 统计页重设计 — 月/周/日周期卡 + 设备下拉 + 两周折线 + 修刷新
布局重做(对照确认稿):
- 3 张周期卡(本月/本周/今日),每张 = ↓下行 · ↑上行 · 使用时长(PeriodCard)
- 右上设备下拉(_DeviceDropdown,先只挂「全部」;每设备归因打通后填设备名,见 TODO #9/#10)
- 柱状图 → 折线图:最近两周(14 天)按天流量(UsageLineChart,CustomPainter 零依赖)

数据源统一 + 修刷新(根治「本月流量冻住」):
- 月/周/日 + 折线全从单一源 usageProvider(30) 聚合(取最后 N 天点求和,末点即今日,
  避开 UTC/本地日期坑);不再 me.weekly + usage 两条管线
- usageProvider/deviceUsageProvider 改 autoDispose(进页重取)+ 下拉刷新(invalidate)
  + 刷新连带 meProvider.refresh。后端每 60s 更新,无需更勤

UI 全走 PangolinColors/Text/Spacing token 单源、零硬编码;中英 l10n key 补齐。
widget 数值断言测试(刀1)按新结构重写并通过;统计/连接页 golden 重生成
(非 CI 闸;components/auth 基线未动)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:43:14 +08:00

146 lines
5.7 KiB
Dart

// 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/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/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<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;
}
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],
};
// 两天用量(旧→新):24日 down/up=1GiB·90min;25日(末天=今日)down/up=0.5GiB·30min。
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},
],
};
final Map<String, dynamic> _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<void> _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,
);
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(),
)),
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');
});
}