430 lines
12 KiB
Dart
430 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
|
|
import 'package:gel_rule_app/backend/models/post.dart';
|
|
import 'package:gel_rule_app/backend/models/provider_diagnostics.dart';
|
|
import 'package:gel_rule_app/backend/models/provider_health.dart';
|
|
import 'package:gel_rule_app/backend/models/tag_suggestion.dart';
|
|
import 'package:gel_rule_app/backend/models/top_period_filter.dart';
|
|
import 'package:gel_rule_app/backend/providers/content_provider.dart';
|
|
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
|
|
import 'package:gel_rule_app/backend/providers/provider_manager.dart';
|
|
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
|
|
import 'package:gel_rule_app/core/utils/result.dart';
|
|
|
|
class FakeProviderRepository implements ProviderRepository {
|
|
final configs = <String, ContentProviderConfig>{};
|
|
final health = <String, ProviderHealth>{};
|
|
final diagnostics = <String, ProviderDiagnostics>{};
|
|
|
|
@override
|
|
Future<Result<void>> ensureSeedProviders() async => const Success(null);
|
|
|
|
@override
|
|
Future<Result<List<ContentProviderConfig>>> getProviders({
|
|
bool enabledOnly = false,
|
|
}) async {
|
|
final values = configs.values
|
|
.where((config) => !enabledOnly || config.enabled)
|
|
.toList()
|
|
..sort((a, b) => a.priority.compareTo(b.priority));
|
|
return Success(values);
|
|
}
|
|
|
|
@override
|
|
Future<Result<ContentProviderConfig?>> getProvider(String id) async {
|
|
return Success(configs[id]);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> saveProvider(ContentProviderConfig config) async {
|
|
configs[config.id] = config;
|
|
return const Success(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> deleteProvider(String id) async {
|
|
configs.remove(id);
|
|
return const Success(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> saveHealth(ProviderHealth value) async {
|
|
health[value.providerId] = value;
|
|
return const Success(null);
|
|
}
|
|
|
|
@override
|
|
Future<Result<ProviderHealth?>> getHealth(String providerId) async {
|
|
return Success(health[providerId]);
|
|
}
|
|
|
|
@override
|
|
Future<Result<void>> saveDiagnostics(ProviderDiagnostics value) async {
|
|
diagnostics[value.providerId] = value;
|
|
return const Success(null);
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class FakeProviderFactory extends ProviderFactory {
|
|
FakeProviderFactory(this.providers);
|
|
|
|
final Map<String, ContentProvider> providers;
|
|
|
|
@override
|
|
ContentProvider create(ContentProviderConfig config) => providers[config.id]!;
|
|
}
|
|
|
|
class FakeProvider implements ContentProvider, TagSuggestionProvider {
|
|
FakeProvider(
|
|
this.id,
|
|
this.name,
|
|
this.posts, {
|
|
this.failSearch = false,
|
|
this.suggestions = const [],
|
|
this.suggestionDelay = Duration.zero,
|
|
});
|
|
|
|
@override
|
|
final String id;
|
|
@override
|
|
final String name;
|
|
@override
|
|
String get baseUrl => 'https://example.test';
|
|
final List<Post> posts;
|
|
final bool failSearch;
|
|
final List<TagSuggestion> suggestions;
|
|
final Duration suggestionDelay;
|
|
int suggestionCalls = 0;
|
|
|
|
@override
|
|
Future<ProviderHealth> checkHealth() async => ProviderHealth(
|
|
providerId: id,
|
|
status: ProviderStatus.online,
|
|
pingMs: 1,
|
|
lastCheckedAt: DateTime.now(),
|
|
);
|
|
|
|
@override
|
|
Future<Post?> getPost(String id) async =>
|
|
posts.where((post) => post.id == id).firstOrNull;
|
|
|
|
@override
|
|
Future<List<Post>> searchPosts({
|
|
required List<String> tags,
|
|
required int page,
|
|
int limit = 50,
|
|
String? rating,
|
|
TopPeriodFilter topPeriod = TopPeriodFilter.none,
|
|
}) async {
|
|
if (failSearch) throw Exception('fail');
|
|
return posts;
|
|
}
|
|
|
|
@override
|
|
Future<List<TagSuggestion>> suggestTags(String query,
|
|
{int limit = 20}) async {
|
|
suggestionCalls++;
|
|
if (suggestionDelay > Duration.zero) {
|
|
await Future<void>.delayed(suggestionDelay);
|
|
}
|
|
return suggestions
|
|
.where((suggestion) => suggestion.name.startsWith(query))
|
|
.take(limit)
|
|
.toList(growable: false);
|
|
}
|
|
}
|
|
|
|
ContentProviderConfig config(String id, int priority, {bool enabled = true}) {
|
|
final now = DateTime.now();
|
|
return ContentProviderConfig(
|
|
id: id,
|
|
name: id,
|
|
baseUrl: 'https://example.test',
|
|
apiType: 'fake',
|
|
enabled: enabled,
|
|
priority: priority,
|
|
timeoutSeconds: 10,
|
|
customHeaders: const {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
);
|
|
}
|
|
|
|
Post post(String providerId, String id) => Post(
|
|
id: id,
|
|
providerId: providerId,
|
|
providerName: providerId,
|
|
previewUrl: '',
|
|
sampleUrl: '',
|
|
fileUrl: '',
|
|
tags: const [],
|
|
rating: 'safe',
|
|
width: 0,
|
|
height: 0,
|
|
createdAt: DateTime.now(),
|
|
fileType: 'unknown',
|
|
score: 0,
|
|
);
|
|
|
|
void main() {
|
|
test('active providers are sorted by priority', () async {
|
|
final repository = FakeProviderRepository()
|
|
..configs['b'] = config('b', 2)
|
|
..configs['a'] = config('a', 1);
|
|
final manager = ProviderManager(
|
|
repository,
|
|
FakeProviderFactory({
|
|
'a': FakeProvider('a', 'a', []),
|
|
'b': FakeProvider('b', 'b', []),
|
|
}),
|
|
);
|
|
|
|
final result =
|
|
await manager.activeProviders() as Success<List<ContentProvider>>;
|
|
expect(result.data.map((provider) => provider.id), ['a', 'b']);
|
|
});
|
|
|
|
test('saved offline health does not block enabled provider retry', () async {
|
|
final repository = FakeProviderRepository()
|
|
..configs['a'] = config('a', 0)
|
|
..configs['b'] = config('b', 1)
|
|
..configs['c'] = config('c', 2)
|
|
..health['b'] = ProviderHealth(
|
|
providerId: 'b',
|
|
status: ProviderStatus.offline,
|
|
pingMs: 0,
|
|
lastCheckedAt: DateTime.now(),
|
|
);
|
|
final manager = ProviderManager(
|
|
repository,
|
|
FakeProviderFactory({
|
|
'a': FakeProvider('a', 'a', [post('a', '1')]),
|
|
'b': FakeProvider('b', 'b', [post('b', '1')]),
|
|
'c': FakeProvider('c', 'c', [], failSearch: true),
|
|
}),
|
|
);
|
|
|
|
final result = await manager.searchAcrossProviders(tags: [], page: 0)
|
|
as Success<List<Post>>;
|
|
expect(result.data.map((item) => item.providerId).toSet(), {'a', 'b'});
|
|
expect(repository.health['c']?.status, ProviderStatus.offline);
|
|
expect(repository.diagnostics['a']?.lastResultCount, 1);
|
|
expect(repository.diagnostics['c']?.lastErrorMessage, 'Search failed');
|
|
});
|
|
|
|
test('all providers results are naturally mixed without provider blocks',
|
|
() async {
|
|
final repository = FakeProviderRepository()
|
|
..configs['a'] = config('a', 0)
|
|
..configs['b'] = config('b', 1)
|
|
..configs['c'] = config('c', 2);
|
|
final manager = ProviderManager(
|
|
repository,
|
|
FakeProviderFactory({
|
|
'a': FakeProvider('a', 'a', [post('a', '1'), post('a', '2')]),
|
|
'b': FakeProvider('b', 'b', [post('b', '1'), post('b', '2')]),
|
|
'c': FakeProvider('c', 'c', [post('c', '1')]),
|
|
}),
|
|
);
|
|
|
|
final result = await manager.searchAcrossProviders(tags: [], page: 0)
|
|
as Success<List<Post>>;
|
|
|
|
final keys = result.data.map((item) => item.cacheKey).toList();
|
|
expect(keys.toSet(), {'a:1', 'a:2', 'b:1', 'b:2', 'c:1'});
|
|
expect(keys, isNot(['a:1', 'a:2', 'b:1', 'b:2', 'c:1']));
|
|
});
|
|
|
|
test('enable disable provider persists config', () async {
|
|
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
|
|
final manager = ProviderManager(
|
|
repository,
|
|
FakeProviderFactory({'a': FakeProvider('a', 'a', [])}),
|
|
);
|
|
|
|
await manager.enableProvider('a', false);
|
|
expect(repository.configs['a']!.enabled, isFalse);
|
|
});
|
|
|
|
test('tag suggestions query every active suggestion provider before limiting',
|
|
() async {
|
|
final repository = FakeProviderRepository()
|
|
..configs['gelbooru'] = config('gelbooru', 0)
|
|
..configs['e621'] = config('e621', 1)
|
|
..configs['e926'] = config('e926', 2);
|
|
final manager = ProviderManager(
|
|
repository,
|
|
FakeProviderFactory({
|
|
'gelbooru': FakeProvider(
|
|
'gelbooru',
|
|
'Gelbooru',
|
|
[],
|
|
suggestions: [
|
|
for (var i = 0; i < 4; i++)
|
|
TagSuggestion(
|
|
name: 'cat_$i',
|
|
category: TagCategory.general,
|
|
postCount: 100 - i,
|
|
providerId: 'gelbooru',
|
|
),
|
|
],
|
|
),
|
|
'e621': FakeProvider(
|
|
'e621',
|
|
'e621',
|
|
[],
|
|
suggestions: const [
|
|
TagSuggestion(
|
|
name: 'cat_tail',
|
|
category: TagCategory.general,
|
|
postCount: 1000,
|
|
providerId: 'e621',
|
|
),
|
|
],
|
|
),
|
|
'e926': FakeProvider(
|
|
'e926',
|
|
'e926',
|
|
[],
|
|
suggestions: const [
|
|
TagSuggestion(
|
|
name: 'cat_ears',
|
|
category: TagCategory.general,
|
|
postCount: 900,
|
|
providerId: 'e926',
|
|
),
|
|
],
|
|
),
|
|
}),
|
|
);
|
|
|
|
final result = await manager.suggestTags('cat', limit: 4)
|
|
as Success<List<TagSuggestion>>;
|
|
|
|
expect(result.data.map((item) => item.providerId).toSet(), {
|
|
'gelbooru',
|
|
'e621',
|
|
'e926',
|
|
});
|
|
});
|
|
|
|
test('tag suggestions timeout slow providers without blocking fast results',
|
|
() async {
|
|
final repository = FakeProviderRepository()
|
|
..configs['fast'] = config('fast', 0)
|
|
..configs['slow'] = config('slow', 1);
|
|
final fast = FakeProvider(
|
|
'fast',
|
|
'fast',
|
|
[],
|
|
suggestions: const [
|
|
TagSuggestion(
|
|
name: 'cat_fast',
|
|
category: TagCategory.general,
|
|
postCount: 20,
|
|
providerId: 'fast',
|
|
),
|
|
],
|
|
);
|
|
final slow = FakeProvider(
|
|
'slow',
|
|
'slow',
|
|
[],
|
|
suggestionDelay: const Duration(seconds: 3),
|
|
suggestions: const [
|
|
TagSuggestion(
|
|
name: 'cat_slow',
|
|
category: TagCategory.general,
|
|
postCount: 200,
|
|
providerId: 'slow',
|
|
),
|
|
],
|
|
);
|
|
final manager = ProviderManager(
|
|
repository,
|
|
FakeProviderFactory({'fast': fast, 'slow': slow}),
|
|
);
|
|
final startedAt = DateTime.now();
|
|
|
|
final result = await manager.suggestTags('cat', limit: 4)
|
|
as Success<List<TagSuggestion>>;
|
|
|
|
expect(DateTime.now().difference(startedAt),
|
|
lessThan(const Duration(milliseconds: 2500)));
|
|
expect(result.data.map((item) => item.name), ['cat_fast']);
|
|
});
|
|
|
|
test('tag suggestions dedupe by name and keep highest post count', () async {
|
|
final repository = FakeProviderRepository()
|
|
..configs['a'] = config('a', 0)
|
|
..configs['b'] = config('b', 1);
|
|
final manager = ProviderManager(
|
|
repository,
|
|
FakeProviderFactory({
|
|
'a': FakeProvider(
|
|
'a',
|
|
'a',
|
|
[],
|
|
suggestions: const [
|
|
TagSuggestion(
|
|
name: 'cat',
|
|
category: TagCategory.general,
|
|
postCount: 10,
|
|
providerId: 'a',
|
|
),
|
|
],
|
|
),
|
|
'b': FakeProvider(
|
|
'b',
|
|
'b',
|
|
[],
|
|
suggestions: const [
|
|
TagSuggestion(
|
|
name: 'cat',
|
|
category: TagCategory.artist,
|
|
postCount: 100,
|
|
providerId: 'b',
|
|
),
|
|
],
|
|
),
|
|
}),
|
|
);
|
|
|
|
final result = await manager.suggestTags('cat', limit: 4)
|
|
as Success<List<TagSuggestion>>;
|
|
|
|
expect(result.data, hasLength(1));
|
|
expect(result.data.single.providerId, 'b');
|
|
expect(result.data.single.postCount, 100);
|
|
});
|
|
|
|
test('tag suggestions cache repeated prefix results', () async {
|
|
final repository = FakeProviderRepository()..configs['a'] = config('a', 0);
|
|
final provider = FakeProvider(
|
|
'a',
|
|
'a',
|
|
[],
|
|
suggestions: const [
|
|
TagSuggestion(
|
|
name: 'cat',
|
|
category: TagCategory.general,
|
|
postCount: 10,
|
|
providerId: 'a',
|
|
),
|
|
],
|
|
);
|
|
final manager = ProviderManager(
|
|
repository,
|
|
FakeProviderFactory({'a': provider}),
|
|
);
|
|
|
|
await manager.suggestTags('cat', limit: 4);
|
|
await manager.suggestTags('cat', limit: 4);
|
|
|
|
expect(provider.suggestionCalls, 1);
|
|
});
|
|
}
|