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>
This commit is contained in:
wangjia
2026-06-28 17:43:14 +08:00
parent 47147a1041
commit 01ebafc05e
12 changed files with 351 additions and 135 deletions
+25 -38
View File
@@ -1,10 +1,9 @@
// stats_page_test.dart — 问题① 前端统计「上屏数值对不对」widget 级断言。
// 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 不打真网络。
// 闭合「统计准不准」的前端那一半:把一份**真实形态的后端响应**(/v1/me·/v1/usage·
// /v1/usage/devices)经真 ApiClient→AccountApi→provider 链(连带测 JSON 解析)喂给
// 重设计后的 StatsPage,断言「本月/本周/今日」三张周期卡的 下行/上行/使用时长 上屏数字
// 正确。注入 MockClient 不打真网络。
import 'dart:convert';
import 'package:flutter/material.dart';
@@ -23,8 +22,7 @@ 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 'package:pangolin_vpn/widgets/period_card.dart';
import '../helpers/harness.dart';
@@ -52,7 +50,6 @@ class _LoggedInTokenStore implements TokenStore {
Future<String?> loadLastEmail() async => null;
}
// 真实形态后端响应(字段面对照 api_contract_test.dart 的冻结样本)。
final Map<String, dynamic> _meJson = {
'email': 'u@test.local',
'plan': 'free',
@@ -61,7 +58,7 @@ final Map<String, dynamic> _meJson = {
'weekly_gb': [0.1, 0.2, 0.0, 1.0, 2.0, 0.5, 1.5],
};
// 两天用量:总 3.0 GB、总 120 分钟(2.0 h)
// 两天用量(旧→新):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},
@@ -69,11 +66,9 @@ final Map<String, dynamic> _usageJson = {
],
};
// 两台设备: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},
],
};
@@ -90,22 +85,27 @@ MockClient _statsMockClient() => MockClient((req) async {
}
});
/// 排空 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,
/// 命中某周期卡的第 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 {
testWidgets('统计页:真实响应经解析后,月/周/日 周期卡 下行/上行/时长 上屏正确', (tester) async {
await tester.binding.setSurfaceSize(const Size(440, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
@@ -118,11 +118,9 @@ void main() {
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(
@@ -133,26 +131,15 @@ void main() {
));
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');
// 本月/本周(仅 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 天=本月)');
// ── 周柱: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,
);
// 今日(末天 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');
});
}