import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; import 'package:ertl_filament_verwaltung/controller/bauteil_controller.dart'; import 'package:ertl_filament_verwaltung/controller/home_controller.dart'; import 'package:ertl_filament_verwaltung/helper/display_formats.dart'; import 'package:ertl_filament_verwaltung/models/bauteil_model.dart'; import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart'; import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart'; import 'package:ertl_filament_verwaltung/pages/home_view.dart'; import 'package:ertl_filament_verwaltung/services/bauteil_photo_service.dart'; import 'package:ertl_filament_verwaltung/services/get_storage_services.dart'; import 'package:ertl_filament_verwaltung/theme/app_theme.dart'; import 'package:ertl_filament_verwaltung/widgets/bauteil_card.dart'; import 'package:ertl_filament_verwaltung/widgets/bauteil_filament_auswahl.dart'; import 'package:ertl_filament_verwaltung/widgets/bauteil_photo.dart'; import 'helpers/memory_storage_service.dart'; class TestPhotoService extends BauteilPhotoService { Uint8List? result; bool fail = false; @override Future pickPhoto() async { if (fail) throw const FormatException('Ungültiges Foto'); return result; } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late MemoryStorageService storage; late HomeController home; late BauteilController controller; late TestPhotoService photos; late Uint8List png; Future createPng(int width, int height) async { final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder); canvas.drawRect( ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), ui.Paint()..color = const Color(0xFF007F78), ); final picture = recorder.endRecording(); final image = await picture.toImage(width, height); final data = await image.toByteData(format: ui.ImageByteFormat.png); image.dispose(); picture.dispose(); return data!.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); } setUpAll(() async { png = await createPng(2, 2); }); KaufFilamentModel spool(String id, String weight) => KaufFilamentModel( filamentId: id, kaufdatum: DateTime(2026, 10, 27), haendler: 'Shop', marke: 'Marke $id', material: 'PLA', preis: 20, farbe: id, gewicht: weight, ); setUp(() { Get.testMode = true; storage = MemoryStorageService(); storage.tables[HomeController.storageKey] = [ spool('a', '1kg').toJson(), spool('b', '1000').toJson(), ]; Get.put(storage); home = Get.put(HomeController()); photos = TestPhotoService(); controller = Get.put(BauteilController(photoService: photos)); }); tearDown(() => Get.reset()); Future show(WidgetTester tester, {bool narrow = false}) async { tester.view.physicalSize = narrow ? const Size(320, 640) : const Size(1100, 1600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( GetMaterialApp(theme: AppTheme.light, home: const BauteilView()), ); await tester.pumpAndSettle(); } void fill({double a = 60, double b = 40}) { controller.nameController.text = 'Testbauteil'; controller.verwendungController.text = 'Werkstatt'; controller.stundenController.text = '1'; controller.minutenController.text = '10'; controller.gewichtController.text = '${a + b - 10}'; controller.abfallController.text = '10'; if (a > 0) { controller.toggleFilament('a', true); controller.verbrauchController('a').text = '$a'; } if (b > 0) { controller.toggleFilament('b', true); controller.verbrauchController('b').text = '$b'; } } Future edit(WidgetTester tester, BauteilModel entry) async { final pending = controller.editBauteil(entry); await tester.pumpAndSettle(); await pending; } double remaining(String id) => home.restgewicht( home.filamente.firstWhere((item) => item.filamentId == id), )!; testWidgets('Dropdown adds only selected filaments and supports removal', ( tester, ) async { await tester.pumpWidget( GetMaterialApp( theme: AppTheme.light, home: Scaffold( body: SingleChildScrollView( child: BauteilFilamentAuswahl(controller: controller), ), ), ), ); expect(find.byKey(const ValueKey('filament-a')), findsNothing); expect(find.byKey(const ValueKey('filament-b')), findsNothing); Future add(String id) async { await tester.tap(find.byType(DropdownButtonFormField)); await tester.pumpAndSettle(); await tester.tap(find.text(controller.filamentLabel(id)).last); await tester.pumpAndSettle(); } await add('a'); controller.verbrauchController('a').text = '25'; expect(find.byKey(const ValueKey('filament-a')), findsOneWidget); expect(find.byKey(const ValueKey('filament-b')), findsNothing); expect(controller.selectedFilamente.keys, ['a']); await add('b'); expect(find.byType(DropdownButtonFormField), findsNothing); expect(find.byKey(const ValueKey('filament-b')), findsOneWidget); await tester.tap(find.byTooltip('Filament entfernen').first); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('filament-a')), findsNothing); expect(controller.selectedFilamente.keys, ['b']); await add('a'); expect(controller.verbrauchController('a').text, '25'); controller.resetForm(); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('filament-a')), findsNothing); expect(find.byKey(const ValueKey('filament-b')), findsNothing); expect(find.byType(DropdownButtonFormField), findsOneWidget); expect(tester.takeException(), isNull); }); test( 'Legacy records retain unknown usage and old weight units are supported', () { expect(spool('a', '1kg').gewichtGramm, 1000); expect(spool('a', '0,75 KG').gewichtGramm, 750); expect(spool('a', '500 g').gewichtGramm, 500); expect(spool('a', 'NaN').gewichtGramm, isNull); final legacy = BauteilModel.fromJson({ 'bauteilId': 'old', 'erstelltAm': DateTime.now().toIso8601String(), 'name': 'Altbestand', 'verwendung': 'Test', 'druckdauerMinuten': 30, 'gewichtGramm': 10, 'abfallGramm': 2, 'filamente': [ {'filamentId': 'a', 'anwendungId': null}, ], }); expect(legacy.filamente.single.verbrauchGramm, 0); expect(legacy.fotoBytes, isNull); expect(legacy.gesamtpreis, isNull); }, ); testWidgets( 'Create, edit, duplicate and delete reconcile stock and survive reload', (tester) async { await show(tester); fill(); controller.gesamtpreisController.text = '12,50'; photos.result = png; await controller.pickPhoto(); await controller.saveBauteil(); expect(remaining('a'), 940); expect(remaining('b'), 960); final originalId = controller.bauteile.single.bauteilId; final originalDate = controller.bauteile.single.erstelltAm; expect(controller.bauteile.single.gesamtpreis, 12.5); expect( storage .getModelList(BauteilController.storageKey) .single['gesamtpreis'], 12.5, ); expect(controller.gesamtpreisController.text, isEmpty); await edit(tester, controller.bauteile.single); expect(controller.foto.value, png); expect(controller.gesamtpreisController.text, '12,5'); controller.gesamtpreisController.text = '18,90'; expect(controller.verfuegbarGramm('a'), 1000); controller.verbrauchController('a').text = '20'; controller.verbrauchController('b').text = '80'; await controller.saveBauteil(); expect(controller.bauteile.single.bauteilId, originalId); expect(controller.bauteile.single.erstelltAm, originalDate); expect(remaining('a'), 980); expect(remaining('b'), 920); await controller.duplicateBauteil(controller.bauteile.single); expect(controller.bauteile, hasLength(2)); expect( controller.bauteile.map((item) => item.bauteilId).toSet(), hasLength(2), ); expect( controller.bauteile.every((item) => item.fotoBase64 != null), isTrue, ); expect( controller.bauteile.every((item) => item.gesamtpreis == 18.9), isTrue, ); expect(remaining('a'), 960); expect(remaining('b'), 840); await controller.deleteBauteil(originalId); expect(remaining('a'), 980); expect(remaining('b'), 920); home.loadFilamente(); expect(remaining('a'), 980); final reloaded = Get.put(BauteilController(), tag: 'reload'); expect(reloaded.bauteile.single.fotoBytes, png); expect(reloaded.bauteile.single.gesamtpreis, 18.9); expect(reloaded.bauteile.single.filamente.last.verbrauchGramm, 80); // Purchases retain the original weight; the persisted ledger is deducted. expect( storage.getModelList(HomeController.storageKey).first['gewicht'], '1kg', ); }, ); testWidgets( 'Overdraw, missing usage and mismatched totals cannot change stock', (tester) async { await show(tester); fill(a: 1001, b: 0); await controller.saveBauteil(); expect(controller.hasError.value, isTrue); expect(controller.message.value, contains('1000 g verfügbar')); expect(remaining('a'), 1000); controller.verbrauchController('a').text = '1'; await controller.saveBauteil(); expect(controller.message.value, contains('Bauteilgewicht + Abfall')); controller.verbrauchController('a').text = ''; await controller.saveBauteil(); expect(controller.bauteile, isEmpty); expect(storage.getModelList(BauteilController.storageKey), isEmpty); }, ); testWidgets( 'Total price rejects invalid amounts, accepts zero and can be cleared', (tester) async { await show(tester); fill(); for (final value in ['-1', 'NaN', 'Infinity', 'abc']) { controller.gesamtpreisController.text = value; await controller.saveBauteil(); expect(storage.getModelList(BauteilController.storageKey), isEmpty); } controller.gesamtpreisController.text = '0'; await controller.saveBauteil(); expect(controller.bauteile.single.gesamtpreis, 0); await edit(tester, controller.bauteile.single); controller.gesamtpreisController.clear(); await controller.saveBauteil(); expect(controller.bauteile.single.gesamtpreis, isNull); expect( storage .getModelList(BauteilController.storageKey) .single['gesamtpreis'], isNull, ); }, ); testWidgets( 'Editing credits only the edited booking; duplicates need new stock', (tester) async { await show(tester); fill(a: 900, b: 0); await controller.saveBauteil(); final first = controller.bauteile.single; fill(a: 60, b: 0); await controller.saveBauteil(); expect(remaining('a'), 40); await edit(tester, first); expect(controller.verfuegbarGramm('a'), 940); controller.gewichtController.text = '940'; controller.verbrauchController('a').text = '950'; await controller.saveBauteil(); expect(controller.message.value, contains('940 g verfügbar')); expect(remaining('a'), 40); await controller.duplicateBauteil(first); expect(controller.bauteile, hasLength(2)); expect(remaining('a'), 40); }, ); testWidgets( 'Failed edit, duplicate and delete leave ledger and stocks unchanged', (tester) async { await show(tester); fill(); await controller.saveBauteil(); final entry = controller.bauteile.single; await edit(tester, entry); controller.verbrauchController('a').text = '50'; controller.verbrauchController('b').text = '50'; storage.failWrites = true; await controller.saveBauteil(); expect(controller.hasError.value, isTrue); expect(controller.editingId.value, entry.bauteilId); expect(controller.verbrauchController('a').text, '50'); expect(remaining('a'), 940); await controller.duplicateBauteil(entry); expect(controller.bauteile, hasLength(1)); await controller.deleteBauteil(entry.bauteilId); expect(controller.bauteile, hasLength(1)); expect(remaining('b'), 960); expect( storage .getModelList(BauteilController.storageKey) .single['filamente'][0]['verbrauchGramm'], 60, ); }, ); testWidgets( 'Photo selection, cancellation, errors and removal preserve the draft', (tester) async { await show(tester); photos.result = png; await controller.pickPhoto(); expect(controller.foto.value, png); photos.result = null; await controller.pickPhoto(); expect(controller.foto.value, png); photos.fail = true; await controller.pickPhoto(); expect(controller.foto.value, png); expect(controller.hasError.value, isTrue); expect(controller.isPickingPhoto.value, isFalse); controller.removePhoto(); expect(controller.foto.value, isNull); }, ); testWidgets( 'Native image processing converts valid images and rejects non-images', (tester) async { final service = BauteilPhotoService(); // Codec operations need real asynchronous work rather than the fake clock. final converted = await tester.runAsync(() => service.preparePhoto(png)); expect(converted, isNotEmpty); await expectLater( service.preparePhoto(Uint8List.fromList([1, 2, 3])), throwsA(anything), ); }, ); testWidgets( 'Narrow card shows thumbnail and menu; cancel and confirm deletion work', (tester) async { await show(tester, narrow: true); fill(); controller.gesamtpreisController.text = '12,50'; photos.result = png; await controller.pickPhoto(); await controller.saveBauteil(); await tester.pumpAndSettle(); await tester.scrollUntilVisible( find.byType(BauteilCard), 250, scrollable: find.byType(Scrollable).first, ); await tester.pumpAndSettle(); expect( find.text('Gesamtpreis ${DisplayFormats.currency(12.5)}'), findsOneWidget, ); expect( find.descendant( of: find.byType(BauteilCard), matching: find.byType(BauteilPhoto), ), findsOneWidget, ); await tester.ensureVisible(find.byTooltip('Bauteil verwalten')); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Bauteil verwalten')); await tester.pumpAndSettle(); await tester.tap(find.text('Löschen')); await tester.pumpAndSettle(); await tester.tap(find.text('Abbrechen')); await tester.pumpAndSettle(); expect(remaining('a'), 940); await tester.tap(find.byTooltip('Bauteil verwalten')); await tester.pumpAndSettle(); await tester.tap(find.text('Löschen')); await tester.pumpAndSettle(); await tester.tap(find.text('Löschen')); await tester.pumpAndSettle(); expect(controller.bauteile, isEmpty); expect(remaining('a'), 1000); expect(tester.takeException(), isNull); }, ); testWidgets('Home displays current remaining grams after saving a part', ( tester, ) async { await show(tester); fill(); await controller.saveBauteil(); await tester.pumpWidget( GetMaterialApp(theme: AppTheme.light, home: const HomeView()), ); await tester.pumpAndSettle(); expect(find.text('940 g verbleibend'), findsOneWidget); expect(find.text('960 g verbleibend'), findsOneWidget); expect(tester.takeException(), isNull); }); }