// 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 saveTokens({required String access, required String refresh}) async {} @override Future loadAccessToken() async => null; @override Future loadRefreshToken() async => null; @override Future clear() async {} @override Future markOnboarded() async {} @override Future isOnboarded() async => true; @override Future saveLastEmail(String email) async {} @override Future loadLastEmail() async => null; } // ── 演示数据(让网格/柱图/账户有内容;不依赖登录态与网络)───────────── const List _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 _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 _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> build() async => _demoNodes; } class _FakeMeNotifier extends MeNotifier { @override Future build() async => _demoMe; } // 固定语言的 locale notifier:localeProvider 是 StateNotifierProvider,override 须返回 LocaleNotifier(子类)。构造后直接钉死 state 为目标语言 // (prefs 在测试态为空,父类 _load() 不会改写)。 class _FixedLocale extends LocaleNotifier { _FixedLocale(AppLang lang) { state = lang; } } const Size _ipad = Size(1180, 820); // 对齐设计源 tabapp.jsx 内屏 Future _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:false(pump 固定帧)。 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)); }