Initial Flutter booru app
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/collection.dart';
|
||||
import 'package:gel_rule_app/backend/models/post.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/collection_repository.dart';
|
||||
import 'package:gel_rule_app/backend/services/collection_service.dart';
|
||||
import 'package:gel_rule_app/core/utils/result.dart';
|
||||
|
||||
class FakeCollectionRepository implements CollectionRepository {
|
||||
final collections = <String, Collection>{};
|
||||
final postsByCollection = <String, Map<String, Post>>{};
|
||||
final cachedPosts = <String, Post>{};
|
||||
|
||||
@override
|
||||
Future<Result<Collection>> save(Collection collection) async {
|
||||
collections[collection.id] = collection;
|
||||
return Success(collection);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> delete(String id) async {
|
||||
collections.remove(id);
|
||||
postsByCollection.remove(id);
|
||||
return const Success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<Collection>>> all() async {
|
||||
return Success(collections.values.toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<Collection?>> getById(String id) async {
|
||||
return Success(collections[id]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> addPost(String collectionId, Post post) async {
|
||||
cachedPosts[post.cacheKey] = post;
|
||||
postsByCollection.putIfAbsent(collectionId, () => {})[post.cacheKey] = post;
|
||||
return const Success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> removePost(
|
||||
String collectionId,
|
||||
String postId,
|
||||
String providerId,
|
||||
) async {
|
||||
postsByCollection[collectionId]?.remove('$providerId:$postId');
|
||||
return const Success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<Post>>> posts(String collectionId) async {
|
||||
return Success(postsByCollection[collectionId]?.values.toList() ?? []);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
Post post(String id) => Post(
|
||||
id: id,
|
||||
providerId: 'gelbooru',
|
||||
providerName: 'Gelbooru',
|
||||
previewUrl: '',
|
||||
sampleUrl: '',
|
||||
fileUrl: '',
|
||||
tags: const [],
|
||||
rating: 'safe',
|
||||
width: 0,
|
||||
height: 0,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: 'unknown',
|
||||
score: 0,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('create update and delete collection', () async {
|
||||
final repository = FakeCollectionRepository();
|
||||
final service = CollectionService(repository);
|
||||
|
||||
final created =
|
||||
await service.createCollection('A', 'desc') as Success<Collection>;
|
||||
await service.updateCollection(created.data.id,
|
||||
name: 'B', description: 'new');
|
||||
expect(
|
||||
(await service.getCollections() as Success<List<Collection>>)
|
||||
.data
|
||||
.single
|
||||
.name,
|
||||
'B');
|
||||
|
||||
await service.deleteCollection(created.data.id);
|
||||
expect((await service.getCollections() as Success<List<Collection>>).data,
|
||||
isEmpty);
|
||||
});
|
||||
|
||||
test('add and remove post while cached metadata remains', () async {
|
||||
final repository = FakeCollectionRepository();
|
||||
final service = CollectionService(repository);
|
||||
final collection =
|
||||
await service.createCollection('A', null) as Success<Collection>;
|
||||
final item = post('1');
|
||||
|
||||
await service.addPostToCollection(collection.data.id, item);
|
||||
expect(
|
||||
(await service.getCollectionPosts(collection.data.id)
|
||||
as Success<List<Post>>)
|
||||
.data,
|
||||
hasLength(1),
|
||||
);
|
||||
|
||||
await service.removePostFromCollection(
|
||||
collection.data.id, item.id, item.providerId);
|
||||
expect(
|
||||
(await service.getCollectionPosts(collection.data.id)
|
||||
as Success<List<Post>>)
|
||||
.data,
|
||||
isEmpty,
|
||||
);
|
||||
expect(repository.cachedPosts[item.cacheKey], item);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/favorite.dart';
|
||||
import 'package:gel_rule_app/backend/models/post.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/favorite_repository.dart';
|
||||
import 'package:gel_rule_app/backend/services/favorite_service.dart';
|
||||
import 'package:gel_rule_app/core/utils/result.dart';
|
||||
|
||||
class FakeFavoriteRepository implements FavoriteRepository {
|
||||
final favorites = <String, Favorite>{};
|
||||
|
||||
@override
|
||||
Future<Result<void>> add(Post post) async {
|
||||
final key = '${post.providerId}:${post.id}';
|
||||
favorites[key] = Favorite(
|
||||
id: key,
|
||||
postId: post.id,
|
||||
providerId: post.providerId,
|
||||
savedAt: DateTime.now(),
|
||||
);
|
||||
return const Success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> remove(String postId, String providerId) async {
|
||||
favorites.remove('$providerId:$postId');
|
||||
return const Success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<bool>> exists(String postId, String providerId) async {
|
||||
return Success(favorites.containsKey('$providerId:$postId'));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<Favorite>>> all() async =>
|
||||
Success(favorites.values.toList());
|
||||
|
||||
@override
|
||||
Future<Result<void>> clear() async {
|
||||
favorites.clear();
|
||||
return const Success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
Post post(String id, String providerId) => 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('add and remove favorite', () async {
|
||||
final service = FavoriteService(FakeFavoriteRepository());
|
||||
await service.addFavorite(post('1', 'a'));
|
||||
expect((await service.isFavorite('1', 'a') as Success<bool>).data, isTrue);
|
||||
await service.removeFavorite('1', 'a');
|
||||
expect((await service.isFavorite('1', 'a') as Success<bool>).data, isFalse);
|
||||
});
|
||||
|
||||
test('duplicate favorite is idempotent', () async {
|
||||
final service = FavoriteService(FakeFavoriteRepository());
|
||||
await service.addFavorite(post('1', 'a'));
|
||||
await service.addFavorite(post('1', 'a'));
|
||||
expect((await service.getFavorites() as Success<List<Favorite>>).data,
|
||||
hasLength(1));
|
||||
});
|
||||
|
||||
test('same post id on different providers does not collide', () async {
|
||||
final service = FavoriteService(FakeFavoriteRepository());
|
||||
await service.addFavorite(post('1', 'a'));
|
||||
await service.addFavorite(post('1', 'b'));
|
||||
expect((await service.getFavorites() as Success<List<Favorite>>).data,
|
||||
hasLength(2));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/post.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/collection_repository.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/favorite_repository.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/post_repository.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/provider_repository.dart';
|
||||
import 'package:gel_rule_app/backend/services/collection_service.dart';
|
||||
import 'package:gel_rule_app/backend/services/settings_service.dart';
|
||||
import 'package:gel_rule_app/core/cache/cache_service.dart';
|
||||
import 'package:gel_rule_app/core/database/app_database.dart';
|
||||
import 'package:gel_rule_app/core/database/database_service.dart';
|
||||
import 'package:gel_rule_app/core/utils/result.dart';
|
||||
|
||||
void main() {
|
||||
late Directory directory;
|
||||
late AppDatabase database;
|
||||
late DatabaseService databaseService;
|
||||
late PostRepository postRepository;
|
||||
late File copiedIsarDll;
|
||||
|
||||
setUpAll(() async {
|
||||
copiedIsarDll =
|
||||
File('${Directory.current.path}${Platform.pathSeparator}isar.dll');
|
||||
if (Platform.isWindows && !copiedIsarDll.existsSync()) {
|
||||
final localAppData = Platform.environment['LOCALAPPDATA'];
|
||||
final source = File(
|
||||
'$localAppData\\Pub\\Cache\\hosted\\pub.dev\\isar_flutter_libs-3.1.0+1\\windows\\isar.dll',
|
||||
);
|
||||
if (source.existsSync()) {
|
||||
await source.copy(copiedIsarDll.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
if (Platform.isWindows && copiedIsarDll.existsSync()) {
|
||||
// Windows keeps the loaded native library locked until the test process exits.
|
||||
// The project .gitignore excludes this copied test artifact.
|
||||
}
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
directory = await Directory.systemTemp.createTemp('gel_rule_isar_test_');
|
||||
database = await AppDatabase.open(directory: directory.path);
|
||||
databaseService = DatabaseService(database);
|
||||
postRepository = PostRepository(databaseService);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.close();
|
||||
if (directory.existsSync()) {
|
||||
await directory.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
test('provider repository seeds default providers', () async {
|
||||
final repository = ProviderRepository(databaseService);
|
||||
|
||||
await repository.ensureSeedProviders();
|
||||
final result = await repository.getProviders();
|
||||
|
||||
expect((result as Success).data.map((provider) => provider.id), [
|
||||
'gelbooru',
|
||||
'rule34',
|
||||
'safebooru',
|
||||
]);
|
||||
});
|
||||
|
||||
test('settings saveEnabledProviders updates provider configs', () async {
|
||||
final providerRepository = ProviderRepository(databaseService);
|
||||
await providerRepository.ensureSeedProviders();
|
||||
final service = SettingsService(
|
||||
databaseService,
|
||||
providerRepository: providerRepository,
|
||||
);
|
||||
|
||||
await service.saveEnabledProviders(['safebooru']);
|
||||
final providers = (await providerRepository.getProviders() as Success).data;
|
||||
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'safebooru').enabled,
|
||||
isTrue);
|
||||
expect(
|
||||
providers.singleWhere((provider) => provider.id == 'gelbooru').enabled,
|
||||
isFalse);
|
||||
expect(providers.singleWhere((provider) => provider.id == 'rule34').enabled,
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test('favorite repository persists favorite and cached post metadata',
|
||||
() async {
|
||||
final repository = FavoriteRepository(databaseService, postRepository);
|
||||
final item = post('1');
|
||||
|
||||
await repository.add(item);
|
||||
|
||||
expect((await repository.exists('1', 'gelbooru') as Success).data, isTrue);
|
||||
expect(
|
||||
(await postRepository.getCachedPost('1', 'gelbooru') as Success)
|
||||
.data
|
||||
?.id,
|
||||
'1');
|
||||
});
|
||||
|
||||
test('collection service preserves createdAt on update and keeps cached post',
|
||||
() async {
|
||||
final repository = CollectionRepository(databaseService, postRepository);
|
||||
final service = CollectionService(repository);
|
||||
final created = await service.createCollection('A', 'old') as Success;
|
||||
final createdAt = created.data.createdAt;
|
||||
|
||||
await service.updateCollection(created.data.id,
|
||||
name: 'B', description: 'new');
|
||||
final updated = (await service.getCollections() as Success).data.single;
|
||||
|
||||
expect(updated.name, 'B');
|
||||
expect(updated.createdAt, createdAt);
|
||||
|
||||
final item = post('2');
|
||||
await service.addPostToCollection(updated.id, item);
|
||||
await service.removePostFromCollection(
|
||||
updated.id, item.id, item.providerId);
|
||||
|
||||
expect((await service.getCollectionPosts(updated.id) as Success).data,
|
||||
isEmpty);
|
||||
expect(
|
||||
(await postRepository.getCachedPost('2', 'gelbooru') as Success)
|
||||
.data
|
||||
?.id,
|
||||
'2');
|
||||
});
|
||||
|
||||
test('cache service prunes to max item count', () async {
|
||||
final service = CacheService(databaseService);
|
||||
|
||||
await service.cachePosts([post('1'), post('2'), post('3')], maxItems: 2);
|
||||
final cached = await service.getCachedPosts() as Success;
|
||||
|
||||
expect(cached.data, hasLength(2));
|
||||
});
|
||||
}
|
||||
|
||||
Post post(String id) => Post(
|
||||
id: id,
|
||||
providerId: 'gelbooru',
|
||||
providerName: 'Gelbooru',
|
||||
previewUrl: 'https://example.test/$id-preview.jpg',
|
||||
sampleUrl: 'https://example.test/$id-sample.jpg',
|
||||
fileUrl: 'https://example.test/$id.jpg',
|
||||
tags: const ['tag'],
|
||||
rating: 'safe',
|
||||
width: 100,
|
||||
height: 100,
|
||||
createdAt: DateTime.now(),
|
||||
fileType: 'image',
|
||||
score: 1,
|
||||
);
|
||||
@@ -0,0 +1,195 @@
|
||||
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_health.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>{};
|
||||
|
||||
@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
|
||||
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 {
|
||||
FakeProvider(this.id, this.name, this.posts, {this.failSearch = false});
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
String get baseUrl => 'https://example.test';
|
||||
final List<Post> posts;
|
||||
final bool failSearch;
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
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('offline provider is skipped and partial failures do not break feed',
|
||||
() 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), ['a']);
|
||||
expect(repository.health['c']?.status, ProviderStatus.offline);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/danbooru_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/gelbooru_mapper.dart';
|
||||
import 'package:gel_rule_app/backend/mappers/rule34_mapper.dart';
|
||||
|
||||
void main() {
|
||||
test('parses Gelbooru array response', () {
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
[
|
||||
{
|
||||
'id': 1,
|
||||
'file_url': 'https://example.test/a.jpg',
|
||||
'sample_url': 'https://example.test/sample.jpg',
|
||||
'preview_url': 'https://example.test/preview.jpg',
|
||||
'tags': 'cat cute',
|
||||
'rating': 'safe',
|
||||
'width': '100',
|
||||
'height': '200',
|
||||
'score': '7',
|
||||
}
|
||||
],
|
||||
providerId: 'gelbooru',
|
||||
providerName: 'Gelbooru',
|
||||
);
|
||||
|
||||
expect(posts, hasLength(1));
|
||||
expect(posts.first.id, '1');
|
||||
expect(posts.first.tags, ['cat', 'cute']);
|
||||
expect(posts.first.fileType, 'image');
|
||||
});
|
||||
|
||||
test('parses Gelbooru object/posts response', () {
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
{
|
||||
'post': [
|
||||
{'id': '2', 'file_url': 'https://example.test/b.gif'}
|
||||
],
|
||||
},
|
||||
providerId: 'gelbooru',
|
||||
providerName: 'Gelbooru',
|
||||
);
|
||||
|
||||
expect(posts.single.id, '2');
|
||||
expect(posts.single.fileType, 'gif');
|
||||
});
|
||||
|
||||
test('parses Rule34 compatible response', () {
|
||||
final posts = Rule34Mapper.postsFromResponse(
|
||||
{
|
||||
'posts': [
|
||||
{'id': '3', 'file_url': 'https://example.test/c.webm'}
|
||||
],
|
||||
},
|
||||
providerId: 'rule34',
|
||||
providerName: 'Rule34',
|
||||
);
|
||||
|
||||
expect(posts.single.providerId, 'rule34');
|
||||
expect(posts.single.fileType, 'video');
|
||||
});
|
||||
|
||||
test('parses Danbooru response', () {
|
||||
final posts = DanbooruMapper.postsFromResponse(
|
||||
[
|
||||
{
|
||||
'id': 4,
|
||||
'file_url': 'https://example.test/d.png',
|
||||
'large_file_url': 'https://example.test/large.png',
|
||||
'preview_file_url': 'https://example.test/preview.png',
|
||||
'tag_string': 'blue sky',
|
||||
'image_width': 640,
|
||||
'image_height': 480,
|
||||
}
|
||||
],
|
||||
providerId: 'danbooru',
|
||||
providerName: 'Danbooru',
|
||||
);
|
||||
|
||||
expect(posts.single.id, '4');
|
||||
expect(posts.single.previewUrl, contains('preview'));
|
||||
expect(posts.single.tags, ['blue', 'sky']);
|
||||
});
|
||||
|
||||
test('missing optional fields do not crash parser', () {
|
||||
final posts = GelbooruMapper.postsFromResponse(
|
||||
[
|
||||
{'id': 5}
|
||||
],
|
||||
providerId: 'gelbooru',
|
||||
providerName: 'Gelbooru',
|
||||
);
|
||||
|
||||
expect(posts.single.fileUrl, '');
|
||||
expect(posts.single.rating, 'unknown');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gel_rule_app/backend/models/search_history.dart';
|
||||
import 'package:gel_rule_app/backend/repositories/search_repository.dart';
|
||||
import 'package:gel_rule_app/backend/services/search_service.dart';
|
||||
import 'package:gel_rule_app/core/utils/result.dart';
|
||||
|
||||
class FakeSearchRepository implements SearchRepository {
|
||||
final items = <SearchHistory>[];
|
||||
|
||||
@override
|
||||
Future<Result<void>> clear() async {
|
||||
items.clear();
|
||||
return const Success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<List<SearchHistory>>> recent({int limit = 20}) async {
|
||||
final sorted = [...items]
|
||||
..sort((a, b) => b.searchedAt.compareTo(a.searchedAt));
|
||||
return Success(sorted.take(limit).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> save(SearchHistory history) async {
|
||||
items.add(history);
|
||||
return const Success(null);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeSearchRepository repository;
|
||||
late SearchService service;
|
||||
|
||||
setUp(() {
|
||||
repository = FakeSearchRepository();
|
||||
service = SearchService(repository);
|
||||
});
|
||||
|
||||
test('parses query and removes duplicates', () {
|
||||
expect(service.parseTags(' cat cute cat '), ['cat', 'cute']);
|
||||
});
|
||||
|
||||
test('recent searches are ordered by searchedAt', () async {
|
||||
await service.saveSearch('old', 1);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||
await service.saveSearch('new', 2);
|
||||
|
||||
final result = await service.recentSearches();
|
||||
expect((result as Success<List<SearchHistory>>).data.first.query, 'new');
|
||||
});
|
||||
|
||||
test('autocomplete uses history tags', () async {
|
||||
await service.saveSearch('cat cute', 2);
|
||||
await service.saveSearch('car', 1);
|
||||
|
||||
final result = await service.autocomplete('ca');
|
||||
expect((result as Success<List<String>>).data, containsAll(['cat', 'car']));
|
||||
});
|
||||
|
||||
test('clear history removes all searches', () async {
|
||||
await service.saveSearch('cat', 1);
|
||||
await service.clearHistory();
|
||||
|
||||
final result = await service.recentSearches();
|
||||
expect((result as Success<List<SearchHistory>>).data, isEmpty);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user