Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s

# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
This commit is contained in:
wangjia
2026-07-11 16:44:05 +08:00
228 changed files with 13418 additions and 1106 deletions
@@ -20,6 +20,7 @@ const _frozenMeKeys = <String>{
'devices_used',
'devices_max',
'quota_today_min',
'quota_cap_min',
'data_today_gb',
'weekly_gb',
'totp_enabled',
@@ -37,6 +38,7 @@ Map<String, dynamic> _meSample() => {
'devices_used': 2,
'devices_max': 5,
'quota_today_min': null, // pro/team 不限 → null
'quota_cap_min': null, // pro/team 不限 → null
'data_today_gb': 1.5,
'weekly_gb': [0.1, 0.2, 0.0, 1.0, 2.0, 0.5, 1.5],
'totp_enabled': true,
+3
View File
@@ -20,6 +20,9 @@ Future<void> testExecutable(FutureOr<void> Function() testMain) async {
'test/fonts/Manrope-Bold.ttf',
]);
await _load('JetBrains Mono', const ['test/fonts/JetBrainsMono-Regular.ttf']);
// Noto Sans SC 子集(= 生产打包同一 client/fonts/ 子集):golden 中文用真字体渲染,
// 不出豆腐块、与真机一致(family 须匹配 PangolinFonts.cjk = 'Noto Sans SC')。
await _load('Noto Sans SC', const ['test/fonts/NotoSansSC-Regular-subset.otf']);
// Lucide 图标字体(family 须带 package 前缀以匹配 PangolinIcons 的 fontPackage)。
await _load('packages/lucide_icons/Lucide', const ['packages/lucide_icons_patched/fonts/lucide.ttf']);
await testMain();
Binary file not shown.
@@ -76,12 +76,12 @@ void main() {
);
});
testWidgets('额度卡(已解锁$suffix', (tester) async {
testWidgets('额度卡(已耗尽$suffix', (tester) async {
await goldenOf(
tester,
QuotaCard(quota: const FreeQuotaState(adUnlocked: true), t: t, onWatchAd: () {}),
QuotaCard(quota: const FreeQuotaState(remainingMinutes: 0), t: t, onWatchAd: () {}),
find.byType(QuotaCard),
'quota_unlocked_$suffix',
'quota_exhausted_$suffix',
dark: dark,
);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 65 KiB

@@ -91,6 +91,15 @@ class _FakeMeNotifier extends MeNotifier {
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(
@@ -112,7 +121,7 @@ Future<void> _shoot(
tokenStoreProvider.overrideWithValue(const _NullTokenStore()),
vpnBridgeProvider.overrideWithValue(VpnBridgeMock()),
navViewProvider.overrideWith((ref) => view),
localeProvider.overrideWith((ref) => lang),
localeProvider.overrideWith((ref) => _FixedLocale(lang)),
nodesProvider.overrideWith(_FakeNodesNotifier.new),
meProvider.overrideWith(_FakeMeNotifier.new),
usageProvider((days: 30, device: null)).overrideWith((ref) async => _demoUsage),
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/services/api_config.dart';
void main() {
test('控制面基址默认走 https(禁止回退明文 http)', () {
expect(kApiBaseUrl, startsWith('https://'),
reason: '控制面已迁 CF Tunnel(api.yanmeiai.com);默认值不得是明文 http');
expect(kApiBaseUrl, isNot(contains('103.119.13.48')),
reason: '不得再硬编码节点 IP 作控制面基址');
});
}
+96 -14
View File
@@ -8,11 +8,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
import 'package:pangolin_vpn/l10n/app_text.dart';
import 'package:pangolin_vpn/l10n/strings_zh.dart';
import 'package:pangolin_vpn/models/node.dart';
import 'package:pangolin_vpn/services/connect_api.dart';
import 'package:pangolin_vpn/services/device_identity.dart';
import 'package:pangolin_vpn/state/app_providers.dart';
import 'package:pangolin_vpn/state/connection_provider.dart';
import 'package:pangolin_vpn/state/nodes_provider.dart';
import 'package:pangolin_vpn/state/quota_provider.dart';
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
class _FixedLocale extends LocaleNotifier {
_FixedLocale(AppLang lang) {
state = lang;
}
}
// 可控假桥:能手动推 VpnStatus,无内部计时器(避免 pending timer)。
class _FakeBridge implements VpnBridge {
@@ -54,6 +65,16 @@ class _FakeConnectApi extends ConnectApi {
void dispose() {}
}
// 内存 SecureKV:避免测试里 flutter_secure_storage 平台通道无 handler 时 deviceId() 挂起
// (进而 _connect 卡在 connecting)。#18 自动重连会走 _connect,必须让 deviceId() 立即返回。
class _MemKV implements SecureKV {
final _m = <String, String>{};
@override
Future<String?> read(String key) async => _m[key];
@override
Future<void> write(String key, String value) async => _m[key] = value;
}
// 两节点都可用(status=up)。
class _StubNodes extends NodesNotifier {
@override
@@ -82,8 +103,10 @@ void main() {
ProviderContainer makeContainer(_FakeBridge bridge,
{NodesNotifier Function()? nodes, DateTime Function()? now}) =>
ProviderContainer(overrides: [
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
vpnBridgeProvider.overrideWithValue(bridge),
nodesProvider.overrideWith(nodes ?? _StubNodes.new),
deviceIdentityProvider.overrideWithValue(DeviceIdentity(store: _MemKV())),
connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()),
connectionProvider.overrideWith(
(ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider), now: now)),
@@ -100,19 +123,42 @@ void main() {
await tester.pump();
}
testWidgets('路径A:意外内核掉线(非用户主动)→ 置「节点异常」提示', (tester) async {
// #18:意外内核掉线(弱网最常见)先自动重连当前节点,不换节点、不立即报「节点异常」;
// 连续失败超过 _kMaxAutoReconnect(3) 次才真报「节点异常」。
testWidgets('#18 意外内核掉线(弱网)→ 先自动重连当前节点,超上限才报「节点异常」', (tester) async {
final bridge = _FakeBridge();
final c = makeContainer(bridge); // 节点都 up,排除路径B
final c = makeContainer(bridge); // 节点都 up(排除路径B);默认智能
addTearDown(bridge.dispose);
addTearDown(c.dispose);
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
await driveOn(tester, c, bridge);
expect(c.read(connectionProvider).phase, VpnPhase.on);
bridge.emit(VpnStatus.off); // 模拟节点数据面死 → REALITY 断 → 内核自报 off
c.read(nodesProvider);
c.read(connectionProvider); // 实例化控制器,使其订阅 bridge.statusStream(否则首个 emit 丢失)
await tester.pump();
final st = c.read(connectionProvider);
expect(st.phase, VpnPhase.off, reason: '内核掉线应回 off');
expect(st.error, t.nodeUnhealthyError, reason: '非用户主动掉线应给「节点异常」提示');
final initialSel = c.read(selectedNodeCodeProvider);
const maxRetries = 3; // = _kMaxAutoReconnect
Future<void> dropOnce() async {
bridge.emit(VpnStatus.on);
await tester.pump();
await tester.pump();
bridge.emit(VpnStatus.off); // 弱网:数据面瞬断 → 内核自报 off
// 泵到重连尝试(_connect fake 会失败回 off)settle,避免下一轮 emit(on) 与本轮 async 交错。
for (var k = 0; k < 15 && c.read(connectionProvider).phase != VpnPhase.off; k++) {
await tester.pump(const Duration(milliseconds: 10));
}
}
for (var i = 0; i < maxRetries; i++) {
await dropOnce();
expect(c.read(connectionProvider).error, isNot(t.nodeUnhealthyError),
reason: '${i + 1}次弱网掉线应自动重连,不应报「节点异常」');
expect(c.read(selectedNodeCodeProvider), initialSel, reason: '弱网不应换节点');
}
// 额度用尽 → 报「节点异常」。
await dropOnce();
expect(c.read(connectionProvider).phase, VpnPhase.off);
expect(c.read(connectionProvider).error, t.nodeUnhealthyError,
reason: '自动重连用尽后应给「节点异常」');
});
testWidgets('路径B:服务端判当前节点 down(智能)→ 切到其他可用节点', (tester) async {
@@ -126,17 +172,21 @@ void main() {
expect(c.read(selectedNodeCodeProvider), 'JP', reason: '智能模式应自动切到其他可用节点');
});
testWidgets('路径B:服务端判当前节点 down(手动)→ 断开 + 提示,不自动换', (tester) async {
// 路径B(服务端判当前节点 down)手动模式无备用:也走自动重连当前节点(不换、不立即报「节点异常」)。
// 重试用尽 → 报「节点异常」的额度逻辑由上面的 kernel-off 用例覆盖(同一 _tryAutoReconnectCurrent)。
testWidgets('#18 路径B 手动节点被判 down(无备用)→ 自动重连当前节点,不换、不立即报「节点异常」', (tester) async {
final bridge = _FakeBridge();
final c = makeContainer(bridge, nodes: _StubNodesHKDown.new);
addTearDown(bridge.dispose);
addTearDown(c.dispose);
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动选定 HK(已 down)
await driveOn(tester, c, bridge);
final st = c.read(connectionProvider);
expect(st.phase, VpnPhase.off, reason: '手动模式节点 down 应断开');
expect(st.error, t.nodeUnhealthyError, reason: '应给节点异常提示');
c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动 HK(已 down),无备用
await driveOn(tester, c, bridge); // on → 看门狗 _checkHealth 判 HK down → _onNodeUnhealthy
for (var k = 0; k < 15 && c.read(connectionProvider).phase != VpnPhase.off; k++) {
await tester.pump(const Duration(milliseconds: 10));
}
expect(c.read(connectionProvider).error, isNot(t.nodeUnhealthyError),
reason: '首次判死应自动重连当前节点,不立即报「节点异常」');
expect(c.read(selectedNodeCodeProvider), 'HK', reason: '手动模式不应自动换节点');
});
@@ -279,4 +329,36 @@ void main() {
bridge.emit(VpnStatus.off); // 收尾:停看门狗/计时器
await tester.pump();
});
// 免费版 10 分钟卡控:连接期倒计时,墙上时钟越过额度即自动切断 + 本地置耗尽(#21)。
testWidgets('免费额度倒计时归零 → 自动切断 + 置耗尽', (tester) async {
final bridge = _FakeBridge();
var fake = DateTime(2026, 7, 1, 12);
final c = makeContainer(bridge, now: () => fake); // 未登录默认免费,剩余 10 分钟
addTearDown(bridge.dispose);
addTearDown(c.dispose);
await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox()));
c.read(nodesProvider);
await tester.pump();
// 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 倒计时(fake api 抛错不影响锁定)。
expect(c.read(quotaProvider).remainingMinutes, 10);
c.read(connectionProvider.notifier).toggle();
await tester.pump();
bridge.emit(VpnStatus.on); // _startElapsed:_connectedAt = fake(T0)
await tester.pump();
await tester.pump();
expect(c.read(connectionProvider).phase, VpnPhase.on);
expect(c.read(connectionProvider).freeCountdown, isNotNull, reason: '连接期应有倒计时');
// 快进墙上时钟越过 10 分钟 → 下一个 1s tick 触发 _refreshElapsed → 切断。
fake = fake.add(const Duration(seconds: 601));
await tester.pump(const Duration(seconds: 1));
await tester.pump();
await tester.pump();
expect(c.read(connectionProvider).phase, VpnPhase.off, reason: '额度用完应自动切断');
expect(c.read(connectionProvider).error, t.quotaExhaustedNotice, reason: '应给「已用完」提示');
expect(c.read(quotaProvider).isExhausted, true, reason: '切断后本地置耗尽 → 按钮灰化');
});
}
+11 -6
View File
@@ -41,24 +41,29 @@ void main() {
expect(const FreeQuotaState(remainingMinutes: 3).isLow, true);
expect(const FreeQuotaState(remainingMinutes: 4).isLow, false);
});
test('isExhausted:剩余 ≤0 为真', () {
expect(const FreeQuotaState(remainingMinutes: 0).isExhausted, true);
expect(const FreeQuotaState(remainingMinutes: 1).isExhausted, false);
});
});
// 未登录默认态:总额 10 / 剩余 10 / 未解锁;watchAd 本地置位
// 未登录默认态:总额 10 / 剩余 10 / 未耗尽
group('quotaProvider', () {
test('默认 10/10 未解锁', () {
test('默认 10/10 未耗尽', () {
final c = _container();
addTearDown(c.dispose);
final q = c.read(quotaProvider);
expect(q.totalMinutes, 10);
expect(q.remainingMinutes, 10);
expect(q.adUnlocked, false);
expect(q.isExhausted, false);
});
test('watchAd 解锁今日使用', () {
test('markExhausted 本地立即置剩余 0', () {
final c = _container();
addTearDown(c.dispose);
c.read(quotaProvider.notifier).watchAd();
expect(c.read(quotaProvider).adUnlocked, true);
c.read(quotaProvider.notifier).markExhausted();
expect(c.read(quotaProvider).remainingMinutes, 0);
expect(c.read(quotaProvider).isExhausted, true);
});
});
}
+15 -7
View File
@@ -12,24 +12,32 @@ void main() {
setUpAll(disableGoogleFontsFetching);
const t = StringsZh();
testWidgets('额度卡:未解锁显示看广告按钮,点击回调', (tester) async {
testWidgets('额度卡(移动·有余额):显示看广告加时按钮,点击回调', (tester) async {
var watched = 0;
await tester.pumpWidget(wrapThemed(
QuotaCard(quota: const FreeQuotaState(), t: t, onWatchAd: () => watched++),
));
await tester.pump();
expect(find.text(t.watchAd), findsOneWidget);
await tester.tap(find.text(t.watchAd));
expect(find.text(t.watchAdMore), findsOneWidget);
await tester.tap(find.text(t.watchAdMore));
expect(watched, 1);
});
testWidgets('额度卡:已解锁显示已解锁文案', (tester) async {
testWidgets('额度卡(耗尽):显示今日已用完 + 看广告加时', (tester) async {
await tester.pumpWidget(wrapThemed(
QuotaCard(quota: const FreeQuotaState(adUnlocked: true), t: t, onWatchAd: () {}),
QuotaCard(quota: const FreeQuotaState(remainingMinutes: 0), t: t, onWatchAd: () {}),
));
await tester.pump();
expect(find.text(t.adUnlocked), findsOneWidget);
expect(find.byIcon(PangolinIcons.checkCircle), findsOneWidget);
expect(find.text(t.quotaUsedUp), findsOneWidget);
expect(find.text(t.watchAdMore), findsOneWidget);
});
testWidgets('额度卡(桌面·有余额):不显示加时按钮', (tester) async {
await tester.pumpWidget(wrapThemed(
QuotaCard(quota: const FreeQuotaState(), t: t, isDesktop: true, onWatchAd: () {}),
));
await tester.pump();
expect(find.text(t.watchAdMore), findsNothing);
});
testWidgets('推荐卡:展示推荐胶囊与文案,选中显示对勾', (tester) async {
@@ -8,10 +8,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/bridge/vpn_bridge.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/l10n/strings_zh.dart';
import 'package:pangolin_vpn/models/node.dart';
import 'package:pangolin_vpn/pangolin_theme.dart';
import 'package:pangolin_vpn/screens/nodes_page.dart';
import 'package:pangolin_vpn/state/app_providers.dart';
import 'package:pangolin_vpn/state/connection_provider.dart';
import 'package:pangolin_vpn/state/nodes_provider.dart';
import 'package:pangolin_vpn/widgets/smart_select_card.dart';
@@ -31,11 +33,19 @@ const _nodes = [
Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443),
];
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
class _FixedLocale extends LocaleNotifier {
_FixedLocale(AppLang lang) {
state = lang;
}
}
void main() {
setUpAll(disableGoogleFontsFetching);
const t = StringsZh();
ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
nodesProvider.overrideWith(_StubNodes.new),
vpnBridgeProvider.overrideWithValue(bridge),
]);
@@ -6,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pangolin_vpn/l10n/strings_zh.dart';
import 'package:pangolin_vpn/pangolin_theme.dart';
import 'package:pangolin_vpn/screens/stats_page.dart';
import 'package:pangolin_vpn/services/api_client.dart';
@@ -38,7 +37,6 @@ class _LoggedIn implements TokenStore {
void main() {
setUpAll(disableGoogleFontsFetching);
const t = StringsZh();
testWidgets('选设备 → /v1/usage 带 device=<uuid>', (tester) async {
await tester.binding.setSurfaceSize(const Size(900, 1200));
+10
View File
@@ -13,6 +13,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.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/l10n/strings_zh.dart';
import 'package:pangolin_vpn/models/node.dart';
import 'package:pangolin_vpn/pangolin_theme.dart';
@@ -20,6 +21,7 @@ import 'package:pangolin_vpn/screens/stats_page.dart';
import 'package:pangolin_vpn/services/api_client.dart';
import 'package:pangolin_vpn/services/token_store.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/nodes_provider.dart';
import 'package:pangolin_vpn/widgets/period_card.dart';
@@ -101,6 +103,13 @@ Finder _metric(String period, int i, String value, String unit) => find.byWidget
w.metrics[i].unit == unit,
);
// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。
class _FixedLocale extends LocaleNotifier {
_FixedLocale(AppLang lang) {
state = lang;
}
}
void main() {
setUpAll(disableGoogleFontsFetching);
const t = StringsZh();
@@ -111,6 +120,7 @@ void main() {
await tester.pumpWidget(ProviderScope(
overrides: [
localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)),
tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()),
apiClientProvider.overrideWithValue(ApiClient(
baseUrl: 'http://test.local',