Skip to content

Commit e8da6fc

Browse files
implement matchesAllowedExtensions validator
1 parent b362433 commit e8da6fc

File tree

3 files changed

+223
-0
lines changed

3 files changed

+223
-0
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import 'package:path/path.dart' as p;
2+
3+
import '../../localization/l10n.dart';
4+
import '../constants.dart';
5+
6+
bool _isValidExtension(String v) => v.isEmpty || v[0] == '.';
7+
8+
/// Expects not empty v
9+
int _numOfExtensionLevels(String v) => r'.'.allMatches(v).length;
10+
11+
/// This function returns a validator that checks if the user input path has an
12+
/// extension that matches at least one element from `extensions`. If a match
13+
/// happens, the validator returns `null`, otherwise, it returns
14+
/// `matchesAllowedExtensionsMsg`, if it is provided, or
15+
/// [FormBuilderLocalizations.current.fileExtensionErrorText], if not.
16+
///
17+
/// ## Parameters
18+
/// - `extensions`: a list of valid allowed extensions. An extension must be started
19+
/// by a dot. Check for examples in the `Errors` section.
20+
/// - `caseSensitive`: whether the match is case sensitive.
21+
///
22+
/// ## Errors
23+
/// - Throws [AssertionError] when `extensions` is empty.
24+
/// - Throws [AssertionError] when an extension from `extensions` is neither
25+
/// empty nor initiated by dot.
26+
/// - Examples of valid extensions: '.exe', '', '.a.b.c', '.', '..'.
27+
/// - Examples of invalid extensions: 'invalid.extension', 'abc', 'a.b.c.'.
28+
///
29+
/// ## Caveats
30+
/// - Remember, extensions must start with a trailing dot. Thus, instead
31+
/// of 'txt', type '.txt'.
32+
Validator<String> matchesAllowedExtensions(
33+
List<String> extensions, {
34+
String Function(List<String>)? matchesAllowedExtensionsMsg,
35+
bool caseSensitive = true,
36+
}) {
37+
assert(extensions.isNotEmpty, 'allowed extensions may not be empty.');
38+
int maxLevel = 1;
39+
for (final String ex in extensions) {
40+
assert(_isValidExtension(ex), 'Invalid extension: $ex');
41+
final int currentLevel = _numOfExtensionLevels(ex);
42+
if (currentLevel > maxLevel) {
43+
maxLevel = currentLevel;
44+
}
45+
}
46+
Set<String> extensionsSet = <String>{};
47+
if (caseSensitive) {
48+
extensionsSet.addAll(extensions);
49+
} else {
50+
for (final String extension in extensions) {
51+
extensionsSet.add(extension.toLowerCase());
52+
}
53+
}
54+
return (String input) {
55+
final String finalInput = caseSensitive ? input : input.toLowerCase();
56+
final String firstLevelExtension = p.extension(finalInput);
57+
final Set<String> extensionsFromUserInput = <String>{firstLevelExtension};
58+
59+
if (firstLevelExtension.isNotEmpty) {
60+
for (int i = 1; i < maxLevel; i++) {
61+
final String extension = p.extension(finalInput, i + 1);
62+
extensionsFromUserInput.add(extension);
63+
}
64+
}
65+
return extensionsFromUserInput.intersection(extensionsSet).isNotEmpty
66+
? null
67+
: matchesAllowedExtensionsMsg?.call(extensions) ??
68+
FormBuilderLocalizations.current.fileExtensionErrorText(
69+
extensions.join(', '),
70+
);
71+
};
72+
}

pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies:
2020
flutter_localizations:
2121
sdk: flutter
2222
intl: ^0.19.0
23+
path: ^1.9.0
2324

