merge: pairing status sync
# Conflicts: # test/features/settings_page_suite.dart
This commit is contained in:
commit
132b1340b9
@ -308,7 +308,7 @@ class GatewayRuntime extends ChangeNotifier with GatewayRuntimeHelpersInternal {
|
||||
'stored device token for role ${connectResult.auth['role']?.toString().trim().isNotEmpty == true ? connectResult.auth['role'].toString().trim() : 'operator'}',
|
||||
);
|
||||
}
|
||||
snapshotInternal = connectResult.snapshot;
|
||||
snapshotInternal = connectResult.snapshot.normalizedForConnectedState();
|
||||
notifyListeners();
|
||||
return;
|
||||
} on GatewayRuntimeException catch (error) {
|
||||
@ -658,7 +658,7 @@ class GatewayRuntime extends ChangeNotifier with GatewayRuntimeHelpersInternal {
|
||||
switch (update.type) {
|
||||
case GatewayRuntimeSessionUpdateType.snapshot:
|
||||
if (update.snapshot != null) {
|
||||
snapshotInternal = update.snapshot!;
|
||||
snapshotInternal = update.snapshot!.normalizedForConnectedState();
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
|
||||
@ -142,7 +142,26 @@ class GatewayConnectionSnapshot {
|
||||
);
|
||||
}
|
||||
|
||||
GatewayConnectionSnapshot normalizedForConnectedState() {
|
||||
if (status != RuntimeConnectionStatus.connected) {
|
||||
return this;
|
||||
}
|
||||
if (lastError == null &&
|
||||
lastErrorCode == null &&
|
||||
lastErrorDetailCode == null) {
|
||||
return this;
|
||||
}
|
||||
return copyWith(
|
||||
clearLastError: true,
|
||||
clearLastErrorCode: true,
|
||||
clearLastErrorDetailCode: true,
|
||||
);
|
||||
}
|
||||
|
||||
bool get pairingRequired {
|
||||
if (status == RuntimeConnectionStatus.connected) {
|
||||
return false;
|
||||
}
|
||||
final detailCode = lastErrorDetailCode?.trim().toUpperCase();
|
||||
final errorCode = lastErrorCode?.trim().toUpperCase();
|
||||
final errorText = lastError?.toLowerCase() ?? '';
|
||||
@ -152,6 +171,9 @@ class GatewayConnectionSnapshot {
|
||||
}
|
||||
|
||||
bool get gatewayTokenMissing {
|
||||
if (status == RuntimeConnectionStatus.connected) {
|
||||
return false;
|
||||
}
|
||||
final detailCode = lastErrorDetailCode?.trim().toUpperCase();
|
||||
final errorText = lastError?.toLowerCase() ?? '';
|
||||
return detailCode == 'AUTH_TOKEN_MISSING' ||
|
||||
|
||||
@ -382,7 +382,7 @@ extension WebSettingsPageGatewayMixinInternal on WebSettingsPageStateInternal {
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
key: ValueKey('web-external-acp-apply-${profile.providerKey}'),
|
||||
key: ValueKey('web-external-acp-save-${profile.providerKey}'),
|
||||
onPressed: () => saveExternalAcpEndpointInternal(
|
||||
controller,
|
||||
profile.providerKey,
|
||||
|
||||
@ -2,14 +2,17 @@
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_message_widgets.dart';
|
||||
import 'package:xworkmate/app/ui_feature_manifest.dart';
|
||||
import 'package:xworkmate/features/settings/settings_page.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/runtime/desktop_platform_service.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/skill_directory_access.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
import 'package:xworkmate/widgets/section_tabs.dart';
|
||||
|
||||
import '../test_support.dart';
|
||||
@ -148,6 +151,29 @@ Future<void> _pumpSettingsPage(
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pumpWithoutSettling(
|
||||
WidgetTester tester, {
|
||||
required Widget child,
|
||||
}) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1600, 1000);
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('zh'),
|
||||
supportedLocales: const [Locale('zh'), Locale('en')],
|
||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||
theme: AppTheme.light(platform: TargetPlatform.macOS),
|
||||
darkTheme: AppTheme.dark(platform: TargetPlatform.macOS),
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> _ensureVisible(WidgetTester tester, Finder finder) async {
|
||||
await tester.ensureVisible(finder.first);
|
||||
await tester.pumpAndSettle();
|
||||
@ -672,13 +698,13 @@ paths:
|
||||
final testButton = find.byKey(
|
||||
ValueKey('external-acp-test-${customProfile.providerKey}'),
|
||||
);
|
||||
final applyButton = find.byKey(
|
||||
final saveButton = find.byKey(
|
||||
ValueKey('external-acp-save-${customProfile.providerKey}'),
|
||||
);
|
||||
|
||||
expect(labelField, findsOneWidget);
|
||||
expect(testButton, findsOneWidget);
|
||||
expect(applyButton, findsOneWidget);
|
||||
expect(saveButton, findsOneWidget);
|
||||
|
||||
await tester.enterText(labelField, 'A');
|
||||
await tester.pump();
|
||||
@ -824,6 +850,51 @@ paths:
|
||||
expect(controller.runtimeLogs, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Assistant homepage chip and settings pairing card stay globally consistent for a connected gateway snapshot',
|
||||
(WidgetTester tester) async {
|
||||
final controller = await createTestController(tester);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
final remoteProfile = controller.settings.primaryRemoteGatewayProfile;
|
||||
setGatewaySnapshotForTest(
|
||||
controller,
|
||||
GatewayConnectionSnapshot.initial(mode: RuntimeConnectionMode.remote)
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
remoteAddress: '${remoteProfile.host}:${remoteProfile.port}',
|
||||
lastError: 'NOT_PAIRED: pairing required',
|
||||
lastErrorCode: 'NOT_PAIRED',
|
||||
lastErrorDetailCode: 'PAIRING_REQUIRED',
|
||||
),
|
||||
);
|
||||
|
||||
await _pumpWithoutSettling(
|
||||
tester,
|
||||
child: ConnectionChipInternal(controller: controller),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('assistant-connection-chip')), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining(
|
||||
'已连接 · ${remoteProfile.host}:${remoteProfile.port}',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
controller.setSettingsTab(SettingsTab.gateway);
|
||||
await _pumpWithoutSettling(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
);
|
||||
|
||||
expect(find.text('需要设备审批'), findsNothing);
|
||||
expect(find.text('Pairing Required'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('SettingsPage hides tabs disabled by feature manifest', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
|
||||
@ -51,13 +51,13 @@ void main() {
|
||||
final testButton = find.byKey(
|
||||
ValueKey('web-external-acp-test-${customProfile.providerKey}'),
|
||||
);
|
||||
final applyButton = find.byKey(
|
||||
ValueKey('web-external-acp-apply-${customProfile.providerKey}'),
|
||||
final saveButton = find.byKey(
|
||||
ValueKey('web-external-acp-save-${customProfile.providerKey}'),
|
||||
);
|
||||
|
||||
expect(labelField, findsOneWidget);
|
||||
expect(testButton, findsOneWidget);
|
||||
expect(applyButton, findsOneWidget);
|
||||
expect(saveButton, findsOneWidget);
|
||||
|
||||
await tester.enterText(labelField, 'A');
|
||||
await tester.pump();
|
||||
|
||||
@ -14,7 +14,7 @@ class TestKeys {
|
||||
);
|
||||
static const Key settingsExternalAcpAuth = Key('external-acp-auth-Codex');
|
||||
static const Key settingsExternalAcpTest = Key('external-acp-test-Codex');
|
||||
static const Key settingsExternalAcpSave = Key('external-acp-apply-Codex');
|
||||
static const Key settingsExternalAcpSave = Key('external-acp-save-Codex');
|
||||
|
||||
static const Key assistantTaskRail = Key('assistant-task-rail');
|
||||
static const Key assistantExecutionTargetButton = Key(
|
||||
|
||||
@ -583,7 +583,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'GatewayConnectionSnapshot keeps pairing-required visible even when status remains connected',
|
||||
'GatewayConnectionSnapshot clears pairing-required and missing-token flags once connected',
|
||||
() {
|
||||
final snapshot = GatewayConnectionSnapshot.initial(
|
||||
mode: RuntimeConnectionMode.local,
|
||||
@ -594,7 +594,56 @@ void main() {
|
||||
lastErrorDetailCode: 'PAIRING_REQUIRED',
|
||||
);
|
||||
|
||||
expect(snapshot.pairingRequired, isTrue);
|
||||
expect(snapshot.pairingRequired, isFalse);
|
||||
expect(snapshot.gatewayTokenMissing, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'GatewayRuntime normalizes connected session snapshots before exposing them globally',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final store = createIsolatedTestStore();
|
||||
final sessionClient = _FakeGatewayRuntimeSessionClient(
|
||||
connectResult: GatewayRuntimeSessionConnectResult(
|
||||
snapshot: GatewayConnectionSnapshot.initial(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
).copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
remoteAddress: 'gateway.example.com:443',
|
||||
lastError: 'NOT_PAIRED: pairing required',
|
||||
lastErrorCode: 'NOT_PAIRED',
|
||||
lastErrorDetailCode: 'PAIRING_REQUIRED',
|
||||
),
|
||||
auth: const <String, dynamic>{'role': 'operator'},
|
||||
returnedDeviceToken: '',
|
||||
raw: const <String, dynamic>{},
|
||||
),
|
||||
);
|
||||
final runtime = GatewayRuntime(
|
||||
store: store,
|
||||
identityStore: DeviceIdentityStore(store),
|
||||
sessionClient: sessionClient,
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
|
||||
await runtime.connectProfile(
|
||||
GatewayConnectionProfile.defaults().copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'gateway.example.com',
|
||||
port: 443,
|
||||
tls: true,
|
||||
useSetupCode: false,
|
||||
),
|
||||
authTokenOverride: 'shared-token-from-form',
|
||||
);
|
||||
|
||||
expect(runtime.snapshot.status, RuntimeConnectionStatus.connected);
|
||||
expect(runtime.snapshot.pairingRequired, isFalse);
|
||||
expect(runtime.snapshot.lastError, isNull);
|
||||
expect(runtime.snapshot.lastErrorCode, isNull);
|
||||
expect(runtime.snapshot.lastErrorDetailCode, isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -86,6 +86,10 @@ class _TestFakeGatewayRuntime extends GatewayRuntime {
|
||||
: super(identityStore: DeviceIdentityStore(store));
|
||||
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
|
||||
GatewayDevicePairingList _pairingList = const GatewayDevicePairingList(
|
||||
pending: <GatewayPendingDevice>[],
|
||||
paired: <GatewayPairedDevice>[],
|
||||
);
|
||||
|
||||
@override
|
||||
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
|
||||
@ -112,6 +116,16 @@ class _TestFakeGatewayRuntime extends GatewayRuntime {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSnapshotForTest(GatewayConnectionSnapshot snapshot) {
|
||||
_snapshot = snapshot.normalizedForConnectedState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setDevicePairingForTest(GatewayDevicePairingList pairingList) {
|
||||
_pairingList = pairingList;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect({bool clearDesiredProfile = true}) async {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
@ -157,8 +171,40 @@ class _TestFakeGatewayRuntime extends GatewayRuntime {
|
||||
return <String, dynamic>{'jobs': const <Object>[]};
|
||||
case 'device.pair.list':
|
||||
return <String, dynamic>{
|
||||
'pending': const <Object>[],
|
||||
'paired': const <Object>[],
|
||||
'pending': _pairingList.pending
|
||||
.map((item) => <String, dynamic>{
|
||||
'requestId': item.requestId,
|
||||
'deviceId': item.deviceId,
|
||||
'label': item.label,
|
||||
'role': item.role,
|
||||
'scopes': item.scopes,
|
||||
'remoteIp': item.remoteIp,
|
||||
'requestedAtMs': item.requestedAtMs,
|
||||
'repair': item.isRepair,
|
||||
})
|
||||
.toList(growable: false),
|
||||
'paired': _pairingList.paired
|
||||
.map((item) => <String, dynamic>{
|
||||
'deviceId': item.deviceId,
|
||||
'displayName': item.displayName,
|
||||
'roles': item.roles,
|
||||
'scopes': item.scopes,
|
||||
'remoteIp': item.remoteIp,
|
||||
'tokens': item.tokens
|
||||
.map((token) => <String, dynamic>{
|
||||
'role': token.role,
|
||||
'scopes': token.scopes,
|
||||
'createdAtMs': token.createdAtMs,
|
||||
'rotatedAtMs': token.rotatedAtMs,
|
||||
'revokedAtMs': token.revokedAtMs,
|
||||
'lastUsedAtMs': token.lastUsedAtMs,
|
||||
})
|
||||
.toList(growable: false),
|
||||
'createdAtMs': item.createdAtMs,
|
||||
'approvedAtMs': item.approvedAtMs,
|
||||
'currentDevice': item.currentDevice,
|
||||
})
|
||||
.toList(growable: false),
|
||||
};
|
||||
case 'system-presence':
|
||||
return const <Object>[];
|
||||
@ -168,6 +214,28 @@ class _TestFakeGatewayRuntime extends GatewayRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
void setGatewaySnapshotForTest(
|
||||
AppController controller,
|
||||
GatewayConnectionSnapshot snapshot,
|
||||
) {
|
||||
final runtime = controller.runtime;
|
||||
if (runtime is! _TestFakeGatewayRuntime) {
|
||||
throw StateError('createTestController() runtime does not support mutation');
|
||||
}
|
||||
runtime.setSnapshotForTest(snapshot);
|
||||
}
|
||||
|
||||
void setGatewayPairingListForTest(
|
||||
AppController controller,
|
||||
GatewayDevicePairingList pairingList,
|
||||
) {
|
||||
final runtime = controller.runtime;
|
||||
if (runtime is! _TestFakeGatewayRuntime) {
|
||||
throw StateError('createTestController() runtime does not support mutation');
|
||||
}
|
||||
runtime.setDevicePairingForTest(pairingList);
|
||||
}
|
||||
|
||||
class _TestFakeCodexRuntime extends CodexRuntime {
|
||||
@override
|
||||
Future<String?> findCodexBinary() async => null;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user