01ebafc05e
布局重做(对照确认稿): - 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>
146 lines
5.7 KiB
Dart
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');
|
|
});
|
|
}
|