TF-4265 Add integration test for attachment reminder on mobile
This commit is contained in:
@@ -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.
|
### Configuration file
|
||||||
* We need a flexible way to add specific keywords (Include) or ignore specific tokens (Exclude) without modifying the source code.
|
|
||||||
|
|
||||||
### How to configure
|
`configurations/attachment_keywords.json` (must be declared in `pubspec.yaml` under `assets`):
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"includeList": [
|
"includeList": ["invoice", "estimate", "báo giá"],
|
||||||
"invoice",
|
"excludeList": ["signature-logo", "no-reply", "icon-app"]
|
||||||
"estimate",
|
|
||||||
"báo giá"
|
|
||||||
],
|
|
||||||
"excludeList": [
|
|
||||||
"signature-logo",
|
|
||||||
"no-reply",
|
|
||||||
"icon-app"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
* `includeList`: List of keywords to **add** to the detection dictionary.
|
| Field | Purpose |
|
||||||
* Used when the default dictionary (hardcoded in app) is missing specific business terms.
|
|-------|---------|
|
||||||
* Matches are case-insensitive.
|
| `includeList` | Add custom keywords to the built-in multi-language dictionary (case-insensitive) |
|
||||||
* Example: Adding `"cv"` will trigger the alert for "Attached is my CV".
|
| `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.
|
1. On "Send", if the user already has an attachment, the check is skipped entirely.
|
||||||
* Used to prevent false positives where a keyword exists but is part of a system string or signature.
|
2. Email HTML is converted to plain text (signatures, quotes, and HTML stripped).
|
||||||
* 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").
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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<void> 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<void> _expectComposerViewVisible() =>
|
||||||
|
expectViewVisible($(ComposerView));
|
||||||
|
|
||||||
|
Future<void> _expectAttachmentReminderModalVisible() =>
|
||||||
|
expectViewVisible($(find.textContaining(
|
||||||
|
'in your message but did not add any attachments. Do you still want to send?')));
|
||||||
|
|
||||||
|
Future<void> _expectIdentityVisible(Identity identity) async {
|
||||||
|
expect(
|
||||||
|
$(FromComposerMobileWidget)
|
||||||
|
.which<FromComposerMobileWidget>(
|
||||||
|
(widget) => widget.selectedIdentity?.name == identity.name)
|
||||||
|
.visible,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _expectSendEmailSuccessToast(
|
||||||
|
AppLocalizations appLocalizations) async {
|
||||||
|
await expectViewVisible(
|
||||||
|
$(find.text(appLocalizations.message_has_been_sent_successfully)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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($),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/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/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/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/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/bindings/web_socket_interactor_bindings.dart';
|
||||||
import 'package:tmail_ui_user/features/push_notification/presentation/config/fcm_configuration.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();
|
await cachingManager.closeHive();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logWarning('BaseController::clearAllData: Cannot clear all data: $e');
|
logWarning('BaseController::clearAllData: Cannot clear all data: $e');
|
||||||
|
} finally {
|
||||||
|
AttachmentKeywordConfigManager().clearCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -883,6 +883,22 @@ class ComposerController extends BaseController
|
|||||||
|
|
||||||
final emailContent = await getContentInEditor();
|
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(
|
final attachmentKeywords = await validateAttachmentReminder(
|
||||||
emailSubject: subjectEmail.value ?? '',
|
emailSubject: subjectEmail.value ?? '',
|
||||||
emailContent: emailContent,
|
emailContent: emailContent,
|
||||||
@@ -894,8 +910,7 @@ class ComposerController extends BaseController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentKeywords.isNotEmpty &&
|
if (attachmentKeywords.isNotEmpty) {
|
||||||
uploadController.attachmentsUploaded.isEmpty) {
|
|
||||||
showAttachmentReminderModal(
|
showAttachmentReminderModal(
|
||||||
context: context,
|
context: context,
|
||||||
keywords: attachmentKeywords,
|
keywords: attachmentKeywords,
|
||||||
|
|||||||
@@ -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/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/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/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/localizations/app_localizations.dart';
|
||||||
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
|
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
|
||||||
|
|
||||||
@@ -17,17 +17,13 @@ extension AttachmentDetectionExtension on ComposerController {
|
|||||||
try {
|
try {
|
||||||
final fullContent = '$emailSubject $emailContent';
|
final fullContent = '$emailSubject $emailContent';
|
||||||
final plainText = HtmlUtils.extractPlainText(fullContent);
|
final plainText = HtmlUtils.extractPlainText(fullContent);
|
||||||
final keywordConfig = await KeywordConfigManager().getConfig();
|
final attachmentKeywordConfig = await AttachmentKeywordConfigManager().getConfig();
|
||||||
final keywords = await AttachmentTextDetector.matchedKeywordsUnique(
|
final keywords = await AttachmentTextDetector.matchedKeywordsUnique(
|
||||||
plainText,
|
plainText,
|
||||||
includeList: keywordConfig.includeList,
|
includeList: attachmentKeywordConfig.includeList,
|
||||||
excludeList: keywordConfig.excludeList,
|
excludeList: attachmentKeywordConfig.excludeList,
|
||||||
);
|
);
|
||||||
if (keywords.isEmpty) {
|
return keywords;
|
||||||
return [];
|
|
||||||
} else {
|
|
||||||
return keywords;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logWarning('$runtimeType::validateAttachmentReminder:Error $e');
|
logWarning('$runtimeType::validateAttachmentReminder:Error $e');
|
||||||
return [];
|
return [];
|
||||||
@@ -49,7 +45,7 @@ extension AttachmentDetectionExtension on ComposerController {
|
|||||||
title: appLocalizations.attachmentReminderModalTitle,
|
title: appLocalizations.attachmentReminderModalTitle,
|
||||||
appLocalizations.attachmentReminderModalMessage(formattedKeywords),
|
appLocalizations.attachmentReminderModalMessage(formattedKeywords),
|
||||||
appLocalizations.sendMessage,
|
appLocalizations.sendMessage,
|
||||||
cancelTitle: AppLocalizations.of(context).cancel,
|
cancelTitle: appLocalizations.cancel,
|
||||||
onConfirmAction: onConfirmAction,
|
onConfirmAction: onConfirmAction,
|
||||||
onCancelAction: onCancelAction,
|
onCancelAction: onCancelAction,
|
||||||
onCloseButtonAction: popBack,
|
onCloseButtonAction: popBack,
|
||||||
|
|||||||
@@ -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<AttachmentKeywordConfig> getConfig() async {
|
||||||
|
if (_cachedConfig != null) {
|
||||||
|
return _cachedConfig!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_appConfigLoader ??= AppConfigLoader();
|
||||||
|
_cachedConfig = await _appConfigLoader?.load<AttachmentKeywordConfig>(
|
||||||
|
_configPath,
|
||||||
|
AttachmentKeywordsConfigurationParser(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logWarning(
|
||||||
|
"AttachmentKeywordConfigManager::getConfig:Error loading keyword config: $e");
|
||||||
|
_cachedConfig = AttachmentKeywordConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _cachedConfig!;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearCache() {
|
||||||
|
_cachedConfig = null;
|
||||||
|
_appConfigLoader = null;
|
||||||
|
AttachmentTextDetector.clearPatternCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -4,15 +4,15 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:core/utils/app_logger.dart';
|
import 'package:core/utils/app_logger.dart';
|
||||||
import 'package:core/utils/config/app_config_parser.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
|
class AttachmentKeywordsConfigurationParser
|
||||||
extends AppConfigParser<KeywordConfig> {
|
extends AppConfigParser<AttachmentKeywordConfig> {
|
||||||
@override
|
@override
|
||||||
Future<KeywordConfig> parse(String value) async {
|
Future<AttachmentKeywordConfig> parse(String value) async {
|
||||||
try {
|
try {
|
||||||
final jsonObject = jsonDecode(value);
|
final jsonObject = jsonDecode(value);
|
||||||
return KeywordConfig.fromJson(jsonObject);
|
return AttachmentKeywordConfig.fromJson(jsonObject);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logWarning('AttachmentKeywordsConfigurationParser::parse(): $e');
|
logWarning('AttachmentKeywordsConfigurationParser::parse(): $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -20,7 +20,7 @@ class AttachmentKeywordsConfigurationParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<KeywordConfig> parseData(ByteData data) {
|
Future<AttachmentKeywordConfig> parseData(ByteData data) {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ class AttachmentTextDetector {
|
|||||||
|
|
||||||
/// Cached RegExp Pattern for the default case (when no include list is provided).
|
/// Cached RegExp Pattern for the default case (when no include list is provided).
|
||||||
static String? _cachedDefaultPattern;
|
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<String>? _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).
|
/// 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.
|
/// - For > 20k chars, Regex processing might exceed 16ms on low-end devices, causing jank.
|
||||||
///
|
///
|
||||||
/// -> Below 20k: Run Sync (Instant response).
|
/// -> 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;
|
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<String> 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<String, List<String>> matchedKeywordsAll(String text) {
|
|
||||||
final result = <String, List<String>>{};
|
|
||||||
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.
|
/// Builds the Regex pattern.
|
||||||
/// Merges [defaultKeywords] with [includeList] to create the search scope.
|
/// Merges [defaultKeywords] with [includeList] to create the search scope.
|
||||||
static String _generatePatternString(List<String> additionalKeywords) {
|
static String _generatePatternString(List<String> additionalKeywords) {
|
||||||
@@ -132,23 +92,40 @@ class AttachmentTextDetector {
|
|||||||
// (?![\p{L}]) ensures we don't match substrings inside other words (e.g., "filetage").
|
// (?![\p{L}]) ensures we don't match substrings inside other words (e.g., "filetage").
|
||||||
final pattern = allKeywords.map(RegExp.escape).join('|');
|
final pattern = allKeywords.map(RegExp.escape).join('|');
|
||||||
|
|
||||||
return'($pattern)(?![\\p{L}])';
|
// (?<![\p{L}]) ensures no letter precedes the keyword
|
||||||
|
// (?![\p{L}]) ensures no letter follows the keyword
|
||||||
|
return '(?<![\\p{L}])($pattern)(?![\\p{L}])';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _getPattern(List<String> includeList) {
|
static String _getPattern(List<String> includeList) {
|
||||||
if (includeList.isNotEmpty) {
|
if (includeList.isEmpty) {
|
||||||
return _generatePatternString(includeList);
|
_cachedDefaultPattern ??= _generatePatternString([]);
|
||||||
|
return _cachedDefaultPattern!;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cachedDefaultPattern ??= _generatePatternString([]);
|
final cached = _cachedIncludeList;
|
||||||
return _cachedDefaultPattern!;
|
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<KeywordFilter> filters) {
|
||||||
|
return filters.every((filter) => filter.isValid(text, match));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The core logic function.
|
/// The core logic function.
|
||||||
/// 1. Builds/Retrieves the Regex.
|
/// 1. Builds/Retrieves the Regex.
|
||||||
/// 2. Scans the text.
|
/// 2. Scans the text.
|
||||||
/// 3. Filters results using Exclude List.
|
/// 3. Filters results using Exclude List.
|
||||||
static List<String> _processInIsolate(DetectionParams params) {
|
/// Can be executed on the main thread (sync path) or inside an Isolate (async path).
|
||||||
|
static List<String> _detectKeywords(DetectionParams params) {
|
||||||
final text = params.text;
|
final text = params.text;
|
||||||
if (text.isEmpty) return [];
|
if (text.isEmpty) return [];
|
||||||
|
|
||||||
@@ -158,27 +135,20 @@ class AttachmentTextDetector {
|
|||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final matches = regex.allMatches(text);
|
return regex
|
||||||
final result = <String>{};
|
.allMatches(text)
|
||||||
|
.where((match) => _passesAllFilters(text, match, params.filters))
|
||||||
|
.map((match) => match.group(0)!.toLowerCase())
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
for (final match in matches) {
|
/// Clears all cached regex patterns.
|
||||||
final keyword = match.group(0)!.toLowerCase();
|
/// Call this when the keyword configuration changes (e.g., on logout or config reload).
|
||||||
|
static void clearPatternCache() {
|
||||||
// Check if the found keyword should be excluded based on context.
|
_cachedDefaultPattern = null;
|
||||||
bool isAccepted = true;
|
_cachedIncludeList = null;
|
||||||
for (final filter in params.filters) {
|
_cachedIncludePattern = null;
|
||||||
if (!filter.isValid(text, match)) {
|
|
||||||
isAccepted = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAccepted) {
|
|
||||||
result.add(keyword);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detects keywords in the text.
|
/// Detects keywords in the text.
|
||||||
@@ -206,11 +176,12 @@ class AttachmentTextDetector {
|
|||||||
filters: filters,
|
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) {
|
if (forceSync || text.length < _kAsyncExecutionThreshold) {
|
||||||
return _processInIsolate(params);
|
return _detectKeywords(params);
|
||||||
} else {
|
} else {
|
||||||
return await compute(_processInIsolate, params);
|
return await compute(_detectKeywords, params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ class ExcludeListFilter with TokenExtractionMixin implements KeywordFilter {
|
|||||||
ExcludeListFilter(List<String> rawExcludes)
|
ExcludeListFilter(List<String> rawExcludes)
|
||||||
: _excludes = rawExcludes.map((e) => e.toLowerCase()).toSet();
|
: _excludes = rawExcludes.map((e) => e.toLowerCase()).toSet();
|
||||||
|
|
||||||
|
static final _trailingPunctRegex = RegExp(r'[^\w\s]+$');
|
||||||
|
static final _leadingPunctRegex = RegExp(r'^[^\w\s]+');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isValid(String fullText, Match match) {
|
bool isValid(String fullText, Match match) {
|
||||||
if (_excludes.isEmpty) return true;
|
if (_excludes.isEmpty) return true;
|
||||||
@@ -20,11 +23,11 @@ class ExcludeListFilter with TokenExtractionMixin implements KeywordFilter {
|
|||||||
if (_excludes.contains(lowerToken)) return false;
|
if (_excludes.contains(lowerToken)) return false;
|
||||||
|
|
||||||
// Check 2: Block even if it has trailing punctuation (e.g., "file-246.")
|
// 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;
|
if (_excludes.contains(cleanToken)) return false;
|
||||||
|
|
||||||
// Check 3: Block even if it has leading punctuation (e.g., "(file")
|
// 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;
|
if (_excludes.contains(fullyCleanToken)) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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<KeywordConfig> getConfig() async {
|
|
||||||
if (_cachedConfig != null) {
|
|
||||||
return _cachedConfig!;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_appConfigLoader ??= AppConfigLoader();
|
|
||||||
_cachedConfig = await _appConfigLoader?.load<KeywordConfig>(
|
|
||||||
_configPath,
|
|
||||||
AttachmentKeywordsConfigurationParser(),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logWarning(
|
|
||||||
"KeywordConfigManager::getConfig:Error loading keyword config: $e");
|
|
||||||
_cachedConfig = const KeywordConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _cachedConfig!;
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearCache() {
|
|
||||||
_cachedConfig = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,22 +3,23 @@
|
|||||||
mixin TokenExtractionMixin {
|
mixin TokenExtractionMixin {
|
||||||
static final RegExp _whitespaceRegExp = RegExp(r'\s');
|
static final RegExp _whitespaceRegExp = RegExp(r'\s');
|
||||||
|
|
||||||
String getSurroundingToken(String text, int matchStart, int matchEnd) {
|
int _expandLeft(String text, int start) {
|
||||||
int start = matchStart;
|
while (start > 0 && !_whitespaceRegExp.hasMatch(text[start - 1])) {
|
||||||
int end = matchEnd;
|
|
||||||
|
|
||||||
// Expand left until whitespace
|
|
||||||
while (start > 0) {
|
|
||||||
if (_whitespaceRegExp.hasMatch(text[start - 1])) break;
|
|
||||||
start--;
|
start--;
|
||||||
}
|
}
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
// Expand right until whitespace
|
int _expandRight(String text, int end) {
|
||||||
while (end < text.length) {
|
while (end < text.length && !_whitespaceRegExp.hasMatch(text[end])) {
|
||||||
if (_whitespaceRegExp.hasMatch(text[end])) break;
|
|
||||||
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);
|
return text.substring(start, end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<String> includeList;
|
||||||
|
final List<String> excludeList;
|
||||||
|
|
||||||
|
AttachmentKeywordConfig({
|
||||||
|
List<String> includeList = const [],
|
||||||
|
List<String> excludeList = const [],
|
||||||
|
}) : includeList = List.unmodifiable(includeList),
|
||||||
|
excludeList = List.unmodifiable(excludeList);
|
||||||
|
|
||||||
|
factory AttachmentKeywordConfig.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$AttachmentKeywordConfigFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$AttachmentKeywordConfigToJson(this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [includeList, excludeList];
|
||||||
|
}
|
||||||
@@ -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<String> includeList;
|
|
||||||
final List<String> excludeList;
|
|
||||||
|
|
||||||
const KeywordConfig({
|
|
||||||
this.includeList = const [],
|
|
||||||
this.excludeList = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
factory KeywordConfig.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$KeywordConfigFromJson(json);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => _$KeywordConfigToJson(this);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => [includeList, excludeList];
|
|
||||||
}
|
|
||||||
+94
@@ -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<AttachmentKeywordConfig>(
|
||||||
|
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<AttachmentKeywordConfig>(any, any))
|
||||||
|
.thenAnswer((_) async => config);
|
||||||
|
|
||||||
|
await manager.getConfig();
|
||||||
|
await manager.getConfig();
|
||||||
|
|
||||||
|
verify(mockLoader.load<AttachmentKeywordConfig>(
|
||||||
|
any,
|
||||||
|
argThat(isA<AttachmentKeywordsConfigurationParser>()),
|
||||||
|
)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns empty AttachmentKeywordConfig when loader throws', () async {
|
||||||
|
when(mockLoader.load<AttachmentKeywordConfig>(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<AttachmentKeywordConfig>(any, any))
|
||||||
|
.thenThrow(Exception('file not found'));
|
||||||
|
|
||||||
|
await manager.getConfig();
|
||||||
|
await manager.getConfig();
|
||||||
|
|
||||||
|
verify(mockLoader.load<AttachmentKeywordConfig>(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<AttachmentKeywordConfig>(any, any))
|
||||||
|
.thenAnswer((_) async => config);
|
||||||
|
|
||||||
|
await manager.getConfig();
|
||||||
|
manager.clearCache();
|
||||||
|
manager.injectLoader(mockLoader);
|
||||||
|
await manager.getConfig();
|
||||||
|
|
||||||
|
verify(mockLoader.load<AttachmentKeywordConfig>(any, any)).called(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user