Files
2026-10-29 15:03:48 +01:00

468 lines
16 KiB
Dart

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<Uint8List?> 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<Uint8List> 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<GetStorageService>(storage);
home = Get.put(HomeController());
photos = TestPhotoService();
controller = Get.put(BauteilController(photoService: photos));
});
tearDown(() => Get.reset());
Future<void> 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<void> 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<void> add(String id) async {
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
expect(
find.text(
'Restgewicht: ${DisplayFormats.number(controller.restgewichtGramm(id)!)} g',
),
findsWidgets,
);
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<String>), 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<String>), 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();
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, 3);
expect(
storage
.getModelList(BauteilController.storageKey)
.single['gesamtpreis'],
3,
);
expect(controller.gesamtpreisController.text, isEmpty);
await edit(tester, controller.bauteile.single);
expect(controller.foto.value, png);
expect(controller.gesamtpreisController.text, '3,00');
controller.aufschlagController.text = '20';
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 == 2.4),
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, 2.4);
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(
'Markup rejects invalid amounts, accepts zero and price is always calculated',
(tester) async {
await show(tester);
fill();
for (final value in ['-1', 'NaN', 'Infinity', 'abc']) {
controller.aufschlagController.text = value;
await controller.saveBauteil();
expect(storage.getModelList(BauteilController.storageKey), isEmpty);
}
controller.aufschlagController.text = '0';
await controller.saveBauteil();
expect(controller.bauteile.single.gesamtpreis, 2);
await edit(tester, controller.bauteile.single);
controller.aufschlagController.text = '25';
await controller.saveBauteil();
expect(controller.bauteile.single.gesamtpreis, 2.5);
expect(
storage
.getModelList(BauteilController.storageKey)
.single['gesamtpreis'],
2.5,
);
},
);
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);
expect(controller.restgewichtGramm('a'), 40);
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();
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(3)}'),
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);
});
}