463 lines
16 KiB
Dart
463 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();
|
|
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();
|
|
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);
|
|
});
|
|
}
|