add some new features

This commit is contained in:
2026-10-29 15:03:48 +01:00
parent c876405a8f
commit 20012db615
39 changed files with 2908 additions and 97 deletions
+286
View File
@@ -0,0 +1,286 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
import 'package:ertl_filament_verwaltung/controller/abfall_controller.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/abfall_berechnung.dart';
import 'package:ertl_filament_verwaltung/helper/storage_keys.dart';
import 'package:ertl_filament_verwaltung/models/abfall_model.dart';
import 'package:ertl_filament_verwaltung/models/bauteil_model.dart';
import 'package:ertl_filament_verwaltung/models/backup_model.dart';
import 'package:ertl_filament_verwaltung/models/kauf_filament_model.dart';
import 'package:ertl_filament_verwaltung/pages/abfall_view.dart';
import 'package:ertl_filament_verwaltung/pages/bauteil_view.dart';
import 'package:ertl_filament_verwaltung/services/get_storage_services.dart';
import 'package:ertl_filament_verwaltung/theme/app_theme.dart';
import 'helpers/memory_storage_service.dart';
void main() {
late MemoryStorageService storage;
KaufFilamentModel rolle(String id, double preis, String startgewicht) =>
KaufFilamentModel(
filamentId: id,
kaufdatum: DateTime(2026),
haendler: 'Shop',
marke: id,
material: 'PLA',
preis: preis,
farbe: id,
gewicht: startgewicht,
);
BauteilModel teil(
String id,
double abfall,
List<BauteilFilamentModel> filamente, {
double? kosten,
}) => BauteilModel(
bauteilId: id,
erstelltAm: DateTime(2026, 10, 29),
name: id,
verwendung: 'Test',
druckdauerMinuten: 30,
gewichtGramm: 20,
abfallGramm: abfall,
filamente: filamente,
abfallKosten: kosten,
);
setUp(() {
Get.testMode = true;
storage = MemoryStorageService();
storage.tables[StorageKeys.filamente] = [rolle('a', 15, '750').toJson()];
Get.put<GetStorageService>(storage);
});
tearDown(() => Get.reset());
Future<BauteilController> showParts(WidgetTester tester) async {
final controller = Get.put(BauteilController());
await tester.pumpWidget(
GetMaterialApp(theme: AppTheme.light, home: const BauteilView()),
);
await tester.pumpAndSettle();
return controller;
}
void fill(BauteilController controller, {double waste = 5}) {
controller.nameController.text = 'Halter';
controller.verwendungController.text = 'Werkstatt';
controller.minutenController.text = '30';
controller.gewichtController.text = '20';
controller.abfallController.text = '$waste';
controller.toggleFilament('a', true);
controller.verbrauchController('a').text = '${20 + waste}';
}
Future<void> edit(
WidgetTester tester,
BauteilController controller,
BauteilModel part,
) async {
final pending = controller.editBauteil(part);
await tester.pumpAndSettle();
await pending;
}
testWidgets(
'Part waste uses original 750g roll despite previous consumption; lifecycle reconciles once',
(tester) async {
// 600g already consumed: only 150g remain before the next print.
storage.tables[StorageKeys.bauteile] = [
teil('vorher', 0, [
const BauteilFilamentModel(filamentId: 'a', verbrauchGramm: 600),
]).toJson(),
];
storage.tables[StorageKeys.abfaelle] = [
AbfallModel(
abfallId: 'manual',
datumZeit: DateTime(2026),
gewichtGramm: 3,
kosten: 1,
).toJson(),
];
final home = Get.put(HomeController());
final waste = Get.put(AbfallController());
waste.kilopreisController.text =
'999'; // Manual rate must never price a part.
final controller = await showParts(tester);
fill(controller);
await controller.saveBauteil();
await tester.pumpAndSettle();
final saved = controller.bauteile.firstWhere(
(entry) => entry.name == 'Halter',
);
expect(saved.abfallKosten, .1); // 15 / 750 * 5, not 15 / 150 * 5.
expect(home.restgewicht(home.filamente.single), 125);
expect(waste.summeAbfall, 8);
expect(waste.summeKosten, closeTo(1.1, 1e-9));
waste.loadAbfaelle();
waste.loadAbfaelle();
expect(waste.abfaelle, hasLength(2));
expect(storage.getModelList(StorageKeys.abfaelle), hasLength(1));
final linked = waste.abfaelle.firstWhere((entry) => entry.ausBauteil);
await waste.deleteAbfall(linked.abfallId);
await waste.editAbfall(linked);
expect(waste.abfaelle, hasLength(2));
expect(waste.editingId.value, isNull);
await edit(tester, controller, saved);
controller.abfallController.text = '10';
controller.verbrauchController('a').text = '30';
await controller.saveBauteil();
await tester.pumpAndSettle();
expect(waste.summeAbfall, 13);
expect(waste.summeKosten, closeTo(1.2, 1e-9));
final changed = controller.bauteile.firstWhere(
(entry) => entry.bauteilId == saved.bauteilId,
);
await controller.duplicateBauteil(changed);
await tester.pumpAndSettle();
expect(waste.summeAbfall, 23);
expect(waste.summeKosten, closeTo(1.4, 1e-9));
final copy = controller.bauteile.firstWhere(
(entry) => entry.bauteilId != saved.bauteilId && entry.name == 'Halter',
);
await controller.deleteBauteil(copy.bauteilId);
await tester.pumpAndSettle();
expect(waste.summeAbfall, 13);
await edit(tester, controller, changed);
controller.abfallController.text = '0';
controller.verbrauchController('a').text = '20';
await controller.saveBauteil();
await tester.pumpAndSettle();
expect(waste.abfaelle.single.abfallId, 'manual');
expect(waste.summeAbfall, 3);
await controller.deleteBauteil(saved.bauteilId);
await tester.pumpAndSettle();
expect(waste.abfaelle.single.abfallId, 'manual');
expect(tester.takeException(), isNull);
},
);
testWidgets(
'Frozen waste cost survives price changes, metadata edits, removed rolls and backups',
(tester) async {
final waste = Get.put(AbfallController());
final controller = await showParts(tester);
fill(controller, waste: 25);
await controller.saveBauteil();
await tester.pumpAndSettle();
expect(waste.summeKosten, .5);
storage.tables[StorageKeys.filamente] = [rolle('a', 30, '750').toJson()];
await edit(tester, controller, controller.bauteile.single);
controller.nameController.text = 'Neuer Name';
await controller.saveBauteil();
await tester.pumpAndSettle();
expect(waste.summeKosten, .5);
storage.tables[StorageKeys.filamente] = [];
waste.loadAbfaelle();
expect(waste.summeKosten, .5);
expect(waste.abfaelle.single.kostenUnbekannt, isFalse);
final backup = BackupModel({
for (final key in BackupModel.keys) key: storage.getModelList(key),
});
final restored = BackupModel.decode(backup.encode());
expect(
BauteilModel.fromJson(restored.data[StorageKeys.bauteile]!.single)
.abfallKosten,
.5,
);
restored.data[StorageKeys.bauteile]!.single['abfallKosten'] = -1;
expect(
() => BackupModel.decode(BackupModel(restored.data).encode()),
throwsFormatException,
);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'Failed part writes create no waste and failed delete keeps linked waste',
(tester) async {
final waste = Get.put(AbfallController());
final controller = await showParts(tester);
fill(controller);
storage.failWrites = true;
await controller.saveBauteil();
await tester.pumpAndSettle();
expect(waste.abfaelle, isEmpty);
expect(controller.nameController.text, 'Halter');
storage.failWrites = false;
await controller.saveBauteil();
await tester.pumpAndSettle();
expect(waste.summeKosten, .1);
storage.failWrites = true;
final deleting = controller.deleteBauteil(
controller.bauteile.single.bauteilId,
);
await tester.pumpAndSettle();
await deleting;
expect(waste.summeKosten, .1);
expect(waste.summeAbfall, 5);
expect(tester.takeException(), isNull);
},
);
test(
'Multi-filament waste uses weighted roll prices and original weights',
() {
final part = teil('multi', 10, [
const BauteilFilamentModel(filamentId: 'a', verbrauchGramm: 60),
const BauteilFilamentModel(filamentId: 'b', verbrauchGramm: 40),
]);
// 6g at 20€/kg and 4g at 40€/kg = 0.28€.
expect(
AbfallBerechnung.kosten(part, [
rolle('a', 15, '0,75 kg'),
rolle('b', 20, '500'),
]),
.28,
);
expect(AbfallBerechnung.kosten(part, [rolle('a', 15, '750')]), isNull);
},
);
testWidgets(
'Existing parts appear with origin and unknown historical prices are labelled',
(tester) async {
storage.tables[StorageKeys.bauteile] = [
teil('Altbestand', 25, [
const BauteilFilamentModel(filamentId: 'a'),
]).toJson(),
teil('Fehlende Rolle', 5, [
const BauteilFilamentModel(filamentId: 'deleted'),
]).toJson(),
];
final waste = Get.put(AbfallController());
expect(waste.summeAbfall, 30);
expect(waste.summeKosten, .5);
expect(
waste.abfaelle.where((entry) => entry.kostenUnbekannt),
hasLength(1),
);
expect(
waste.abfaelle.where((entry) => entry.kostenNachtraeglich),
hasLength(1),
);
tester.view.physicalSize = const Size(320, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
GetMaterialApp(theme: AppTheme.light, home: const AbfallView()),
);
await tester.pumpAndSettle();
waste.scrollController.jumpTo(
waste.scrollController.position.maxScrollExtent,
);
await tester.pumpAndSettle();
expect(find.text('Abfallkosten: nicht ermittelbar'), findsOneWidget);
expect(find.byTooltip('Abfalleintrag verwalten'), findsNothing);
expect(tester.takeException(), isNull);
},
);
}