Compare commits

..
2 Commits
8 changed files with 482 additions and 144 deletions
+20
View File
@@ -1,4 +1,24 @@
const lunarisChangelog = [ const lunarisChangelog = [
LunarisChange(
version: '2.3.3',
title: 'Tag Suggestions Overlay Fix & Rule34 API Alignment',
bullets: [
'Tag Suggestions Hanging Fix: eliminated the bug where the tag suggestion card remained stuck on screen after clearing text or tapping outside. Added immediate dismissal on empty input, outside-tap backdrop dismissal, controller listener, and unfocus on submission.',
'Rule34 Dedicated Autocomplete: connected Rule34 directly to its official fast /autocomplete.php?q= endpoint, displaying real-time tag suggestions with accurate post counts (e.g. braid (216,984)).',
'Rule34 Tag Types (fields=tag_info): added tag_info field to post query for accurate tag categories per post.',
'Rule34 Authentication & Outage Handling: added detection for "missing authentication", rate limits, and server overload/search down ("success": false) with informative guidance to enter API Key & User ID.',
],
),
LunarisChange(
version: '2.3.2',
title: 'Gelbooru DAPI Compliance & Tag Query Optimization',
bullets: [
'Official Gelbooru DAPI Alignment: fixed tag suggestions pattern to use SQL LIKE wildcard (%) instead of literal asterisk (*), restoring accurate tag autocompletion.',
'Popularity Tag Sorting: added orderby=count and order=DESC to Gelbooru tag search so the most relevant and widely used tags appear first.',
'Batch Tag Categorization: migrated post tag metadata queries to the official "names" parameter, resolving categories for up to 80 tags in a single batch request instead of dozens of sequential requests.',
'Throttling & Auth Guidance: enhanced API credentials helper text and informative error messages with direct instructions to configure API Key & User ID from Gelbooru account options.',
],
),
LunarisChange( LunarisChange(
version: '2.3.1', version: '2.3.1',
title: 'Cloud Mirrors & Author Announcements Fixes', title: 'Cloud Mirrors & Author Announcements Fixes',
+164 -90
View File
@@ -65,45 +65,55 @@ class GelbooruProvider
if (rating != null && rating.isNotEmpty) 'rating:$rating', if (rating != null && rating.isNotEmpty) 'rating:$rating',
..._topTags(topPeriod), ..._topTags(topPeriod),
]; ];
final response = await _dio.get<dynamic>( try {
'/index.php', final response = await _dio.get<dynamic>(
queryParameters: { '/index.php',
'page': 'dapi', queryParameters: {
's': 'post', 'page': 'dapi',
'q': 'index', 's': 'post',
'json': '1', 'q': 'index',
'pid': page, 'json': '1',
'limit': limit, 'pid': page,
'tags': queryTags.join(' '), 'limit': limit,
..._queryParameters, 'tags': queryTags.join(' '),
}, ..._queryParameters,
); },
return GelbooruMapper.postsFromResponse( );
response.data, _checkResponse(response);
providerId: id, return GelbooruMapper.postsFromResponse(
providerName: name, response.data,
); providerId: id,
providerName: name,
);
} on DioException catch (e) {
_handleDioError(e);
}
} }
@override @override
Future<Post?> getPost(String id) async { Future<Post?> getPost(String id) async {
final response = await _dio.get<dynamic>( try {
'/index.php', final response = await _dio.get<dynamic>(
queryParameters: { '/index.php',
'page': 'dapi', queryParameters: {
's': 'post', 'page': 'dapi',
'q': 'index', 's': 'post',
'json': '1', 'q': 'index',
'id': id, 'json': '1',
..._queryParameters, 'id': id,
}, ..._queryParameters,
); },
final posts = GelbooruMapper.postsFromResponse( );
response.data, _checkResponse(response);
providerId: this.id, final posts = GelbooruMapper.postsFromResponse(
providerName: name, response.data,
); providerId: this.id,
return posts.isEmpty ? null : posts.first; providerName: name,
);
return posts.isEmpty ? null : posts.first;
} on DioException catch (e) {
_handleDioError(e);
}
} }
@override @override
@@ -142,18 +152,22 @@ class GelbooruProvider
@override @override
Future<List<PostComment>> getComments(String postId) async { Future<List<PostComment>> getComments(String postId) async {
final response = await _dio.get<dynamic>( try {
'/index.php', final response = await _dio.get<dynamic>(
queryParameters: { '/index.php',
'page': 'dapi', queryParameters: {
's': 'comment', 'page': 'dapi',
'q': 'index', 's': 'comment',
'json': '1', 'q': 'index',
'post_id': postId, 'json': '1',
..._queryParameters, 'post_id': postId,
}, ..._queryParameters,
); },
return _commentsFromResponse(response.data, postId); );
return _commentsFromResponse(response.data, postId);
} catch (_) {
return const [];
}
} }
@override @override
@@ -161,33 +175,39 @@ class GelbooruProvider
{int limit = 20}) async { {int limit = 20}) async {
final trimmed = query.trim(); final trimmed = query.trim();
if (trimmed.isEmpty) return const []; if (trimmed.isEmpty) return const [];
final response = await _dio.get<dynamic>( try {
'/index.php', final response = await _dio.get<dynamic>(
queryParameters: { '/index.php',
'page': 'dapi', queryParameters: {
's': 'tag', 'page': 'dapi',
'q': 'index', 's': 'tag',
'json': '1', 'q': 'index',
'name_pattern': '$trimmed*', 'json': '1',
'limit': limit.clamp(1, 100), 'name_pattern': '$trimmed%',
..._queryParameters, 'orderby': 'count',
}, 'order': 'DESC',
); 'limit': limit.clamp(1, 100),
return _tagItems(response.data) ..._queryParameters,
.whereType<Map>() },
.map((item) { );
final json = Map<String, dynamic>.from(item); return _tagItems(response.data)
return TagSuggestion( .whereType<Map>()
name: (json['name'] ?? '').toString(), .map((item) {
category: tagCategoryFromString( final json = Map<String, dynamic>.from(item);
(json['type'] ?? json['tag_type'] ?? json['category']).toString(), return TagSuggestion(
), name: (json['name'] ?? '').toString(),
postCount: _int(json['count'] ?? json['post_count']), category: tagCategoryFromString(
providerId: id, (json['type'] ?? json['tag_type'] ?? json['category']).toString(),
); ),
}) postCount: _int(json['count'] ?? json['post_count']),
.where((tag) => tag.name.isNotEmpty) providerId: id,
.toList(growable: false); );
})
.where((tag) => tag.name.isNotEmpty)
.toList(growable: false);
} catch (_) {
return const [];
}
} }
@override @override
@@ -201,27 +221,81 @@ class GelbooruProvider
if (cleaned.isEmpty) return const {}; if (cleaned.isEmpty) return const {};
final groups = <String, List<String>>{}; final groups = <String, List<String>>{};
for (final tag in cleaned) { final tagToCategory = <String, TagCategory>{};
try {
final suggestions = await suggestTags(tag, limit: 3); // Batch query tag metadata using Gelbooru DAPI "names" parameter
final exact = suggestions.where((item) => item.name == tag).firstOrNull; try {
final category = exact?.category ?? TagCategory.general; for (var i = 0; i < cleaned.length; i += 50) {
final key = switch (category) { final chunk = cleaned.sublist(
TagCategory.artist => 'artist', i,
TagCategory.copyright => 'copyright', (i + 50 > cleaned.length) ? cleaned.length : i + 50,
TagCategory.character => 'character', );
TagCategory.meta => 'meta', final response = await _dio.get<dynamic>(
TagCategory.species => 'species', '/index.php',
TagCategory.general || TagCategory.unknown => 'general', queryParameters: {
}; 'page': 'dapi',
groups.putIfAbsent(key, () => <String>[]).add(tag); 's': 'tag',
} catch (_) { 'q': 'index',
groups.putIfAbsent('general', () => <String>[]).add(tag); 'json': '1',
'names': chunk.join(' '),
'limit': chunk.length.clamp(1, 100),
..._queryParameters,
},
);
for (final item in _tagItems(response.data).whereType<Map>()) {
final json = Map<String, dynamic>.from(item);
final name = (json['name'] ?? '').toString().trim();
if (name.isNotEmpty) {
tagToCategory[name] = tagCategoryFromString(
(json['type'] ?? json['tag_type'] ?? json['category']).toString(),
);
}
}
} }
} catch (_) {
// Gracefully continue to fallback categorization
}
for (final tag in cleaned) {
final category = tagToCategory[tag] ?? TagCategory.general;
final key = switch (category) {
TagCategory.artist => 'artist',
TagCategory.copyright => 'copyright',
TagCategory.character => 'character',
TagCategory.meta => 'meta',
TagCategory.species => 'species',
TagCategory.general || TagCategory.unknown => 'general',
};
groups.putIfAbsent(key, () => <String>[]).add(tag);
} }
return groups; return groups;
} }
void _checkResponse(Response<dynamic> response) {
if (response.data is String) {
final text = (response.data as String).toLowerCase();
if (text.contains('throttled') ||
text.contains('authentication required') ||
text.contains('requires authentication')) {
unavailable(
'$name requires authentication or is throttled. '
'Please configure API Key & User ID in Settings -> Providers.',
);
}
}
}
Never _handleDioError(DioException error) {
final status = error.response?.statusCode;
if (status == 401 || status == 403 || status == 429) {
unavailable(
'$name requires authentication or rate limit exceeded ($status). '
'Please enter your API Key and User ID in Settings -> Providers.',
);
}
throw error;
}
Never unavailable(String message) => Never unavailable(String message) =>
throw ProviderUnavailableException(message); throw ProviderUnavailableException(message);
+140 -22
View File
@@ -1,5 +1,8 @@
import 'package:dio/dio.dart';
import '../mappers/rule34_mapper.dart'; import '../mappers/rule34_mapper.dart';
import '../models/post.dart'; import '../models/post.dart';
import '../models/tag_suggestion.dart';
import '../models/top_period_filter.dart'; import '../models/top_period_filter.dart';
import 'gelbooru_provider.dart'; import 'gelbooru_provider.dart';
@@ -36,27 +39,142 @@ class Rule34Provider extends GelbooruProvider {
TopPeriodFilter.month || TopPeriodFilter.year => const <String>[], TopPeriodFilter.month || TopPeriodFilter.year => const <String>[],
TopPeriodFilter.allTime => const ['sort:score:desc'], TopPeriodFilter.allTime => const ['sort:score:desc'],
}; };
final response = await dio.get<dynamic>( try {
'/index.php', final response = await dio.get<dynamic>(
queryParameters: { '/index.php',
'page': 'dapi', queryParameters: {
's': 'post', 'page': 'dapi',
'q': 'index', 's': 'post',
'json': '1', 'q': 'index',
'pid': page, 'json': '1',
'limit': limit, 'pid': page,
'tags': [ 'limit': limit,
...tags, 'fields': 'tag_info',
if (rating != null) 'rating:$rating', 'tags': [
...topTags, ...tags,
].join(' '), if (rating != null) 'rating:$rating',
...queryParameters, ...topTags,
}, ].join(' '),
); ...queryParameters,
return Rule34Mapper.postsFromResponse( },
response.data, );
providerId: id, _checkRule34Response(response);
providerName: name, return Rule34Mapper.postsFromResponse(
); response.data,
providerId: id,
providerName: name,
);
} on DioException catch (e) {
_handleRule34DioError(e);
}
}
@override
Future<Post?> getPost(String id) async {
try {
final response = await dio.get<dynamic>(
'/index.php',
queryParameters: {
'page': 'dapi',
's': 'post',
'q': 'index',
'json': '1',
'id': id,
'fields': 'tag_info',
...queryParameters,
},
);
_checkRule34Response(response);
final posts = Rule34Mapper.postsFromResponse(
response.data,
providerId: this.id,
providerName: name,
);
return posts.isEmpty ? null : posts.first;
} on DioException catch (e) {
_handleRule34DioError(e);
}
}
@override
Future<List<TagSuggestion>> suggestTags(String query,
{int limit = 20}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
try {
// Official Rule34 autocomplete endpoint (fast, public, with exact post count)
final response = await dio.get<dynamic>(
'/autocomplete.php',
queryParameters: {
'q': trimmed,
},
);
if (response.data is List) {
final list = response.data as List;
final suggestions = list
.whereType<Map>()
.map((item) {
final json = Map<String, dynamic>.from(item);
final rawLabel = (json['label'] ?? '').toString();
final rawValue = (json['value'] ?? '').toString();
// Parse label "braid (216984)" -> name: "braid", postCount: 216984
final match =
RegExp(r'^(.*?)\s*\((\d+)\)$').firstMatch(rawLabel);
final name = (match?.group(1) ?? rawValue).trim();
final postCount = int.tryParse(match?.group(2) ?? '') ?? 0;
return TagSuggestion(
name: name.isNotEmpty ? name : rawValue,
category: TagCategory.general,
postCount: postCount,
providerId: id,
);
})
.where((tag) => tag.name.isNotEmpty)
.take(limit)
.toList(growable: false);
if (suggestions.isNotEmpty) return suggestions;
}
} catch (_) {
// Gracefully fall back to standard DAPI tag search
}
return super.suggestTags(query, limit: limit);
}
void _checkRule34Response(Response<dynamic> response) {
if (response.data is String) {
final text = (response.data as String).toLowerCase();
if (text.contains('missing authentication') ||
text.contains('throttled') ||
text.contains('authentication required')) {
unavailable(
'$name requires authentication. '
'Please configure your API Key and User ID in Settings -> Providers '
'(obtain from https://rule34.xxx/index.php?page=account&s=options).',
);
}
if (text.contains('search down') ||
text.contains('server is overloaded')) {
unavailable(
'$name searcher is temporarily down or overloaded. Please try again in a few minutes.',
);
}
} else if (response.data is Map) {
final map = response.data as Map;
if (map['success'] == false || map['success'] == 'false') {
final msg = map['message'] ?? 'search down';
unavailable('$name error: $msg');
}
}
}
Never _handleRule34DioError(DioException error) {
final status = error.response?.statusCode;
if (status == 401 || status == 403 || status == 429) {
unavailable(
'$name requires authentication or rate limit reached ($status). '
'Please enter your API Key and User ID in Settings -> Providers.',
);
}
throw error;
} }
} }
@@ -129,7 +129,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _apiKey, controller: _apiKey,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'API key', labelText: 'API key',
helperText: 'Gelbooru: api_key, Danbooru: api_key', helperText:
'Gelbooru/Rule34/Danbooru API key (Account -> Options)',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -137,7 +138,8 @@ class _ProviderFormScreenState extends ConsumerState<ProviderFormScreen> {
controller: _userId, controller: _userId,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'User ID', labelText: 'User ID',
helperText: 'Gelbooru-compatible APIs may require user_id', helperText:
'Gelbooru/Rule34 User ID (required for API access, see Options)',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
+75 -29
View File
@@ -64,10 +64,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_focusNode = FocusNode(); _focusNode = FocusNode();
_tagScrollController = ScrollController(); _tagScrollController = ScrollController();
_focusNode.addListener(_handleFocusChanged); _focusNode.addListener(_handleFocusChanged);
_controller.addListener(_handleControllerChanged);
_lastExternalValue = widget.initialValue ?? ''; _lastExternalValue = widget.initialValue ?? '';
_setFromQuery(_lastExternalValue); _setFromQuery(_lastExternalValue);
} }
void _handleControllerChanged() {
if (_controller.text.trim().isEmpty && _suggestionsOverlay != null) {
_removeSuggestionsOverlay();
}
}
@override @override
void didUpdateWidget(covariant TagInputSearchBar oldWidget) { void didUpdateWidget(covariant TagInputSearchBar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -89,6 +96,7 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
_debounce?.cancel(); _debounce?.cancel();
_removeSuggestionsOverlay(); _removeSuggestionsOverlay();
_tagScrollController.dispose(); _tagScrollController.dispose();
_controller.removeListener(_handleControllerChanged);
_focusNode.removeListener(_handleFocusChanged); _focusNode.removeListener(_handleFocusChanged);
_focusNode.dispose(); _focusNode.dispose();
_controller.dispose(); _controller.dispose();
@@ -97,15 +105,19 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleFocusChanged() { void _handleFocusChanged() {
if (!mounted) return; if (!mounted) return;
if (!_focusNode.hasFocus && !_localDirty) { if (!_focusNode.hasFocus) {
final next = widget.initialValue ?? ''; _removeSuggestionsOverlay();
if (next != _lastExternalValue) { if (!_localDirty) {
_setFromQuery(next); final next = widget.initialValue ?? '';
_lastExternalValue = next; if (next != _lastExternalValue) {
_setFromQuery(next);
_lastExternalValue = next;
}
} }
} else {
_syncSuggestionsOverlay();
} }
setState(() {}); setState(() {});
_syncSuggestionsOverlay();
} }
@override @override
@@ -209,17 +221,24 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _syncSuggestionsOverlay() { void _syncSuggestionsOverlay() {
final token = _activeToken; final token = _activeToken;
if (!_focusNode.hasFocus || token.isEmpty) {
_removeSuggestionsOverlay();
return;
}
final suggestions = widget.suggestions final suggestions = widget.suggestions
.where((item) => item.name.toLowerCase().startsWith(token)) .where((item) => item.name.toLowerCase().startsWith(token))
.toList(growable: false); .toList(growable: false);
if (!_focusNode.hasFocus || token.isEmpty || suggestions.isEmpty) { if (suggestions.isEmpty) {
_removeSuggestionsOverlay(); _removeSuggestionsOverlay();
return; return;
} }
final overlay = Overlay.maybeOf(context); final overlay = Overlay.maybeOf(context);
final renderBox = final renderBox =
_fieldKey.currentContext?.findRenderObject() as RenderBox?; _fieldKey.currentContext?.findRenderObject() as RenderBox?;
if (overlay == null || renderBox == null || !renderBox.hasSize) return; if (overlay == null || renderBox == null || !renderBox.hasSize) {
_removeSuggestionsOverlay();
return;
}
final size = renderBox.size; final size = renderBox.size;
_removeSuggestionsOverlay(); _removeSuggestionsOverlay();
@@ -231,14 +250,22 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
suggestions: suggestions, suggestions: suggestions,
query: token, query: token,
onSelected: _applySuggestion, onSelected: _applySuggestion,
onDismiss: () {
_removeSuggestionsOverlay();
_focusNode.unfocus();
},
), ),
); );
overlay.insert(_suggestionsOverlay!); overlay.insert(_suggestionsOverlay!);
} }
void _removeSuggestionsOverlay() { void _removeSuggestionsOverlay() {
_suggestionsOverlay?.remove(); if (_suggestionsOverlay != null) {
_suggestionsOverlay = null; if (_suggestionsOverlay!.mounted) {
_suggestionsOverlay!.remove();
}
_suggestionsOverlay = null;
}
} }
Widget _buildTagChip(BuildContext context, String tag) { Widget _buildTagChip(BuildContext context, String tag) {
@@ -302,10 +329,15 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _handleDraftChanged(String value) { void _handleDraftChanged(String value) {
_localDirty = true; _localDirty = true;
if (value.trim().isEmpty) {
_debounce?.cancel();
_removeSuggestionsOverlay();
setState(() {});
_notifyChanged();
return;
}
final composing = _controller.value.composing; final composing = _controller.value.composing;
if (!composing.isValid && if (!composing.isValid && RegExp(r'\s$').hasMatch(value)) {
value.isNotEmpty &&
RegExp(r'\s$').hasMatch(value)) {
_commitDraft(value); _commitDraft(value);
return; return;
} }
@@ -334,22 +366,24 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
void _submit() { void _submit() {
_debounce?.cancel(); _debounce?.cancel();
_commitDraft(_controller.text);
_removeSuggestionsOverlay(); _removeSuggestionsOverlay();
_commitDraft(_controller.text);
_lastExternalValue = _query; _lastExternalValue = _query;
_localDirty = false; _localDirty = false;
_focusNode.unfocus();
widget.onSubmitted(_query); widget.onSubmitted(_query);
} }
void _clear() { void _clear() {
_debounce?.cancel(); _debounce?.cancel();
_removeSuggestionsOverlay();
_localDirty = true; _localDirty = true;
setState(() { setState(() {
_tags = []; _tags = [];
_controller.clear(); _controller.clear();
}); });
_notifyChanged(); _notifyChanged();
_focusNode.requestFocus(); _focusNode.unfocus();
} }
void _editTag(String tag) { void _editTag(String tag) {
@@ -446,6 +480,7 @@ class _TagSuggestionOverlay extends StatelessWidget {
required this.suggestions, required this.suggestions,
required this.query, required this.query,
required this.onSelected, required this.onSelected,
required this.onDismiss,
}); });
final LayerLink link; final LayerLink link;
@@ -454,26 +489,37 @@ class _TagSuggestionOverlay extends StatelessWidget {
final List<TagSuggestion> suggestions; final List<TagSuggestion> suggestions;
final String query; final String query;
final ValueChanged<String> onSelected; final ValueChanged<String> onSelected;
final VoidCallback onDismiss;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Positioned.fill( return Stack(
child: CompositedTransformFollower( children: [
link: link, Positioned.fill(
showWhenUnlinked: false, child: GestureDetector(
offset: Offset(0, yOffset), behavior: HitTestBehavior.translucent,
child: Align( onTap: onDismiss,
alignment: Alignment.topLeft, ),
child: SizedBox( ),
width: width.clamp(280, 620).toDouble(), Positioned.fill(
child: _TagSuggestionDropdown( child: CompositedTransformFollower(
suggestions: suggestions, link: link,
query: query, showWhenUnlinked: false,
onSelected: onSelected, offset: Offset(0, yOffset),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: width.clamp(280, 620).toDouble(),
child: _TagSuggestionDropdown(
suggestions: suggestions,
query: query,
onSelected: onSelected,
),
),
), ),
), ),
), ),
), ],
); );
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: gel_rule_app name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app. description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none" publish_to: "none"
version: 2.3.1+41 version: 2.3.3+43
environment: environment:
sdk: ">=3.3.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/gelbooru_provider.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/core/http/dio_client.dart';
void main() {
test('ProviderFactory creates GelbooruProvider with query credentials', () {
final factory = ProviderFactory();
final config = ContentProviderConfig(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
apiType: 'gelbooru',
enabled: true,
priority: 10,
timeoutSeconds: 20,
customHeaders: const {
'query.api_key': 'test_api_key',
'query.user_id': '123456',
},
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
final provider = factory.create(config);
expect(provider, isA<GelbooruProvider>());
final gelbooru = provider as GelbooruProvider;
expect(gelbooru.id, 'gelbooru');
expect(gelbooru.queryParameters['api_key'], 'test_api_key');
expect(gelbooru.queryParameters['user_id'], '123456');
});
test('GelbooruProvider mediaHeaders contains Referer and User-Agent', () {
final client = DioClient(baseUrl: 'https://gelbooru.com');
final provider = GelbooruProvider(
id: 'gelbooru',
name: 'Gelbooru',
baseUrl: 'https://gelbooru.com',
dioClient: client,
);
expect(provider.baseUrl, 'https://gelbooru.com');
});
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gel_rule_app/backend/models/content_provider_config.dart';
import 'package:gel_rule_app/backend/providers/provider_factory.dart';
import 'package:gel_rule_app/backend/providers/rule34_provider.dart';
void main() {
test('ProviderFactory creates Rule34Provider with credentials', () {
final factory = ProviderFactory();
final config = ContentProviderConfig(
id: 'rule34',
name: 'Rule34',
baseUrl: 'https://api.rule34.xxx',
apiType: 'rule34',
enabled: true,
priority: 1,
timeoutSeconds: 20,
customHeaders: const {
'query.api_key': 'test_key',
'query.user_id': '99999',
},
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
);
final provider = factory.create(config);
expect(provider, isA<Rule34Provider>());
final r34 = provider as Rule34Provider;
expect(r34.id, 'rule34');
expect(r34.baseUrl, 'https://api.rule34.xxx');
expect(r34.queryParameters['api_key'], 'test_key');
expect(r34.queryParameters['user_id'], '99999');
});
}