Files
pangolin/client/test/golden/tablet_pages_golden_test.dart
T
wangjia c4eb92fc2d feat(ds-flow): Phase 4.2/4.3/4.5 — CJK golden 字体 + timer 泄漏修复 + 基线重生成
4.5 harness/资源修复:UpdateNotifier 初始延迟由不可取消的 Future.delayed 改为
可取消 Timer,onDispose 一并取消——修 golden 整屏挂载时 pending-timer 泄漏
(生产也受益,provider dispose 不再悬挂 timer)。

4.2 CJK 测试字体:拷生产子集 NotoSansSC-Regular-subset.otf 进 test/fonts/ +
flutter_test_config 注册(family 'Noto Sans SC'),golden 中文真渲染不出豆腐块。

顺带修 tablet_pages_golden_test 既存编译错(6 语言改动遗留):localeProvider
override 返回值 AppLang → 改返回 LocaleNotifier 子类 _FixedLocale(钉死语言态)。

4.3 权威 Linux 容器(ghcr.io/cirruslabs/flutter)重生成全量 golden 基线:
34 tests All passed,32 张 PNG 更新(CJK 真字 + 暖阴影 + tablet zh/en 矩阵)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:27:09 +08:00

172 lines
7.4 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;
}
// 固定语言的 locale notifier:localeProvider 是 StateNotifierProvider<LocaleNotifier,
// AppLang>,override 须返回 LocaleNotifier(子类)。构造后直接钉死 state 为目标语言
// (prefs 在测试态为空,父类 _load() 不会改写)。
class _FixedLocale extends LocaleNotifier {
_FixedLocale(AppLang lang) {
state = lang;
}
}
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) => _FixedLocale(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));
}