aee9ba7b72
1. 记住邮箱:登录页预填上次邮箱(TokenStore.saveLastEmail,登录/注册成功时存, 退出登录不清除)。 2. 密码显隐:登录/注册密码框加眼睛按钮切换明文(PangolinIcons.eye/eyeOff)。 3. 7天免登陆:RootFlow 改为响应 authProvider——启动时有有效会话直接进主界面; AuthNotifier.refresh() 在服务端拒绝(refresh 过期/超 7 天)时 logout 回登录页, 网络错误则保留会话。会话恢复期间显示极简启动屏。 flutter analyze 0 error;114 tests passed。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
3.6 KiB
Dart
96 lines
3.6 KiB
Dart
// nodes_provider_test.dart — 节点选择 / 智能选择派生
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pangolin_vpn/l10n/app_text.dart';
|
|
import 'package:pangolin_vpn/models/node.dart';
|
|
import 'package:pangolin_vpn/services/token_store.dart';
|
|
import 'package:pangolin_vpn/state/auth_provider.dart';
|
|
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
|
|
|
// ── Stub TokenStore(无平台依赖)──────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
// 直接喂固定节点列表(不触发真实探针/HTTP)。
|
|
class _FakeNodes extends NodesNotifier {
|
|
_FakeNodes(this.list);
|
|
final List<Node> list;
|
|
@override
|
|
Future<List<Node>> build() async => list;
|
|
}
|
|
|
|
const _nodes = [
|
|
Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 30, uuid: 'hk'),
|
|
Node(code: 'JP', nameZh: '日本', nameEn: 'Tokyo', ping: 12, uuid: 'jp'),
|
|
Node(code: 'US', nameZh: '美国', nameEn: 'LA', ping: 200, uuid: 'us'),
|
|
];
|
|
|
|
ProviderContainer makeContainer({List<Override> overrides = const []}) =>
|
|
ProviderContainer(overrides: [
|
|
tokenStoreProvider.overrideWithValue(const _NullTokenStore()),
|
|
...overrides,
|
|
]);
|
|
|
|
void main() {
|
|
test('默认智能选择(AUTO)', () {
|
|
final c = makeContainer();
|
|
addTearDown(c.dispose);
|
|
expect(c.read(selectedNodeCodeProvider), kSmartNodeCode);
|
|
expect(c.read(isSmartSelectProvider), true);
|
|
});
|
|
|
|
test('无节点时 effectiveNode 返回占位(不再伪造演示节点)', () {
|
|
final c = makeContainer();
|
|
addTearDown(c.dispose);
|
|
expect(c.read(effectiveNodeProvider).code, kPlaceholderNode.code);
|
|
});
|
|
|
|
test('智能选择取实测延迟最小节点', () async {
|
|
final c = makeContainer(overrides: [nodesProvider.overrideWith(() => _FakeNodes(_nodes))]);
|
|
addTearDown(c.dispose);
|
|
await c.read(nodesProvider.future);
|
|
expect(c.read(effectiveNodeProvider).code, 'JP'); // ping 12 最小
|
|
});
|
|
|
|
test('选中具体节点后生效节点随之改变', () async {
|
|
final c = makeContainer(overrides: [nodesProvider.overrideWith(() => _FakeNodes(_nodes))]);
|
|
addTearDown(c.dispose);
|
|
await c.read(nodesProvider.future);
|
|
c.read(selectedNodeCodeProvider.notifier).state = 'US';
|
|
expect(c.read(isSmartSelectProvider), false);
|
|
expect(c.read(effectiveNodeProvider).code, 'US');
|
|
});
|
|
|
|
test('pingLabel:未测得显示 —', () {
|
|
expect(const Node(code: 'X', nameZh: 'x', nameEn: 'x', ping: 0).pingLabel, '—');
|
|
expect(const Node(code: 'X', nameZh: 'x', nameEn: 'x', ping: 42).pingLabel, '42ms');
|
|
});
|
|
|
|
test('localizedSub 不串语言:无标签用拉丁地名', () {
|
|
const tw = Node(code: 'TW', nameZh: '台湾 台北', nameEn: 'Taipei', ping: 28);
|
|
expect(tw.localizedSub(AppLang.zh), 'Taipei');
|
|
expect(tw.localizedSub(AppLang.en), 'Taipei');
|
|
const hk = Node(code: 'HK', nameZh: '香港 · 流媒体', nameEn: 'Hong Kong', ping: 18, tag: NodeTag.streaming);
|
|
expect(hk.localizedSub(AppLang.zh), '流媒体优化');
|
|
expect(hk.localizedSub(AppLang.en), 'Streaming');
|
|
});
|
|
}
|