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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
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),
|
||||
|
||||
@@ -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 作控制面基址');
|
||||
});
|
||||
}
|
||||
@@ -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: '切断后本地置耗尽 → 按钮灰化');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||