Files
pangolin/client/test/golden/tablet_pages_golden_test.dart
T
wangjia e25649cd27 feat(client/ios): iPad tablet 正式适配——断点≥900 对齐设计源 + tablet golden 回归闸
- form_factor 触屏 tablet 阈值 600→900,对齐 design/ui_kits/tablet(1180×820 横屏
  侧栏):iPad 横屏/12.9″竖屏走侧栏,标准/11″竖屏回落 mobile 底 Tab,避免双栏挤压
- form_factor_test 补全断点边界(900/899)与各 iPad 尺寸用例
- 新增 tablet_pages_golden_test:四视图×明暗×中英 12 张 golden,注入演示数据
  (6 节点/周流量/30 天用量)让 2 列网格与周柱满渲染,作回归闸
- design/CONTRACT.md:tablet 像素契约(取自 tabapp.jsx)+ 逐屏验收清单
- 真机 iPad mini 6 视觉签核通过;141 测试全绿

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:00:14 +08:00

154 lines
6.3 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/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,
);
});
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(30).overrideWith((ref) async => _demoUsage),
],
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));
}