diff --git a/lib/screens/profiles_screen.dart b/lib/screens/profiles_screen.dart index bcc628b..f877e47 100644 --- a/lib/screens/profiles_screen.dart +++ b/lib/screens/profiles_screen.dart @@ -92,14 +92,6 @@ class ProfilesScreen extends StatelessWidget { }, child: const Text('Open'), ), - FilledButton( - onPressed: () async { - await context - .read() - .applyProfile(profile.id); - }, - child: const Text('Apply'), - ), OutlinedButton( onPressed: () async { final resolved = diff --git a/lib/services/profile_workspace_coordinator.dart b/lib/services/profile_workspace_coordinator.dart index 4a64a36..4e65f6e 100644 --- a/lib/services/profile_workspace_coordinator.dart +++ b/lib/services/profile_workspace_coordinator.dart @@ -134,8 +134,7 @@ class ProfileWorkspaceCoordinator { name: name, notes: notes, ); - await messagesProvider.cloneCurrentStorageTo(profileId); - await contactsProvider.cloneCurrentStorageTo(profileId); + await _initializeEmptyStorageNamespace(profileId); await profileManager.upsertProfile(profile); return profile; } @@ -180,6 +179,7 @@ class ProfileWorkspaceCoordinator { Future openProfile(String profileId) async { await _saveActiveCustomProfileSnapshot(); + await connectionProvider.disconnect(); await profileManager.setActiveProfileId(profileId); await _switchRuntimeScope(profileId); @@ -360,4 +360,11 @@ class ProfileWorkspaceCoordinator { namespace: targetNamespace, ); } + + Future _initializeEmptyStorageNamespace(String namespace) async { + await _messageStorageService.clearMessages(namespace: namespace); + await _contactStorageService.clearContacts(namespace: namespace); + await _contactStorageService.clearContactGroups(namespace: namespace); + await _contactStorageService.clearPendingAdverts(namespace: namespace); + } } diff --git a/test/services/profile_workspace_coordinator_test.dart b/test/services/profile_workspace_coordinator_test.dart new file mode 100644 index 0000000..d49ab54 --- /dev/null +++ b/test/services/profile_workspace_coordinator_test.dart @@ -0,0 +1,333 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_client/meshcore_client.dart'; +import 'package:meshcore_sar_app/models/config_profile.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/contact_group.dart'; +import 'package:meshcore_sar_app/models/device_info.dart'; +import 'package:meshcore_sar_app/providers/app_provider.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/map_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/services/app_config_snapshot_service.dart'; +import 'package:meshcore_sar_app/services/contact_storage_service.dart'; +import 'package:meshcore_sar_app/services/device_config_applicator.dart'; +import 'package:meshcore_sar_app/services/message_storage_service.dart'; +import 'package:meshcore_sar_app/services/profile_manager.dart'; +import 'package:meshcore_sar_app/services/profile_workspace_coordinator.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; +import 'package:meshcore_sar_app/services/map_workspace_snapshot_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: ConfigProfile.defaultProfileId, + ); + }); + + group('ProfileWorkspaceCoordinator', () { + test( + 'createProfileFromCurrent starts with empty contacts and messages', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final messageStorage = MessageStorageService(); + final contactStorage = ContactStorageService(); + + await messageStorage.saveMessages([_buildMessage('default-msg')]); + await contactStorage.saveContacts([ + _buildContact('Default Contact', 1), + ]); + await contactStorage.saveContactGroups([ + SavedContactGroup( + id: 'group-1', + sectionKey: 'manual', + label: 'Team', + query: '', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + ), + ]); + await contactStorage.savePendingAdverts([ + {'id': 'pending-1'}, + ]); + + final coordinator = _buildCoordinator(profileManager: manager); + + final profile = await coordinator.createProfileFromCurrent( + name: 'Empty Profile', + ); + + expect( + await messageStorage.loadMessages(namespace: profile.id), + isEmpty, + ); + expect( + await contactStorage.loadContacts(namespace: profile.id), + isEmpty, + ); + expect( + await contactStorage.loadContactGroups(namespace: profile.id), + isEmpty, + ); + expect( + await contactStorage.loadPendingAdverts(namespace: profile.id), + isEmpty, + ); + + expect(await messageStorage.loadMessages(), hasLength(1)); + expect(await contactStorage.loadContacts(), hasLength(1)); + }, + ); + + test( + 'duplicateProfile copies contacts and messages from source profile', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final source = ConfigProfile( + id: 'profile-source', + name: 'Source', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + updatedAt: DateTime.parse('2026-03-16T12:00:00Z'), + sections: const ConfigProfileSections(), + ); + await manager.upsertProfile(source); + + final messageStorage = MessageStorageService(); + final contactStorage = ContactStorageService(); + + await messageStorage.saveMessages([ + _buildMessage('source-msg'), + ], namespace: source.id); + await contactStorage.saveContacts([ + _buildContact('Source Contact', 2), + ], namespace: source.id); + await contactStorage.saveContactGroups([ + SavedContactGroup( + id: 'source-group', + sectionKey: 'manual', + label: 'Source Team', + query: '', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + ), + ], namespace: source.id); + await contactStorage.savePendingAdverts([ + {'id': 'source-pending'}, + ], namespace: source.id); + + final coordinator = _buildCoordinator(profileManager: manager); + + final duplicate = await coordinator.duplicateProfile(source); + + final duplicatedMessages = await messageStorage.loadMessages( + namespace: duplicate.id, + ); + final duplicatedContacts = await contactStorage.loadContacts( + namespace: duplicate.id, + ); + final duplicatedGroups = await contactStorage.loadContactGroups( + namespace: duplicate.id, + ); + final duplicatedPending = await contactStorage.loadPendingAdverts( + namespace: duplicate.id, + ); + + expect(duplicatedMessages.map((item) => item.id), ['source-msg']); + expect(duplicatedContacts.map((item) => item.advName), [ + 'Source Contact', + ]); + expect(duplicatedGroups.map((item) => item.id), ['source-group']); + expect(duplicatedPending, [ + {'id': 'source-pending'}, + ]); + }, + ); + + test( + 'openProfile disconnects current connection before switching', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final target = ConfigProfile( + id: 'profile-target', + name: 'Target', + createdAt: DateTime.parse('2026-03-16T12:00:00Z'), + updatedAt: DateTime.parse('2026-03-16T12:00:00Z'), + sections: const ConfigProfileSections(), + ); + await manager.upsertProfile(target); + + final connectionProvider = _FakeConnectionProvider(); + final coordinator = _buildCoordinator( + profileManager: manager, + connectionProvider: connectionProvider, + ); + + await coordinator.openProfile(target.id); + + expect(connectionProvider.disconnectCallCount, 1); + expect(manager.activeProfileId, target.id); + }, + ); + }); +} + +ProfileWorkspaceCoordinator _buildCoordinator({ + required ProfileManager profileManager, + _FakeConnectionProvider? connectionProvider, +}) { + return ProfileWorkspaceCoordinator( + profileManager: profileManager, + connectionProvider: connectionProvider ?? _FakeConnectionProvider(), + contactsProvider: _FakeContactsProvider(), + messagesProvider: _FakeMessagesProvider(), + mapProvider: _FakeMapProvider(), + drawingProvider: _FakeDrawingProvider(), + channelsProvider: _FakeChannelsProvider(), + appProvider: _FakeAppProvider(), + appConfigSnapshotService: _FakeAppConfigSnapshotService(), + mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(), + deviceConfigApplicator: _FakeDeviceConfigApplicator(), + ); +} + +Message _buildMessage(String id) { + return Message( + id: id, + messageType: MessageType.channel, + senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]), + channelIdx: 0, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: 1700000000, + text: id, + receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500), + isRead: true, + ); +} + +Contact _buildContact(String name, int fillByte) { + return Contact( + publicKey: Uint8List.fromList(List.filled(32, fillByte)), + type: ContactType.chat, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: (46.0569 * 1e6).round(), + advLon: (14.5058 * 1e6).round(), + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); +} + +class _FakeAppConfigSnapshotService extends AppConfigSnapshotService { + @override + Future capture(AppProvider appProvider) async { + return const AppSettingsProfileSection(); + } +} + +class _FakeMapWorkspaceSnapshotService extends MapWorkspaceSnapshotService { + @override + Future capture({ + required MapProvider mapProvider, + required DrawingProvider drawingProvider, + }) async { + return const MapWorkspaceProfileSection(); + } +} + +class _FakeDeviceConfigApplicator extends DeviceConfigApplicator { + @override + ConfigProfileSections capture({ + required ConnectionProvider connectionProvider, + required ChannelsProvider channelsProvider, + }) { + return const ConfigProfileSections(); + } +} + +class _FakeConnectionProvider implements ConnectionProvider { + int disconnectCallCount = 0; + + @override + DeviceInfo get deviceInfo => DeviceInfo(); + + @override + Future disconnect() async { + disconnectCallCount += 1; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeContactsProvider implements ContactsProvider { + @override + Future persistNow() async {} + + @override + Future reloadFromStorage({ + String? namespace, + Uint8List? devicePublicKey, + }) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMessagesProvider implements MessagesProvider { + @override + Future persistNow() async {} + + @override + Future reloadFromStorage({String? namespace}) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMapProvider implements MapProvider { + @override + Future reloadProfileScopedState() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeDrawingProvider implements DrawingProvider { + @override + Future reloadProfileScopedState() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeChannelsProvider implements ChannelsProvider { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeAppProvider implements AppProvider { + @override + Future reloadProfileScopedSettings() async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}