From 1a88371681a36d5b4930d27eebc1cb064326bd55 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 12:02:32 +0800 Subject: [PATCH] fix: keep settings account summary on canonical state --- .../desktop_settings_flow_test.dart | 265 +++++++++++++++ lib/app/workspace_page_registry.dart | 2 - lib/features/settings/settings_page_core.dart | 29 +- ...settings_page_account_status_canonical.png | Bin 0 -> 28373 bytes .../settings/settings_page_core_test.dart | 308 ++++++++++++++++++ 5 files changed, 591 insertions(+), 13 deletions(-) create mode 100644 integration_test/desktop_settings_flow_test.dart create mode 100644 test/features/settings/goldens/settings_page_account_status_canonical.png create mode 100644 test/features/settings/settings_page_core_test.dart 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 0000000000000000000000000000000000000000..c40e018d955c26accdb1f6f53b68453df926ce1a GIT binary patch literal 28373 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®yDJ2Wf3bO<b53qYPY{298`X? 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 VKL%g&tdxxItocQ=veP&3{SQ5gx6}Xt literal 0 HcmV?d00001 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(); + } +}