TF-4265 Add integration test for attachment reminder on mobile

This commit is contained in:
dab246
2026-01-30 16:09:38 +07:00
committed by Dat H. Pham
parent 74c0c75023
commit 2a3325ab7a
17 changed files with 781 additions and 899 deletions
@@ -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"
]
}
```
**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.
@@ -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($),
);
}
+3
View File
@@ -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();
}
}
@@ -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,
@@ -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,
@@ -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();
}
}
@@ -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<KeywordConfig> {
extends AppConfigParser<AttachmentKeywordConfig> {
@override
Future<KeywordConfig> parse(String value) async {
Future<AttachmentKeywordConfig> 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<KeywordConfig> parseData(ByteData data) {
Future<AttachmentKeywordConfig> parseData(ByteData data) {
throw UnimplementedError();
}
}
@@ -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<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).
///
@@ -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<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.
/// Merges [defaultKeywords] with [includeList] to create the search scope.
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").
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) {
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<KeywordFilter> 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<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;
if (text.isEmpty) return [];
@@ -158,27 +135,20 @@ class AttachmentTextDetector {
caseSensitive: false,
);
final matches = regex.allMatches(text);
final result = <String>{};
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);
}
}
}
@@ -8,6 +8,9 @@ class ExcludeListFilter with TokenExtractionMixin implements KeywordFilter {
ExcludeListFilter(List<String> 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;
@@ -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 {
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);
}
}
@@ -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];
}
@@ -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);
});
});
}