From 2a3325ab7af6971313a57c7c8f60ce0fae3ee51c Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 30 Jan 2026 16:09:38 +0700 Subject: [PATCH] TF-4265 Add integration test for attachment reminder on mobile --- .../attachment_keywords_configuration.md | 83 +- .../attachment_reminder_scenario.dart | 109 ++ .../composer/attachment_reminder_test.dart | 10 + lib/features/base/base_controller.dart | 3 + .../presentation/composer_controller.dart | 19 +- .../attachment_detection_extension.dart | 16 +- .../attachment_keyword_config_manager.dart | 52 + ...achment_keywords_configuration_parser.dart | 10 +- .../manager/attachment_text_detector.dart | 129 +-- .../manager/exclude_list_filter.dart | 7 +- .../manager/keyword_config_manager.dart | 43 - .../mixin/token_extraction_mixin.dart | 21 +- .../model/attachment_keyword_config.dart | 24 + .../presentation/model/keyword_config.dart | 23 - ...ttachment_keyword_config_manager_test.dart | 94 ++ .../attachment_text_detector_test.dart | 976 ++++++------------ .../manager/exclude_list_filter_test.dart | 61 ++ 17 files changed, 781 insertions(+), 899 deletions(-) create mode 100644 integration_test/scenarios/composer/attachment_reminder_scenario.dart create mode 100644 integration_test/tests/composer/attachment_reminder_test.dart create mode 100644 lib/features/composer/presentation/manager/attachment_keyword_config_manager.dart delete mode 100644 lib/features/composer/presentation/manager/keyword_config_manager.dart create mode 100644 lib/features/composer/presentation/model/attachment_keyword_config.dart delete mode 100644 lib/features/composer/presentation/model/keyword_config.dart create mode 100644 test/features/composer/presentation/manager/attachment_keyword_config_manager_test.dart create mode 100644 test/features/composer/presentation/manager/exclude_list_filter_test.dart diff --git a/docs/configuration/attachment_keywords_configuration.md b/docs/configuration/attachment_keywords_configuration.md index 7fad1e729..396dbf927 100644 --- a/docs/configuration/attachment_keywords_configuration.md +++ b/docs/configuration/attachment_keywords_configuration.md @@ -1,77 +1,30 @@ -## Configuration for Attachment Keyword Detection +## Attachment Keyword Detection — Configuration -### Context +The system warns users when email content implies an attachment (e.g. "please find attached") but no file is attached. -* When a user composes an email, the system detects if the content implies an attachment (e.g., "Please find attached...") but no file is attached. -* We need a flexible way to add specific keywords (Include) or ignore specific tokens (Exclude) without modifying the source code. +### Configuration file -### How to configure - -1. Configuration File Location - - * The configuration is managed in the JSON file: `configurations/attachment_keywords.json` - * This file must be declared in `pubspec.yaml` under `assets`. - -2. JSON Structure - -The file consists of two main arrays: +`configurations/attachment_keywords.json` (must be declared in `pubspec.yaml` under `assets`): ```json { - "includeList": [ - "invoice", - "estimate", - "báo giá" - ], - "excludeList": [ - "signature-logo", - "no-reply", - "icon-app" - ] + "includeList": ["invoice", "estimate", "báo giá"], + "excludeList": ["signature-logo", "no-reply", "icon-app"] } - ``` -* `includeList`: List of keywords to **add** to the detection dictionary. - * Used when the default dictionary (hardcoded in app) is missing specific business terms. - * Matches are case-insensitive. - * Example: Adding `"cv"` will trigger the alert for "Attached is my CV". +| Field | Purpose | +|-------|---------| +| `includeList` | Add custom keywords to the built-in multi-language dictionary (case-insensitive) | +| `excludeList` | Suppress false positives — tokens matched here are ignored even if they contain a keyword | +### How it works -* `excludeList`: List of tokens to **ignore/block** from detection. - * Used to prevent false positives where a keyword exists but is part of a system string or signature. - * The filter checks the full surrounding token (e.g., if you exclude `"signature-logo"`, the text `"check signature-logo"` will be ignored even if it contains "signature"). +1. On "Send", if the user already has an attachment, the check is skipped entirely. +2. Email HTML is converted to plain text (signatures, quotes, and HTML stripped). +3. `includeList` is merged with the built-in dictionary; a Unicode-aware Regex is built (cached). +4. Each match is validated against `excludeList` using full surrounding token context. +5. If keywords remain and no file is attached, a warning dialog is shown. - - -3. Application Logic - -* **Loading:** The file is loaded lazily and cached in memory upon the first request (Singleton pattern). -* **Execution Flow:** - 1. User clicks "Send". - 2. App loads `includeList` and merges it with the default multi-language dictionary. - 3. Regex scans the text. - 4. Matches are filtered against the `excludeList`. - 5. If valid keywords remain and no file is attached, a warning dialog is shown. - - - -4. Example Customization - -If you want to support a new document type called "Blueprints" and ignore a specific CSS class in the email body: - -**Modify `configurations\attachment_keywords.json`:** - -```json -{ - "includeList": [ - "blueprints", - "bản vẽ" - ], - "excludeList": [ - "css-class-attachment", - "div-id-file" - ] -} - -``` \ No newline at end of file +**Sync/Async:** Emails < 20,000 chars run synchronously; larger emails offload to a Dart Isolate via `compute()`. +**Cache:** Config and Regex pattern are cached in memory; both cleared on logout. diff --git a/integration_test/scenarios/composer/attachment_reminder_scenario.dart b/integration_test/scenarios/composer/attachment_reminder_scenario.dart new file mode 100644 index 000000000..af8e498d4 --- /dev/null +++ b/integration_test/scenarios/composer/attachment_reminder_scenario.dart @@ -0,0 +1,109 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; +import 'package:jmap_dart_client/jmap/identities/identity.dart'; +import 'package:model/email/prefix_email_address.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_view.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/from_composer_mobile_widget.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +import '../../base/base_test_scenario.dart'; +import '../../models/provisioning_identity.dart'; +import '../../robots/composer_robot.dart'; +import '../../robots/identities_list_menu_robot.dart'; +import '../../robots/thread_robot.dart'; + +class AttachmentReminderScenario extends BaseTestScenario { + const AttachmentReminderScenario(super.$); + + @override + Future runTestLogic() async { + const email = String.fromEnvironment('BASIC_AUTH_EMAIL'); + + final threadRobot = ThreadRobot($); + final composerRobot = ComposerRobot($); + final identitiesListMenuRobot = IdentitiesListMenuRobot($); + final imagePaths = ImagePaths(); + final appLocalizations = AppLocalizations(); + + final identity1 = Identity( + name: 'Identity with attachment keyword', + email: email, + htmlSignature: Signature('Signature file'), + sortOrder: UnsignedInt(0), + ); + final identity2 = Identity( + name: 'Identity without attachment keyword', + email: email, + htmlSignature: Signature('Signature'), + sortOrder: UnsignedInt(100), + ); + await provisionIdentities([ + ProvisioningIdentity(identity: identity1, isDefault: true), + ProvisioningIdentity(identity: identity2), + ]); + await $.pumpAndSettle(); + + // Send email with attachment keyword in signature + await threadRobot.openComposer(); + await $.pumpAndSettle(); + await _expectComposerViewVisible(); + + await composerRobot.grantContactPermission(); + await composerRobot.addSubject('Test Reminder'); + await composerRobot.addContent('Test Reminder'); + await composerRobot.tapToRecipientExpandButton(); + await $.pumpAndSettle(); + await _expectIdentityVisible(identity1); + await composerRobot.addRecipientIntoField( + prefixEmailAddress: PrefixEmailAddress.to, + email: email, + ); + await composerRobot.sendEmail(imagePaths); + await _expectSendEmailSuccessToast(appLocalizations); + + // Send email without attachment keyword in signature + await threadRobot.openComposer(); + await $.pumpAndSettle(); + await composerRobot.grantContactPermission(); + await composerRobot.addSubject('Test Reminder'); + await composerRobot.addContent('file in content'); + await composerRobot.tapToRecipientExpandButton(); + await $.pumpAndSettle(); + await composerRobot.addRecipientIntoField( + prefixEmailAddress: PrefixEmailAddress.to, + email: email, + ); + await composerRobot.tapFromFieldPopupMenu(); + await identitiesListMenuRobot.selectIdentityByName(identity2.name!); + await $.pumpAndSettle(); + await _expectIdentityVisible(identity2); + + await composerRobot.sendEmail(imagePaths); + await _expectAttachmentReminderModalVisible(); + } + + Future _expectComposerViewVisible() => + expectViewVisible($(ComposerView)); + + Future _expectAttachmentReminderModalVisible() => + expectViewVisible($(find.textContaining( + 'in your message but did not add any attachments. Do you still want to send?'))); + + Future _expectIdentityVisible(Identity identity) async { + expect( + $(FromComposerMobileWidget) + .which( + (widget) => widget.selectedIdentity?.name == identity.name) + .visible, + isTrue, + ); + } + + Future _expectSendEmailSuccessToast( + AppLocalizations appLocalizations) async { + await expectViewVisible( + $(find.text(appLocalizations.message_has_been_sent_successfully)), + ); + } +} diff --git a/integration_test/tests/composer/attachment_reminder_test.dart b/integration_test/tests/composer/attachment_reminder_test.dart new file mode 100644 index 000000000..c6aa7ed5e --- /dev/null +++ b/integration_test/tests/composer/attachment_reminder_test.dart @@ -0,0 +1,10 @@ +import '../../base/test_base.dart'; +import '../../scenarios/composer/attachment_reminder_scenario.dart'; + +void main() { + TestBase().runPatrolTest( + description: + 'Should not see attachment reminder when send email with attachment keyword in signature', + scenarioBuilder: ($) => AttachmentReminderScenario($), + ); +} diff --git a/lib/features/base/base_controller.dart b/lib/features/base/base_controller.dart index 006ba94f8..8e6533a31 100644 --- a/lib/features/base/base_controller.dart +++ b/lib/features/base/base_controller.dart @@ -45,6 +45,7 @@ import 'package:tmail_ui_user/features/push_notification/domain/state/destroy_fi import 'package:tmail_ui_user/features/push_notification/domain/state/get_stored_firebase_registration_state.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/destroy_firebase_registration_interactor.dart'; import 'package:tmail_ui_user/features/push_notification/domain/usecases/get_stored_firebase_registration_interactor.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keyword_config_manager.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/bindings/fcm_interactor_bindings.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/bindings/web_socket_interactor_bindings.dart'; import 'package:tmail_ui_user/features/push_notification/presentation/config/fcm_configuration.dart'; @@ -598,6 +599,8 @@ abstract class BaseController extends GetxController await cachingManager.closeHive(); } catch (e) { logWarning('BaseController::clearAllData: Cannot clear all data: $e'); + } finally { + AttachmentKeywordConfigManager().clearCache(); } } diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index b7c0e4ac5..7eb59e95b 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -883,6 +883,22 @@ class ComposerController extends BaseController final emailContent = await getContentInEditor(); + if (uploadController.attachmentsUploaded.isNotEmpty) { + if (!context.mounted) { + logWarning('ComposerController::_prepareToSendMessages: CONTEXT IS NOT MOUNTED'); + _sendButtonState = ButtonState.enabled; + return; + } + _sendMessageToServer( + context: context, + session: session, + accountId: accountId, + arguments: arguments, + emailContent: emailContent, + ); + return; + } + final attachmentKeywords = await validateAttachmentReminder( emailSubject: subjectEmail.value ?? '', emailContent: emailContent, @@ -894,8 +910,7 @@ class ComposerController extends BaseController return; } - if (attachmentKeywords.isNotEmpty && - uploadController.attachmentsUploaded.isEmpty) { + if (attachmentKeywords.isNotEmpty) { showAttachmentReminderModal( context: context, keywords: attachmentKeywords, diff --git a/lib/features/composer/presentation/extensions/attachment_detection_extension.dart b/lib/features/composer/presentation/extensions/attachment_detection_extension.dart index 929afa052..d4628d8f9 100644 --- a/lib/features/composer/presentation/extensions/attachment_detection_extension.dart +++ b/lib/features/composer/presentation/extensions/attachment_detection_extension.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_manager.dart'; import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_text_detector.dart'; -import 'package:tmail_ui_user/features/composer/presentation/manager/keyword_config_manager.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keyword_config_manager.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; @@ -17,17 +17,13 @@ extension AttachmentDetectionExtension on ComposerController { try { final fullContent = '$emailSubject $emailContent'; final plainText = HtmlUtils.extractPlainText(fullContent); - final keywordConfig = await KeywordConfigManager().getConfig(); + final attachmentKeywordConfig = await AttachmentKeywordConfigManager().getConfig(); final keywords = await AttachmentTextDetector.matchedKeywordsUnique( plainText, - includeList: keywordConfig.includeList, - excludeList: keywordConfig.excludeList, + includeList: attachmentKeywordConfig.includeList, + excludeList: attachmentKeywordConfig.excludeList, ); - if (keywords.isEmpty) { - return []; - } else { - return keywords; - } + return keywords; } catch (e) { logWarning('$runtimeType::validateAttachmentReminder:Error $e'); return []; @@ -49,7 +45,7 @@ extension AttachmentDetectionExtension on ComposerController { title: appLocalizations.attachmentReminderModalTitle, appLocalizations.attachmentReminderModalMessage(formattedKeywords), appLocalizations.sendMessage, - cancelTitle: AppLocalizations.of(context).cancel, + cancelTitle: appLocalizations.cancel, onConfirmAction: onConfirmAction, onCancelAction: onCancelAction, onCloseButtonAction: popBack, diff --git a/lib/features/composer/presentation/manager/attachment_keyword_config_manager.dart b/lib/features/composer/presentation/manager/attachment_keyword_config_manager.dart new file mode 100644 index 000000000..cb1960dc2 --- /dev/null +++ b/lib/features/composer/presentation/manager/attachment_keyword_config_manager.dart @@ -0,0 +1,52 @@ +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/config/app_config_loader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_text_detector.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/attachment_keyword_config.dart'; +import 'package:tmail_ui_user/main/utils/app_config.dart'; + +class AttachmentKeywordConfigManager { + static final AttachmentKeywordConfigManager _instance = AttachmentKeywordConfigManager._(); + + factory AttachmentKeywordConfigManager() => _instance; + + AttachmentKeywordConfigManager._(); + + AttachmentKeywordConfig? _cachedConfig; + AppConfigLoader? _appConfigLoader; + + static const String _configPath = + AppConfig.attachmentKeywordsConfigurationPath; + + @visibleForTesting + void injectLoader(AppConfigLoader loader) { + _appConfigLoader = loader; + } + + Future getConfig() async { + if (_cachedConfig != null) { + return _cachedConfig!; + } + + try { + _appConfigLoader ??= AppConfigLoader(); + _cachedConfig = await _appConfigLoader?.load( + _configPath, + AttachmentKeywordsConfigurationParser(), + ); + } catch (e) { + logWarning( + "AttachmentKeywordConfigManager::getConfig:Error loading keyword config: $e"); + _cachedConfig = AttachmentKeywordConfig(); + } + + return _cachedConfig!; + } + + void clearCache() { + _cachedConfig = null; + _appConfigLoader = null; + AttachmentTextDetector.clearPatternCache(); + } +} diff --git a/lib/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart b/lib/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart index 2d8f43bc7..e00d18759 100644 --- a/lib/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart +++ b/lib/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart @@ -4,15 +4,15 @@ import 'dart:typed_data'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/config/app_config_parser.dart'; -import 'package:tmail_ui_user/features/composer/presentation/model/keyword_config.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/attachment_keyword_config.dart'; class AttachmentKeywordsConfigurationParser - extends AppConfigParser { + extends AppConfigParser { @override - Future parse(String value) async { + Future parse(String value) async { try { final jsonObject = jsonDecode(value); - return KeywordConfig.fromJson(jsonObject); + return AttachmentKeywordConfig.fromJson(jsonObject); } catch (e) { logWarning('AttachmentKeywordsConfigurationParser::parse(): $e'); rethrow; @@ -20,7 +20,7 @@ class AttachmentKeywordsConfigurationParser } @override - Future parseData(ByteData data) { + Future parseData(ByteData data) { throw UnimplementedError(); } } diff --git a/lib/features/composer/presentation/manager/attachment_text_detector.dart b/lib/features/composer/presentation/manager/attachment_text_detector.dart index beed66b1a..771121f8c 100644 --- a/lib/features/composer/presentation/manager/attachment_text_detector.dart +++ b/lib/features/composer/presentation/manager/attachment_text_detector.dart @@ -52,7 +52,10 @@ class AttachmentTextDetector { /// Cached RegExp Pattern for the default case (when no include list is provided). static String? _cachedDefaultPattern; - /// Threshold to switch from Sync to Async execution. + /// Cached RegExp Pattern for the case when a custom include list is provided. + static List? _cachedIncludeList; + static String? _cachedIncludePattern; + /// Threshold to switch from Sync to Async execution on native platforms. /// /// Value: 20,000 characters (approx. 4-5 pages of text). /// @@ -64,57 +67,14 @@ class AttachmentTextDetector { /// - For > 20k chars, Regex processing might exceed 16ms on low-end devices, causing jank. /// /// -> Below 20k: Run Sync (Instant response). - /// -> Above 20k: Run Async (Safety for UI). + /// -> Above 20k: Run Async via `compute` (offloads to a separate Isolate on native). + /// + /// **Web limitation:** Flutter Web does not support true Dart isolates. On web, `compute()` + /// runs on the same main thread, so the async branch does NOT provide UI isolation. + /// Long drafts (> 20k chars) may still block the event loop on web. A proper web + /// strategy (e.g., Service Worker or chunked async processing) is not yet implemented. static const int _kAsyncExecutionThreshold = 20000; - /// Detect if the text contains keywords suggesting there is an attachment. - /// [lang] is the language code (`en`, `fr`, `ru`, `vi`, `ar`, ...). - static bool containsAttachmentKeyword(String text, {required String lang}) { - final lowerText = text.toLowerCase(); - final keywords = _keywordsByLang[lang.toLowerCase()]; - if (keywords == null) return false; - - return keywords.any((k) => lowerText.contains(k.toLowerCase())); - } - - /// Returns a list of matched keywords (if any) for the specified language - static List matchedKeywords(String text, {required String lang}) { - final lowerText = text.toLowerCase(); - final keywords = _keywordsByLang[lang.toLowerCase()]; - if (keywords == null) return []; - - return keywords - .where((k) => lowerText.contains(k.toLowerCase())) - .toList(); - } - - /// Detect if text contains keyword in any language - static bool containsAnyAttachmentKeyword(String text) { - final lowerText = text.toLowerCase(); - for (final keywords in _keywordsByLang.values) { - if (keywords.any((k) => lowerText.contains(k.toLowerCase()))) { - return true; - } - } - return false; - } - - /// Returns a map of languages with a list of matched keywords - static Map> matchedKeywordsAll(String text) { - final result = >{}; - final lowerText = text.toLowerCase(); - - _keywordsByLang.forEach((lang, keywords) { - final matches = - keywords.where((k) => lowerText.contains(k.toLowerCase())).toList(); - if (matches.isNotEmpty) { - result[lang] = matches; - } - }); - - return result; - } - /// Builds the Regex pattern. /// Merges [defaultKeywords] with [includeList] to create the search scope. static String _generatePatternString(List additionalKeywords) { @@ -132,23 +92,40 @@ class AttachmentTextDetector { // (?![\p{L}]) ensures we don't match substrings inside other words (e.g., "filetage"). final pattern = allKeywords.map(RegExp.escape).join('|'); - return'($pattern)(?![\\p{L}])'; + // (? includeList) { - if (includeList.isNotEmpty) { - return _generatePatternString(includeList); + if (includeList.isEmpty) { + _cachedDefaultPattern ??= _generatePatternString([]); + return _cachedDefaultPattern!; } - _cachedDefaultPattern ??= _generatePatternString([]); - return _cachedDefaultPattern!; + final cached = _cachedIncludeList; + if (cached != null && + cached.length == includeList.length && + cached.every(includeList.contains)) { + return _cachedIncludePattern!; + } + + _cachedIncludeList = List.unmodifiable(includeList); + _cachedIncludePattern = _generatePatternString(includeList); + return _cachedIncludePattern!; + } + + static bool _passesAllFilters( + String text, Match match, List filters) { + return filters.every((filter) => filter.isValid(text, match)); } /// The core logic function. /// 1. Builds/Retrieves the Regex. /// 2. Scans the text. /// 3. Filters results using Exclude List. - static List _processInIsolate(DetectionParams params) { + /// Can be executed on the main thread (sync path) or inside an Isolate (async path). + static List _detectKeywords(DetectionParams params) { final text = params.text; if (text.isEmpty) return []; @@ -158,27 +135,20 @@ class AttachmentTextDetector { caseSensitive: false, ); - final matches = regex.allMatches(text); - final result = {}; + return regex + .allMatches(text) + .where((match) => _passesAllFilters(text, match, params.filters)) + .map((match) => match.group(0)!.toLowerCase()) + .toSet() + .toList(); + } - for (final match in matches) { - final keyword = match.group(0)!.toLowerCase(); - - // Check if the found keyword should be excluded based on context. - bool isAccepted = true; - for (final filter in params.filters) { - if (!filter.isValid(text, match)) { - isAccepted = false; - break; - } - } - - if (isAccepted) { - result.add(keyword); - } - } - - return result.toList(); + /// Clears all cached regex patterns. + /// Call this when the keyword configuration changes (e.g., on logout or config reload). + static void clearPatternCache() { + _cachedDefaultPattern = null; + _cachedIncludeList = null; + _cachedIncludePattern = null; } /// Detects keywords in the text. @@ -206,11 +176,12 @@ class AttachmentTextDetector { filters: filters, ); - // Decision: Run Sync (fast) or Async (safe) + // On native: below threshold → sync (faster), above → offload to Isolate via compute(). + // On web: compute() does not create a real Isolate; both paths run on the main thread. if (forceSync || text.length < _kAsyncExecutionThreshold) { - return _processInIsolate(params); + return _detectKeywords(params); } else { - return await compute(_processInIsolate, params); + return await compute(_detectKeywords, params); } } } diff --git a/lib/features/composer/presentation/manager/exclude_list_filter.dart b/lib/features/composer/presentation/manager/exclude_list_filter.dart index f468bd1e5..d41811849 100644 --- a/lib/features/composer/presentation/manager/exclude_list_filter.dart +++ b/lib/features/composer/presentation/manager/exclude_list_filter.dart @@ -8,6 +8,9 @@ class ExcludeListFilter with TokenExtractionMixin implements KeywordFilter { ExcludeListFilter(List rawExcludes) : _excludes = rawExcludes.map((e) => e.toLowerCase()).toSet(); + static final _trailingPunctRegex = RegExp(r'[^\w\s]+$'); + static final _leadingPunctRegex = RegExp(r'^[^\w\s]+'); + @override bool isValid(String fullText, Match match) { if (_excludes.isEmpty) return true; @@ -20,11 +23,11 @@ class ExcludeListFilter with TokenExtractionMixin implements KeywordFilter { if (_excludes.contains(lowerToken)) return false; // Check 2: Block even if it has trailing punctuation (e.g., "file-246.") - final cleanToken = lowerToken.replaceAll(RegExp(r'[^\w\s]+$'), ''); + final cleanToken = lowerToken.replaceAll(_trailingPunctRegex, ''); if (_excludes.contains(cleanToken)) return false; // Check 3: Block even if it has leading punctuation (e.g., "(file") - final fullyCleanToken = cleanToken.replaceAll(RegExp(r'^[^\w\s]+'), ''); + final fullyCleanToken = cleanToken.replaceAll(_leadingPunctRegex, ''); if (_excludes.contains(fullyCleanToken)) return false; return true; diff --git a/lib/features/composer/presentation/manager/keyword_config_manager.dart b/lib/features/composer/presentation/manager/keyword_config_manager.dart deleted file mode 100644 index 99e66694d..000000000 --- a/lib/features/composer/presentation/manager/keyword_config_manager.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:core/utils/app_logger.dart'; -import 'package:core/utils/config/app_config_loader.dart'; -import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart'; -import 'package:tmail_ui_user/features/composer/presentation/model/keyword_config.dart'; -import 'package:tmail_ui_user/main/utils/app_config.dart'; - -class KeywordConfigManager { - static final KeywordConfigManager _instance = KeywordConfigManager._(); - - factory KeywordConfigManager() => _instance; - - KeywordConfigManager._(); - - KeywordConfig? _cachedConfig; - AppConfigLoader? _appConfigLoader; - - static const String _configPath = - AppConfig.attachmentKeywordsConfigurationPath; - - Future getConfig() async { - if (_cachedConfig != null) { - return _cachedConfig!; - } - - try { - _appConfigLoader ??= AppConfigLoader(); - _cachedConfig = await _appConfigLoader?.load( - _configPath, - AttachmentKeywordsConfigurationParser(), - ); - } catch (e) { - logWarning( - "KeywordConfigManager::getConfig:Error loading keyword config: $e"); - _cachedConfig = const KeywordConfig(); - } - - return _cachedConfig!; - } - - void clearCache() { - _cachedConfig = null; - } -} diff --git a/lib/features/composer/presentation/mixin/token_extraction_mixin.dart b/lib/features/composer/presentation/mixin/token_extraction_mixin.dart index 17b4e2435..f47ae4d33 100644 --- a/lib/features/composer/presentation/mixin/token_extraction_mixin.dart +++ b/lib/features/composer/presentation/mixin/token_extraction_mixin.dart @@ -3,22 +3,23 @@ mixin TokenExtractionMixin { static final RegExp _whitespaceRegExp = RegExp(r'\s'); - String getSurroundingToken(String text, int matchStart, int matchEnd) { - int start = matchStart; - int end = matchEnd; - - // Expand left until whitespace - while (start > 0) { - if (_whitespaceRegExp.hasMatch(text[start - 1])) break; + int _expandLeft(String text, int start) { + while (start > 0 && !_whitespaceRegExp.hasMatch(text[start - 1])) { start--; } + return start; + } - // Expand right until whitespace - while (end < text.length) { - if (_whitespaceRegExp.hasMatch(text[end])) break; + int _expandRight(String text, int end) { + while (end < text.length && !_whitespaceRegExp.hasMatch(text[end])) { end++; } + return end; + } + String getSurroundingToken(String text, int matchStart, int matchEnd) { + final start = _expandLeft(text, matchStart); + final end = _expandRight(text, matchEnd); return text.substring(start, end); } } \ No newline at end of file diff --git a/lib/features/composer/presentation/model/attachment_keyword_config.dart b/lib/features/composer/presentation/model/attachment_keyword_config.dart new file mode 100644 index 000000000..68e0d6060 --- /dev/null +++ b/lib/features/composer/presentation/model/attachment_keyword_config.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'attachment_keyword_config.g.dart'; + +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class AttachmentKeywordConfig with EquatableMixin { + final List includeList; + final List excludeList; + + AttachmentKeywordConfig({ + List includeList = const [], + List excludeList = const [], + }) : includeList = List.unmodifiable(includeList), + excludeList = List.unmodifiable(excludeList); + + factory AttachmentKeywordConfig.fromJson(Map json) => + _$AttachmentKeywordConfigFromJson(json); + + Map toJson() => _$AttachmentKeywordConfigToJson(this); + + @override + List get props => [includeList, excludeList]; +} diff --git a/lib/features/composer/presentation/model/keyword_config.dart b/lib/features/composer/presentation/model/keyword_config.dart deleted file mode 100644 index 56f878af6..000000000 --- a/lib/features/composer/presentation/model/keyword_config.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'keyword_config.g.dart'; - -@JsonSerializable(includeIfNull: false, explicitToJson: true) -class KeywordConfig with EquatableMixin { - final List includeList; - final List excludeList; - - const KeywordConfig({ - this.includeList = const [], - this.excludeList = const [], - }); - - factory KeywordConfig.fromJson(Map json) => - _$KeywordConfigFromJson(json); - - Map toJson() => _$KeywordConfigToJson(this); - - @override - List get props => [includeList, excludeList]; -} diff --git a/test/features/composer/presentation/manager/attachment_keyword_config_manager_test.dart b/test/features/composer/presentation/manager/attachment_keyword_config_manager_test.dart new file mode 100644 index 000000000..969eb7c53 --- /dev/null +++ b/test/features/composer/presentation/manager/attachment_keyword_config_manager_test.dart @@ -0,0 +1,94 @@ +import 'package:core/utils/config/app_config_loader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keyword_config_manager.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/attachment_keyword_config.dart'; + +import 'attachment_keyword_config_manager_test.mocks.dart'; + +@GenerateMocks([AppConfigLoader]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AttachmentKeywordConfigManager manager; + late MockAppConfigLoader mockLoader; + + setUp(() { + manager = AttachmentKeywordConfigManager(); + manager.clearCache(); + mockLoader = MockAppConfigLoader(); + manager.injectLoader(mockLoader); + }); + + group('AttachmentKeywordConfigManager.getConfig', () { + test('returns config loaded from loader on first call', () async { + final config = AttachmentKeywordConfig( + includeList: ['invoice'], + excludeList: ['invoice-draft'], + ); + + when(mockLoader.load( + any, + any, + )).thenAnswer((_) async => config); + + final result = await manager.getConfig(); + + expect(result.includeList, equals(['invoice'])); + expect(result.excludeList, equals(['invoice-draft'])); + }); + + test('returns cached result on second call without calling loader again', () async { + final config = AttachmentKeywordConfig(includeList: ['invoice']); + + when(mockLoader.load(any, any)) + .thenAnswer((_) async => config); + + await manager.getConfig(); + await manager.getConfig(); + + verify(mockLoader.load( + any, + argThat(isA()), + )).called(1); + }); + + test('returns empty AttachmentKeywordConfig when loader throws', () async { + when(mockLoader.load(any, any)) + .thenThrow(Exception('file not found')); + + final result = await manager.getConfig(); + + expect(result.includeList, isEmpty); + expect(result.excludeList, isEmpty); + }); + + test('caches the empty fallback config on error (loader not called again)', () async { + when(mockLoader.load(any, any)) + .thenThrow(Exception('file not found')); + + await manager.getConfig(); + await manager.getConfig(); + + verify(mockLoader.load(any, any)).called(1); + }); + }); + + group('AttachmentKeywordConfigManager.clearCache', () { + test('clearCache forces re-load on next getConfig call', () async { + final config = AttachmentKeywordConfig(includeList: ['invoice']); + + when(mockLoader.load(any, any)) + .thenAnswer((_) async => config); + + await manager.getConfig(); + manager.clearCache(); + manager.injectLoader(mockLoader); + await manager.getConfig(); + + verify(mockLoader.load(any, any)).called(2); + }); + }); +} diff --git a/test/features/composer/presentation/manager/attachment_text_detector_test.dart b/test/features/composer/presentation/manager/attachment_text_detector_test.dart index 23c9b5905..278c29c3e 100644 --- a/test/features/composer/presentation/manager/attachment_text_detector_test.dart +++ b/test/features/composer/presentation/manager/attachment_text_detector_test.dart @@ -1,7 +1,15 @@ import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/config/app_config_loader.dart'; +import 'package:core/utils/html/html_utils.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keyword_config_manager.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart'; import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_text_detector.dart'; -import 'package:tmail_ui_user/main/localizations/language_code_constants.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/attachment_keyword_config.dart'; + +import 'attachment_text_detector_test.mocks.dart'; /// Helper: generate email about [targetLength] characters long String generateLongEmail(int targetLength, {bool includeKeywords = true}) { @@ -16,255 +24,37 @@ String generateLongEmail(int targetLength, {bool includeKeywords = true}) { return buffer.toString(); } +@GenerateMocks([AppConfigLoader]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); - - group('AttachmentTextDetector.containsAttachmentKeyword', () { - test('English - should detect "attach" and "attachment"', () { - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Please see the attached document for details.", - lang: 'en', - ), - isTrue, - ); - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "No attachments here.", - lang: 'en', - ), - isTrue, - ); + /// Replicates the exact pipeline inside [validateAttachmentReminder]: + /// 1. Concatenate subject + body + /// 2. Strip HTML (removes blockquotes, scripts, styles, tmail-signature) + /// 3. Run keyword detection with config include/exclude lists + Future> runPipeline({ + required String subject, + required String body, + List includeList = const [], + List excludeList = const [], + }) async { + final fullContent = '$subject $body'; + final plainText = HtmlUtils.extractPlainText(fullContent); + return AttachmentTextDetector.matchedKeywordsUnique( + plainText, + includeList: includeList, + excludeList: excludeList, + forceSync: true, + ); + } - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Nothing relevant", - lang: 'en', - ), - isFalse, - ); - }); + late MockAppConfigLoader mockLoader; - test('French - should detect common attachment words', () { - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Veuillez trouver la pièce jointe.", - lang: 'fr', - ), - isTrue, - ); - - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Ci-joint le fichier joint pour votre examen.", - lang: 'fr', - ), - isTrue, - ); - - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Aucun document ici", - lang: 'fr', - ), - isFalse, - ); - }); - - test('Russian - should detect common attachment words', () { - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Смотрите приложение с отчетом.", - lang: 'ru', - ), - isTrue, - ); - - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Документ прикрепить ниже.", - lang: 'ru', - ), - isTrue, - ); - - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Текст без вложений", - lang: 'ru', - ), - isFalse, - ); - }); - - test('Vietnamese - should detect common attachment words', () { - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Vui lòng xem tài liệu đính kèm.", - lang: 'vi', - ), - isTrue, - ); - - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Đây là một file rất quan trọng.", - lang: 'vi', - ), - isTrue, - ); - - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Không có gì cần gửi thêm.", - lang: 'vi', - ), - isFalse, - ); - }); - - test('Unsupported language should return false', () { - expect( - AttachmentTextDetector.containsAttachmentKeyword( - "Some random text", - lang: 'de', - ), - isFalse, - ); - }); - }); - - group('AttachmentTextDetector.matchedKeywords', () { - test('should return matched keywords for Vietnamese', () { - final matches = AttachmentTextDetector.matchedKeywords( - "Đây là báo cáo và tài liệu đính kèm.", - lang: 'vi', - ); - - expect(matches, containsAll(['báo cáo', 'tài liệu', 'đính kèm'])); - }); - - test('should return empty list when no keywords found', () { - final matches = AttachmentTextDetector.matchedKeywords( - "Nội dung không có gì liên quan", - lang: 'vi', - ); - - expect(matches, isEmpty); - }); - - test('should return matched keywords for English', () { - final matches = AttachmentTextDetector.matchedKeywords( - "Please check the attachment and attach your signature.", - lang: 'en', - ); - - expect(matches, containsAll(['attachment', 'attach'])); - }); - }); - - group('AttachmentTextDetector.containsAnyAttachmentKeyword', () { - test('should detect when email contains English keyword', () { - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword( - "Please see the ATTACHed document.", - ), - isTrue, - ); - }); - - test('should detect when email contains Vietnamese keyword', () { - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword( - "Đây là tài liệu đính kèm.", - ), - isTrue, - ); - }); - - test('should detect when email contains Russian keyword', () { - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword( - "Документ прикрепить ниже.", - ), - isTrue, - ); - }); - - test('should detect when email contains French keyword', () { - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword( - "Veuillez trouver la pièce jointe.", - ), - isTrue, - ); - }); - - test('should return false when no keywords found', () { - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword( - "This email has nothing special.", - ), - isFalse, - ); - }); - - test('should detect when email contains multiple languages', () { - const email = """ - Please see the attached document. - Vui lòng xem tài liệu đính kèm. - Смотрите приложение. - """; - - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), - isTrue, - ); - }); - }); - - group('AttachmentTextDetector.matchedKeywordsAll', () { - test('should return matches for multiple languages', () { - const email = """ - Please see the attached document. - Vui lòng xem tài liệu đính kèm. - Смотрите приложение с отчётом. - """; - - final matches = AttachmentTextDetector.matchedKeywordsAll(email); - - expect(matches.keys, containsAll(['en', 'vi', 'ru'])); - expect(matches['en'], contains('attach')); - expect(matches['vi'], containsAll(['tài liệu', 'đính kèm'])); - expect(matches['ru'], containsAll(['приложение', 'отчёт'])); - }); - - test('should return matches only for one language', () { - const email = "Veuillez trouver la pièce jointe."; - - final matches = AttachmentTextDetector.matchedKeywordsAll(email); - - expect(matches.keys, equals(['fr'])); - expect(matches['fr'], contains('pièce jointe')); - }); - - test('should return empty map when no keywords found', () { - const email = "Completely unrelated text."; - - final matches = AttachmentTextDetector.matchedKeywordsAll(email); - - expect(matches, isEmpty); - }); - - test('should be case-insensitive', () { - const email = "ATTACHMENT and PiÈce Jointe included."; - - final matches = AttachmentTextDetector.matchedKeywordsAll(email); - - expect(matches['en'], contains('attachment')); - expect(matches['fr'], contains('pièce jointe')); - }); + setUp(() { + AttachmentKeywordConfigManager().clearCache(); + mockLoader = MockAppConfigLoader(); + AttachmentKeywordConfigManager().injectLoader(mockLoader); + AttachmentTextDetector.clearPatternCache(); }); group('AttachmentTextDetector.matchedKeywordsUnique', () { @@ -277,16 +67,16 @@ void main() { final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); - expect(matches, - containsAll(['tài liệu', 'đính kèm', 'приложение'])); + expect(matches, containsAll(['tài liệu', 'đính kèm', 'приложение'])); expect(matches.toSet().length, matches.length, reason: 'No duplicates allowed'); }); - test('should return unique matches even if repeated multiple times', () async { + test('should return unique matches even if repeated multiple times', + () async { const email = """ - file file file attach attach attach - đính kèm đính kèm + file file file attach attach attach + đính kèm đính kèm приложение приложение """; @@ -312,318 +102,40 @@ void main() { }); }); - /// Measure performance when detecting long emails, e.g. several thousand characters - group('AttachmentTextDetector Benchmark', () { - late String longEmail; - - setUp(() { - // Generate email ~50,000 characters long (many unrelated paragraphs) - final buffer = StringBuffer(); - const sample = "This is a random paragraph with no attachments. "; - for (int i = 0; i < 1000; i++) { - buffer.write(sample); - } - // Insert some keywords in the middle - buffer.write("Here is the pièce jointe and báo cáo attached."); - longEmail = buffer.toString(); - }); - - test('Benchmark containsAnyAttachmentKeyword', () { - final sw = Stopwatch()..start(); - final result = - AttachmentTextDetector.containsAnyAttachmentKeyword(longEmail); - sw.stop(); - - log( - 'containsAnyAttachmentKeyword -> result=$result, elapsed=${sw.elapsedMicroseconds}µs', - ); - expect(result, isTrue); - }); - - test('Benchmark matchedKeywordsAll', () { - final sw = Stopwatch()..start(); - final matches = AttachmentTextDetector.matchedKeywordsAll(longEmail); - sw.stop(); - - log( - 'matchedKeywordsAll -> found=${matches.keys}, elapsed=${sw.elapsedMicroseconds}µs', - ); - expect(matches.isNotEmpty, isTrue); - }); - }); - - /// Compare time when email is 100K, 500K, 1M characters long - group('AttachmentTextDetector Stress Benchmark', () { - final sizes = [100000, 500000, 1000000]; // 100K, 500K, 1M characters - - for (final size in sizes) { - test('Benchmark size=$size containsAnyAttachmentKeyword', () { - final email = generateLongEmail(size); - final sw = Stopwatch()..start(); - final result = - AttachmentTextDetector.containsAnyAttachmentKeyword(email); - sw.stop(); - - log( - 'containsAnyAttachmentKeyword(size=$size) -> result=$result, elapsed=${sw.elapsedMilliseconds}ms', - ); - expect(result, isTrue); - }); - - test('Benchmark size=$size matchedKeywordsAll', () { - final email = generateLongEmail(size); - final sw = Stopwatch()..start(); - final matches = AttachmentTextDetector.matchedKeywordsAll(email); - sw.stop(); - - log( - 'matchedKeywordsAll(size=$size) -> found=${matches.keys}, elapsed=${sw.elapsedMilliseconds}ms', - ); - expect(matches.isNotEmpty, isTrue); - }); - } - }); - - /// Test input validation and edge cases - group('AttachmentTextDetector Input Validation', () { - test('should handle empty input gracefully', () async { - expect(AttachmentTextDetector.containsAnyAttachmentKeyword(''), isFalse); - expect((await AttachmentTextDetector.matchedKeywordsUnique('')), isEmpty); - expect(AttachmentTextDetector.matchedKeywordsAll(''), isEmpty); - }); - - test('should handle special characters and symbols', () async { - const email = "attach@#\$%^&*()_+ pièce jointe!@#\$%"; - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); - expect(matches, containsAll(['attach', 'pièce jointe'])); - }); - - test('should handle whitespace-only input', () async { - const email = " \n\t\r "; - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), isFalse); - expect((await AttachmentTextDetector.matchedKeywordsUnique(email)), isEmpty); - }); - - test('should handle newlines and tabs in content', () { - const email = "Please\nsee\tthe\r\nattached\tdocument."; - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - }); - }); - - /// Test language code edge cases and validation - group('AttachmentTextDetector Language Code Validation', () { - test('should handle invalid language codes', () { - expect( - AttachmentTextDetector.containsAttachmentKeyword("attach", lang: ""), - isFalse); - expect( - AttachmentTextDetector.containsAttachmentKeyword("attach", - lang: "invalid"), - isFalse); - expect( - AttachmentTextDetector.containsAttachmentKeyword("attach", - lang: "xyz"), - isFalse); - }); - - test('should handle language code case variations', () { - expect( - AttachmentTextDetector.containsAttachmentKeyword("attach", - lang: "EN"), - isTrue); - expect( - AttachmentTextDetector.containsAttachmentKeyword("attach", - lang: "En"), - isTrue); - expect( - AttachmentTextDetector.containsAttachmentKeyword("pièce jointe", - lang: "FR"), - isTrue); - expect( - AttachmentTextDetector.containsAttachmentKeyword("pièce jointe", - lang: "Fr"), - isTrue); - }); - - test('should return empty results for unsupported languages', () { - expect(AttachmentTextDetector.matchedKeywords("attach", lang: "de"), - isEmpty); - expect(AttachmentTextDetector.matchedKeywords("attach", lang: "es"), - isEmpty); - expect(AttachmentTextDetector.matchedKeywords("attach", lang: "ja"), - isEmpty); - }); - }); - - /// Test boundary conditions and word matching - group('AttachmentTextDetector Boundary Conditions', () { - test('should handle keywords at text boundaries', () { - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("attach"), - isTrue); - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("attachment."), - isTrue); - expect(AttachmentTextDetector.containsAnyAttachmentKeyword(".attach"), - isTrue); - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword("(attachment)"), - isTrue); - }); - - test('should handle partial word matches correctly', () { - // Should NOT match "attach" in words where it's just a substring - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("detachment"), - isFalse); - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword("reattachment"), - isTrue); // contains "attachment" - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("attachments"), - isTrue); // contains "attachment" - }); - - test('should handle keywords with punctuation', () { - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("attach,"), - isTrue); - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("attach;"), - isTrue); - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("attach:"), - isTrue); - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("attach?"), - isTrue); - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("attach!"), - isTrue); - }); - - test('should handle single character boundaries', () { - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("a"), isFalse); - expect(AttachmentTextDetector.containsAnyAttachmentKeyword("file"), - isTrue); // Vietnamese keyword - }); - }); - - /// Test Unicode and encoding edge cases - group('AttachmentTextDetector Unicode Handling', () { - test('should handle emoji and special Unicode characters', () async { - const email = "📎 attachment 📄 file 🔗 pièce jointe"; - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); - expect(matches, containsAll(['attachment', 'file', 'pièce jointe'])); - }); - - test('should handle mixed Unicode scripts', () { - const email = "文档 attachment документ pièce jointe تقرير"; - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - }); - - test('should handle Unicode normalization variations', () { - // Test composed vs decomposed Unicode characters for French - const email1 = "pièce jointe"; // é as single character (NFC) - const email2 = - "pie\u0300ce jointe"; // é as e + combining grave accent (NFD) - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email1), isTrue); - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email2), isTrue); - }); - - test('should handle zero-width characters', () { - const email = "attach\u200Bment"; // Zero-width space - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - }); - }); - - /// Test real-world scenarios and integration cases +/// Test real-world scenarios and integration cases group('AttachmentTextDetector Real-World Scenarios', () { test('should handle common email signatures and footers', () async { const email = """ Please review the attached document. - + Best regards, John Doe - + This email and any attachments are confidential and may be privileged. """; final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, containsAll(['attached', 'attachments'])); }); - test('should handle HTML-like content', () { - const email = - "Please see the attached document in the <file> section."; - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - }); - test('should handle quoted email content', () async { const email = """ Hi John, - + Please find the attachment below. - + > On Mon, Jan 1, 2024, Jane wrote: > I need the document attached to your previous email. > Can you resend the file? - + Thanks! """; final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, containsAll(['attachment', 'attached', 'file'])); }); - - test('should handle multiple languages in single email', () async { - const email = """ - English: Please see the attached file. - French: Veuillez voir le fichier joint. - Vietnamese: Vui lòng xem tài liệu đính kèm. - Russian: Пожалуйста, смотрите приложение. - """; - final matchesAll = AttachmentTextDetector.matchedKeywordsAll(email); - expect(matchesAll.keys, containsAll(['en', 'fr', 'vi', 'ru'])); - - final matchesUnique = await AttachmentTextDetector.matchedKeywordsUnique(email); - expect( - matchesUnique, - containsAll([ - 'attached', - 'file', - 'fichier joint', - 'tài liệu', - 'đính kèm', - 'приложение' - ])); - }); - - test('should handle email threading and forwarding markers', () { - const email = """ - FW: Important Document - - Please see attached report. - - -----Original Message----- - From: sender@example.com - The attachment contains sensitive information. - """; - expect( - AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - }); }); /// Test performance and memory edge cases group('AttachmentTextDetector Performance Edge Cases', () { - test('should handle extremely large input without memory issues', () { - // Test with 5MB+ content - final hugeEmail = generateLongEmail(5000000, includeKeywords: false); - expect( - () => AttachmentTextDetector.containsAnyAttachmentKeyword(hugeEmail), - returnsNormally); - }); - test('should handle repeated keyword patterns efficiently', () async { final email = "attach " * 10000; // 10K repetitions, ~70k chars final sw = Stopwatch()..start(); @@ -637,18 +149,6 @@ void main() { expect(sw.elapsedMilliseconds, lessThan(100)); // Should complete quickly }); - test('should handle very long single line efficiently', () { - final longLine = "word " * 100000 + "attachment"; // 100K words + keyword - final sw = Stopwatch()..start(); - final result = - AttachmentTextDetector.containsAnyAttachmentKeyword(longLine); - sw.stop(); - - expect(result, isTrue); - expect(sw.elapsedMilliseconds, - lessThan(500)); // Should complete within 500ms - }); - test('should handle many different keywords in large text', () async { final email = """ attach attachment file document report @@ -656,70 +156,24 @@ void main() { приложение документ файл отчёт вложение đính kèm tài liệu tệp báo cáo """ * - 1000; // Repeat 1000 times + 1000; // Repeat 1000 times, ~200,000 chars final sw = Stopwatch()..start(); - final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique( + email, + forceSync: true, + ); sw.stop(); expect(matches.length, greaterThan(10)); - expect(sw.elapsedMilliseconds, - lessThan(1000)); // Should complete within 1 second + expect(sw.elapsedMilliseconds, lessThan(3000)); }); }); /// Test edge cases in Arabic language group('AttachmentTextDetector Arabic Tests', () { - const lang = LanguageCodeConstants.arabic; - - test('containsAttachmentKeyword should return true when Arabic keyword exists', () { - const text = 'الرجاء مراجعة المستند المرفق'; - final result = AttachmentTextDetector.containsAttachmentKeyword(text, lang: lang); - expect(result, isTrue); - }); - - test('containsAttachmentKeyword should return false when no Arabic keyword exists', () { - const text = 'مرحبا كيف حالك اليوم؟'; - final result = AttachmentTextDetector.containsAttachmentKeyword(text, lang: lang); - expect(result, isFalse); - }); - - test('matchedKeywords should return matched Arabic keywords', () { - const text = 'تم إرسال ملف تقرير مرفق مع الرسالة'; - final result = AttachmentTextDetector.matchedKeywords(text, lang: lang); - expect(result, containsAll(['ملف', 'تقرير', 'مرفق'])); - }); - - test('matchedKeywords matches singular when plural form appears (e.g., مرفقات contains مرفق)', () { - const text = 'هذه مجرد رسالة عادية بدون أي مرفقات'; - final result = AttachmentTextDetector.matchedKeywords(text, lang: LanguageCodeConstants.arabic); - expect(result, contains('مرفق')); - expect(result, isNotEmpty); - }); - - test('matchedKeywords should return empty list when no Arabic keywords exist', () { - const text = 'هذه رسالة للتجربة فقط بدون أي صور أو روابط.'; - final result = AttachmentTextDetector.matchedKeywords(text, lang: LanguageCodeConstants.arabic); - expect(result, isEmpty); - }); - - test('containsAnyAttachmentKeyword should detect Arabic keyword among all languages', () { - const text = 'مرفق هام موجود في هذه الرسالة'; - final result = AttachmentTextDetector.containsAnyAttachmentKeyword(text); - expect(result, isTrue); - }); - - test('matchedKeywordsAll should return only Arabic matches', () { - const text = 'إليك المستند مرفق للعرض'; - final result = AttachmentTextDetector.matchedKeywordsAll(text); - - expect(result.keys, contains(lang)); - expect(result[lang], containsAll(['مستند', 'مرفق'])); - // Make sure there are no other languages - expect(result.keys.length, equals(1)); - }); - - test('matchedKeywordsUnique should return unique Arabic keywords only', () async { + test('matchedKeywordsUnique should return unique Arabic keywords only', + () async { const text = 'مرفق ملف مرفق تقرير ملف'; final result = await AttachmentTextDetector.matchedKeywordsUnique(text); @@ -728,50 +182,25 @@ void main() { expect(result.length, equals(3)); }); - test('containsAnyAttachmentKeyword detects keywords across multiple languages', () { - const text = 'Please see the attached document. ' - 'الرجاء مراجعة المستند المرفق. ' - 'Vui lòng xem tài liệu đính kèm.'; - - final result = AttachmentTextDetector.containsAnyAttachmentKeyword(text); - expect(result, isTrue); - }); - - test('matchedKeywordsAll should return matches grouped by language', () { - const text = 'Here is the attachment. ' - 'إليك ملف تقرير مرفق. ' - 'Vui lòng xem báo cáo đính kèm. ' - 'pièce jointe est incluse.'; - - final result = AttachmentTextDetector.matchedKeywordsAll(text); - - expect(result.keys, containsAll([ - LanguageCodeConstants.english, - LanguageCodeConstants.arabic, - LanguageCodeConstants.vietnamese, - LanguageCodeConstants.french, - ])); - - expect(result[LanguageCodeConstants.english], contains('attachment')); - expect(result[LanguageCodeConstants.arabic], containsAll(['ملف', 'تقرير', 'مرفق'])); - expect(result[LanguageCodeConstants.vietnamese], containsAll(['báo cáo', 'đính kèm'])); - expect(result[LanguageCodeConstants.french], contains('pièce jointe')); - }); - - test('matchedKeywordsUnique should return unique keywords from all languages', () async { - const text = 'Attached báo cáo مرفق pièce jointe file مستند attach đính kèm'; + test( + 'matchedKeywordsUnique should return unique keywords from all languages', + () async { + const text = + 'Attached báo cáo مرفق pièce jointe file مستند attach đính kèm'; final result = await AttachmentTextDetector.matchedKeywordsUnique(text); - expect(result, containsAll([ - 'attach', - 'pièce jointe', - 'đính kèm', - 'báo cáo', - 'file', - 'مرفق', - 'مستند' - ])); + expect( + result, + containsAll([ + 'attach', + 'pièce jointe', + 'đính kèm', + 'báo cáo', + 'file', + 'مرفق', + 'مستند' + ])); // Ensure no duplicates expect(result.length, equals(result.toSet().length)); @@ -819,8 +248,8 @@ void main() { test( 'Valid Suffix (Number): Should ACCEPT words followed by numbers (e.g., file123)', () async { - final result = - await AttachmentTextDetector.matchedKeywordsUnique('Check file123 now.'); + final result = await AttachmentTextDetector.matchedKeywordsUnique( + 'Check file123 now.'); expect(result, contains('file')); }); @@ -852,8 +281,8 @@ void main() { 'Longest Match Priority: Should match "attachment" instead of "attach"', () async { // Because we sort by length desc, "attachment" comes before "attach" in regex - final result = - await AttachmentTextDetector.matchedKeywordsUnique('See attachment.'); + final result = await AttachmentTextDetector.matchedKeywordsUnique( + 'See attachment.'); expect(result, contains('attachment')); // Note: "attachment" contains "attach", but since it consumes the text, @@ -882,7 +311,8 @@ void main() { }); group('Performance Benchmark', () { - test('Large Text Performance: Should process 100k chars under 50ms', () async { + test('Large Text Performance: Should process 100k chars under 50ms', + () async { // Generate a large text (~100k characters) final buffer = StringBuffer(); for (int i = 0; i < 5000; i++) { @@ -917,8 +347,8 @@ void main() { group('AttachmentTextDetector with exclude filter', () { test('Basic detection without filters', () async { - final result = - await AttachmentTextDetector.matchedKeywordsUnique('Check file attachment'); + final result = await AttachmentTextDetector.matchedKeywordsUnique( + 'Check file attachment'); expect(result, containsAll(['file', 'attachment'])); }); @@ -1016,7 +446,8 @@ void main() { group('AttachmentTextDetector (Include/Exclude) filters', () { group('Strict Logic Tests (Include/Exclude)', () { test( - 'IncludeList: Should strictly accept listed tokens and reject others', () async { + 'IncludeList: Should strictly accept listed tokens and reject others', + () async { const text = "Please see attachment-vip and delete file-trash."; final result = await AttachmentTextDetector.matchedKeywordsUnique( @@ -1075,7 +506,9 @@ void main() { expect(result2, contains('attachment-vip')); }); - test('Include List should ADD keywords to detection (Fixing previous issue)', () async { + test( + 'Include List should ADD keywords to detection (Fixing previous issue)', + () async { const text = "Please check the invoice-2024."; final result = await AttachmentTextDetector.matchedKeywordsUnique( @@ -1124,8 +557,7 @@ void main() { } final bigText = sb.toString(); - final stopwatch = Stopwatch() - ..start(); + final stopwatch = Stopwatch()..start(); final result = await AttachmentTextDetector.matchedKeywordsUnique( bigText, @@ -1143,8 +575,7 @@ void main() { test('Integration: Large Text via Isolate (Async)', () async { final bigText = "file-vip " * 5000; // ~45k chars (> 20k threshold) - final stopwatch = Stopwatch() - ..start(); + final stopwatch = Stopwatch()..start(); final result = await AttachmentTextDetector.matchedKeywordsUnique( bigText, @@ -1152,20 +583,19 @@ void main() { ); stopwatch.stop(); - log('Total Async Time (incl. Isolate spawn): ${stopwatch - .elapsedMilliseconds}ms'); + log('Total Async Time (incl. Isolate spawn): ${stopwatch.elapsedMilliseconds}ms'); expect(result, contains('file-vip')); - expect(stopwatch.elapsedMilliseconds, lessThan(300)); + expect(stopwatch.elapsedMilliseconds, lessThan(2000)); }); test('Safety: Input with potential catastrophic backtracking', () async { final trickyText = "${"a" * 50000} file ${"b" * 50000}"; - final stopwatch = Stopwatch() - ..start(); + final stopwatch = Stopwatch()..start(); final result = await AttachmentTextDetector.matchedKeywordsUnique( - trickyText, forceSync: true); + trickyText, + forceSync: true); stopwatch.stop(); expect(result, contains('file')); @@ -1238,4 +668,230 @@ void main() { }); }); }); + + group('validateAttachmentReminder pipeline - subject scanning', () { + test('detects keyword in subject even when body is empty', () async { + final result = await runPipeline( + subject: 'Please find the attached document', + body: '', + ); + expect(result, contains('attached')); + }); + + test('detects keyword in body even when subject is empty', () async { + final result = await runPipeline( + subject: '', + body: 'I have attached the file for your review.', + ); + expect(result, containsAll(['attached', 'file'])); + }); + + test('detects keywords from both subject and body', () async { + final result = await runPipeline( + subject: 'Attachment for project', + body: 'Please see the file I mentioned.', + ); + expect(result, containsAll(['attachment', 'file'])); + }); + + test('returns empty when neither subject nor body has keywords', () async { + final result = await runPipeline( + subject: 'Meeting tomorrow', + body: 'Let us discuss the agenda.', + ); + expect(result, isEmpty); + }); + }); + + group('validateAttachmentReminder pipeline - HTML stripping', () { + test('detects keyword wrapped in HTML tags', () async { + final result = await runPipeline( + subject: '', + body: '

