From 1071bb49cfe6a1ffe38efa073099184206d0c7cf Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 30 Jan 2026 13:16:17 +0700 Subject: [PATCH] TF-4265 Add exclude list support to `AttachmentTextDetector` --- .../presentation/composer_controller.dart | 2 +- .../attachment_detection_extension.dart | 7 +- .../manager/attachment_text_detector.dart | 98 ++++++-- .../manager/detection_params.dart | 10 + .../manager/exclude_list_filter.dart | 55 ++++ .../presentation/manager/keyword_filter.dart | 6 + .../attachment_text_detector_test.dart | 237 +++++++++++++----- 7 files changed, 330 insertions(+), 85 deletions(-) create mode 100644 lib/features/composer/presentation/manager/detection_params.dart create mode 100644 lib/features/composer/presentation/manager/exclude_list_filter.dart create mode 100644 lib/features/composer/presentation/manager/keyword_filter.dart diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index f6146d66f..4ab4291f3 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -886,7 +886,7 @@ class ComposerController extends BaseController _sendButtonState = ButtonState.enabled; return; } - final attachmentKeywords = validateAttachmentReminder( + final attachmentKeywords = await validateAttachmentReminder( emailSubject: subjectEmail.value ?? '', emailContent: emailContent, ); diff --git a/lib/features/composer/presentation/extensions/attachment_detection_extension.dart b/lib/features/composer/presentation/extensions/attachment_detection_extension.dart index ca918ba67..9ac335e86 100644 --- a/lib/features/composer/presentation/extensions/attachment_detection_extension.dart +++ b/lib/features/composer/presentation/extensions/attachment_detection_extension.dart @@ -9,14 +9,15 @@ import 'package:tmail_ui_user/main/routes/route_navigation.dart'; extension AttachmentDetectionExtension on ComposerController { - List validateAttachmentReminder({ + Future> validateAttachmentReminder({ required String emailContent, required String emailSubject, - }) { + }) async { try { final fullContent = '$emailSubject $emailContent'; final plainText = HtmlUtils.extractPlainText(fullContent); - final keywords = AttachmentTextDetector.matchedKeywordsUnique(plainText); + final keywords = + await AttachmentTextDetector.matchedKeywordsUnique(plainText); if (keywords.isEmpty) { return []; } else { diff --git a/lib/features/composer/presentation/manager/attachment_text_detector.dart b/lib/features/composer/presentation/manager/attachment_text_detector.dart index 37373a6b5..1cc7ac353 100644 --- a/lib/features/composer/presentation/manager/attachment_text_detector.dart +++ b/lib/features/composer/presentation/manager/attachment_text_detector.dart @@ -1,3 +1,6 @@ +import 'package:flutter/foundation.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/detection_params.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/keyword_filter.dart'; import 'package:tmail_ui_user/main/localizations/language_code_constants.dart'; class AttachmentTextDetector { @@ -47,6 +50,22 @@ class AttachmentTextDetector { ], }; + static RegExp? _combinedRegExp; + /// SYNC/ASYNC EXECUTION THRESHOLD + /// + /// Value: 20,000 characters (approx. 4-5 pages of text). + /// + /// Technical Rationale: + /// 1. Frame Budget (16ms): To maintain 60fps, operations on the UI thread must stay under 16ms. + /// 2. Isolate Overhead: `compute` takes about 2-4ms just to copy data and start the thread. + /// 3. Real-world Benchmark: + /// - For < 20k chars, Sync execution is usually faster (< 10ms) than the Async overhead. + /// - 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). + 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}) { @@ -95,48 +114,85 @@ class AttachmentTextDetector { return result; } - // Store a single combined RegExp pattern - static RegExp? _combinedRegExp; - - /// Initializes a single optimized RegExp that contains all keywords. + /// Initializes the single optimized RegExp pattern. static void _initializeRegExp() { if (_combinedRegExp != null) return; - // Flatten all keywords from all languages into a single list - final allKeywords = _keywordsByLang.values.expand((list) => list).toSet().toList(); + final allKeywords = + _keywordsByLang.values.expand((l) => l).toSet().toList(); - // Sort keywords by length descending (longest first). + // Sort by length descending (Longest first). + // Crucial to ensure "attachment" is matched before "attach". allKeywords.sort((a, b) => b.length.compareTo(a.length)); - // Create a pattern like: (attachment|attach|file|files)(?![\p{L}]) - // Join all keywords with OR operator (|) - final patternString = allKeywords.map(RegExp.escape).join('|'); + // LOGIC: + // 1. RegExp.escape: Neutralizes special characters. + // 2. No Nested Quantifiers: Prevents catastrophic backtracking. + // 3. Linear Scan: Uses logical OR (|) for a single-pass scan. + final pattern = allKeywords.map(RegExp.escape).join('|'); + // Pattern: (keyword1|keyword2)(?![\p{L}]) + // (?![\p{L}]): Negative lookahead to ensure the next char is NOT a letter. + // Allows: numbers (file123), punctuation (file.), spaces (file ). + // Rejects: letter extensions (filetage). _combinedRegExp = RegExp( - '($patternString)(?![\\p{L}])', + '($pattern)(?![\\p{L}])', unicode: true, - caseSensitive: false, // Let Regex handle case-insensitivity + caseSensitive: false, ); } - /// Detects unique keywords in the provided text based on predefined language lists. - static List matchedKeywordsUnique(String text) { - if (text.isEmpty) return []; - - // Ensure RegExp are initialized + static List _processMatchedKeywordsInIsolate(DetectionParams params) { + // Re-initialize RegExp (Isolates do not share static memory with the main app) _initializeRegExp(); + final text = params.text; + final filters = params.filters; + + if (text.isEmpty) return []; + final matches = _combinedRegExp!.allMatches(text); final result = {}; for (final match in matches) { - // match.group(0) returns the actual text found (e.g., "FILE", "File"). - // We convert it to lowercase to normalize the result list. - if (match.group(0) != null) { - result.add(match.group(0)!.toLowerCase()); + final keyword = match.group(0)!.toLowerCase(); + + // Apply Filter Pipeline + bool isAccepted = true; + for (final filter in filters) { + if (!filter.isValid(text, match)) { + isAccepted = false; + break; // Stop checking other filters to save CPU + } + } + + if (isAccepted) { + result.add(keyword); } } return result.toList(); } + + /// Smart keyword detection (Hybrid Sync/Async). + /// Automatically decides whether to run on the UI Thread or a Background Thread. + /// [forceSync]: If true, it will be MANDATORY to run on the UI Thread (ignoring the threshold). + /// Use this parameter for unit tests to benchmark the algorithm more accurately. + static Future> matchedKeywordsUnique( + String text, { + List filters = const [], + bool forceSync = false, + }) async { + // If text is short, run Synchronously to avoid Isolate overhead cost. + if (forceSync || text.length < _kAsyncExecutionThreshold) { + return _processMatchedKeywordsInIsolate( + DetectionParams(text: text, filters: filters), + ); + } else { // If text is long, use `compute` to offload to an Isolate, preventing UI freeze. + return await compute( + _processMatchedKeywordsInIsolate, + DetectionParams(text: text, filters: filters), + ); + } + } } diff --git a/lib/features/composer/presentation/manager/detection_params.dart b/lib/features/composer/presentation/manager/detection_params.dart new file mode 100644 index 000000000..467eca492 --- /dev/null +++ b/lib/features/composer/presentation/manager/detection_params.dart @@ -0,0 +1,10 @@ +import 'package:tmail_ui_user/features/composer/presentation/manager/keyword_filter.dart'; + +/// Data Transfer Object used to package data sent to the Isolate. +/// Since Isolates cannot share memory, we need a simple object to pass parameters. +class DetectionParams { + final String text; + final List filters; + + DetectionParams({required this.text, required this.filters}); +} \ No newline at end of file diff --git a/lib/features/composer/presentation/manager/exclude_list_filter.dart b/lib/features/composer/presentation/manager/exclude_list_filter.dart new file mode 100644 index 000000000..f0393eeee --- /dev/null +++ b/lib/features/composer/presentation/manager/exclude_list_filter.dart @@ -0,0 +1,55 @@ +import 'package:tmail_ui_user/features/composer/presentation/manager/keyword_filter.dart'; + +/// Implementation of Exclude List Filter. +/// Optimized for memory usage by avoiding unnecessary String allocations. +class ExcludeListFilter implements KeywordFilter { + final Set _excludes; + + /// Constructor takes a List for convenience, but converts to Set for O(1) lookup. + ExcludeListFilter(List rawExcludes) + : _excludes = rawExcludes.map((e) => e.toLowerCase()).toSet(); + + /// Static RegExp to check for whitespace. + /// Using a static instance avoids re-compilation in loops. + static final RegExp _whitespaceRegExp = RegExp(r'\s'); + + @override + bool isValid(String fullText, Match match) { + if (_excludes.isEmpty) return true; + + // Extract the full token surrounding the match + final token = _getSurroundingToken(fullText, match.start, match.end); + final lowerToken = token.toLowerCase(); + + // Exact match with the token (e.g., "file-246") + if (_excludes.contains(lowerToken)) return false; + + // Match with trailing punctuation removed (e.g., "file246." -> "file246") + final cleanToken = lowerToken.replaceAll(RegExp(r'[^\w\s]+$'), ''); + if (_excludes.contains(cleanToken)) return false; + + return true; + } + + /// Extracts the surrounding token without creating garbage strings. + /// Instead of using .trim() inside a loop (which allocates memory), + /// we check characters directly. + String _getSurroundingToken(String text, int matchStart, int matchEnd) { + int start = matchStart; + int end = matchEnd; + + // Expand to the left until whitespace or start of string + while (start > 0) { + // Check the character before the current start index + if (_whitespaceRegExp.hasMatch(text[start - 1])) break; + start--; + } + + // Expand to the right until whitespace or end of string + while (end < text.length) { + if (_whitespaceRegExp.hasMatch(text[end])) break; + end++; + } + return text.substring(start, end); + } +} diff --git a/lib/features/composer/presentation/manager/keyword_filter.dart b/lib/features/composer/presentation/manager/keyword_filter.dart new file mode 100644 index 000000000..6543b27bd --- /dev/null +++ b/lib/features/composer/presentation/manager/keyword_filter.dart @@ -0,0 +1,6 @@ +/// Common interface for all keyword filters. +abstract class KeywordFilter { + /// Returns `true` if the keyword is valid. + /// Returns `false` if the keyword should be rejected. + bool isValid(String fullText, Match match); +} \ No newline at end of file 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 d7e3ccee4..7c17f3ed9 100644 --- a/test/features/composer/presentation/manager/attachment_text_detector_test.dart +++ b/test/features/composer/presentation/manager/attachment_text_detector_test.dart @@ -1,6 +1,7 @@ import 'package:core/utils/app_logger.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_text_detector.dart'; +import 'package:tmail_ui_user/features/composer/presentation/manager/exclude_list_filter.dart'; import 'package:tmail_ui_user/main/localizations/language_code_constants.dart'; /// Helper: generate email about [targetLength] characters long @@ -17,6 +18,8 @@ String generateLongEmail(int targetLength, {bool includeKeywords = true}) { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('AttachmentTextDetector.containsAttachmentKeyword', () { test('English - should detect "attach" and "attachment"', () { expect( @@ -266,14 +269,14 @@ void main() { }); group('AttachmentTextDetector.matchedKeywordsUnique', () { - test('should return unique matches across multiple languages', () { + test('should return unique matches across multiple languages', () async { const email = """ Please see the attached document. Vui lòng xem tài liệu đính kèm. Смотрите приложение с отчетом. """; - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, containsAll(['tài liệu', 'đính kèm', 'приложение'])); @@ -281,14 +284,14 @@ void main() { reason: 'No duplicates allowed'); }); - test('should return unique matches even if repeated multiple times', () { + 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 приложение приложение """; - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect( matches, containsAll(['file', 'attach', 'đính kèm', 'приложение'])); @@ -296,15 +299,15 @@ void main() { reason: 'Duplicates must be removed'); }); - test('should return empty list if no keywords present', () { + test('should return empty list if no keywords present', () async { const email = "This email does not mention anything related to document."; - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, isEmpty); }); - test('should match Russian word with both ё and е', () { + test('should match Russian word with both ё and е', () async { const email = "Вот наш отчёт и вот наш снова."; - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, containsAll(['отчёт'])); }); @@ -384,25 +387,25 @@ void main() { /// Test input validation and edge cases group('AttachmentTextDetector Input Validation', () { - test('should handle empty input gracefully', () { + test('should handle empty input gracefully', () async { expect(AttachmentTextDetector.containsAnyAttachmentKeyword(''), isFalse); - expect(AttachmentTextDetector.matchedKeywordsUnique(''), isEmpty); + expect((await AttachmentTextDetector.matchedKeywordsUnique('')), isEmpty); expect(AttachmentTextDetector.matchedKeywordsAll(''), isEmpty); }); - test('should handle special characters and symbols', () { + test('should handle special characters and symbols', () async { const email = "attach@#\$%^&*()_+ pièce jointe!@#\$%"; expect( AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, containsAll(['attach', 'pièce jointe'])); }); - test('should handle whitespace-only input', () { + test('should handle whitespace-only input', () async { const email = " \n\t\r "; expect( AttachmentTextDetector.containsAnyAttachmentKeyword(email), isFalse); - expect(AttachmentTextDetector.matchedKeywordsUnique(email), isEmpty); + expect((await AttachmentTextDetector.matchedKeywordsUnique(email)), isEmpty); }); test('should handle newlines and tabs in content', () { @@ -504,11 +507,11 @@ void main() { /// Test Unicode and encoding edge cases group('AttachmentTextDetector Unicode Handling', () { - test('should handle emoji and special Unicode characters', () { + test('should handle emoji and special Unicode characters', () async { const email = "📎 attachment 📄 file 🔗 pièce jointe"; expect( AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, containsAll(['attachment', 'file', 'pièce jointe'])); }); @@ -538,7 +541,7 @@ void main() { /// Test real-world scenarios and integration cases group('AttachmentTextDetector Real-World Scenarios', () { - test('should handle common email signatures and footers', () { + test('should handle common email signatures and footers', () async { const email = """ Please review the attached document. @@ -547,7 +550,7 @@ void main() { This email and any attachments are confidential and may be privileged. """; - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, containsAll(['attached', 'attachments'])); }); @@ -558,7 +561,7 @@ void main() { AttachmentTextDetector.containsAnyAttachmentKeyword(email), isTrue); }); - test('should handle quoted email content', () { + test('should handle quoted email content', () async { const email = """ Hi John, @@ -570,11 +573,11 @@ void main() { Thanks! """; - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); expect(matches, containsAll(['attachment', 'attached', 'file'])); }); - test('should handle multiple languages in single email', () { + test('should handle multiple languages in single email', () async { const email = """ English: Please see the attached file. French: Veuillez voir le fichier joint. @@ -584,7 +587,7 @@ void main() { final matchesAll = AttachmentTextDetector.matchedKeywordsAll(email); expect(matchesAll.keys, containsAll(['en', 'fr', 'vi', 'ru'])); - final matchesUnique = AttachmentTextDetector.matchedKeywordsUnique(email); + final matchesUnique = await AttachmentTextDetector.matchedKeywordsUnique(email); expect( matchesUnique, containsAll([ @@ -622,10 +625,13 @@ void main() { returnsNormally); }); - test('should handle repeated keyword patterns efficiently', () { - final email = "attach " * 10000; // 10K repetitions + test('should handle repeated keyword patterns efficiently', () async { + final email = "attach " * 10000; // 10K repetitions, ~70k chars final sw = Stopwatch()..start(); - final result = AttachmentTextDetector.matchedKeywordsUnique(email); + final result = await AttachmentTextDetector.matchedKeywordsUnique( + email, + forceSync: true, + ); sw.stop(); expect(result, equals(['attach'])); @@ -644,7 +650,7 @@ void main() { lessThan(500)); // Should complete within 500ms }); - test('should handle many different keywords in large text', () { + test('should handle many different keywords in large text', () async { final email = """ attach attachment file document report pièce jointe fichier joint document joint @@ -654,7 +660,7 @@ void main() { 1000; // Repeat 1000 times final sw = Stopwatch()..start(); - final matches = AttachmentTextDetector.matchedKeywordsUnique(email); + final matches = await AttachmentTextDetector.matchedKeywordsUnique(email); sw.stop(); expect(matches.length, greaterThan(10)); @@ -714,9 +720,9 @@ void main() { expect(result.keys.length, equals(1)); }); - test('matchedKeywordsUnique should return unique Arabic keywords only', () { + test('matchedKeywordsUnique should return unique Arabic keywords only', () async { const text = 'مرفق ملف مرفق تقرير ملف'; - final result = AttachmentTextDetector.matchedKeywordsUnique(text); + final result = await AttachmentTextDetector.matchedKeywordsUnique(text); expect(result, containsAll(['مرفق', 'ملف', 'تقرير'])); // Ensure no duplicates @@ -753,10 +759,10 @@ void main() { expect(result[LanguageCodeConstants.french], contains('pièce jointe')); }); - test('matchedKeywordsUnique should return unique keywords from all languages', () { + 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 = AttachmentTextDetector.matchedKeywordsUnique(text); + final result = await AttachmentTextDetector.matchedKeywordsUnique(text); expect(result, containsAll([ 'attach', @@ -775,27 +781,27 @@ void main() { group('AttachmentTextDetector.matchedKeywordsUnique', () { group('Logic Tests', () { - test('Basic Match: Should detect simple keywords', () { - final result = AttachmentTextDetector.matchedKeywordsUnique( + test('Basic Match: Should detect simple keywords', () async { + final result = await AttachmentTextDetector.matchedKeywordsUnique( 'Please find the attachment.'); expect(result, contains('attachment')); }); - test('Case Insensitivity: Should detect mixed case keywords', () { + test('Case Insensitivity: Should detect mixed case keywords', () async { final result = - AttachmentTextDetector.matchedKeywordsUnique('FiLe is here'); + await AttachmentTextDetector.matchedKeywordsUnique('FiLe is here'); expect(result, contains('file')); }); - test('Unique Results: Should not return duplicates', () { - final result = AttachmentTextDetector.matchedKeywordsUnique( + test('Unique Results: Should not return duplicates', () async { + final result = await AttachmentTextDetector.matchedKeywordsUnique( 'File here, file there, FILE everywhere.'); expect(result.length, 1); expect(result, contains('file')); }); - test('Multiple Keywords: Should detect different keywords', () { - final result = AttachmentTextDetector.matchedKeywordsUnique( + test('Multiple Keywords: Should detect different keywords', () async { + final result = await AttachmentTextDetector.matchedKeywordsUnique( 'See file and attachment'); expect(result, containsAll(['file', 'attachment'])); }); @@ -804,40 +810,40 @@ void main() { group('Suffix & Boundary Tests (Crucial)', () { test( 'Invalid Suffix: Should IGNORE words extended by letters (e.g., filetage)', - () { + () async { // "filetage" contains "file", but followed by 't' (letter) -> Should fail - final result = AttachmentTextDetector.matchedKeywordsUnique( + final result = await AttachmentTextDetector.matchedKeywordsUnique( 'This is a filetage system.'); expect(result, isEmpty); }); test( 'Valid Suffix (Number): Should ACCEPT words followed by numbers (e.g., file123)', - () { + () async { final result = - AttachmentTextDetector.matchedKeywordsUnique('Check file123 now.'); + await AttachmentTextDetector.matchedKeywordsUnique('Check file123 now.'); expect(result, contains('file')); }); test( 'Valid Suffix (Punctuation): Should ACCEPT words followed by punctuation', - () { - final result = AttachmentTextDetector.matchedKeywordsUnique( + () async { + final result = await AttachmentTextDetector.matchedKeywordsUnique( 'Is this a file? Yes, file.'); expect(result, contains('file')); }); test('Valid Suffix (Whitespace): Should ACCEPT words followed by space', - () { + () async { final result = - AttachmentTextDetector.matchedKeywordsUnique('file name'); + await AttachmentTextDetector.matchedKeywordsUnique('file name'); expect(result, contains('file')); }); test('Valid Suffix (End of String): Should ACCEPT word at the very end', - () { + () async { final result = - AttachmentTextDetector.matchedKeywordsUnique('Open file'); + await AttachmentTextDetector.matchedKeywordsUnique('Open file'); expect(result, contains('file')); }); }); @@ -845,10 +851,10 @@ void main() { group('Complex & Unicode Tests', () { test( 'Longest Match Priority: Should match "attachment" instead of "attach"', - () { + () async { // Because we sort by length desc, "attachment" comes before "attach" in regex final result = - AttachmentTextDetector.matchedKeywordsUnique('See attachment.'); + await AttachmentTextDetector.matchedKeywordsUnique('See attachment.'); expect(result, contains('attachment')); // Note: "attachment" contains "attach", but since it consumes the text, @@ -857,27 +863,27 @@ void main() { expect(result, isNot(contains('attach'))); }); - test('Vietnamese Support: Should detect keywords with accents', () { - final result = AttachmentTextDetector.matchedKeywordsUnique( + test('Vietnamese Support: Should detect keywords with accents', () async { + final result = await AttachmentTextDetector.matchedKeywordsUnique( 'Gửi tài liệu đính kèm.'); expect(result, containsAll(['tài liệu', 'đính kèm'])); }); - test('Russian Support: Should detect Cyrillic characters', () { + test('Russian Support: Should detect Cyrillic characters', () async { final result = - AttachmentTextDetector.matchedKeywordsUnique('Это файл.'); + await AttachmentTextDetector.matchedKeywordsUnique('Это файл.'); expect(result, contains('файл')); }); - test('Arabic Support: Should detect Arabic characters', () { - final result = AttachmentTextDetector.matchedKeywordsUnique( + test('Arabic Support: Should detect Arabic characters', () async { + final result = await AttachmentTextDetector.matchedKeywordsUnique( 'هذا ملف مهم'); // "This is an important file" expect(result, contains('ملف')); }); }); group('Performance Benchmark', () { - test('Large Text Performance: Should process 100k chars under 50ms', () { + 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++) { @@ -887,11 +893,14 @@ void main() { } if (i % 150 == 0) buffer.write('filetage '); // Insert trap word } - final largeText = buffer.toString(); + final largeText = buffer.toString(); // ~215k chars // Measure execution time final stopwatch = Stopwatch()..start(); - final result = AttachmentTextDetector.matchedKeywordsUnique(largeText); + final result = await AttachmentTextDetector.matchedKeywordsUnique( + largeText, + forceSync: true, + ); stopwatch.stop(); log('Performance result: Found ${result.length} keywords in ${stopwatch.elapsedMilliseconds}ms for ${largeText.length} chars.'); @@ -906,4 +915,112 @@ void main() { }); }); }); + + group('AttachmentTextDetector with exclude filter', () { + test('Basic detection without filters', () async { + final result = + await AttachmentTextDetector.matchedKeywordsUnique('Check file attachment'); + expect(result, containsAll(['file', 'attachment'])); + }); + + test('Exclude Filter: Should exclude blacklisted tokens', () async { + final excludeFilter = ExcludeListFilter(['file-246', 'my_file']); + + final result = await AttachmentTextDetector.matchedKeywordsUnique( + 'Check file-246 and my_file please.', + filters: [excludeFilter], + ); + + expect(result, isEmpty); + }); + + test('Exclude Filter: Should NOT exclude partial matches', () async { + final excludeFilter = ExcludeListFilter(['file-246']); + + final result = await AttachmentTextDetector.matchedKeywordsUnique( + 'Check file-999.', + filters: [excludeFilter], + ); + + expect(result, contains('file')); + }); + + test('Multiple Filters Integration (Future Proof)', () async { + final excludeFilter = ExcludeListFilter(['file-bad']); + + final result = await AttachmentTextDetector.matchedKeywordsUnique( + 'file-good vs file-bad', + filters: [ + excludeFilter, + ], + ); + + expect(result, contains('file')); + expect(result, isNot(contains('file-bad'))); + }); + }); + + group('AttachmentTextDetector Sync vs Async Logic', () { + test('Should run SYNC for short text and detect keywords', () async { + const shortText = "Please check the attached file for details."; + + expect(shortText.length, lessThan(20000)); + + final result = + await AttachmentTextDetector.matchedKeywordsUnique(shortText); + + expect(result, containsAll(['attached', 'file'])); + }); + + test('Should run ASYNC (via compute) for large text', () async { + final sb = StringBuffer(); + sb.write("nonsense " * 3000); + sb.write(" attachment "); + sb.write("garbage " * 1000); + + final longText = sb.toString(); + + expect(longText.length, greaterThan(20000)); + + final result = + await AttachmentTextDetector.matchedKeywordsUnique(longText); + + expect(result, contains('attachment')); + expect(result, isNot(contains('nonsense'))); + }); + + test( + 'Should apply Filters correctly even when running in Isolate (Long text)', + () async { + final sb = StringBuffer(); + sb.write("padding " * 3000); + sb.write(" file-246 "); + sb.write(" file-ok "); + sb.write("padding " * 1000); + + final longText = sb.toString(); + + final excludeFilter = ExcludeListFilter(['file-246']); + + final result = await AttachmentTextDetector.matchedKeywordsUnique( + longText, + filters: [excludeFilter], + ); + + expect(result, contains('file')); + }); + + test('Should handle multiple async calls concurrently', () async { + final longText = "file " * 4000; // ~20k chars + + final futures = List.generate( + 10, (_) => AttachmentTextDetector.matchedKeywordsUnique(longText)); + + final results = await Future.wait(futures); + + for (final res in results) { + expect(res, contains('file')); + } + }); + }); }