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
镜像账户级时区曲线到「按设备」: - 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>
163 lines
7.0 KiB
Dart
163 lines
7.0 KiB
Dart
// 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: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));
|
||
}
|