fix: keep settings account summary on canonical state

This commit is contained in:
Haitao Pan 2026-04-11 12:02:32 +08:00
parent 6f66fd44bc
commit 9412149485
5 changed files with 591 additions and 13 deletions

View File

@ -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<Text>(
find.byKey(const ValueKey('settings-account-summary-service-url')),
);
final accountIdentifierText = tester.widget<Text>(
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<FilledButton>(
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<void> saveSettingsDraft(SettingsSnapshot snapshot) async {
_settingsDraft = snapshot;
notifyListeners();
}
Future<void> 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<String> syncedBaseUrls = <String>[];
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<AccountSyncResult> 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<void> logoutAccount() async {
accountSessionTokenInternal = '';
accountSessionInternal = null;
accountSyncStateInternal = null;
accountStatusInternal = 'Signed out';
pendingAccountMfaTicketInternal = '';
pendingAccountBaseUrlInternal = '';
notifyListeners();
}
}

View File

@ -120,14 +120,12 @@ workspacePageSpecsInternal = <WorkspaceDestination, WorkspacePageSpec>{
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(

View File

@ -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<SettingsPage> createState() => _SettingsPageState();
}
@ -75,20 +72,21 @@ class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
),
onPressed: accountBusy
? null
: () => _disconnectManagedBase(settings),
: () => _disconnectManagedBase(settingsDraft),
child: Text(appText('断开', 'Disconnect')),
),
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -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<Text>(
find.byKey(const ValueKey('settings-account-summary-service-url')),
);
final accountIdentifierText = tester.widget<Text>(
find.byKey(
const ValueKey('settings-account-summary-account-identifier'),
),
);
final syncButton = tester.widget<FilledButton>(
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<FilledButton>(
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<void> saveSettingsDraft(SettingsSnapshot snapshot) async {
_settingsDraft = snapshot;
notifyListeners();
}
Future<void> 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<String> syncedBaseUrls = <String>[];
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<AccountSyncResult> 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<void> logoutAccount() async {
accountSessionTokenInternal = '';
accountSessionInternal = null;
accountSyncStateInternal = null;
accountStatusInternal = 'Signed out';
pendingAccountMfaTicketInternal = '';
pendingAccountBaseUrlInternal = '';
notifyListeners();
}
}