Please review the attached document.

', + ); + expect(result, contains('attached')); + }); + + test('detects keyword inside nested HTML structure', () async { + final result = await runPipeline( + subject: '', + body: + '

See the file for details.

', + ); + expect(result, contains('file')); + }); + + test('does NOT detect keyword inside blockquote (quoted reply)', () async { + final result = await runPipeline( + subject: '', + body: ''' +

Thanks for your message.

+
+

Original: please find the file attached.

+
+ ''', + ); + // Only "Thanks for your message" is scanned — blockquote is stripped + expect(result, isEmpty); + }); + + test('does NOT detect keyword inside tmail-signature div', () async { + final result = await runPipeline( + subject: '', + body: ''' +

Looking forward to the meeting.

+
+

Best regards, John. Please find attached my contact card.

+
+ ''', + ); + // "Looking forward to the meeting" has no attachment keywords + // signature is stripped — no false alarm + expect(result, isEmpty); + }); + + test('detects keyword in body even when signature also has keywords', + () async { + final result = await runPipeline( + subject: '', + body: ''' +

I have attached the report.

+
+

Please find attached my vCard.

+
+ ''', + ); + // Signature stripped, but body keyword remains + expect(result, contains('attached')); + }); + }); + + group('validateAttachmentReminder pipeline - includeList config', () { + test('detects custom keyword from includeList', () async { + final result = await runPipeline( + subject: '', + body: 'Please review the invoice I prepared.', + includeList: ['invoice'], + ); + expect(result, contains('invoice')); + }); + + test('does NOT detect custom keyword when not in includeList', () async { + final result = await runPipeline( + subject: '', + body: 'Please review the invoice I prepared.', + // No includeList → 'invoice' is not a default keyword + ); + expect(result, isEmpty); + }); + + test('detects both default and custom keywords together', () async { + final result = await runPipeline( + subject: '', + body: 'Please find the attached invoice.', + includeList: ['invoice'], + ); + expect(result, containsAll(['attached', 'invoice'])); + }); + }); + + group('validateAttachmentReminder pipeline - excludeList config', () { + test('blocks specific token matching an exclude entry', () async { + final result = await runPipeline( + subject: '', + body: 'Refer to ticket file-246 for details.', + excludeList: ['file-246'], + ); + // "file" matched but surrounded by "-246" → token "file-246" is blocked + expect(result, isEmpty); + }); + + test('keeps standalone keyword not matching any exclude entry', () async { + final result = await runPipeline( + subject: '', + body: 'Please send the file today.', + excludeList: ['file-246'], + ); + // "file" is standalone — not blocked + expect(result, contains('file')); + }); + + test('blocks excluded token but keeps other keywords in same text', + () async { + final result = await runPipeline( + subject: '', + body: 'See file-246 and also the attached document.', + excludeList: ['file-246'], + ); + expect(result, isNot(contains('file'))); + expect(result, contains('attached')); + }); + }); + + group('validateAttachmentReminder pipeline - config manager integration', () { + test('uses includeList and excludeList from loaded config', () async { + when(mockLoader.load( + any, + argThat(isA()), + )).thenAnswer((_) async => AttachmentKeywordConfig( + includeList: ['invoice'], + excludeList: ['file-246'], + )); + + final config = await AttachmentKeywordConfigManager().getConfig(); + + final result = await runPipeline( + subject: '', + body: 'See file-246 and the attached invoice.', + includeList: config.includeList, + excludeList: config.excludeList, + ); + + expect(result, isNot(contains('file'))); // blocked by excludeList + expect(result, contains('invoice')); // added by includeList + expect(result, contains('attached')); // default keyword + }); + + test('falls back to empty config when loader throws', () async { + when(mockLoader.load(any, any)) + .thenThrow(Exception('config not found')); + + final config = await AttachmentKeywordConfigManager().getConfig(); + + // Fallback config has empty lists — only default keywords are detected + final result = await runPipeline( + subject: '', + body: 'Please find the file attached.', + includeList: config.includeList, + excludeList: config.excludeList, + ); + + expect(result, containsAll(['file', 'attached'])); + }); + }); + + group('validateAttachmentReminder pipeline - edge cases', () { + test('returns empty for empty subject and body', () async { + final result = await runPipeline(subject: '', body: ''); + expect(result, isEmpty); + }); + + test('returns empty for whitespace-only content', () async { + final result = await runPipeline( + subject: ' ', + body: ' \n\t ', + ); + expect(result, isEmpty); + }); + + test('deduplicates when keyword appears in both subject and body', + () async { + final result = await runPipeline( + subject: 'File attached', + body: '

The file is ready, attached for review.

', + ); + expect(result.where((k) => k == 'file').length, equals(1)); + expect(result.where((k) => k == 'attached').length, equals(1)); + }); + }); } diff --git a/test/features/composer/presentation/manager/exclude_list_filter_test.dart b/test/features/composer/presentation/manager/exclude_list_filter_test.dart new file mode 100644 index 000000000..019cf9f71 --- /dev/null +++ b/test/features/composer/presentation/manager/exclude_list_filter_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/exclude_list_filter.dart'; + +/// Helper: simulate a regex [Match] at [start]..[end] within [text]. +Match _fakeMatch(String text, int start, int end) { + final regex = RegExp(RegExp.escape(text.substring(start, end))); + return regex.allMatches(text).firstWhere((m) => m.start == start); +} + +void main() { + group('ExcludeListFilter.isValid', () { + test('returns true when excludeList is empty', () { + final filter = ExcludeListFilter([]); + const text ='See the file attached.'; + final match = _fakeMatch(text, 8, 12); // "file" + expect(filter.isValid(text, match), isTrue); + }); + + test('blocks token that exactly matches an exclude entry', () { + final filter = ExcludeListFilter(['file-246']); + const text ='Please find file-246 here.'; + final match = _fakeMatch(text, 12, 16); // "file" + expect(filter.isValid(text, match), isFalse); + }); + + test('blocks token with trailing punctuation (e.g. "file-246.")', () { + final filter = ExcludeListFilter(['file-246']); + const text ='Check file-246.'; + final match = _fakeMatch(text, 6, 10); // "file" + expect(filter.isValid(text, match), isFalse); + }); + + test('blocks token with leading punctuation (e.g. "(file-246")', () { + final filter = ExcludeListFilter(['file-246']); + const text ='See (file-246 for details.'; + final match = _fakeMatch(text, 5, 9); // "file" + expect(filter.isValid(text, match), isFalse); + }); + + test('allows token not in the exclude list', () { + final filter = ExcludeListFilter(['invoice-draft']); + const text ='Please attach the file.'; + final match = _fakeMatch(text, 18, 22); // "file" + expect(filter.isValid(text, match), isTrue); + }); + + test('matching is case-insensitive', () { + final filter = ExcludeListFilter(['File-246']); + const text ='See file-246 here.'; + final match = _fakeMatch(text, 4, 8); // "file" + expect(filter.isValid(text, match), isFalse); + }); + + test('allows a standalone keyword not surrounded by exclude context', () { + final filter = ExcludeListFilter(['file-246']); + const text ='Please attach the file here.'; + final match = _fakeMatch(text, 18, 22); // "file" + expect(filter.isValid(text, match), isTrue); + }); + }); +}