mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 12:07:42 +01:00
Two failures, both pre-existing and both invisible as failures — each reported only as "did not complete" after stalling the suite. app/test/keyboard_operability_test.dart awaited writeLog() straight from the test body. LogStorageService is backed by a FileDocumentStore there, so that awaits real dart:io, and the fake-async zone never completes it: the test hangs rather than fails. Seeding now happens inside runAsync alongside the pumping, which is the only zone where that write can resolve. The screen's own initState load needs the same real clock, and pumpAndSettle() cannot be used to wait for it — inside runAsync it waits on a frame scheduler this screen never reports as idle — so the helper pumps a bounded number of times instead. 473 app tests now pass in 14s. scripts/ci_mirror.sh ran bare pytest while python-tests.yml runs it under xvfb-run. Without a display the gate's real-Tk tests collapse: the focus ring has nothing to walk, so three TestRealFocusRing tests fail locally and pass in CI. The mirror was reporting red on a green tree, which is worse than not mirroring at all. It now uses the workflow's exact xvfb-run invocation and fails loudly if xvfb-run is missing. Neither change touches production code: the delete control and its id-gate were already correct, and the Python suite already passed under a display (609 passed, 100% coverage). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj
241 lines
8.5 KiB
Dart
241 lines
8.5 KiB
Dart
/// Pointer-free operability tests for controls that had no keyboard route.
|
|
///
|
|
/// The desktop build is this Dart running as Flutter **web** in a Chrome
|
|
/// `--app` window, so "mobile-only" gestures are not a fallback there — they
|
|
/// are the whole interaction. Each test below stands in for something that was
|
|
/// literally impossible without a pointer.
|
|
///
|
|
/// These are also the repo's first keyboard-reachability assertions: before
|
|
/// this file, `test/` had ~109 `tester.tap` calls and zero checks that anything
|
|
/// was focusable at all.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:diet_guard_app/models/food_entry.dart';
|
|
import 'package:diet_guard_app/screens/history_screen.dart';
|
|
import 'package:diet_guard_app/services/budget_history_service.dart';
|
|
import 'package:diet_guard_app/services/document_store_io.dart';
|
|
import 'package:diet_guard_app/services/log_storage_service.dart';
|
|
import 'package:diet_guard_app/ui/theme.dart';
|
|
import 'package:diet_guard_app/widgets/day_status_calendar.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/semantics.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// Landscape and *short* — the supported floor. Phone-portrait sizes, which is
|
|
/// all the existing suite pinned, exercise neither constraint.
|
|
const _desktopSize = Size(1366, 768);
|
|
|
|
void main() {
|
|
group('calendar day cells', () {
|
|
testWidgets('are focusable, so date navigation has a keyboard route', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: DayStatusCalendar(
|
|
statusByDate: const {},
|
|
month: DateTime(2026, 7),
|
|
today: DateTime(2026, 7, 30),
|
|
onPrevMonth: _noop,
|
|
onNextMonth: _noop,
|
|
onDaySelected: (_) {},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
// A bare GestureDetector — what this replaced — builds no InkWell and no
|
|
// focus node, so no day was reachable by Tab and the whole
|
|
// date-filtered-history route was pointer-only.
|
|
expect(
|
|
find.byType(InkWell),
|
|
findsWidgets,
|
|
reason: 'day cells must be focusable, not bare GestureDetectors',
|
|
);
|
|
});
|
|
|
|
testWidgets('expose a button semantic for screen readers', (tester) async {
|
|
final handle = tester.ensureSemantics();
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: DayStatusCalendar(
|
|
statusByDate: const {},
|
|
month: DateTime(2026, 7),
|
|
today: DateTime(2026, 7, 30),
|
|
onPrevMonth: _noop,
|
|
onNextMonth: _noop,
|
|
onDaySelected: (_) {},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final node = tester.getSemantics(find.byType(InkWell).first);
|
|
expect(node.hasFlag(SemanticsFlag.isButton), isTrue);
|
|
// Disposed inline, not via addTearDown: Flutter verifies outstanding
|
|
// handles at the end of the test body, before tearDowns run.
|
|
handle.dispose();
|
|
});
|
|
|
|
testWidgets('gain a tab stop per day only when selection is enabled', (
|
|
tester,
|
|
) async {
|
|
// Counts InkWells rather than matching a semantics label: the day cells
|
|
// merge their own label with the day-number Text inside them, so a label
|
|
// finder is brittle. The month-navigation chevrons are IconButtons and
|
|
// contribute a constant number of InkWells in both cases, so the
|
|
// *difference* is exactly the per-day tab stops -- which is the property
|
|
// under test.
|
|
Future<int> inkWells({required bool selectable}) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: DayStatusCalendar(
|
|
statusByDate: const {},
|
|
month: DateTime(2026, 7),
|
|
today: DateTime(2026, 7, 30),
|
|
onPrevMonth: _noop,
|
|
onNextMonth: _noop,
|
|
onDaySelected: selectable ? (_) {} : null,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
return tester.widgetList(find.byType(InkWell)).length;
|
|
}
|
|
|
|
final readOnlyCount = await inkWells(selectable: false);
|
|
final selectableCount = await inkWells(selectable: true);
|
|
|
|
// July 2026 has 31 days. Before the fix this difference was zero: the
|
|
// cells were bare GestureDetectors, so no day was ever a tab stop.
|
|
expect(selectableCount - readOnlyCount, 31);
|
|
});
|
|
});
|
|
|
|
group('width caps', () {
|
|
test('prose cap matches the design-system line-length rule', () {
|
|
// tokens.md rule 21: 40rem / ~65-70 characters. Without this the desktop
|
|
// window rendered prose at roughly 180 characters per line.
|
|
expect(AppWidth.prose, 640);
|
|
});
|
|
|
|
test('a single-purpose field is capped well below the prose width', () {
|
|
expect(AppWidth.field, lessThan(AppWidth.prose));
|
|
});
|
|
|
|
test('the short-viewport breakpoint trips before the 768px floor', () {
|
|
expect(AppWidth.shortViewport, lessThan(768));
|
|
});
|
|
});
|
|
|
|
group('history entry delete', () {
|
|
late Directory tempDir;
|
|
|
|
setUp(() async {
|
|
tempDir = await Directory.systemTemp.createTemp('diet_guard_kbd_');
|
|
LogStorageService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir));
|
|
});
|
|
|
|
tearDown(() async {
|
|
LogStorageService.resetForTesting();
|
|
BudgetHistoryService.resetForTesting();
|
|
await tempDir.delete(recursive: true);
|
|
});
|
|
|
|
testWidgets('has a focusable control, not long-press only', (tester) async {
|
|
tester.view.physicalSize = _desktopSize;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
|
|
await _pumpHistoryScreen(tester, {
|
|
'2026-07-30': [
|
|
const FoodEntry(
|
|
id: 'has-id',
|
|
time: '2026-07-30T08:00:00+02:00',
|
|
desc: 'oatmeal with raisins',
|
|
grams: 100,
|
|
kcal: 430,
|
|
proteinG: 12,
|
|
carbsG: 60,
|
|
fatG: 9,
|
|
source: 'food bank',
|
|
),
|
|
],
|
|
});
|
|
|
|
// Delete used to be bound only to onLongPress, which has no keyboard
|
|
// equivalent whatsoever.
|
|
expect(
|
|
find.byTooltip('Delete entry'),
|
|
findsOneWidget,
|
|
reason: 'delete needs a tab-reachable control, not long-press only',
|
|
);
|
|
});
|
|
|
|
testWidgets('offers no delete control for an entry without an id', (
|
|
tester,
|
|
) async {
|
|
tester.view.physicalSize = _desktopSize;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
|
|
await _pumpHistoryScreen(tester, {
|
|
'2026-07-30': [
|
|
const FoodEntry(
|
|
time: '2026-07-30T09:00:00+02:00',
|
|
desc: 'legacy entry',
|
|
grams: 100,
|
|
kcal: 100,
|
|
proteinG: 1,
|
|
carbsG: 2,
|
|
fatG: 3,
|
|
source: 'legacy',
|
|
),
|
|
],
|
|
});
|
|
|
|
// Delete stays id-only to avoid ambiguous time+desc matches, so the
|
|
// control must be absent rather than present-and-failing.
|
|
expect(find.byTooltip('Delete entry'), findsNothing);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// A do-nothing month-navigation callback.
|
|
void _noop() {}
|
|
|
|
/// Seed [log] to disk, then pump [HistoryScreen] until its load has painted.
|
|
///
|
|
/// Both halves must happen inside `runAsync`, and that is the whole point of
|
|
/// this helper. `LogStorageService` here is backed by a `FileDocumentStore`,
|
|
/// so `writeLog` awaits real `dart:io`; awaited from the test body it waits
|
|
/// on a `Future` the fake-async zone never completes, and the test hangs
|
|
/// rather than failing -- reported only as "did not complete" once the suite
|
|
/// times out. `HistoryScreen` then loads through a fire-and-forget `Future`
|
|
/// in `initState`, which needs the same real clock to resolve.
|
|
///
|
|
/// The pumping is a bounded manual loop rather than `pumpAndSettle()`, which
|
|
/// inside `runAsync` waits on a frame scheduler this screen never reports as
|
|
/// idle -- another hang, with the same unhelpful symptom.
|
|
Future<void> _pumpHistoryScreen(WidgetTester tester, DayLog log) async {
|
|
await tester.runAsync(() async {
|
|
await LogStorageService.instance.writeLog(log);
|
|
await tester.pumpWidget(const MaterialApp(home: HistoryScreen()));
|
|
for (var i = 0; i < 10; i++) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
await tester.pump();
|
|
}
|
|
});
|
|
}
|