diff --git a/integration_test/desktop_settings_flow_test.dart b/integration_test/desktop_settings_flow_test.dart new file mode 100644 index 00000000..3f1accaa --- /dev/null +++ b/integration_test/desktop_settings_flow_test.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:xworkmate/app/app_controller_desktop_core.dart'; +import 'package:xworkmate/features/settings/settings_page.dart'; +import 'package:xworkmate/i18n/app_language.dart'; +import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/runtime/runtime_controllers_settings.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'settings page keeps canonical account status and logout behavior aligned', + (tester) async { + final fixtures = _buildSettingsPageFixtures(); + final controller = fixtures.controller; + final canonicalSettings = fixtures.canonicalSettings; + + final staleDraft = canonicalSettings.copyWith( + accountBaseUrl: 'https://draft-accounts.svc.plus', + accountUsername: 'draft@svc.plus', + acpBridgeServerModeConfig: canonicalSettings.acpBridgeServerModeConfig + .copyWith( + cloudSynced: canonicalSettings + .acpBridgeServerModeConfig + .cloudSynced + .copyWith( + accountBaseUrl: 'https://draft-accounts.svc.plus', + accountIdentifier: 'draft@svc.plus', + lastSyncAt: 987654321, + remoteServerSummary: + const AcpBridgeServerRemoteServerSummary( + endpoint: 'wss://draft-gateway.svc.plus', + hasAdvancedOverrides: true, + ), + ), + ), + ); + await controller.saveSettingsDraft(staleDraft); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(platform: TargetPlatform.macOS), + home: Scaffold( + body: RepaintBoundary( + key: const ValueKey('settings-page-boundary'), + child: SizedBox( + width: 1600, + height: 1200, + child: SettingsPage(controller: controller), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + final serviceUrlText = tester.widget( + find.byKey(const ValueKey('settings-account-summary-service-url')), + ); + final accountIdentifierText = tester.widget( + find.byKey( + const ValueKey('settings-account-summary-account-identifier'), + ), + ); + expect(serviceUrlText.data ?? '', contains('https://accounts.svc.plus')); + expect( + serviceUrlText.data ?? '', + isNot(contains('https://draft-accounts.svc.plus')), + ); + expect(accountIdentifierText.data ?? '', contains('canonical@svc.plus')); + expect( + accountIdentifierText.data ?? '', + isNot(contains('draft@svc.plus')), + ); + + await controller.settingsController.syncAccountSettings( + baseUrl: controller.settings.accountBaseUrl, + ); + await tester.pump(); + + expect( + controller.settingsController.syncedBaseUrls, + contains('https://accounts.svc.plus'), + ); + expect( + controller.settingsController.syncedBaseUrls, + isNot(contains('https://draft-accounts.svc.plus')), + ); + + await controller.settingsController.logoutAccount(); + await tester.pump(); + + expect(find.text('未登录'), findsOneWidget); + final loggedOutButton = tester.widget( + find.byKey(const ValueKey('settings-account-logout-button')), + ); + expect(loggedOutButton.onPressed, isNull); + }, + ); +} + +SettingsSnapshot _buildCanonicalSettings() { + final defaults = SettingsSnapshot.defaults(); + return defaults.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'canonical@svc.plus', + accountLocalMode: false, + acpBridgeServerModeConfig: defaults.acpBridgeServerModeConfig.copyWith( + cloudSynced: defaults.acpBridgeServerModeConfig.cloudSynced.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountIdentifier: 'canonical@svc.plus', + lastSyncAt: 123456789, + remoteServerSummary: const AcpBridgeServerRemoteServerSummary( + endpoint: 'wss://gateway.svc.plus', + hasAdvancedOverrides: false, + ), + ), + ), + ); +} + +_SettingsPageFixtures _buildSettingsPageFixtures() { + final canonicalSettings = _buildCanonicalSettings().copyWith( + appLanguage: AppLanguage.zh, + ); + final settingsController = _FakeSettingsController() + ..seedSignedInState(canonicalSettings); + final controller = _FakeSettingsPageController( + settingsController: settingsController, + settingsDraft: canonicalSettings, + ); + addTearDown(() { + controller.dispose(); + settingsController.dispose(); + }); + return _SettingsPageFixtures( + controller: controller, + canonicalSettings: canonicalSettings, + ); +} + +class _SettingsPageFixtures { + _SettingsPageFixtures({ + required this.controller, + required this.canonicalSettings, + }); + + final _FakeSettingsPageController controller; + final SettingsSnapshot canonicalSettings; +} + +class _FakeSettingsPageController extends ChangeNotifier + implements AppController { + _FakeSettingsPageController({ + required this.settingsController, + required SettingsSnapshot settingsDraft, + }) : _settingsDraft = settingsDraft; + + @override + final _FakeSettingsController settingsController; + SettingsSnapshot _settingsDraft; + + @override + SettingsSnapshot get settings => settingsController.snapshot; + + @override + SettingsSnapshot get settingsDraft => _settingsDraft; + + Future saveSettingsDraft(SettingsSnapshot snapshot) async { + _settingsDraft = snapshot; + notifyListeners(); + } + + Future saveSettings(SettingsSnapshot snapshot) async { + settingsController.snapshotInternal = snapshot; + _settingsDraft = snapshot; + notifyListeners(); + } + + @override + void navigateHome() {} + + @override + void openSettings({ + SettingsTab tab = SettingsTab.gateway, + SettingsDetailPage? detail, + SettingsNavigationContext? navigationContext, + }) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSettingsController extends SettingsController { + _FakeSettingsController() + : super(SecureConfigStore(enableSecureStorage: false)); + + final List syncedBaseUrls = []; + + void seedSignedInState(SettingsSnapshot settings) { + snapshotInternal = settings; + lastSnapshotJsonInternal = settings.toJsonString(); + accountSessionTokenInternal = 'session-token'; + accountSessionInternal = const AccountSessionSummary( + userId: 'u-1', + email: 'canonical@svc.plus', + name: 'Canonical', + role: 'member', + mfaEnabled: false, + ); + accountSyncStateInternal = AccountSyncState.defaults().copyWith( + syncState: 'ready', + syncMessage: 'Remote defaults synced', + lastSyncAtMs: 123456789, + lastSyncSource: 'https://accounts.svc.plus', + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + openclawUrl: 'wss://gateway.svc.plus', + apisixUrl: 'https://apisix.svc.plus', + ), + ); + accountStatusInternal = 'Signed in as canonical@svc.plus'; + accountBusyInternal = false; + pendingAccountMfaTicketInternal = ''; + pendingAccountBaseUrlInternal = ''; + } + + Future syncAccountSettings({String baseUrl = ''}) async { + syncedBaseUrls.add(baseUrl); + accountBusyInternal = true; + notifyListeners(); + accountSyncStateInternal = AccountSyncState.defaults().copyWith( + syncState: 'ready', + syncMessage: 'Remote defaults synced', + lastSyncAtMs: 123456789, + lastSyncSource: baseUrl, + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + openclawUrl: 'wss://gateway.svc.plus', + apisixUrl: 'https://apisix.svc.plus', + ), + ); + accountBusyInternal = false; + final email = accountSessionInternal?.email.trim() ?? ''; + accountStatusInternal = email.isEmpty ? 'Signed in' : 'Signed in as $email'; + notifyListeners(); + return const AccountSyncResult( + state: 'ready', + message: 'Remote defaults synced', + ); + } + + Future logoutAccount() async { + accountSessionTokenInternal = ''; + accountSessionInternal = null; + accountSyncStateInternal = null; + accountStatusInternal = 'Signed out'; + pendingAccountMfaTicketInternal = ''; + pendingAccountBaseUrlInternal = ''; + notifyListeners(); + } +} diff --git a/lib/app/workspace_page_registry.dart b/lib/app/workspace_page_registry.dart index 0a9ab9c6..6de266a2 100644 --- a/lib/app/workspace_page_registry.dart +++ b/lib/app/workspace_page_registry.dart @@ -120,14 +120,12 @@ workspacePageSpecsInternal = { initialTab: controller.settingsTab, initialDetail: controller.settingsDetail, navigationContext: controller.settingsNavigationContext, - showSectionTabs: true, ), mobileBuilder: (controller, onOpenDetail) => SettingsPage( controller: controller, initialTab: controller.settingsTab, initialDetail: controller.settingsDetail, navigationContext: controller.settingsNavigationContext, - showSectionTabs: true, ), ), WorkspaceDestination.account: WorkspacePageSpec( diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index 9890c918..d34ab39c 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -19,15 +19,12 @@ class SettingsPage extends StatefulWidget { this.initialTab = SettingsTab.gateway, this.initialDetail, this.navigationContext, - this.showSectionTabs = true, }); final AppController controller; final SettingsTab initialTab; final SettingsDetailPage? initialDetail; final SettingsNavigationContext? navigationContext; - final bool showSectionTabs; - @override State createState() => _SettingsPageState(); } @@ -75,20 +72,21 @@ class _SettingsPageState extends State { controller.settingsController, ]), builder: (context, _) { - final settings = controller.settingsDraft; + final currentSettings = controller.settings; + final settingsDraft = controller.settingsDraft; final accountState = controller.settingsController.accountSyncState; final accountBusy = controller.settingsController.accountBusy; final accountSignedIn = controller.settingsController.accountSignedIn; final accountSession = controller.settingsController.accountSession; - final cloudSync = settings.acpBridgeServerModeConfig.cloudSynced; + final cloudSync = currentSettings.acpBridgeServerModeConfig.cloudSynced; final remoteSummary = cloudSync.remoteServerSummary.endpoint.trim(); final serviceUrl = cloudSync.accountBaseUrl.trim().isNotEmpty ? cloudSync.accountBaseUrl.trim() - : settings.accountBaseUrl.trim(); + : currentSettings.accountBaseUrl.trim(); final accountIdentifier = cloudSync.accountIdentifier.trim().isNotEmpty ? cloudSync.accountIdentifier.trim() - : settings.accountUsername.trim().isNotEmpty - ? settings.accountUsername.trim() + : currentSettings.accountUsername.trim().isNotEmpty + ? currentSettings.accountUsername.trim() : (accountSession?.email.trim() ?? ''); final sessionLabel = accountSignedIn ? appText( @@ -191,14 +189,23 @@ class _SettingsPageState extends State { const SizedBox(height: 8), Text( '${appText('服务地址', 'Service URL')}: ${serviceUrl.isEmpty ? appText('待配置', 'Pending') : serviceUrl}', + key: const ValueKey( + 'settings-account-summary-service-url', + ), ), const SizedBox(height: 6), Text( '${appText('账户标识', 'Account Identifier')}: ${accountIdentifier.isEmpty ? appText('待登录', 'Not signed in') : accountIdentifier}', + key: const ValueKey( + 'settings-account-summary-account-identifier', + ), ), const SizedBox(height: 6), Text( '${appText('最近同步', 'Last Sync')}: ${_formatSyncTime(cloudSync.lastSyncAt)}', + key: const ValueKey( + 'settings-account-summary-last-sync', + ), ), ], ), @@ -212,7 +219,7 @@ class _SettingsPageState extends State { key: const ValueKey('settings-account-sync-button'), onPressed: accountBusy ? null - : () => _syncAccount(settings), + : () => _syncAccount(currentSettings), child: Text(appText('重新同步', 'Sync Again')), ), FilledButton.tonal( @@ -295,7 +302,7 @@ class _SettingsPageState extends State { key: const ValueKey('settings-base-sync-button'), onPressed: accountBusy ? null - : () => _syncAccount(settings), + : () => _syncAccount(settingsDraft), child: Text(appText('重新同步', 'Sync Again')), ), FilledButton.tonal( @@ -304,7 +311,7 @@ class _SettingsPageState extends State { ), onPressed: accountBusy ? null - : () => _disconnectManagedBase(settings), + : () => _disconnectManagedBase(settingsDraft), child: Text(appText('断开', 'Disconnect')), ), ], diff --git a/test/features/settings/goldens/settings_page_account_status_canonical.png b/test/features/settings/goldens/settings_page_account_status_canonical.png new file mode 100644 index 00000000..c40e018d Binary files /dev/null and b/test/features/settings/goldens/settings_page_account_status_canonical.png differ diff --git a/test/features/settings/settings_page_core_test.dart b/test/features/settings/settings_page_core_test.dart new file mode 100644 index 00000000..b3a335d6 --- /dev/null +++ b/test/features/settings/settings_page_core_test.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller_desktop_core.dart'; +import 'package:xworkmate/features/settings/settings_page.dart'; +import 'package:xworkmate/i18n/app_language.dart'; +import 'package:xworkmate/models/app_models.dart'; +import 'package:xworkmate/runtime/runtime_controllers_settings.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Settings page account status', () { + testWidgets( + 'reads canonical settings instead of a stale draft and syncs from the active account URL', + (tester) async { + final fixtures = _buildSettingsPageFixtures(); + final controller = fixtures.controller; + final canonicalSettings = fixtures.canonicalSettings; + + final staleDraft = canonicalSettings.copyWith( + accountBaseUrl: 'https://draft-accounts.svc.plus', + accountUsername: 'draft@svc.plus', + acpBridgeServerModeConfig: canonicalSettings.acpBridgeServerModeConfig + .copyWith( + cloudSynced: canonicalSettings + .acpBridgeServerModeConfig + .cloudSynced + .copyWith( + accountBaseUrl: 'https://draft-accounts.svc.plus', + accountIdentifier: 'draft@svc.plus', + lastSyncAt: 987654321, + remoteServerSummary: + const AcpBridgeServerRemoteServerSummary( + endpoint: 'wss://draft-gateway.svc.plus', + hasAdvancedOverrides: true, + ), + ), + ), + ); + await controller.saveSettingsDraft(staleDraft); + expect(controller.settings.accountBaseUrl, 'https://accounts.svc.plus'); + expect(controller.settingsController.accountSignedIn, isTrue); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(platform: TargetPlatform.macOS), + home: Scaffold( + body: RepaintBoundary( + key: const ValueKey('settings-page-boundary'), + child: SizedBox( + width: 1600, + height: 1200, + child: SettingsPage(controller: controller), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + final serviceUrlText = tester.widget( + find.byKey(const ValueKey('settings-account-summary-service-url')), + ); + final accountIdentifierText = tester.widget( + find.byKey( + const ValueKey('settings-account-summary-account-identifier'), + ), + ); + final syncButton = tester.widget( + find.byKey(const ValueKey('settings-account-sync-button')), + ); + + final serviceUrlTextContent = + serviceUrlText.data ?? serviceUrlText.textSpan?.toPlainText() ?? ''; + final accountIdentifierTextContent = + accountIdentifierText.data ?? + accountIdentifierText.textSpan?.toPlainText() ?? + ''; + + expect(serviceUrlTextContent, contains('https://accounts.svc.plus')); + expect( + serviceUrlTextContent, + isNot(contains('https://draft-accounts.svc.plus')), + ); + expect(accountIdentifierTextContent, contains('canonical@svc.plus')); + expect(accountIdentifierTextContent, isNot(contains('draft@svc.plus'))); + expect(syncButton.onPressed, isNotNull); + + await controller.settingsController.syncAccountSettings( + baseUrl: controller.settings.accountBaseUrl, + ); + await tester.pump(); + + expect( + controller.settingsController.syncedBaseUrls, + contains('https://accounts.svc.plus'), + ); + expect( + controller.settingsController.syncedBaseUrls, + isNot(contains('https://draft-accounts.svc.plus')), + ); + + await controller.settingsController.logoutAccount(); + await tester.pump(); + + expect(find.text('未登录'), findsOneWidget); + final loggedOutButton = tester.widget( + find.byKey(const ValueKey('settings-account-logout-button')), + ); + expect(loggedOutButton.onPressed, isNull); + }, + ); + + testWidgets('renders the signed-in account status card consistently', ( + tester, + ) async { + final fixtures = _buildSettingsPageFixtures(); + final controller = fixtures.controller; + await tester.binding.setSurfaceSize(const Size(1600, 1200)); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(platform: TargetPlatform.macOS), + home: Scaffold( + body: RepaintBoundary( + key: const ValueKey('settings-page-boundary'), + child: SizedBox( + width: 1600, + height: 1200, + child: SettingsPage(controller: controller), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + await expectLater( + find.byKey(const ValueKey('settings-page-boundary')), + matchesGoldenFile('goldens/settings_page_account_status_canonical.png'), + ); + }); + }); +} + +SettingsSnapshot _buildCanonicalSettings() { + final defaults = SettingsSnapshot.defaults(); + return defaults.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountUsername: 'canonical@svc.plus', + accountLocalMode: false, + acpBridgeServerModeConfig: defaults.acpBridgeServerModeConfig.copyWith( + cloudSynced: defaults.acpBridgeServerModeConfig.cloudSynced.copyWith( + accountBaseUrl: 'https://accounts.svc.plus', + accountIdentifier: 'canonical@svc.plus', + lastSyncAt: 123456789, + remoteServerSummary: const AcpBridgeServerRemoteServerSummary( + endpoint: 'wss://gateway.svc.plus', + hasAdvancedOverrides: false, + ), + ), + ), + ); +} + +_SettingsPageFixtures _buildSettingsPageFixtures() { + final canonicalSettings = _buildCanonicalSettings().copyWith( + appLanguage: AppLanguage.zh, + ); + final settingsController = _FakeSettingsController() + ..seedSignedInState(canonicalSettings); + final controller = _FakeSettingsPageController( + settingsController: settingsController, + settingsDraft: canonicalSettings, + ); + addTearDown(() { + controller.dispose(); + settingsController.dispose(); + }); + return _SettingsPageFixtures( + controller: controller, + canonicalSettings: canonicalSettings, + ); +} + +class _SettingsPageFixtures { + _SettingsPageFixtures({ + required this.controller, + required this.canonicalSettings, + }); + + final _FakeSettingsPageController controller; + final SettingsSnapshot canonicalSettings; +} + +class _FakeSettingsPageController extends ChangeNotifier + implements AppController { + _FakeSettingsPageController({ + required this.settingsController, + required SettingsSnapshot settingsDraft, + }) : _settingsDraft = settingsDraft; + + @override + final _FakeSettingsController settingsController; + + SettingsSnapshot _settingsDraft; + + @override + SettingsSnapshot get settings => settingsController.snapshot; + + @override + SettingsSnapshot get settingsDraft => _settingsDraft; + + Future saveSettingsDraft(SettingsSnapshot snapshot) async { + _settingsDraft = snapshot; + notifyListeners(); + } + + Future saveSettings(SettingsSnapshot snapshot) async { + settingsController.snapshotInternal = snapshot; + _settingsDraft = snapshot; + notifyListeners(); + } + + @override + void navigateHome() {} + + @override + void openSettings({ + SettingsTab tab = SettingsTab.gateway, + SettingsDetailPage? detail, + SettingsNavigationContext? navigationContext, + }) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSettingsController extends SettingsController { + _FakeSettingsController() + : super(SecureConfigStore(enableSecureStorage: false)); + + final List syncedBaseUrls = []; + + void seedSignedInState(SettingsSnapshot settings) { + snapshotInternal = settings; + lastSnapshotJsonInternal = settings.toJsonString(); + accountSessionTokenInternal = 'session-token'; + accountSessionInternal = const AccountSessionSummary( + userId: 'u-1', + email: 'canonical@svc.plus', + name: 'Canonical', + role: 'member', + mfaEnabled: false, + ); + accountSyncStateInternal = AccountSyncState.defaults().copyWith( + syncState: 'ready', + syncMessage: 'Remote defaults synced', + lastSyncAtMs: 123456789, + lastSyncSource: 'https://accounts.svc.plus', + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + openclawUrl: 'wss://gateway.svc.plus', + apisixUrl: 'https://apisix.svc.plus', + ), + ); + accountStatusInternal = 'Signed in as canonical@svc.plus'; + accountBusyInternal = false; + pendingAccountMfaTicketInternal = ''; + pendingAccountBaseUrlInternal = ''; + } + + Future syncAccountSettings({String baseUrl = ''}) async { + syncedBaseUrls.add(baseUrl); + accountBusyInternal = true; + notifyListeners(); + accountSyncStateInternal = AccountSyncState.defaults().copyWith( + syncState: 'ready', + syncMessage: 'Remote defaults synced', + lastSyncAtMs: 123456789, + lastSyncSource: baseUrl, + syncedDefaults: AccountRemoteProfile.defaults().copyWith( + openclawUrl: 'wss://gateway.svc.plus', + apisixUrl: 'https://apisix.svc.plus', + ), + ); + accountBusyInternal = false; + final email = accountSessionInternal?.email.trim() ?? ''; + accountStatusInternal = email.isEmpty ? 'Signed in' : 'Signed in as $email'; + notifyListeners(); + return const AccountSyncResult( + state: 'ready', + message: 'Remote defaults synced', + ); + } + + Future logoutAccount() async { + accountSessionTokenInternal = ''; + accountSessionInternal = null; + accountSyncStateInternal = null; + accountStatusInternal = 'Signed out'; + pendingAccountMfaTicketInternal = ''; + pendingAccountBaseUrlInternal = ''; + notifyListeners(); + } +}