import 'dart:typed_data';
import 'package:consumer_app/features/image_suggestion/image_suggestion_page.dart';
import 'package:consumer_app/features/image_suggestion/image_suggestion_status_page.dart';
import 'package:consumer_app/core/errors/api_error_code.dart';
import 'package:consumer_app/features/detail/content_detail_page.dart';
import 'package:consumer_app/l10n/app_localizations.dart';
import 'package:consumer_app/models/content_models.dart';
import 'package:consumer_app/models/image_suggestion_models.dart';
import 'package:consumer_app/repositories/discovery_repository.dart';
import 'package:consumer_app/repositories/image_suggestion_repository.dart';
import 'package:consumer_app/features/image_suggestion/image_suggestion_labels.dart';
import 'package:consumer_app/shared/map/map_surface.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('requires all rights confirmations before submitting', (
tester,
) async {
FilePicker.platform = _FakeFilePicker();
addTearDown(() => FilePicker.platform = _FakeFilePicker());
final repository = _SuccessfulImageSuggestionRepository();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: ImageSuggestionPage(
item: _item('places'),
repository: repository,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(OutlinedButton, 'Bild auswählen'));
await tester.pumpAndSettle();
final submitLabel = find.text('Zur Prüfung einreichen');
final pageScroll = find.byType(ListView);
for (var index = 0; index < 8 && submitLabel.evaluate().isEmpty; index++) {
await tester.drag(pageScroll, const Offset(0, -480));
await tester.pumpAndSettle();
}
expect(submitLabel, findsOneWidget);
final submitFinder = find.ancestor(
of: submitLabel,
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
);
expect(tester.widget<ButtonStyleButton>(submitFinder).onPressed, isNull);
final rightsList = find.byType(CheckboxListTile);
for (var index = 0; index < 4; index++) {
await tester.ensureVisible(rightsList.at(index));
await tester.pumpAndSettle();
await tester.tap(rightsList.at(index));
}
await tester.ensureVisible(submitLabel);
await tester.pumpAndSettle();
await tester.pump();
expect(tester.widget<ButtonStyleButton>(submitFinder).onPressed, isNotNull);
await tester.tap(submitLabel);
await tester.pumpAndSettle();
expect(find.text('Bildvorschlag eingereicht'), findsOneWidget);
expect(repository.submittedFilename, 'suggested.jpg');
});
testWidgets('shows the CTA for a service without an image', (tester) async {
final item = _item('services');
await tester.pumpWidget(_detailApp(item));
await tester.pumpAndSettle();
expect(find.text('Bild vorschlagen'), findsAtLeastNWidgets(1));
});
testWidgets(
'shows the CTA for an explicitly marked placeholder without media',
(tester) async {
final item = DiscoveryItem.fromJson({
'id': 7,
'resource': 'places',
'title': 'Vorläufiger Eintrag',
'media_placeholder': true,
'image_url': '',
'media': <Map<String, dynamic>>[],
});
await tester.pumpWidget(_detailApp(item));
await tester.pumpAndSettle();
expect(find.text('Bild vorschlagen'), findsAtLeastNWidgets(1));
},
);
testWidgets('shows the CTA beside an attached placeholder image', (
tester,
) async {
final item = DiscoveryItem.fromJson({
'id': 7,
'resource': 'places',
'title': 'Vorläufiges Bild',
'image_url': 'https://example.test/placeholder.jpg',
'media': [
{
'id': 8,
'url': 'https://example.test/placeholder.jpg',
'is_placeholder': true,
},
],
});
await tester.pumpWidget(_detailApp(item));
await tester.pumpAndSettle();
expect(find.text('Bild vorschlagen'), findsAtLeastNWidgets(1));
});
testWidgets('does not show the CTA for a job without an image', (
tester,
) async {
final item = _item('jobs');
await tester.pumpWidget(_detailApp(item));
await tester.pumpAndSettle();
expect(find.text('Bild vorschlagen'), findsNothing);
});
testWidgets('detail map does not duplicate the object as a search center', (
tester,
) async {
final item = DiscoveryItem.fromJson({
'id': 453,
'resource': 'places',
'title': 'Felsenkeller',
'geo': {'available': true, 'lat': 50.9795, 'lng': 11.3235},
});
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: ContentDetailPage(
item: item,
repository: _FakeDiscoveryRepository(item),
onOpenMap: () {},
),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView).first, const Offset(0, -1200));
await tester.pumpAndSettle();
expect(find.byType(MapSurface), findsOneWidget);
final map = tester.widget<MapSurface>(find.byType(MapSurface));
expect(map.initialSelectedItem?.id, 453);
expect(map.searchLatitude, isNull);
expect(map.searchLongitude, isNull);
});
testWidgets('blocks a direct upload page for an unsupported target', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: ImageSuggestionPage(
item: _item('news'),
repository: _FakeImageSuggestionRepository(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Bildvorschlag nicht verfügbar'), findsOneWidget);
expect(find.text('Zur Prüfung einreichen'), findsNothing);
});
testWidgets('does not show the CTA for excluded content types', (
tester,
) async {
for (final resource in ['news', 'guides']) {
await tester.pumpWidget(_detailApp(_item(resource)));
await tester.pumpAndSettle();
expect(
find.text('Bild vorschlagen'),
findsNothing,
reason: 'unexpected image suggestion CTA for $resource',
);
}
});
testWidgets('localizes the services image suggestion target type', (
tester,
) async {
final item = DiscoveryItem.fromJson({
'id': 7,
'resource': 'services',
'title': 'Beispiel-Einrichtung',
});
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: ImageSuggestionPage(
item: item,
repository: _FakeImageSuggestionRepository(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Ziel: Service oder Einrichtung'), findsOneWidget);
});
test('restricts image suggestions to supported public content types', () {
expect(isImageSuggestionTargetAllowed('places'), isTrue);
expect(isImageSuggestionTargetAllowed('services'), isTrue);
expect(isImageSuggestionTargetAllowed('events'), isFalse);
expect(isImageSuggestionTargetAllowed('classifieds'), isFalse);
expect(isImageSuggestionTargetAllowed('jobs'), isFalse);
expect(isImageSuggestionTargetAllowed('real-estate'), isFalse);
expect(isImageSuggestionTargetAllowed('news'), isFalse);
expect(isImageSuggestionTargetAllowed('guides'), isFalse);
});
testWidgets('localizes service-unavailable response errors', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: ImageSuggestionStatusPage(repository: _ResponseErrorRepository()),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Rückfrage beantworten'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Weitere Angaben');
await tester.tap(find.text('Antwort senden'));
await tester.pumpAndSettle();
expect(
find.text('Dieser Dienst ist momentan nicht verfügbar.'),
findsOneWidget,
);
});
}
DiscoveryItem _item(String resource) {
return DiscoveryItem.fromJson({
'id': 7,
'resource': resource,
'title': 'Testeintrag',
'image_url': '',
'media': <Map<String, dynamic>>[],
});
}
Widget _detailApp(DiscoveryItem item) {
return MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
home: ContentDetailPage(
item: item,
repository: _FakeDiscoveryRepository(item),
),
);
}
class _FakeImageSuggestionRepository implements ImageSuggestionRepository {
@override
Future<ImageSuggestionData> submit({
required String targetType,
required int targetId,
required List<int> bytes,
required String filename,
required bool rightsHolderConfirmed,
required bool lawfulSourceConfirmed,
required bool personsConsentConfirmed,
required bool platformLicenseConfirmed,
required String idempotencyKey,
String source = '',
String license = '',
String photographer = '',
String description = '',
}) => throw UnimplementedError();
@override
Future<ImageSuggestionList> own({String? status}) async {
return const ImageSuggestionList([]);
}
@override
Future<ImageSuggestionData> respond({
required int suggestionId,
required String response,
required String idempotencyKey,
}) => throw UnimplementedError();
}
class _SuccessfulImageSuggestionRepository
extends _FakeImageSuggestionRepository {
String? submittedFilename;
@override
Future<ImageSuggestionData> submit({
required String targetType,
required int targetId,
required List<int> bytes,
required String filename,
required bool rightsHolderConfirmed,
required bool lawfulSourceConfirmed,
required bool personsConsentConfirmed,
required bool platformLicenseConfirmed,
required String idempotencyKey,
String source = '',
String license = '',
String photographer = '',
String description = '',
}) async {
submittedFilename = filename;
return const ImageSuggestionData(
id: 10,
targetType: 'places',
targetId: 7,
status: 'submitted',
originalFilename: 'suggested.jpg',
createdAt: '',
updatedAt: '',
reasonCode: '',
mediaCandidateId: 0,
technicalStatus: 'pending',
malwareStatus: 'pending',
);
}
}
class _FakeFilePicker extends FilePicker {
@override
Future<FilePickerResult?> pickFiles({
String? dialogTitle,
String? initialDirectory,
FileType type = FileType.any,
List<String>? allowedExtensions,
Function(FilePickerStatus)? onFileLoading,
bool allowCompression = true,
int compressionQuality = 30,
bool allowMultiple = false,
bool withData = false,
bool withReadStream = false,
bool lockParentWindow = false,
bool readSequential = false,
}) async {
return FilePickerResult([
PlatformFile(
name: 'suggested.jpg',
size: 3,
bytes: Uint8List.fromList(const [1, 2, 3]),
),
]);
}
}
class _ResponseErrorRepository implements ImageSuggestionRepository {
@override
Future<ImageSuggestionData> submit({
required String targetType,
required int targetId,
required List<int> bytes,
required String filename,
required bool rightsHolderConfirmed,
required bool lawfulSourceConfirmed,
required bool personsConsentConfirmed,
required bool platformLicenseConfirmed,
required String idempotencyKey,
String source = '',
String license = '',
String photographer = '',
String description = '',
}) => throw UnimplementedError();
@override
Future<ImageSuggestionList> own({String? status}) async {
return ImageSuggestionList([
ImageSuggestionData(
id: 8,
targetType: 'places',
targetId: 453,
status: 'needs_info',
originalFilename: 'felsenkeller.jpg',
createdAt: '',
updatedAt: '',
reasonCode: 'needs_info',
mediaCandidateId: 0,
technicalStatus: 'passed',
malwareStatus: 'passed',
requestMessage: 'Bitte ergänze die Quelle.',
),
]);
}
@override
Future<ImageSuggestionData> respond({
required int suggestionId,
required String response,
required String idempotencyKey,
}) => throw const ApiException(
ApiErrorCode.serviceUnavailable,
statusCode: 503,
serverCode: 'meinweimar_image_suggestion_storage_unavailable',
);
}
class _FakeDiscoveryRepository implements DiscoveryRepository {
const _FakeDiscoveryRepository(this.item);
final DiscoveryItem item;
@override
Future<DiscoveryResponse> discover({
required List<String> types,
String? search,
double? latitude,
double? longitude,
double? radiusKm = 25,
int page = 1,
int perPage = 20,
String? category,
String? region,
String? status,
Map<String, String> filters = const {},
String sort = 'relevance',
double? minLatitude,
double? maxLatitude,
double? minLongitude,
double? maxLongitude,
}) async {
return DiscoveryResponse(
items: [item],
meta: DiscoveryMeta.fromJson(const {}),
);
}
@override
Future<DiscoveryItem> detail({
required String resource,
required int id,
}) async {
return item;
}
}
