mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:07:40 +01:00
fix: clear every macro field before a prefill, add the phone's delivery button
`fillControllersFromDish` set the dish's name, four macros and `grams` but left `perGrams` untouched. A `100` left there by an earlier food-bank pick made `nutritionForPortion` read the dish's per-portion macros as per-100g and rescale them: one real entry on 2026-08-23 logged 472 kcal as 1217.8 (258/100). The PC never had this -- `_gatelock_delivery.py` calls `_clear_inputs()` first -- so the fix is that same discipline, as one `MacroControllers.clear()` call rather than a `perGrams.clear()` line a seventh field could forget. `fillFrom` now clears too, and `edit_entry_screen` routes through it instead of six hand-written assignments; its `initState` fill had the same shape. The old tests asserted controller *text* and passed throughout the bug, so the new ones assert the computed `Nutrition` -- verified failing (400 -> 1200.0) before the fix. `clear()` gets an enumerated test so a new field fails until it is cleared too. Also adds the log screen's "Today's delivery" button, matching the PC lock screen's affordance: explicit taps are unguarded per the Triggers table, still skip dishes already logged, and show the queue count. The whole action row moved to its own widget, which put `log_meal_screen.dart` back under the cap. The label is a FittedBox in an Expanded -- measured, since a Row clips an oversized label without ever throwing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQpCiPaSbyRpwsLkH3dgsX
This commit is contained in:
parent
c1ad2e2406
commit
c897279c8b
@ -41,6 +41,10 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
|
||||
super.initState();
|
||||
final e = widget.entry;
|
||||
_descController = TextEditingController(text: e.desc);
|
||||
// Same clear-before-fill discipline as `fillControllersFromDish`: the
|
||||
// entry's macros describe the whole eaten portion, so any reference
|
||||
// weight left in `perGrams` would rescale them on save.
|
||||
_macros.clear();
|
||||
_macros.kcal.text = e.kcal.toStringAsFixed(0);
|
||||
_macros.protein.text = e.proteinG.toStringAsFixed(0);
|
||||
_macros.carbs.text = e.carbsG.toStringAsFixed(0);
|
||||
@ -85,12 +89,10 @@ class _EditEntryScreenState extends State<EditEntryScreen> {
|
||||
|
||||
void _onSuggestionSelected(FoodSuggestion suggestion) {
|
||||
_descController.text = suggestion.name;
|
||||
_macros.kcal.text = suggestion.nutrition.kcal.toStringAsFixed(0);
|
||||
_macros.protein.text = suggestion.nutrition.proteinG.toStringAsFixed(0);
|
||||
_macros.carbs.text = suggestion.nutrition.carbsG.toStringAsFixed(0);
|
||||
_macros.fat.text = suggestion.nutrition.fatG.toStringAsFixed(0);
|
||||
_macros.perGrams.text = suggestion.nutrition.grams.toStringAsFixed(0);
|
||||
_macros.grams.text = suggestion.nutrition.grams.toStringAsFixed(0);
|
||||
// Routed through `fillFrom` rather than six hand-written assignments, so
|
||||
// this path and the log screen's cannot drift and neither can forget a
|
||||
// field. It clears first, so nothing survives from the previous pick.
|
||||
_macros.fillFrom(suggestion.nutrition);
|
||||
setState(() {
|
||||
_source = 'food bank';
|
||||
_suggestions = const [];
|
||||
|
||||
@ -20,6 +20,7 @@ library;
|
||||
|
||||
import 'package:diet_guard_app/models/kuchnia_dish.dart';
|
||||
import 'package:diet_guard_app/models/slot.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_import.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_queue.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_spread.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
@ -31,11 +32,24 @@ import 'package:flutter/material.dart';
|
||||
///
|
||||
/// Free function rather than a mixin method so the numeric formatting is
|
||||
/// testable without pumping a widget.
|
||||
///
|
||||
/// Clears every macro field first, mirroring `_gatelock_delivery.py`'s
|
||||
/// `self._clear_inputs()`. Dropping that step is what corrupted a real log
|
||||
/// entry on 2026-08-23: a dish carries *per-portion* macros and leaves
|
||||
/// [MacroControllers.perGrams] untouched, so a `100` left behind by an
|
||||
/// earlier food-bank pick made `nutritionForPortion` read them as *per-100g*
|
||||
/// and scale them by `grams / 100` -- 472 kcal logged as 1217.8.
|
||||
///
|
||||
/// The clear is a single call covering all six controllers rather than a
|
||||
/// `perGrams.clear()` line here, so a seventh field added later cannot be
|
||||
/// forgotten at this call site; `MacroControllers.clear()` is enumerated by
|
||||
/// its own test.
|
||||
void fillControllersFromDish(
|
||||
KuchniaDish dish,
|
||||
TextEditingController desc,
|
||||
MacroControllers macros,
|
||||
) {
|
||||
macros.clear();
|
||||
desc.text = dish.name;
|
||||
final (grams, values) = dishFieldValues(dish);
|
||||
macros.kcal.text = values.$1;
|
||||
@ -111,6 +125,72 @@ mixin LogMealKuchniaMixin<T extends StatefulWidget> on State<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads today's delivery on an **explicit** user tap, unguarded.
|
||||
///
|
||||
/// Deliberately [refreshDelivery], not [KuchniaQueueService.refreshOnce]:
|
||||
/// per `docs/kuchnia-wikinga.md`'s Triggers table, a user who asks gets a
|
||||
/// look, the same as the CLI and the settings button. The guard exists to
|
||||
/// rate-limit *automatic* triggers, and applying it here would make the
|
||||
/// button silently do nothing for the rest of the day after the startup
|
||||
/// load — the failure that has nothing to tap in the kitchen.
|
||||
///
|
||||
/// Still skips dishes already logged today (a dish logged on the PC must
|
||||
/// not be re-offered) and still records the fetch, so a successful tap
|
||||
/// also spares the automatic path a second walk.
|
||||
///
|
||||
/// Returns a line to show the user, or null when a dish was prefilled and
|
||||
/// the form speaks for itself.
|
||||
Future<String?> loadDeliveryOnTap() async {
|
||||
if (!KuchniaQueueService.isInitialized) return 'Catering is not ready yet.';
|
||||
final logged = await LogStorageService.instance.todayEntries();
|
||||
if (!mounted) return null;
|
||||
final result = await refreshDelivery(DateTime.now());
|
||||
if (!mounted) return null;
|
||||
if (!result.ok) return result.reason;
|
||||
if (result.dishes.isEmpty) return 'No delivery today.';
|
||||
await KuchniaQueueService.instance.recordFetched(DateTime.now());
|
||||
if (!mounted) return null;
|
||||
final ordered = dishesInSlotOrder(
|
||||
result.dishes,
|
||||
daySlots(MealScheduleService.current),
|
||||
);
|
||||
KuchniaQueueService.instance.offer(
|
||||
ordered,
|
||||
alreadyLogged: {
|
||||
for (final entry in logged) entry.desc.trim().toLowerCase(),
|
||||
},
|
||||
);
|
||||
if (KuchniaQueueService.next == null) {
|
||||
return 'All delivered dishes are already logged.';
|
||||
}
|
||||
prefillNextDish();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Whether an explicit delivery fetch is in flight.
|
||||
///
|
||||
/// Only guards against a double tap opening two logins at once -- it is not
|
||||
/// the daily guard, which an explicit tap deliberately bypasses.
|
||||
bool deliveryBusy = false;
|
||||
|
||||
/// Runs [loadDeliveryOnTap] and shows its outcome as a SnackBar.
|
||||
///
|
||||
/// Lives here rather than on the screen because `log_meal_screen.dart` is at
|
||||
/// the 250-line cap, and this is the mixin's own behaviour anyway.
|
||||
Future<void> loadDeliveryAndReport() async {
|
||||
setState(() => deliveryBusy = true);
|
||||
String? message;
|
||||
try {
|
||||
message = await loadDeliveryOnTap();
|
||||
} finally {
|
||||
if (mounted) setState(() => deliveryBusy = false);
|
||||
}
|
||||
if (!mounted || message == null) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
/// Prefills the form with the next queued dish, if there is one.
|
||||
///
|
||||
/// Safe to call when the queue is empty: it simply clears the offer.
|
||||
|
||||
@ -18,6 +18,7 @@ import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:diet_guard_app/widgets/autocomplete_suggestion_list.dart';
|
||||
import 'package:diet_guard_app/widgets/log_meal_actions_row.dart';
|
||||
import 'package:diet_guard_app/widgets/macro_input_row.dart';
|
||||
import 'package:diet_guard_app/widgets/slot_selector_row.dart';
|
||||
import 'package:diet_guard_app/widgets/sync_health_banner.dart';
|
||||
@ -178,7 +179,6 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -218,17 +218,11 @@ class _LogMealScreenState extends State<LogMealScreen>
|
||||
const SizedBox(height: 8),
|
||||
MacroInputRow(controllers: _macros, compact: true),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Tooltip(
|
||||
message: 'Log meal',
|
||||
child: FilledButton(
|
||||
onPressed: _onLogMeal,
|
||||
child: const Icon(Icons.check_circle),
|
||||
),
|
||||
),
|
||||
],
|
||||
LogMealActionsRow(
|
||||
onLoadDelivery: loadDeliveryAndReport,
|
||||
onLog: _onLogMeal,
|
||||
deliveryBusy: deliveryBusy,
|
||||
dishesQueued: dishesStillQueued,
|
||||
),
|
||||
// Mutually exclusive: _status carries a validation complaint,
|
||||
// _progress the post-log summary. A successful log clears one
|
||||
|
||||
95
app/lib/widgets/log_meal_actions_row.dart
Normal file
95
app/lib/widgets/log_meal_actions_row.dart
Normal file
@ -0,0 +1,95 @@
|
||||
/// The log form's action row: load today's delivery, then log the meal.
|
||||
///
|
||||
/// Its own file because `log_meal_screen.dart` sits at the repo's 250-line
|
||||
/// ceiling; lifting the whole row rather than just the delivery button keeps
|
||||
/// both controls' layout in one place and returns budget to the screen.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/services/kuchnia_client.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A delivery button beside the log button.
|
||||
///
|
||||
/// The delivery control mirrors the PC lock screen's "🍱 Today's delivery":
|
||||
/// standing in the kitchen, there must be something to tap. It stays visible
|
||||
/// and enabled even when there is nothing to load — the outcome arrives as a
|
||||
/// message rather than as a disabled button, because a disabled control gives
|
||||
/// no way to retry after a late delivery and a hidden one reads as broken.
|
||||
class LogMealActionsRow extends StatelessWidget {
|
||||
/// Creates the action row.
|
||||
const LogMealActionsRow({
|
||||
required this.onLoadDelivery,
|
||||
required this.onLog,
|
||||
this.deliveryBusy = false,
|
||||
this.dishesQueued = 0,
|
||||
this.canFetchDelivery,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Loads today's delivery and prefills the first/next dish.
|
||||
final Future<void> Function() onLoadDelivery;
|
||||
|
||||
/// Writes the current form as a log entry.
|
||||
final VoidCallback onLog;
|
||||
|
||||
/// Whether a delivery fetch is in flight; disables the button meanwhile so
|
||||
/// a second tap cannot start a concurrent login against the caterer.
|
||||
final bool deliveryBusy;
|
||||
|
||||
/// How many delivered dishes are still queued, shown as the PC's
|
||||
/// "(N more to go)" so the user knows the walk is not finished.
|
||||
final int dishesQueued;
|
||||
|
||||
/// Whether the platform can fetch at all. Defaults to
|
||||
/// [kuchniaFetchSupported] — false on web, where the desktop build runs and
|
||||
/// the caterer's panel blocks browser requests, so the control is hidden
|
||||
/// rather than shipped dead.
|
||||
final bool? canFetchDelivery;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canFetch = canFetchDelivery ?? kuchniaFetchSupported;
|
||||
return Row(
|
||||
children: [
|
||||
// Flexible, not Spacer-and-hope: `OutlinedButton.icon` lays its label
|
||||
// out unbounded, so on a narrow phone the queue suffix renders wider
|
||||
// than the row and is clipped without ever throwing an overflow. The
|
||||
// Flexible caps the width and the FittedBox scales the text into it.
|
||||
if (canFetch)
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: "Load today's catering delivery",
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: deliveryBusy ? null : onLoadDelivery,
|
||||
icon: const Icon(Icons.restaurant),
|
||||
label: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(_label, maxLines: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// No Spacer: it is a flex child too, so it would split the free space
|
||||
// with the Flexible above and re-create the clipping this guards
|
||||
// against. The Flexible takes the slack and pushes the log button
|
||||
// right on its own.
|
||||
if (!canFetch) const Spacer(),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Tooltip(
|
||||
message: 'Log meal',
|
||||
child: FilledButton(
|
||||
onPressed: onLog,
|
||||
child: const Icon(Icons.check_circle),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String get _label {
|
||||
if (deliveryBusy) return 'Loading…';
|
||||
if (dishesQueued > 0) return "Today's delivery ($dishesQueued more to go)";
|
||||
return "Today's delivery";
|
||||
}
|
||||
}
|
||||
@ -43,7 +43,12 @@ class MacroControllers {
|
||||
///
|
||||
/// Both [perGrams] and [grams] are set to the suggestion's own weight, so
|
||||
/// the row starts out describing exactly the portion that was banked.
|
||||
///
|
||||
/// Clears first so no field can survive from a previous pick. This one
|
||||
/// assignment set happens to cover all six today, but the clear is what
|
||||
/// makes that non-load-bearing -- see [clear].
|
||||
void fillFrom(Nutrition nutrition) {
|
||||
clear();
|
||||
kcal.text = nutrition.kcal.toStringAsFixed(0);
|
||||
protein.text = nutrition.proteinG.toStringAsFixed(0);
|
||||
carbs.text = nutrition.carbsG.toStringAsFixed(0);
|
||||
|
||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.67+1
|
||||
version: 1.0.68+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@ -147,4 +147,51 @@ void main() {
|
||||
expect(find.text('1200'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('a stale per-grams does not rescale an edited entry on save', (
|
||||
tester,
|
||||
) async {
|
||||
// The edit screen reaches the same `nutritionForPortion` as the log form,
|
||||
// so it is exposed to the identical corruption: pick a suggestion (which
|
||||
// sets `perGrams`), retype the macros as a whole portion, and the save
|
||||
// would scale them by grams/perGrams. Asserting the *written entry* is
|
||||
// what catches it -- the fields on screen look right either way.
|
||||
await tester.runAsync(() async {
|
||||
await FoodBankService.instance.addManualEntry(
|
||||
const FoodBankRecord(
|
||||
desc: 'pizza obok pracy',
|
||||
kcal: 1200,
|
||||
proteinG: 52,
|
||||
carbsG: 145,
|
||||
fatG: 42,
|
||||
grams: 400,
|
||||
count: 1,
|
||||
),
|
||||
);
|
||||
await LogStorageService.instance.writeLog({
|
||||
'2026-06-22': [_entry],
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: EditEntryScreen(entry: _entry)),
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('pizza obok pracy').last);
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await LogStorageService.instance.readLog())['2026-06-22']!
|
||||
.first;
|
||||
expect(
|
||||
saved.kcal,
|
||||
1200,
|
||||
reason: 'the banked portion must save as itself, not rescaled by '
|
||||
'its own reference weight.',
|
||||
);
|
||||
expect(saved.grams, 400);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -51,12 +51,36 @@ class _HostState extends State<_Host> with LogMealKuchniaMixin<_Host> {
|
||||
final TextEditingController desc = TextEditingController();
|
||||
final MacroControllers macros = MacroControllers();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [
|
||||
macros.kcal,
|
||||
macros.protein,
|
||||
macros.carbs,
|
||||
macros.fat,
|
||||
macros.perGrams,
|
||||
macros.grams,
|
||||
]) {
|
||||
c.addListener(() {
|
||||
if (source == 'food bank') source = 'manual';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
TextEditingController get descController => desc;
|
||||
|
||||
@override
|
||||
MacroControllers get macroControllers => macros;
|
||||
|
||||
/// Mirrors the screen: a macro edit demotes a food-bank pick to manual,
|
||||
/// and a prefilled dish stamps catering.
|
||||
String source = 'manual';
|
||||
|
||||
@override
|
||||
void onDishPrefilled() => source = 'catering';
|
||||
|
||||
/// Stands in for the screen's submit: the entry write is irrelevant here,
|
||||
/// the queue advance is what is under test.
|
||||
void submit() {
|
||||
|
||||
183
app/test/screens/log_meal_prefill_nutrition_test.dart
Normal file
183
app/test/screens/log_meal_prefill_nutrition_test.dart
Normal file
@ -0,0 +1,183 @@
|
||||
/// What a prefilled dish actually *logs*, as distinct from what it displays.
|
||||
///
|
||||
/// Split from `log_meal_kuchnia_mixin_test.dart` (250-line cap) along the
|
||||
/// seam the 2026-08-23 corruption exposed: that file asserts the queue's
|
||||
/// mechanics and the controllers' text, and every one of its tests passed
|
||||
/// while a delivered dish logged 2.58x its real calories. The bug lived in
|
||||
/// the *computed* `Nutrition`, which nothing there looked at.
|
||||
///
|
||||
/// So these tests assert the computed value, never the field text.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/models/kuchnia_dish.dart';
|
||||
import 'package:diet_guard_app/screens/log_meal_actions.dart';
|
||||
import 'package:diet_guard_app/screens/log_meal_kuchnia_mixin.dart';
|
||||
import 'package:diet_guard_app/services/document_store.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_queue.dart';
|
||||
import 'package:diet_guard_app/widgets/macro_input_row.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// In-memory store, so these tests need no plugin channel.
|
||||
class _MemoryStore implements DocumentStore {
|
||||
final Map<String, String> documents = {};
|
||||
|
||||
@override
|
||||
Future<String?> read(String name) async => documents[name];
|
||||
|
||||
@override
|
||||
Future<void> write(String name, String contents) async {
|
||||
documents[name] = contents;
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest widget that uses the mixin the way the log screen does.
|
||||
class _Host extends StatefulWidget {
|
||||
const _Host();
|
||||
|
||||
@override
|
||||
State<_Host> createState() => _HostState();
|
||||
}
|
||||
|
||||
class _HostState extends State<_Host> with LogMealKuchniaMixin<_Host> {
|
||||
final TextEditingController desc = TextEditingController();
|
||||
final MacroControllers macros = MacroControllers();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [
|
||||
macros.kcal,
|
||||
macros.protein,
|
||||
macros.carbs,
|
||||
macros.fat,
|
||||
macros.perGrams,
|
||||
macros.grams,
|
||||
]) {
|
||||
c.addListener(() {
|
||||
if (source == 'food bank') source = 'manual';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
TextEditingController get descController => desc;
|
||||
|
||||
@override
|
||||
MacroControllers get macroControllers => macros;
|
||||
|
||||
/// Mirrors the screen: a macro edit demotes a food-bank pick to manual,
|
||||
/// and a prefilled dish stamps catering.
|
||||
String source = 'manual';
|
||||
|
||||
@override
|
||||
void onDishPrefilled() => source = 'catering';
|
||||
|
||||
/// Stands in for the screen's submit: the entry write is irrelevant here,
|
||||
/// the queue advance is what is under test.
|
||||
void submit() {
|
||||
final logged = desc.text;
|
||||
desc.clear();
|
||||
macros.clear();
|
||||
advanceQueueAfterLog(logged);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
Text(queueStatusLine ?? '', textDirection: TextDirection.ltr);
|
||||
}
|
||||
|
||||
KuchniaDish _dish(String name, int priority) => KuchniaDish(
|
||||
name: name,
|
||||
kcal: 400,
|
||||
proteinG: 25,
|
||||
carbsG: 45.5,
|
||||
fatG: 12,
|
||||
grams: 300,
|
||||
priority: priority,
|
||||
slotLabel: 'Obiad',
|
||||
);
|
||||
|
||||
void main() {
|
||||
late _HostState host;
|
||||
|
||||
Future<void> pumpHost(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const MaterialApp(home: _Host()));
|
||||
host = tester.state(find.byType(_Host));
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
await KuchniaQueueService.initForTesting(_MemoryStore());
|
||||
});
|
||||
|
||||
tearDown(KuchniaQueueService.resetForTesting);
|
||||
|
||||
testWidgets('a stale per-grams does not rescale the prefilled dish', (
|
||||
tester,
|
||||
) async {
|
||||
// The 2026-08-23 corruption: a food-bank pick leaves `perGrams` at 100,
|
||||
// the dish prefills over it without clearing, and `nutritionForPortion`
|
||||
// reads the dish's *per-portion* macros as *per-100g* and multiplies them
|
||||
// by 300/100. Asserting controller text passes throughout that bug --
|
||||
// only the computed Nutrition sees it.
|
||||
await pumpHost(tester);
|
||||
host.macros.perGrams.text = '100';
|
||||
|
||||
KuchniaQueueService.instance.offer([_dish('Pancakes', 1)]);
|
||||
host.prefillNextDish();
|
||||
await tester.pump();
|
||||
|
||||
final nutrition = nutritionFromControllers(host.macros, 'kuchnia wikinga');
|
||||
expect(
|
||||
nutrition.kcal,
|
||||
400,
|
||||
reason: 'the dish must log its own calories. 1200 here is the stale '
|
||||
'per-grams rescaling a per-portion dish by 300/100.',
|
||||
);
|
||||
expect(nutrition.proteinG, 25);
|
||||
expect(nutrition.carbsG, 45.5);
|
||||
expect(nutrition.fatG, 12);
|
||||
expect(nutrition.grams, 300);
|
||||
});
|
||||
|
||||
testWidgets('clear() empties every macro controller', (tester) async {
|
||||
// Enumerated deliberately: the bug was a *forgotten* field, so a new
|
||||
// seventh controller must fail this until it is cleared too.
|
||||
await pumpHost(tester);
|
||||
final controllers = <String, TextEditingController>{
|
||||
'kcal': host.macros.kcal,
|
||||
'protein': host.macros.protein,
|
||||
'carbs': host.macros.carbs,
|
||||
'fat': host.macros.fat,
|
||||
'perGrams': host.macros.perGrams,
|
||||
'grams': host.macros.grams,
|
||||
};
|
||||
for (final c in controllers.values) {
|
||||
c.text = '99';
|
||||
}
|
||||
|
||||
host.macros.clear();
|
||||
|
||||
controllers.forEach((name, c) {
|
||||
expect(c.text, isEmpty, reason: '$name survived clear()');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('the prefill stamps catering even though clearing demotes it', (
|
||||
tester,
|
||||
) async {
|
||||
// Ordering is load-bearing and invisible: `clear()` fires the macro
|
||||
// listener, which demotes a food-bank pick to 'manual'. `onDishPrefilled`
|
||||
// runs after and must have the last word, or a delivered dish logs as a
|
||||
// manual entry and the queue never advances.
|
||||
await pumpHost(tester);
|
||||
host.source = 'food bank';
|
||||
host.macros.perGrams.text = '100';
|
||||
|
||||
KuchniaQueueService.instance.offer([_dish('Pancakes', 1)]);
|
||||
host.prefillNextDish();
|
||||
await tester.pump();
|
||||
|
||||
expect(host.source, 'catering');
|
||||
});
|
||||
}
|
||||
123
app/test/widgets/log_meal_actions_row_test.dart
Normal file
123
app/test/widgets/log_meal_actions_row_test.dart
Normal file
@ -0,0 +1,123 @@
|
||||
/// The log form's action row, and the delivery button the phone lacked.
|
||||
///
|
||||
/// The PC lock screen has had a "🍱 Today's delivery" button since the import
|
||||
/// shipped; the phone had the queue but no visible control, so standing in
|
||||
/// the kitchen there was nothing to tap. These tests pin the affordance and
|
||||
/// the two ways it silently fails: a clipped label, and a dead control on the
|
||||
/// web build.
|
||||
library;
|
||||
|
||||
import 'package:diet_guard_app/widgets/log_meal_actions_row.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pump(
|
||||
WidgetTester tester, {
|
||||
int queued = 0,
|
||||
bool busy = false,
|
||||
bool canFetch = true,
|
||||
Future<void> Function()? onLoad,
|
||||
VoidCallback? onLog,
|
||||
double width = 360,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: width,
|
||||
child: LogMealActionsRow(
|
||||
onLoadDelivery: onLoad ?? () async {},
|
||||
onLog: onLog ?? () {},
|
||||
deliveryBusy: busy,
|
||||
dishesQueued: queued,
|
||||
canFetchDelivery: canFetch,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('the delivery button is there to tap', (tester) async {
|
||||
var taps = 0;
|
||||
await pump(tester, onLoad: () async => taps++);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.restaurant));
|
||||
await tester.pump();
|
||||
|
||||
expect(taps, 1);
|
||||
});
|
||||
|
||||
testWidgets('the queue count is visible, as the PC shows it', (tester) async {
|
||||
await pump(tester, queued: 4);
|
||||
expect(find.text("Today's delivery (4 more to go)"), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the long label is scaled, not silently clipped', (tester) async {
|
||||
// A Row clips an oversized child without throwing, so "no overflow
|
||||
// exception" proves nothing. Measure the rendered text instead.
|
||||
await pump(tester, queued: 4, width: 300);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// `getSize` on the Text reports its *unscaled* intrinsic width, because
|
||||
// FittedBox scales via a transform rather than by resizing its child.
|
||||
// The painted extent is what the user sees, so measure the rect the
|
||||
// FittedBox actually occupies.
|
||||
final painted = tester.getRect(
|
||||
find.ancestor(
|
||||
of: find.text("Today's delivery (4 more to go)"),
|
||||
matching: find.byType(FittedBox),
|
||||
),
|
||||
);
|
||||
final row = tester.getRect(find.byType(LogMealActionsRow));
|
||||
expect(
|
||||
painted.width,
|
||||
lessThanOrEqualTo(row.width),
|
||||
reason: 'the label must fit the row it is rendered in',
|
||||
);
|
||||
expect(painted.right, lessThanOrEqualTo(row.right + 0.5));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a fetch in flight disables the button', (tester) async {
|
||||
await pump(tester, busy: true);
|
||||
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byType(OutlinedButton),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
expect(find.text('Loading…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the button stays visible with nothing queued', (tester) async {
|
||||
// Never hidden or disabled on an empty queue: the outcome arrives as a
|
||||
// SnackBar so a late delivery can still be retried.
|
||||
await pump(tester);
|
||||
|
||||
expect(find.text("Today's delivery"), findsOneWidget);
|
||||
final button = tester.widget<OutlinedButton>(
|
||||
find.byType(OutlinedButton),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('web gets no delivery button at all', (tester) async {
|
||||
// The desktop target *is* the web build, and the caterer's panel blocks
|
||||
// browser requests -- an unguarded button would ship a dead control.
|
||||
await pump(tester, canFetch: false);
|
||||
|
||||
expect(find.byIcon(Icons.restaurant), findsNothing);
|
||||
expect(find.byIcon(Icons.check_circle), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the log button still logs', (tester) async {
|
||||
var logged = 0;
|
||||
await pump(tester, onLog: () => logged++);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.check_circle));
|
||||
await tester.pump();
|
||||
|
||||
expect(logged, 1);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user