mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-15 04:43:41 +00:00
feat(download): auto-categorize downloads into folders by file extension (#1572)
This commit is contained in:
1 parent
d125d4ab75
commit
9aae13453a
55 files changed
+4403
-217
No files matched your search
@@ -1,5 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
void main() {
|
||||
test('auto-start tasks uses the backend-owned top-level config field', () {
|
||||
@@ -18,6 +22,17 @@ void main() {
|
||||
expect(DownloaderConfig.fromJson(json).autoStartTasks, isFalse);
|
||||
});
|
||||
|
||||
test('legacy extra downloadCategories is ignored on parse', () {
|
||||
final json = DownloaderConfig().toJson();
|
||||
(json['extra'] as Map<String, dynamic>)['downloadCategories'] = [
|
||||
{'name': 'Music', 'path': '/old/Music'},
|
||||
];
|
||||
|
||||
final decoded = DownloaderConfig.fromJson(json);
|
||||
expect(decoded.categories, isEmpty);
|
||||
expect(decoded.toJson()['extra'], isNot(contains('downloadCategories')));
|
||||
});
|
||||
|
||||
test('REST server settings round-trip through the Go downloader config', () {
|
||||
final config = DownloaderConfig()
|
||||
..api = ApiServerConfig(
|
||||
@@ -78,4 +93,180 @@ void main() {
|
||||
expect(decoded.analyticsEnabled, isFalse);
|
||||
expect(decoded.analyticsClientId, 'client-id');
|
||||
});
|
||||
|
||||
test('categories and the auto-categorize switch round-trip through the config', () {
|
||||
final config = DownloaderConfig()
|
||||
..categories = [
|
||||
DownloadCategory(name: 'Program', path: '/dl/Program', extensions: const ['exe', 'msi']),
|
||||
]
|
||||
..autoCategorize = true;
|
||||
|
||||
final decoded = DownloaderConfig.fromJson(config.toJson());
|
||||
expect(decoded.autoCategorize, isTrue);
|
||||
expect(decoded.categories.single.name, 'Program');
|
||||
expect(decoded.categories.single.extensions, ['exe', 'msi']);
|
||||
});
|
||||
|
||||
test('config parses when the category fields are absent or null', () {
|
||||
final missing = DownloaderConfig().toJson()
|
||||
..remove('categories')
|
||||
..remove('autoCategorize');
|
||||
expect(DownloaderConfig.fromJson(missing).categories, isEmpty);
|
||||
expect(DownloaderConfig.fromJson(missing).autoCategorize, isFalse);
|
||||
|
||||
// The Go backend marshals a nil category slice and an unset switch as null.
|
||||
final nil = DownloaderConfig().toJson()
|
||||
..['categories'] = null
|
||||
..['autoCategorize'] = null;
|
||||
expect(DownloaderConfig.fromJson(nil).categories, isEmpty);
|
||||
expect(DownloaderConfig.fromJson(nil).autoCategorize, isFalse);
|
||||
});
|
||||
|
||||
test('auto categorize can be turned off and round-trips', () {
|
||||
final config = DownloaderConfig(autoCategorize: false);
|
||||
expect(DownloaderConfig.fromJson(config.toJson()).autoCategorize, isFalse);
|
||||
});
|
||||
|
||||
test('auto categorize is off unless the config turns it on', () {
|
||||
expect(DownloaderConfig().autoCategorize, isFalse);
|
||||
expect(DownloaderConfig(autoCategorize: true).autoCategorize, isTrue);
|
||||
});
|
||||
|
||||
test('effectiveExtensions returns the stored list and never falls back', () {
|
||||
expect(DownloadCategory(name: 'Custom', path: '/x', extensions: const ['zip']).effectiveExtensions(), ['zip']);
|
||||
final builtin = DownloadCategory(name: '', nameKey: 'categoryMusic', path: '/m');
|
||||
expect(builtin.effectiveExtensions(), isEmpty);
|
||||
expect(DownloadCategory(name: 'Custom', path: '/x').effectiveExtensions(), isEmpty);
|
||||
});
|
||||
|
||||
test('normalizeCategoryExtensions trims lowercases strips dots dedupes and splits on separators', () {
|
||||
expect(normalizeCategoryExtensions([' .MP3 ', 'exe', '.Exe', 'FLAC', 'zip']), ['mp3', 'exe', 'flac', 'zip']);
|
||||
expect(normalizeCategoryExtensions(['a b,c']), ['a', 'b', 'c']);
|
||||
expect(normalizeCategoryExtensions(['', ' ', '..']), isEmpty);
|
||||
});
|
||||
|
||||
test('effectiveExtensions normalizes stored lists in input order', () {
|
||||
final stored = DownloadCategory(name: 'X', path: '/x', extensions: const [' .ZIP ', 'Exe', 'exe']);
|
||||
expect(stored.effectiveExtensions(), ['zip', 'exe']);
|
||||
});
|
||||
|
||||
group('rebaseBuiltinCategoryPaths', () {
|
||||
DownloadCategory builtin(String categoryPath, {bool isDeleted = false}) => DownloadCategory(
|
||||
name: '',
|
||||
nameKey: 'categoryMusic',
|
||||
path: categoryPath,
|
||||
isBuiltIn: true,
|
||||
isDeleted: isDeleted,
|
||||
extensions: const ['mp3'],
|
||||
);
|
||||
|
||||
test('rebases built-in categories that live under the old directory', () {
|
||||
final config = DownloaderConfig()
|
||||
..categories = [
|
||||
builtin(path.join('/dl', 'Music')),
|
||||
DownloadCategory(name: 'Mine', path: path.join('/dl', 'Mine')),
|
||||
DownloadCategory(
|
||||
name: 'Elsewhere',
|
||||
path: path.join('/other', 'Music'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryVideo',
|
||||
),
|
||||
builtin(path.join('/dl', 'Old'), isDeleted: true),
|
||||
];
|
||||
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/data');
|
||||
|
||||
expect(config.categories[0].path, path.join('/data', 'Music'));
|
||||
expect(config.categories[1].path, path.join('/dl', 'Mine'));
|
||||
expect(config.categories[2].path, path.join('/other', 'Music'));
|
||||
expect(config.categories[3].path, path.join('/dl', 'Old'));
|
||||
});
|
||||
|
||||
test('rebases a built-in category that points at the old directory itself', () {
|
||||
final config = DownloaderConfig()..categories = [builtin('/dl')];
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/data');
|
||||
expect(config.categories.single.path, '/data');
|
||||
});
|
||||
|
||||
test('does nothing when the directories are empty or unchanged', () {
|
||||
final config = DownloaderConfig()..categories = [builtin(path.join('/dl', 'Music'))];
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/dl');
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '', newDir: '/data');
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '');
|
||||
expect(config.categories.single.path, path.join('/dl', 'Music'));
|
||||
});
|
||||
|
||||
test('rebases a windows path that the backend seeded with a backslash', () {
|
||||
// The backend seeds the categories with the separator of its platform.
|
||||
final config = DownloaderConfig()
|
||||
..categories = [
|
||||
builtin(r'C:\Users\me\Downloads\Music'),
|
||||
DownloadCategory(name: 'Mine', path: r'C:\Users\me\Downloads\Mine'),
|
||||
];
|
||||
|
||||
rebaseBuiltinCategoryPaths(config, oldDir: r'C:\Users\me\Downloads', newDir: r'C:\Users\me\New');
|
||||
|
||||
// The joined separator depends on the platform the test runs on, so the
|
||||
// assertion compares the forward slash form.
|
||||
expect(config.categories[0].path.replaceAll(r'\', '/'), 'C:/Users/me/New/Music');
|
||||
expect(config.categories[1].path, r'C:\Users\me\Downloads\Mine');
|
||||
});
|
||||
});
|
||||
|
||||
test('go builtinCategoryExtensions stays in sync with kDefaultCategoryExtensions', () {
|
||||
File? goSource;
|
||||
for (final candidate in ['pkg/base/model.go', '../../pkg/base/model.go']) {
|
||||
final file = File(candidate);
|
||||
if (file.existsSync()) {
|
||||
goSource = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(goSource, isNotNull, reason: 'pkg/base/model.go not found from ${Directory.current}');
|
||||
|
||||
final block = RegExp(
|
||||
r'builtinCategoryExtensions = map\[string\]\[\]string\{([\s\S]*?)\n\}',
|
||||
).firstMatch(goSource!.readAsStringSync());
|
||||
expect(block, isNotNull, reason: 'builtinCategoryExtensions map literal not found in pkg/base/model.go');
|
||||
|
||||
final entryPattern = RegExp(r'"(\w+)":\s*\{([^}]*)\}');
|
||||
final extensionPattern = RegExp(r'"([^"]+)"');
|
||||
final goDefaults = <String, Set<String>>{};
|
||||
for (final entry in entryPattern.allMatches(block!.group(1)!)) {
|
||||
goDefaults[entry.group(1)!] = extensionPattern.allMatches(entry.group(2)!).map((m) => m.group(1)!).toSet();
|
||||
}
|
||||
expect(goDefaults, isNotEmpty, reason: 'no built-in extension entries parsed from pkg/base/model.go');
|
||||
|
||||
final dartDefaults = kDefaultCategoryExtensions.map((key, value) => MapEntry(key, value.toSet()));
|
||||
expect(goDefaults.keys.toSet(), dartDefaults.keys.toSet(), reason: 'built-in category nameKeys drifted');
|
||||
for (final key in goDefaults.keys) {
|
||||
expect(goDefaults[key], dartDefaults[key], reason: 'extension list drifted for $key');
|
||||
}
|
||||
});
|
||||
|
||||
test('runs the extension rule table shared with the backend', () {
|
||||
// pkg/download/testdata/category_matching_cases.json is also read by the Go
|
||||
// test, so a rule that only changes on one side breaks the other side.
|
||||
final cases = jsonDecode(_readSharedRuleTable()) as Map<String, dynamic>;
|
||||
for (final c in cases['normalizeExtensions'] as List) {
|
||||
final input = (c['input'] as List).cast<String>();
|
||||
final expected = (c['expected'] as List).cast<String>();
|
||||
expect(normalizeCategoryExtensions(input), expected, reason: 'normalizeCategoryExtensions($input)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The rule table is shared with the backend, see
|
||||
/// pkg/download/testdata/category_matching_cases.json.
|
||||
String _readSharedRuleTable() {
|
||||
for (final candidate in [
|
||||
'pkg/download/testdata/category_matching_cases.json',
|
||||
'../../pkg/download/testdata/category_matching_cases.json',
|
||||
]) {
|
||||
final file = File(candidate);
|
||||
if (file.existsSync()) {
|
||||
return file.readAsStringSync();
|
||||
}
|
||||
}
|
||||
throw StateError('category_matching_cases.json not found from ${Directory.current}');
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/app/application/downloader_config_init.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
void main() {
|
||||
DownloaderConfig configWith({List<DownloadCategory> categories = const [], String downloadDir = ''}) {
|
||||
final config = DownloaderConfig(downloadDir: downloadDir)..categories = List.of(categories);
|
||||
return config;
|
||||
}
|
||||
|
||||
group('loadDownloaderConfig', () {
|
||||
test('persists when the load succeeded and init changed the config', () async {
|
||||
var saved = 0;
|
||||
final config = await loadDownloaderConfig(
|
||||
load: () async => configWith(),
|
||||
save: (_) async => saved++,
|
||||
defaultDownloadDir: () async => '/downloads',
|
||||
);
|
||||
expect(saved, 1);
|
||||
expect(config.categories, hasLength(4));
|
||||
});
|
||||
|
||||
test('does not persist when the initial load failed', () async {
|
||||
var saved = 0;
|
||||
final config = await loadDownloaderConfig(
|
||||
load: () async => throw StateError('backend unavailable'),
|
||||
save: (_) async => saved++,
|
||||
defaultDownloadDir: () async => '/downloads',
|
||||
);
|
||||
expect(saved, 0);
|
||||
// The in-memory fallback still gets usable defaults.
|
||||
expect(config.downloadDir, '/downloads');
|
||||
expect(config.categories, hasLength(4));
|
||||
});
|
||||
|
||||
test('skips persist when init changed nothing', () async {
|
||||
var saved = 0;
|
||||
await loadDownloaderConfig(
|
||||
load: () async => configWith(
|
||||
categories: [
|
||||
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
|
||||
],
|
||||
downloadDir: '/downloads',
|
||||
),
|
||||
save: (_) async => saved++,
|
||||
defaultDownloadDir: () async => '/other',
|
||||
);
|
||||
expect(saved, 0);
|
||||
});
|
||||
|
||||
test('persists the default download dir when it is missing', () async {
|
||||
var saved = 0;
|
||||
final config = await loadDownloaderConfig(
|
||||
load: () async => configWith(
|
||||
categories: [
|
||||
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
|
||||
],
|
||||
),
|
||||
save: (_) async => saved++,
|
||||
defaultDownloadDir: () async => '/downloads',
|
||||
);
|
||||
expect(saved, 1);
|
||||
expect(config.downloadDir, '/downloads');
|
||||
});
|
||||
});
|
||||
|
||||
group('initDownloaderConfig', () {
|
||||
test('seeds built-in categories with default extensions when empty', () async {
|
||||
final config = configWith();
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/downloads');
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(config.categories.map((c) => c.nameKey).toList(), [
|
||||
'categoryMusic',
|
||||
'categoryVideo',
|
||||
'categoryDocument',
|
||||
'categoryProgram',
|
||||
]);
|
||||
for (final category in config.categories) {
|
||||
expect(category.isBuiltIn, isTrue);
|
||||
expect(category.path, path.join('/downloads', category.nameKey!.replaceFirst('category', '')));
|
||||
expect(category.extensions, kDefaultCategoryExtensions[category.nameKey]);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps existing categories untouched', () async {
|
||||
final existing = DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']);
|
||||
final config = configWith(categories: [existing], downloadDir: '/downloads');
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/other');
|
||||
|
||||
expect(changed, isFalse);
|
||||
expect(config.categories, hasLength(1));
|
||||
expect(config.categories.single, same(existing));
|
||||
});
|
||||
|
||||
test('reports a change when only the default download dir was filled', () async {
|
||||
final config = configWith(
|
||||
categories: [
|
||||
DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']),
|
||||
],
|
||||
);
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/downloads');
|
||||
|
||||
expect(changed, isTrue);
|
||||
expect(config.downloadDir, '/downloads');
|
||||
});
|
||||
|
||||
test('leaves the config untouched when the default download dir is unknown', () async {
|
||||
final config = configWith();
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '');
|
||||
|
||||
// A relative or unknown directory would make the backend write into its
|
||||
// own working directory, so it is left for the backend to resolve.
|
||||
expect(changed, isFalse);
|
||||
expect(config.downloadDir, isEmpty);
|
||||
expect(config.categories, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
import 'package:gopeed/features/settings/presentation/widgets/extension_tag_editor.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
|
||||
Widget _wrap(Widget child) => shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: child),
|
||||
);
|
||||
|
||||
List<String> _chipTexts(WidgetTester tester) => tester
|
||||
.widgetList<Text>(find.descendant(of: find.byType(Wrap), matching: find.byType(Text)))
|
||||
.map((text) => text.data ?? '')
|
||||
.toList();
|
||||
|
||||
Color _chipBorder(WidgetTester tester, String tag) {
|
||||
final chip = tester.widget<Container>(find.byKey(ValueKey('extension-chip-$tag')));
|
||||
final border = (chip.decoration! as BoxDecoration).border! as Border;
|
||||
return border.top.color;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('separators stay in the input until Enter commits them as tags', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(ExtensionTagEditor(inputKey: const ValueKey('field'), initialTags: const ['.ZIP', ' Exe', 'exe'])),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_chipTexts(tester), ['zip', 'exe']);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'mp3, flac');
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'exe']);
|
||||
expect(find.text('mp3, flac'), findsOneWidget);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'exe', 'mp3', 'flac']);
|
||||
expect(find.text('mp3, flac'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), '.APE');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'exe', 'mp3', 'flac', 'ape']);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-exe')));
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'mp3', 'flac', 'ape']);
|
||||
expect(find.byKey(const ValueKey('extension-chip-remove-exe')), findsNothing);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'DO');
|
||||
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
|
||||
expect(state.flush(), ['zip', 'mp3', 'flac', 'ape', 'do']);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'mp3', 'flac', 'ape', 'do']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('typing a duplicate highlights the existing chip without adding it', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(ExtensionTagEditor(inputKey: const ValueKey('field'), initialTags: const ['zip', 'exe'])),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final zipIdle = _chipBorder(tester, 'zip');
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), ' EXE ');
|
||||
await tester.pump();
|
||||
expect(_chipBorder(tester, 'exe'), isNot(zipIdle));
|
||||
expect(_chipBorder(tester, 'zip'), zipIdle);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'exe']);
|
||||
expect(_chipBorder(tester, 'exe'), zipIdle);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('placeholder surfaces default extensions only while the editor is empty', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
ExtensionTagEditor(
|
||||
inputKey: const ValueKey('field'),
|
||||
initialTags: const [],
|
||||
placeholderText: 'empty hint',
|
||||
usagePlaceholderText: 'usage hint',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('empty hint'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'a');
|
||||
await tester.pump();
|
||||
expect(find.text('empty hint'), findsNothing);
|
||||
expect(find.text('usage hint'), findsNothing);
|
||||
expect(_chipTexts(tester), isEmpty);
|
||||
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsNothing);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['a']);
|
||||
expect(find.text('empty hint'), findsNothing);
|
||||
expect(find.text('usage hint'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'b');
|
||||
await tester.pump();
|
||||
expect(find.text('usage hint'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), '');
|
||||
await tester.pump();
|
||||
expect(find.text('usage hint'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-a')));
|
||||
await tester.pump();
|
||||
expect(find.text('empty hint'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('empty state offers a restore-defaults action', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
ExtensionTagEditor(
|
||||
inputKey: const ValueKey('field'),
|
||||
initialTags: const [],
|
||||
placeholderText: 'empty state',
|
||||
restoreDefaults: const ['Exe', '.MSI', 'exe'],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('empty state'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-restore-defaults')));
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['exe', 'msi']);
|
||||
expect(find.text('empty state'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-exe')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-msi')));
|
||||
await tester.pump();
|
||||
expect(find.text('empty state'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-restore-defaults')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('extensions owned by another category are blocked with an error', (WidgetTester tester) async {
|
||||
var conflict = false;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
ExtensionTagEditor(
|
||||
inputKey: const ValueKey('field'),
|
||||
initialTags: const ['zip'],
|
||||
takenByCategory: const {'aaa': 'Music'},
|
||||
onConflictChanged: (value) => conflict = value,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(conflict, isFalse);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'mp3, aaa');
|
||||
await tester.pump();
|
||||
expect(conflict, isTrue);
|
||||
expect(find.text('aaa is already used by Music'), findsOneWidget);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip', 'mp3']);
|
||||
expect(find.text('aaa'), findsOneWidget);
|
||||
expect(find.text('aaa is already used by Music'), findsOneWidget);
|
||||
expect(conflict, isTrue);
|
||||
|
||||
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
|
||||
expect(state.flush(), ['zip', 'mp3']);
|
||||
expect(find.text('aaa'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('field')), 'zzz');
|
||||
await tester.pump();
|
||||
expect(conflict, isFalse);
|
||||
expect(find.text('aaa is already used by Music'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a tag claimed by another category renders red until removed', (WidgetTester tester) async {
|
||||
var conflict = true;
|
||||
await tester.pumpWidget(
|
||||
_wrap(
|
||||
ExtensionTagEditor(
|
||||
inputKey: const ValueKey('field'),
|
||||
initialTags: const ['zip', 'aaa'],
|
||||
takenByCategory: const {'aaa': 'Music'},
|
||||
onConflictChanged: (value) => conflict = value,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final zipIdle = _chipBorder(tester, 'zip');
|
||||
expect(_chipBorder(tester, 'aaa'), isNot(zipIdle));
|
||||
expect(find.text('aaa is already used by Music'), findsOneWidget);
|
||||
|
||||
final state = tester.state<ExtensionTagEditorState>(find.byType(ExtensionTagEditor));
|
||||
expect(state.flush(), ['zip']);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('extension-chip-remove-aaa')));
|
||||
await tester.pump();
|
||||
expect(_chipTexts(tester), ['zip']);
|
||||
expect(find.text('aaa is already used by Music'), findsNothing);
|
||||
expect(conflict, isFalse);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1682,6 +1682,44 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('changing the download directory rebases built-in category paths', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
final settingsController = RebaseSettingsController();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
|
||||
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
|
||||
settingsControllerProvider.overrideWith(() => settingsController),
|
||||
],
|
||||
child: const _SettingsTestApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Downloads'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final directoryInput = find.byKey(const ValueKey('download-directory-input'));
|
||||
expect(directoryInput, findsOneWidget);
|
||||
await tester.enterText(directoryInput, '/dl/new');
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final saved = settingsController.state.requireValue.config;
|
||||
expect(saved.downloadDir, '/dl/new');
|
||||
expect(
|
||||
{for (final category in saved.categories) category.name: category.path.replaceAll(r'\', '/')},
|
||||
{
|
||||
'Music': '/dl/new/Music',
|
||||
'Video': '/dl/new/Video',
|
||||
'Document': '/dl/new/Document',
|
||||
'Program': '/dl/new/Program',
|
||||
'Mine': '/dl/old/Mine',
|
||||
},
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('android download directory picker only switches to verified raw paths', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(390, 760));
|
||||
DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android;
|
||||
@@ -2526,6 +2564,50 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('extensions owned by another category block the add dialog', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1024, 900));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new),
|
||||
appPlatformControllerProvider.overrideWith(FakePlatformController.new),
|
||||
settingsControllerProvider.overrideWith(CategorySettingsController.new),
|
||||
],
|
||||
child: const _SettingsTestApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Downloads'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('add-download-category')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-name')), '影集');
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-path')), '/tmp/shows');
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'dat');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('dat is already used by 资料'), findsOneWidget);
|
||||
AppPrimaryButton saveButton() => tester.widget<AppPrimaryButton>(
|
||||
find.ancestor(of: find.text('Save'), matching: find.byType(AppPrimaryButton)),
|
||||
);
|
||||
expect(saveButton().onPressed, isNull);
|
||||
|
||||
await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'mkv');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('dat is already used by 资料'), findsNothing);
|
||||
expect(saveButton().onPressed, isNotNull);
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('影集'), findsOneWidget);
|
||||
expect(find.text('/tmp/shows'), findsOneWidget);
|
||||
expect(find.text('dat is already used by 资料'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('settings content stretches responsively and stays centered', (WidgetTester tester) async {
|
||||
await _setTestSize(tester, const Size(1800, 900));
|
||||
await tester.pumpWidget(
|
||||
@@ -6082,7 +6164,24 @@ class CategorySettingsController extends FakeSettingsController {
|
||||
@override
|
||||
Future<SettingsState> build() async {
|
||||
final config = DownloaderConfig();
|
||||
config.extra.downloadCategories = [DownloadCategory(name: '资料', path: '/tmp/data')];
|
||||
config.categories = [
|
||||
DownloadCategory(name: '资料', path: '/tmp/data', extensions: ['dat']),
|
||||
];
|
||||
return SettingsState(config: config);
|
||||
}
|
||||
}
|
||||
|
||||
class RebaseSettingsController extends FakeSettingsController {
|
||||
@override
|
||||
Future<SettingsState> build() async {
|
||||
final config = DownloaderConfig()..downloadDir = '/dl/old';
|
||||
config.categories = [
|
||||
DownloadCategory(name: 'Music', path: '/dl/old/Music', isBuiltIn: true, nameKey: 'categoryMusic'),
|
||||
DownloadCategory(name: 'Video', path: '/dl/old/Video', isBuiltIn: true, nameKey: 'categoryVideo'),
|
||||
DownloadCategory(name: 'Document', path: '/dl/old/Document', isBuiltIn: true, nameKey: 'categoryDocument'),
|
||||
DownloadCategory(name: 'Program', path: '/dl/old/Program', isBuiltIn: true, nameKey: 'categoryProgram'),
|
||||
DownloadCategory(name: 'Mine', path: '/dl/old/Mine'),
|
||||
];
|
||||
return SettingsState(config: config);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user