diff --git a/lib/features/email/presentation/bindings/email_bindings.dart b/lib/features/email/presentation/bindings/email_bindings.dart index 561f7ec1a..f7b305a07 100644 --- a/lib/features/email/presentation/bindings/email_bindings.dart +++ b/lib/features/email/presentation/bindings/email_bindings.dart @@ -5,6 +5,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/get_email_content_i import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_email_read_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/remove_a_label_from_an_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/bindings/email_interactor_bindings.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; @@ -25,6 +26,7 @@ class EmailBindings extends Bindings { Get.find(), Get.find(), Get.find(), + Get.find(), Get.find(), currentEmailId: currentEmailId, ), tag: tag); diff --git a/lib/features/email/presentation/bindings/email_interactor_bindings.dart b/lib/features/email/presentation/bindings/email_interactor_bindings.dart index 9e4e3a543..73824b51d 100644 --- a/lib/features/email/presentation/bindings/email_interactor_bindings.dart +++ b/lib/features/email/presentation/bindings/email_interactor_bindings.dart @@ -24,6 +24,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_email_read_ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/remove_a_label_from_an_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; import 'package:tmail_ui_user/features/mailbox/data/datasource/mailbox_datasource.dart'; import 'package:tmail_ui_user/features/mailbox/data/datasource/state_datasource.dart'; @@ -121,6 +122,9 @@ class EmailInteractorBindings extends InteractorsBindings { Get.lazyPut( () => AddALabelToAnEmailInteractor(Get.find()), ); + Get.lazyPut( + () => RemoveALabelFromAnEmailInteractor(Get.find()), + ); } @override diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index d53c17504..fb0eaa1cd 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -47,6 +47,7 @@ import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_read_sta import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_star_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/parse_calendar_event_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/print_email_state.dart'; +import 'package:tmail_ui_user/features/email/domain/state/remove_a_label_from_an_email_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/send_receipt_to_sender_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/unsubscribe_email_state.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/add_a_label_to_an_email_interactor.dart'; @@ -60,6 +61,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_ import 'package:tmail_ui_user/features/email/domain/usecases/maybe_calendar_event_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/parse_calendar_event_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/remove_a_label_from_an_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/send_receipt_to_sender_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; @@ -119,6 +121,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final StoreOpenedEmailInteractor _storeOpenedEmailInteractor; final PrintEmailInteractor _printEmailInteractor; final AddALabelToAnEmailInteractor addALabelToAnEmailInteractor; + final RemoveALabelFromAnEmailInteractor removeALabelFromAnEmailInteractor; final EmailId? _currentEmailId; CreateNewEmailRuleFilterInteractor? _createNewEmailRuleFilterInteractor; @@ -189,6 +192,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { this._getAllIdentitiesInteractor, this._storeOpenedEmailInteractor, this.addALabelToAnEmailInteractor, + this.removeALabelFromAnEmailInteractor, this._printEmailInteractor, { EmailId? currentEmailId, }) : _currentEmailId = currentEmailId; @@ -247,6 +251,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { calendarEventSuccess(success); } else if (success is AddALabelToAnEmailSuccess) { handleAddLabelToEmailSuccess(success); + } else if (success is RemoveALabelFromAnEmailSuccess) { + handleRemoveLabelFromEmailSuccess(success); } else { super.handleSuccessViewState(success); } @@ -266,6 +272,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { _calendarEventFailure(failure); } else if (failure is AddALabelToAnEmailFailure) { handleAddLabelToEmailFailure(failure); + } else if (failure is RemoveALabelFromAnEmailFailure) { + handleRemoveLabelFromEmailFailure(failure); } else { super.handleFailureViewState(failure); } diff --git a/lib/features/email/presentation/extensions/handle_label_for_email_extension.dart b/lib/features/email/presentation/extensions/handle_label_for_email_extension.dart index 3e5327004..db87a9dee 100644 --- a/lib/features/email/presentation/extensions/handle_label_for_email_extension.dart +++ b/lib/features/email/presentation/extensions/handle_label_for_email_extension.dart @@ -1,5 +1,4 @@ import 'package:core/utils/platform_info.dart'; -import 'package:dartz/dartz.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; @@ -8,6 +7,7 @@ import 'package:labels/extensions/label_extension.dart'; import 'package:labels/model/label.dart'; import 'package:model/email/presentation_email.dart'; import 'package:tmail_ui_user/features/email/domain/state/add_a_label_to_an_email_state.dart'; +import 'package:tmail_ui_user/features/email/domain/state/remove_a_label_from_an_email_state.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/email_loaded_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart'; @@ -27,16 +27,23 @@ extension HandleLabelForEmailExtension on SingleEmailController { } void toggleLabelToEmail(EmailId emailId, Label label, bool isSelected) { - if (isSelected) { - final accountId = mailboxDashBoardController.accountId.value; - final session = mailboxDashBoardController.sessionCurrent; + final accountId = mailboxDashBoardController.accountId.value; + final session = mailboxDashBoardController.sessionCurrent; + if (isSelected) { _addALabelToAnEmail( session: session, accountId: accountId, emailId: emailId, label: label, ); + } else { + _removeALabelFromAnEmail( + session: session, + accountId: accountId, + emailId: emailId, + label: label, + ); } } @@ -49,24 +56,22 @@ extension HandleLabelForEmailExtension on SingleEmailController { final labelDisplay = label.safeDisplayName; if (session == null) { - consumeState( - Stream.value( - Left(AddALabelToAnEmailFailure( - exception: NotFoundSessionException(), - labelDisplay: labelDisplay, - )), + emitFailure( + controller: this, + failure: AddALabelToAnEmailFailure( + exception: NotFoundSessionException(), + labelDisplay: labelDisplay, ), ); return; } if (accountId == null) { - consumeState( - Stream.value( - Left(AddALabelToAnEmailFailure( - exception: NotFoundAccountIdException(), - labelDisplay: labelDisplay, - )), + emitFailure( + controller: this, + failure: AddALabelToAnEmailFailure( + exception: NotFoundAccountIdException(), + labelDisplay: labelDisplay, ), ); return; @@ -74,13 +79,12 @@ extension HandleLabelForEmailExtension on SingleEmailController { final labelKeyword = label.keyword; if (labelKeyword == null) { - consumeState( - Stream.value(Left( - AddALabelToAnEmailFailure( - exception: LabelKeywordIsNull(), - labelDisplay: labelDisplay, - ), - )), + emitFailure( + controller: this, + failure: AddALabelToAnEmailFailure( + exception: LabelKeywordIsNull(), + labelDisplay: labelDisplay, + ), ); return; } @@ -100,6 +104,7 @@ extension HandleLabelForEmailExtension on SingleEmailController { _autoSyncLabelToSelectedEmailOnMemory( emailId: success.emailId, labelKeyword: success.labelKeyword, + remove: false, ); } @@ -110,11 +115,13 @@ extension HandleLabelForEmailExtension on SingleEmailController { void _autoSyncLabelToSelectedEmailOnMemory({ required EmailId emailId, required KeyWordIdentifier labelKeyword, + required bool remove, }) { _updateLabelInEmailOnMemory( emailId: emailId, labelKeyword: labelKeyword, isMobileThreadDisabled: PlatformInfo.isMobile && !isThreadDetailEnabled, + remove: remove, ); } @@ -122,27 +129,32 @@ extension HandleLabelForEmailExtension on SingleEmailController { required EmailId emailId, required KeyWordIdentifier labelKeyword, required bool isMobileThreadDisabled, + required bool remove, }) { _updateLabelOnSelectedEmailIfNeeded( emailId: emailId, labelKeyword: labelKeyword, isMobileThreadDisabled: isMobileThreadDisabled, + remove: remove, ); _updateLabelOnThreadIfNeeded( emailId: emailId, labelKeyword: labelKeyword, isMobileThreadDisabled: isMobileThreadDisabled, + remove: remove, ); _updateLabelOnCurrentEmailLoaded( emailId: emailId, labelKeyword: labelKeyword, + remove: remove, ); _notifyLabelUpdated( emailId: emailId, labelKeyword: labelKeyword, + remove: remove, ); } @@ -150,19 +162,21 @@ extension HandleLabelForEmailExtension on SingleEmailController { required EmailId emailId, required KeyWordIdentifier labelKeyword, required bool isMobileThreadDisabled, + required bool remove, }) { if (!isMobileThreadDisabled) return; final selectedEmail = mailboxDashBoardController.selectedEmail.value; if (selectedEmail?.id != emailId) return; - selectedEmail?.keywords?.addKeyword(labelKeyword); + selectedEmail?.keywords?.toggleKeyword(labelKeyword, remove); } void _updateLabelOnThreadIfNeeded({ required EmailId emailId, required KeyWordIdentifier labelKeyword, required bool isMobileThreadDisabled, + required bool remove, }) { if (isMobileThreadDisabled) return; @@ -173,20 +187,21 @@ extension HandleLabelForEmailExtension on SingleEmailController { controller.emailIdsPresentation.toggleEmailKeywordById( emailId: emailId, keyword: labelKeyword, - remove: false, + remove: remove, ); controller.emailsInThreadDetailInfo.value = controller.emailsInThreadDetailInfo.toggleEmailKeywordById( emailId: emailId, keyword: labelKeyword, - remove: false, + remove: remove, ); } void _updateLabelOnCurrentEmailLoaded({ required EmailId emailId, required KeyWordIdentifier labelKeyword, + required bool remove, }) { final emailLoaded = currentEmailLoaded.value; if (emailLoaded == null) return; @@ -195,17 +210,18 @@ extension HandleLabelForEmailExtension on SingleEmailController { currentEmailLoaded.value = emailLoaded.toggleEmailKeyword( emailId: emailId, keyword: labelKeyword, - remove: false, + remove: remove, ); } void _notifyLabelUpdated({ required EmailId emailId, required KeyWordIdentifier labelKeyword, + required bool remove, }) { mailboxDashBoardController.updateEmailFlagByEmailIds( [emailId], - isLabelAdded: true, + isLabelAdded: !remove, labelKeyword: labelKeyword, ); @@ -235,4 +251,73 @@ extension HandleLabelForEmailExtension on SingleEmailController { dialogLabel: 'add-label-to-email-modal', ); } + + void _removeALabelFromAnEmail({ + required Session? session, + required AccountId? accountId, + required Label label, + required EmailId emailId, + }) { + final labelDisplay = label.safeDisplayName; + + if (session == null) { + emitFailure( + controller: this, + failure: RemoveALabelFromAnEmailFailure( + exception: NotFoundSessionException(), + labelDisplay: labelDisplay, + ), + ); + return; + } + + if (accountId == null) { + emitFailure( + controller: this, + failure: RemoveALabelFromAnEmailFailure( + exception: NotFoundAccountIdException(), + labelDisplay: labelDisplay, + ), + ); + return; + } + + final labelKeyword = label.keyword; + if (labelKeyword == null) { + emitFailure( + controller: this, + failure: RemoveALabelFromAnEmailFailure( + exception: LabelKeywordIsNull(), + labelDisplay: labelDisplay, + ), + ); + return; + } + + consumeState(removeALabelFromAnEmailInteractor.execute( + session, + accountId, + emailId, + labelKeyword, + label.safeDisplayName, + )); + } + + void handleRemoveLabelFromEmailSuccess( + RemoveALabelFromAnEmailSuccess success, + ) { + toastManager.showMessageSuccess(success); + + _autoSyncLabelToSelectedEmailOnMemory( + emailId: success.emailId, + labelKeyword: success.labelKeyword, + remove: true, + ); + } + + void handleRemoveLabelFromEmailFailure( + RemoveALabelFromAnEmailFailure failure, + ) { + toastManager.showMessageFailure(failure); + } } diff --git a/lib/features/thread/data/extensions/map_keywords_extension.dart b/lib/features/thread/data/extensions/map_keywords_extension.dart index b56d6dec0..26d7a1e54 100644 --- a/lib/features/thread/data/extensions/map_keywords_extension.dart +++ b/lib/features/thread/data/extensions/map_keywords_extension.dart @@ -27,4 +27,16 @@ extension MapKeywordsExtension on Map? { void addKeyword(KeyWordIdentifier keyword) { this?[keyword] = true; } + + void removeKeyword(KeyWordIdentifier keyword) { + this?.remove(keyword); + } + + void toggleKeyword(KeyWordIdentifier keyword, bool remove) { + if (remove) { + this?.remove(keyword); + } else { + this?[keyword] = true; + } + } } diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 018614b8f..02740eafa 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -5381,5 +5381,25 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "removeLabelFromEmailSuccessfullyMessage": "Email removed from the \"{labelName}\" label", + "@removeLabelFromEmailSuccessfullyMessage": { + "type": "text", + "placeholders_order": [ + "labelName" + ], + "placeholders": { + "labelName": {} + } + }, + "removeLabelFromEmailFailureMessage": "Cannot remove email from the \"{labelName}\" label", + "@removeLabelFromEmailFailureMessage": { + "type": "text", + "placeholders_order": [ + "labelName" + ], + "placeholders": { + "labelName": {} + } } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 07b820271..eddc3b68c 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -5709,4 +5709,19 @@ class AppLocalizations { name: 'editLabelFailure', ); } + String removeLabelFromEmailSuccessfullyMessage(String labelName) { + return Intl.message( + 'Email removed from the "$labelName" label', + name: 'removeLabelFromEmailSuccessfullyMessage', + args: [labelName], + ); + } + + String removeLabelFromEmailFailureMessage(String labelName) { + return Intl.message( + 'Cannot remove email from the "$labelName" label', + name: 'removeLabelFromEmailFailureMessage', + args: [labelName], + ); + } } diff --git a/lib/main/utils/toast_manager.dart b/lib/main/utils/toast_manager.dart index e9cdcdff4..e7d2dde06 100644 --- a/lib/main/utils/toast_manager.dart +++ b/lib/main/utils/toast_manager.dart @@ -28,6 +28,7 @@ import 'package:tmail_ui_user/features/email/domain/state/calendar_event_reply_s import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_star_state.dart'; import 'package:tmail_ui_user/features/download/domain/state/parse_email_by_blob_id_state.dart'; import 'package:tmail_ui_user/features/download/domain/state/preview_email_from_eml_file_state.dart'; +import 'package:tmail_ui_user/features/email/domain/state/remove_a_label_from_an_email_state.dart'; import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; import 'package:tmail_ui_user/features/home/domain/state/get_session_state.dart'; import 'package:tmail_ui_user/features/labels/domain/state/create_new_label_state.dart'; @@ -212,6 +213,11 @@ class ToastManager { appLocalizations.addLabelToThreadFailureMessage(failure.labelDisplay); } else if (failure is EditLabelFailure) { message = message ?? appLocalizations.editLabelFailure; + } else if (failure is RemoveALabelFromAnEmailFailure) { + message = message ?? + appLocalizations.removeLabelFromEmailFailureMessage( + failure.labelDisplay, + ); } log('ToastManager::showMessageFailure: Message: $message'); if (message?.trim().isNotEmpty == true) { @@ -291,6 +297,10 @@ class ToastManager { message = appLocalizations.addLabelToThreadSuccessfullyMessage( success.labelDisplay, ); + } else if (success is RemoveALabelFromAnEmailSuccess) { + message = appLocalizations.removeLabelFromEmailSuccessfullyMessage( + success.labelDisplay, + ); } log('ToastManager::showMessageSuccess: Message: $message'); if (message?.trim().isNotEmpty == true) { diff --git a/model/lib/extensions/keyword_identifier_extension.dart b/model/lib/extensions/keyword_identifier_extension.dart index 3da71e49d..d81eeb255 100644 --- a/model/lib/extensions/keyword_identifier_extension.dart +++ b/model/lib/extensions/keyword_identifier_extension.dart @@ -28,5 +28,6 @@ extension KeyWordIdentifierExtension on KeyWordIdentifier { PatchObject generateUnsubscribeActionPath() => _boolPatch(true); - PatchObject generateLabelActionPath() => _boolPatch(true); + PatchObject generateLabelActionPath({bool remove = false}) => + _boolPatch(remove ? null : true); } diff --git a/test/features/email/presentation/controller/single_email_controller_test.dart b/test/features/email/presentation/controller/single_email_controller_test.dart index c6b5b3f29..071535893 100644 --- a/test/features/email/presentation/controller/single_email_controller_test.dart +++ b/test/features/email/presentation/controller/single_email_controller_test.dart @@ -36,6 +36,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_ import 'package:tmail_ui_user/features/email/domain/usecases/maybe_calendar_event_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/parse_calendar_event_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/remove_a_label_from_an_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; @@ -75,6 +76,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(), MockSpec(fallbackGenerators: fallbackGenerators), MockSpec(fallbackGenerators: fallbackGenerators), MockSpec(fallbackGenerators: fallbackGenerators), @@ -109,6 +111,7 @@ void main() { final getAllIdentitiesInteractor = MockGetAllIdentitiesInteractor(); final storeOpenedEmailInteractor = MockStoreOpenedEmailInteractor(); final addALabelToAnEmailInteractor = MockAddALabelToAnEmailInteractor(); + final removeALabelFromAnEmailInteractor = MockRemoveALabelFromAnEmailInteractor(); final mailboxDashboardController = MockMailboxDashBoardController(); final downloadController = MockDownloadController(); final downloadManager = MockDownloadManager(); @@ -175,6 +178,7 @@ void main() { getAllIdentitiesInteractor, storeOpenedEmailInteractor, addALabelToAnEmailInteractor, + removeALabelFromAnEmailInteractor, printEmailInteractor, ); }); diff --git a/test/features/thread/presentation/extensions/map_keywords_extension_test.dart b/test/features/thread/presentation/extensions/map_keywords_extension_test.dart index 80e9a9f7d..d1fe7a19c 100644 --- a/test/features/thread/presentation/extensions/map_keywords_extension_test.dart +++ b/test/features/thread/presentation/extensions/map_keywords_extension_test.dart @@ -171,4 +171,190 @@ void main() { expect(map[keyword], true); }); }); + + group('MapKeywordsExtension - removeKeyword()', () { + test('removes existing keyword from map', () { + final keyword = KeyWordIdentifier('\$seen'); + final map = { + keyword: true, + KeyWordIdentifier('\$flagged'): true, + }; + + map.removeKeyword(keyword); + + expect(map.length, 1); + expect(map.containsKey(keyword), false); + }); + + test('removes keyword even if its value is false', () { + final keyword = KeyWordIdentifier('\$seen'); + final map = { + keyword: false, + }; + + map.removeKeyword(keyword); + + expect(map.isEmpty, true); + }); + + test('does nothing when keyword does not exist', () { + final map = { + KeyWordIdentifier('\$flagged'): true, + }; + final keyword = KeyWordIdentifier('\$seen'); + + map.removeKeyword(keyword); + + expect(map.length, 1); + expect(map.values.first, true); + }); + + test('does not throw when map is empty', () { + final map = {}; + final keyword = KeyWordIdentifier('\$seen'); + + expect(() => map.removeKeyword(keyword), returnsNormally); + expect(map.isEmpty, true); + }); + + test('does not throw when map is null', () { + Map? map; + final keyword = KeyWordIdentifier('\$seen'); + + expect(() => map.removeKeyword(keyword), returnsNormally); + }); + + test('calling removeKeyword on null map has no side effects', () { + Map? map; + final keyword = KeyWordIdentifier('\$seen'); + + map.removeKeyword(keyword); + + expect(map, isNull); + }); + + test('mutates the same map instance', () { + final keyword = KeyWordIdentifier('\$seen'); + final map = { + keyword: true, + }; + + final originalIdentity = identityHashCode(map); + + map.removeKeyword(keyword); + + expect(identityHashCode(map), originalIdentity); + expect(map.isEmpty, true); + }); + }); + + group('MapKeywordsExtension - toggleKeyword()', () { + test('adds keyword when remove is false on empty map', () { + final keyword = KeyWordIdentifier('\$seen'); + final map = {}; + + map.toggleKeyword(keyword, false); + + expect(map.length, 1); + expect(map[keyword], true); + }); + + test('adds keyword when remove is false on non-empty map', () { + final keyword = KeyWordIdentifier('\$seen'); + final map = { + KeyWordIdentifier('\$flagged'): true, + }; + + map.toggleKeyword(keyword, false); + + expect(map.length, 2); + expect(map[keyword], true); + }); + + test('overwrites existing keyword value to true when remove is false', () { + final keyword = KeyWordIdentifier('\$seen'); + final map = { + keyword: false, + }; + + map.toggleKeyword(keyword, false); + + expect(map.length, 1); + expect(map[keyword], true); + }); + + test('removes keyword when remove is true', () { + final keyword = KeyWordIdentifier('\$seen'); + final map = { + keyword: true, + KeyWordIdentifier('\$flagged'): true, + }; + + map.toggleKeyword(keyword, true); + + expect(map.length, 1); + expect(map.containsKey(keyword), false); + }); + + test('does nothing when remove is true and keyword does not exist', () { + final map = { + KeyWordIdentifier('\$flagged'): true, + }; + final keyword = KeyWordIdentifier('\$seen'); + + map.toggleKeyword(keyword, true); + + expect(map.length, 1); + expect(map.values.first, true); + }); + + test('does not throw when map is empty and remove is true', () { + final map = {}; + final keyword = KeyWordIdentifier('\$seen'); + + expect(() => map.toggleKeyword(keyword, true), returnsNormally); + expect(map.isEmpty, true); + }); + + test('adds keyword when map is empty and remove is false', () { + final map = {}; + final keyword = KeyWordIdentifier('\$seen'); + + expect(() => map.toggleKeyword(keyword, false), returnsNormally); + expect(map.length, 1); + expect(map[keyword], true); + }); + + test('does not throw when map is null', () { + Map? map; + final keyword = KeyWordIdentifier('\$seen'); + + expect(() => map.toggleKeyword(keyword, true), returnsNormally); + expect(() => map.toggleKeyword(keyword, false), returnsNormally); + }); + + test('calling toggleKeyword on null map has no side effects', () { + Map? map; + final keyword = KeyWordIdentifier('\$seen'); + + map.toggleKeyword(keyword, false); + map.toggleKeyword(keyword, true); + + expect(map, isNull); + }); + + test('mutates the same map instance', () { + final keyword = KeyWordIdentifier('\$seen'); + final map = { + keyword: true, + }; + + final originalIdentity = identityHashCode(map); + + map.toggleKeyword(keyword, true); + + expect(identityHashCode(map), originalIdentity); + expect(map.isEmpty, true); + }); + }); }