diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index e3b4baf..14fd0b4 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -24,10 +24,14 @@ const _easeOut = Cubic(0.22, 1, 0.36, 1); enum _AuthMode { login, register } class AuthScreen extends ConsumerStatefulWidget { - const AuthScreen({super.key, required this.onDone, required this.t}); + const AuthScreen({super.key, required this.onDone, required this.t, this.api}); final VoidCallback onDone; final AppText t; + /// 测试可注入(见 test/widget/auth_invite_field_test.dart);生产不传,内部 + /// 用 kApiBaseUrl 现建一个,行为不变。 + final AuthApi? api; + @override ConsumerState createState() => _AuthScreenState(); } @@ -44,6 +48,7 @@ class _AuthScreenState extends ConsumerState final _email = TextEditingController(); final _code = TextEditingController(); final _pw = TextEditingController(); + final _invite = TextEditingController(); final _emailFocus = FocusNode(); final _pwFocus = FocusNode(); @@ -54,7 +59,7 @@ class _AuthScreenState extends ConsumerState duration: const Duration(milliseconds: 620), ); - late final AuthApi _api = AuthApi(baseUrl: kApiBaseUrl); + late final AuthApi _api = widget.api ?? AuthApi(baseUrl: kApiBaseUrl); bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text); @@ -79,6 +84,7 @@ class _AuthScreenState extends ConsumerState _email.dispose(); _code.dispose(); _pw.dispose(); + _invite.dispose(); _emailFocus.dispose(); _pwFocus.dispose(); _codeFocus.dispose(); @@ -108,6 +114,7 @@ class _AuthScreenState extends ConsumerState code: _code.text.trim(), password: _pw.text, device: device, + inviteCode: _invite.text.trim(), ); await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim()); await ref.read(authProvider.notifier).saveTokens(tokens); @@ -464,6 +471,11 @@ class _AuthScreenState extends ConsumerState ), const SizedBox(height: 16), _field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh, onSubmit: _submitRegister)), + const SizedBox(height: 16), + _field(c, icon: PangolinIcons.gift, label: t.inviteCodeFieldLabel, focused: false, + child: TextField(key: const Key('invite-code-field'), controller: _invite, + textInputAction: TextInputAction.done, + decoration: _bare.copyWith(hintText: ''))), const SizedBox(height: 18), _primaryBtn(c, label: _loading ? '...' : t.doCreate, onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null), diff --git a/client/test/widget/auth_invite_field_test.dart b/client/test/widget/auth_invite_field_test.dart new file mode 100644 index 0000000..f3db199 --- /dev/null +++ b/client/test/widget/auth_invite_field_test.dart @@ -0,0 +1,103 @@ +// auth_invite_field_test.dart — Task 13: 注册页「邀请码(选填)」输入框 +// +// 驱动路径:切到注册 tab(step0)→ 填邮箱 → 点发送验证码(mock 网络 204)→ +// 填 6 位验证码 → 点下一步(进 step1 设密码步)→ 断言 step1 渲染出 +// key 为 invite-code-field 的 TextField,label 为 t.inviteCodeFieldLabel。 +import 'dart:convert'; + +import 'package:flutter/material.dart'; +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/services/auth_api.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/widgets/auth_screen.dart'; + +import '../helpers/harness.dart'; + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +void main() { + setUpAll(disableGoogleFontsFetching); + const t = StringsZh(); + + Future _driveToRegisterStep1(WidgetTester tester) async { + final mockClient = MockClient((req) async { + if (req.method == 'POST' && req.url.path == '/v1/auth/code') { + return http.Response('', 204); + } + return http.Response(jsonEncode({'message_zh': 'unexpected'}), 404); + }); + final api = AuthApi(baseUrl: 'http://test.invalid', client: mockClient); + + await tester.pumpWidget(ProviderScope( + overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: AuthScreen(t: t, onDone: () {}, api: api), + ), + )); + await tester.pumpAndSettle(); + + // 切到注册 tab。 + await tester.tap(find.text(t.tabRegister)); + await tester.pumpAndSettle(); + + // step0:填邮箱 → 点发送验证码(mock 204)。 + await tester.enterText(find.byType(TextField).first, 'invitee@example.com'); + await tester.pump(); + await tester.tap(find.text(t.sendCode)); + await tester.pumpAndSettle(); + + // 填 6 位验证码 → 下一步,进 step1。 + await tester.enterText(find.byType(TextField).at(1), '123456'); + await tester.pump(); + await tester.tap(find.text(t.doNext)); + await tester.pumpAndSettle(); + } + + testWidgets('注册 step1(设密码步)渲染邀请码(选填)输入框', (tester) async { + await _driveToRegisterStep1(tester); + + expect(find.byKey(const Key('invite-code-field')), findsOneWidget); + expect(find.text(t.inviteCodeFieldLabel), findsOneWidget); + }); + + testWidgets('登录路径不含邀请码输入框', (tester) async { + final api = AuthApi(baseUrl: 'http://test.invalid', client: MockClient((req) async => http.Response('', 404))); + await tester.pumpWidget(ProviderScope( + overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: AuthScreen(t: t, onDone: () {}, api: api), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('invite-code-field')), findsNothing); + }); +}