Files
pangolin/client/test/golden/tablet_pages_golden_test.dart
T
wangjia e333f59cb3
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 19s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Failing after 14s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 6s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m8s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
feat(stats): #10 第②层 —— 统计页按设备过滤(per-device 本地日曲线)
镜像账户级时区曲线到「按设备」:
- migration 000018 `usage_device_hourly`(每设备 UTC 小时桶,稀疏)。
- ReportUsage deviceID>0 时加 AccumulateDeviceHourly;NodeStore 接口+impl+mock。
- usage.Store.DeviceHourlyRange(JOIN devices 校验归属+解析 uuid→id);UsageCurve
  增 deviceUUID 形参:空=账户级,非空=该设备本地日曲线(分桶逻辑复用)。
- /v1/usage?device=<uuid>;客户端 account_api.usage(device)、usageProvider key 改
  记录 (days,device)、stats_page 接 statsDeviceProvider → 选设备即重取该设备曲线。
测试:per-device 曲线隔离+归属校验(A的设备不进B)、UsageCurve(deviceUUID)、
migration v18、客户端 widget(选设备→/v1/usage 带 device=)。全量 go/flutter 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:04:33 +08:00

163 lines
7.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tablet_pages_golden_test.dart — iPad/tablet 整页 golden(连接/节点/统计/账户)
//
// 用 debugDefaultTargetPlatformOverride=iOS + 1180×820 surface 让 context.formFactor
// 判定为 tablet(宽 ≥900),渲染 TabletShell 各页,对照 design/ui_kits/tablet/tabapp.jsx
// 像素基准做保真校准 / 回归闸。注入演示数据(节点/用量/账户)让网格与柱图有内容;
// 覆盖矩阵 = 四视图 × {light,dark} × {zh,en}。生成基准:flutter test --update-goldens。
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.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/models/device_usage.dart';
import 'package:pangolin_vpn/models/me.dart';
import 'package:pangolin_vpn/models/node.dart';
import 'package:pangolin_vpn/models/usage_point.dart';
import 'package:pangolin_vpn/pangolin_theme.dart';
import 'package:pangolin_vpn/services/token_store.dart';
import 'package:pangolin_vpn/shell/home_shell.dart';
import 'package:pangolin_vpn/shell/tablet_shell.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/navigation_provider.dart';
import 'package:pangolin_vpn/state/nodes_provider.dart';
class _NullTokenStore implements TokenStore {
const _NullTokenStore();
@override
Future<void> saveTokens({required String access, required String refresh}) async {}
@override
Future<String?> loadAccessToken() async => null;
@override
Future<String?> loadRefreshToken() async => null;
@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;
}
// ── 演示数据(让网格/柱图/账户有内容;不依赖登录态与网络)─────────────
const List<Node> _demoNodes = [
Node(code: 'HK', nameZh: '香港 · 流媒体', nameEn: 'Hong Kong', ping: 18, tag: NodeTag.streaming, host: 'hk', port: 443),
Node(code: 'TW', nameZh: '台北', nameEn: 'Taipei', ping: 28, host: 'tw', port: 443),
Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 32, host: 'jp', port: 443),
Node(code: 'KR', nameZh: '首尔', nameEn: 'Seoul', ping: 38, host: 'kr', port: 443),
Node(code: 'SG', nameZh: '新加坡', nameEn: 'Singapore', ping: 45, tag: NodeTag.p2p, host: 'sg', port: 443),
Node(code: 'US', nameZh: '洛杉矶', nameEn: 'Los Angeles', ping: 120, host: 'us', port: 443),
];
const Me _demoMe = Me(
email: 'me@pangolin.vpn',
plan: 'free',
devicesUsed: 2,
devicesMax: 1,
quotaTodayMin: 6,
dataTodayGb: 1.2,
weeklyGb: [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0],
);
final List<UsagePoint> _demoUsage = List.generate(30, (i) {
return UsagePoint(
date: '2026-06-${(i + 1).toString().padLeft(2, '0')}',
bytesUp: 120 * 1024 * 1024 + i * 7 * 1024 * 1024,
bytesDown: 900 * 1024 * 1024 + i * 31 * 1024 * 1024,
minutesUsed: 24 + (i % 5) * 11,
);
});
// 分设备用量演示数据(统计页「设备明细」section):三台设备,流量递减以验证占比条。
const List<DeviceUsage> _demoDeviceUsage = [
DeviceUsage(uuid: 'd1', name: 'iPhone 15', platform: 'ios', bytesUp: 1500 * 1024 * 1024, bytesDown: 9800 * 1024 * 1024, minutesUsed: 312),
DeviceUsage(uuid: 'd2', name: 'MacBook Pro', platform: 'macos', bytesUp: 800 * 1024 * 1024, bytesDown: 5200 * 1024 * 1024, minutesUsed: 188),
DeviceUsage(uuid: 'd3', name: 'Pixel 8', platform: 'android', bytesUp: 200 * 1024 * 1024, bytesDown: 1400 * 1024 * 1024, minutesUsed: 64),
];
class _FakeNodesNotifier extends NodesNotifier {
@override
Future<List<Node>> build() async => _demoNodes;
}
class _FakeMeNotifier extends MeNotifier {
@override
Future<Me> build() async => _demoMe;
}
const Size _ipad = Size(1180, 820); // 对齐设计源 tabapp.jsx 内屏
Future<void> _shoot(
WidgetTester tester,
NavView view,
String name, {
bool settle = true,
Brightness brightness = Brightness.light,
AppLang lang = AppLang.zh,
}) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
tester.view.physicalSize = _ipad;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
try {
await tester.pumpWidget(ProviderScope(
overrides: [
tokenStoreProvider.overrideWithValue(const _NullTokenStore()),
vpnBridgeProvider.overrideWithValue(VpnBridgeMock()),
navViewProvider.overrideWith((ref) => view),
localeProvider.overrideWith((ref) => lang),
nodesProvider.overrideWith(_FakeNodesNotifier.new),
meProvider.overrideWith(_FakeMeNotifier.new),
usageProvider((days: 30, device: null)).overrideWith((ref) async => _demoUsage),
deviceUsageProvider(30).overrideWith((ref) async => _demoDeviceUsage),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
theme: brightness == Brightness.dark ? PangolinTheme.dark : PangolinTheme.light,
home: const HomeShell(),
),
));
if (settle) {
await tester.pumpAndSettle();
} else {
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
await expectLater(find.byType(TabletShell), matchesGoldenFile('goldens/$name.png'));
} finally {
debugDefaultTargetPlatformOverride = null;
}
}
void main() {
// 连接页 off 态含连接键虚线环静态,但稳妥起见用 settle:falsepump 固定帧)。
for (final lang in const [AppLang.zh, AppLang.en]) {
final l = lang == AppLang.zh ? 'zh' : 'en';
testWidgets('tablet 连接·light·$l',
(t) => _shoot(t, NavView.connect, 'tablet_connect_light_$l', settle: false, lang: lang));
testWidgets('tablet 节点·light·$l',
(t) => _shoot(t, NavView.servers, 'tablet_servers_light_$l', lang: lang));
testWidgets('tablet 统计·light·$l',
(t) => _shoot(t, NavView.stats, 'tablet_stats_light_$l', lang: lang));
testWidgets('tablet 账户·light·$l',
(t) => _shoot(t, NavView.account, 'tablet_account_light_$l', lang: lang));
}
// 深色覆盖(中文,验证暗主题各视图)。
testWidgets('tablet 连接·dark·zh',
(t) => _shoot(t, NavView.connect, 'tablet_connect_dark_zh', settle: false, brightness: Brightness.dark));
testWidgets('tablet 节点·dark·zh',
(t) => _shoot(t, NavView.servers, 'tablet_servers_dark_zh', brightness: Brightness.dark));
testWidgets('tablet 统计·dark·zh',
(t) => _shoot(t, NavView.stats, 'tablet_stats_dark_zh', brightness: Brightness.dark));
testWidgets('tablet 账户·dark·zh',
(t) => _shoot(t, NavView.account, 'tablet_account_dark_zh', brightness: Brightness.dark));
}