0f05385995
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 28s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 37s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 22s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 13s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 29s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 8s
ci-pangolin / Go — build + test (pull_request) Successful in 14s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 9s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 11m2s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 13m15s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 13m22s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 15m0s
早前 i18n 把默认语种改英文(localeProvider 默认 AppLang.en),但这几个测试硬编码 const t = StringsZh() 中文预期,被测代码经 appTextProvider 渲染英文 → 断言失配 (既存 broken,先于 ds-flow;DinD 修复后 Flutter test job 才跑到暴露)。 修:各失败测试的 ProviderScope/Container overrides 加 localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),把被测代码钉回 中文(与硬编码 StringsZh 预期一致)。只动测试文件,未碰 lib/golden/test_config。 - connection_watchdog_test(节点异常提示 / 免费额度归零切断) - node_connect_confirm_test(点节点/智能卡弹确认取消不切换) - stats_page_test(PeriodCard 周期卡中文标签) 容器复跑 test/unit+widget+contract:+97 All passed。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
6.1 KiB
Dart
156 lines
6.1 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/app_text.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/app_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,
|
|
);
|
|
|
|
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
|
|
class _FixedLocale extends LocaleNotifier {
|
|
_FixedLocale(AppLang lang) {
|
|
state = lang;
|
|
}
|
|
}
|
|
|
|
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: [
|
|
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
|
|
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');
|
|
});
|
|
}
|