Initial Flutter booru app

This commit is contained in:
2026-05-23 11:46:52 +03:00
commit 63da741295
187 changed files with 24113 additions and 0 deletions
+124
View File
@@ -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);
});
}
+88
View File
@@ -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));
});
}
+159
View File
@@ -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,
);
+195
View File
@@ -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);
});
}
+96
View File
@@ -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');
});
}
+70
View File
@@ -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);
});
}