2425
dev_dependencies:
2526
faker_dart: ^0.2.2
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:form_builder_validators/localization/l10n.dart';
3+
import 'package:form_builder_validators/new_api_prototype/constants.dart';
4+
import 'package:form_builder_validators/new_api_prototype/string_validators/path_validators.dart';
5+
6+
void main() {
7+
group('Validator: matchesAllowedExtensionsValidator', () {
8+
final List<({List<String> extensions, bool isValid, String userInput})>
9+
testCases =
10+
<({List<String> extensions, bool isValid, String userInput})>[
11+
(userInput: '.gitignore', extensions: <String>['.', ''], isValid: true),
12+
(userInput: 'file.txt', extensions: <String>['.txt'], isValid: true),
13+
(userInput: 'file.exe', extensions: <String>['.txt'], isValid: false),
14+
15+
// Empty and null cases
16+
(userInput: '', extensions: <String>['.txt'], isValid: false),
17+
(userInput: 'noextension', extensions: <String>['.txt'], isValid: false),
18+
19+
// Multiple extensions
20+
(
21+
userInput: 'script.js',
22+
extensions: <String>['.js', '.ts'],
23+
isValid: true
24+
),
25+
(
26+
userInput: 'style.css',
27+
extensions: <String>['.js', '.ts'],
28+
isValid: false
29+
),
30+
31+
// Case sensitivity
32+
(userInput: 'file.TXT', extensions: <String>['.txt'], isValid: false),
33+
(userInput: 'file.Txt', extensions: <String>['.txt'], isValid: false),
34+
35+
// Path handling
36+
(userInput: '../dir1/file.2', extensions: <String>['.2'], isValid: true),
37+
(
38+
userInput: '/absolute/path/file.txt',
39+
extensions: <String>['.txt'],
40+
isValid: true
41+
),
42+
(
43+
userInput: 'relative/path/file.txt',
44+
extensions: <String>['.txt'],
45+
isValid: true
46+
),
47+
48+
// Multiple dots
49+
(
50+
userInput: '.gitignore.exe',
51+
extensions: <String>['.exe'],
52+
isValid: true
53+
),
54+
(
55+
userInput: '.gitignore.nexe.exe',
56+
extensions: <String>['.exe'],
57+
isValid: true
58+
),
59+
(
60+
userInput: '.gitignore.exe.nexe',
61+
extensions: <String>['.exe'],
62+
isValid: false
63+
),
64+
(
65+
userInput: '.gitignore.exe.nexe',
66+
extensions: <String>['.exe.nexe'],
67+
isValid: true
68+
),
69+
(userInput: 'archive.tar.gz', extensions: <String>['.gz'], isValid: true),
70+
(userInput: '.hidden', extensions: <String>['.hidden'], isValid: false),
71+
(
72+
userInput: '.hidden.hidden',
73+
extensions: <String>['.hidden'],
74+
isValid: true
75+
),
76+
(userInput: '..double', extensions: <String>['.double'], isValid: true),
77+
(userInput: 'file.', extensions: <String>[''], isValid: false),
78+
(userInput: 'file.', extensions: <String>['.', ''], isValid: true),
79+
(userInput: '.', extensions: <String>['.', ''], isValid: true),
80+
81+
// Special characters
82+
(userInput: 'file name.txt', extensions: <String>['.txt'], isValid: true),
83+
(userInput: 'file-name.txt', extensions: <String>['.txt'], isValid: true),
84+
];
85+
for (final ({
86+
List<String> extensions,
87+
bool isValid,
88+
String userInput
89+
}) testCase in testCases) {
90+
test(
91+
'Should ${testCase.isValid ? 'return null' : 'return error message'} when input is "${testCase.userInput}" for extensions ${testCase.extensions}',
92+
() {
93+
final String errorMsg =
94+
FormBuilderLocalizations.current.fileExtensionErrorText(
95+
testCase.extensions.join(', '),
96+
);
97+
final Validator<String> v =
98+
matchesAllowedExtensions(testCase.extensions);
99+
100+
expect(
101+
v(testCase.userInput),
102+
testCase.isValid ? isNull : errorMsg,
103+
);
104+
});
105+
}
106+
test('Should throw AssertionError when allowed extensions is empty', () {
107+
expect(() => matchesAllowedExtensions(<String>[]), throwsAssertionError);
108+
});
109+
test(
110+
'Should throw AssertionError when an invalid extension is provided to extensions',
111+
() {
112+
expect(
113+
() => matchesAllowedExtensions(<String>['invalid extension', '.txt']),
114+
throwsAssertionError);
115+
});
116+
117+
test(
118+
'Should accept files with extension "tXt" or empty when case insensitive',
119+
() {
120+
final List<String> extensions = <String>['.tXt', ''];
121+
final String errorMsg =
122+
FormBuilderLocalizations.current.fileExtensionErrorText(
123+
extensions.join(', '),
124+
);
125+
final Validator<String> v =
126+
matchesAllowedExtensions(extensions, caseSensitive: false);
127+
128+
expect(v('valid.tXt'), isNull, reason: 'Input: "valid.tXt"');
129+
expect(v('valid.tXT'), isNull, reason: 'Input: "valid.tXT"');
130+
expect(v('emptyExtension'), isNull, reason: 'Input: "emptyExtension"');
131+
expect(v(''), isNull, reason: 'Input: empty string');
132+
expect(v('invalid.txt '), errorMsg, reason: 'Input: "invalid.txt "');
133+
expect(v('text.ttxt'), errorMsg, reason: 'Input: "text.ttxt"');
134+
expect(v('text. txt'), errorMsg, reason: 'Input: "text. txt"');
135+
});
136+
test(
137+
'Should accept files with extension ".abc" or ".a.b.d" or "" with custom message',
138+
() {
139+
final List<String> extensions = <String>['.abc', '.a.b.c', ''];
140+
final String errorMsg = 'custom error message';
141+
final Validator<String> v = matchesAllowedExtensions(extensions,
142+
matchesAllowedExtensionsMsg: (_) => errorMsg);
143+
144+
expect(v('/valid/.abc'), isNull, reason: 'Input: "/valid/.abc"');
145+
// todo check if there is a bug with the p.extension function.
146+
// It seems to have a bug => https://github.com/dart-lang/core/issues/723
147+
//expect(v('/valid/.a.b'), errorMsg, reason: 'Input: "/valid/.a.b"');
148+
});
149+
});
150+
}

0 commit comments

Comments
 (0)