From 550027e1bc514a70e8c43105f79381f304ed8c45 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Thu, 14 May 2026 17:37:26 +0300 Subject: [PATCH] test: cover list creation, share queues, and photo FAB menu --- test/helpers/fakes.dart | 23 ++- .../pending_note_share_service_test.dart | 47 ++++++ .../pending_photo_share_service_test.dart | 71 +++++++++ test/widgets/checklist_selector_test.dart | 140 ++++++++++++++++++ test/widgets/photo_add_button_test.dart | 118 +++++++++++++++ 5 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 test/services/pending_note_share_service_test.dart create mode 100644 test/services/pending_photo_share_service_test.dart create mode 100644 test/widgets/checklist_selector_test.dart create mode 100644 test/widgets/photo_add_button_test.dart diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index e4ddf0f..3a7dde7 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -80,7 +80,28 @@ class FakePhotoBoardController extends PhotoBoardController { Future deletePhoto(Photo photo) async {} @override - Future uploadPhotos(List files, {int? folderId}) async {} + Future uploadPhotos(List files, {int? folderId}) async { + lastUploaded = files; + lastUploadFolderId = folderId; + } + + String? lastCreatedFolderName; + List? lastUploaded; + int? lastUploadFolderId; + + @override + Future createFolder(String name) async { + lastCreatedFolderName = name; + final folder = PhotoFolder( + id: 999, + houseId: houseId, + name: name, + sortOrder: 0, + createdAt: 0, + updatedAt: 0, + ); + return folder; + } } /// A fake [NotesController] that does not touch any services. diff --git a/test/services/pending_note_share_service_test.dart b/test/services/pending_note_share_service_test.dart new file mode 100644 index 0000000..fa86753 --- /dev/null +++ b/test/services/pending_note_share_service_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pantry/services/pending_note_share_service.dart'; + +void main() { + final service = PendingNoteShareService.instance; + + setUp(() { + // Drain anything left over from a previous test. + service.takeForHouse(1); + service.takeForHouse(2); + }); + + test('takeForHouse returns null when there is no pending share', () { + expect(service.takeForHouse(1), isNull); + }); + + test('push then takeForHouse returns the share and clears it', () { + service.push(const PendingNoteShare(houseId: 1, content: 'hello')); + + final taken = service.takeForHouse(1); + expect(taken, isNotNull); + expect(taken!.content, 'hello'); + + // Once taken, the pending slot is empty. + expect(service.pending, isNull); + expect(service.takeForHouse(1), isNull); + }); + + test('takeForHouse for a different house returns null and keeps share', () { + service.push(const PendingNoteShare(houseId: 1, content: 'hello')); + + expect(service.takeForHouse(2), isNull); + expect(service.pending, isNotNull); + expect(service.pending!.houseId, 1); + }); + + test('push notifies listeners', () { + var notifications = 0; + void listener() => notifications++; + service.addListener(listener); + + service.push(const PendingNoteShare(houseId: 1, content: 'hi')); + + service.removeListener(listener); + expect(notifications, 1); + }); +} diff --git a/test/services/pending_photo_share_service_test.dart b/test/services/pending_photo_share_service_test.dart new file mode 100644 index 0000000..c0c0fc4 --- /dev/null +++ b/test/services/pending_photo_share_service_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pantry/services/pending_photo_share_service.dart'; + +void main() { + final service = PendingPhotoShareService.instance; + + // Singleton — drain anything left from a previous test before each case so + // tests are order-independent. + setUp(() { + service.takeForHouse(1); + service.takeForHouse(2); + service.takeForHouse(3); + }); + + test('takeForHouse returns empty list when nothing is queued', () { + expect(service.takeForHouse(1), isEmpty); + }); + + test('enqueue makes the share available to the matching house', () { + service.enqueue( + const PendingPhotoShare(houseId: 1, folderId: null, paths: ['/a.jpg']), + ); + + final taken = service.takeForHouse(1); + + expect(taken, hasLength(1)); + expect(taken.first.houseId, 1); + expect(taken.first.paths, ['/a.jpg']); + }); + + test('takeForHouse drains only entries for the requested house', () { + service.enqueue( + const PendingPhotoShare(houseId: 1, folderId: null, paths: ['/a.jpg']), + ); + service.enqueue( + const PendingPhotoShare(houseId: 2, folderId: 7, paths: ['/b.jpg']), + ); + + final fromHouse1 = service.takeForHouse(1); + expect(fromHouse1, hasLength(1)); + expect(fromHouse1.first.houseId, 1); + + // House 2's entry is still there until claimed. + final fromHouse2 = service.takeForHouse(2); + expect(fromHouse2, hasLength(1)); + expect(fromHouse2.first.folderId, 7); + expect(fromHouse2.first.paths, ['/b.jpg']); + }); + + test('takeForHouse removes consumed entries (idempotent on second call)', () { + service.enqueue( + const PendingPhotoShare(houseId: 1, folderId: null, paths: ['/a.jpg']), + ); + + expect(service.takeForHouse(1), hasLength(1)); + expect(service.takeForHouse(1), isEmpty); + }); + + test('enqueue notifies listeners', () { + var notifications = 0; + void listener() => notifications++; + service.addListener(listener); + + service.enqueue( + const PendingPhotoShare(houseId: 1, folderId: null, paths: ['/a.jpg']), + ); + + service.removeListener(listener); + expect(notifications, 1); + }); +} diff --git a/test/widgets/checklist_selector_test.dart b/test/widgets/checklist_selector_test.dart new file mode 100644 index 0000000..9f20694 --- /dev/null +++ b/test/widgets/checklist_selector_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pantry/models/checklist.dart'; +import 'package:pantry/widgets/checklist_selector.dart'; + +import '../helpers/test_app.dart'; +import '../helpers/test_models.dart'; + +void main() { + ChecklistList listA() => + makeChecklistList(id: 1, name: 'Groceries', icon: 'cart'); + ChecklistList listB() => + makeChecklistList(id: 2, name: 'Hardware', icon: 'wrench'); + + testWidgets('renders current list name in the closed state', (tester) async { + final a = listA(); + final b = listB(); + await tester.pumpWidget( + wrapForTest( + ChecklistSelector( + lists: [a, b], + currentList: a, + onSelected: (_) {}, + onCreateNew: () {}, + ), + ), + ); + + expect(find.text('Groceries'), findsOneWidget); + expect(find.text('Hardware'), findsNothing); + }); + + testWidgets('opening the dropdown shows all lists plus "+ New list"', ( + tester, + ) async { + final a = listA(); + final b = listB(); + await tester.pumpWidget( + wrapForTest( + ChecklistSelector( + lists: [a, b], + currentList: a, + onSelected: (_) {}, + onCreateNew: () {}, + ), + ), + ); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + + // Each list shows in the open menu in addition to the closed-state copy. + expect(find.text('Groceries'), findsNWidgets(2)); + expect(find.text('Hardware'), findsOneWidget); + expect(find.text('New list'), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('selecting a different list invokes onSelected', (tester) async { + final a = listA(); + final b = listB(); + ChecklistList? selected; + var createNewCalls = 0; + + await tester.pumpWidget( + wrapForTest( + ChecklistSelector( + lists: [a, b], + currentList: a, + onSelected: (l) => selected = l, + onCreateNew: () => createNewCalls++, + ), + ), + ); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Hardware')); + await tester.pumpAndSettle(); + + expect(selected?.id, 2); + expect(createNewCalls, 0); + }); + + testWidgets('selecting "+ New list" invokes onCreateNew and not onSelected', ( + tester, + ) async { + final a = listA(); + final b = listB(); + ChecklistList? selected; + var createNewCalls = 0; + + await tester.pumpWidget( + wrapForTest( + ChecklistSelector( + lists: [a, b], + currentList: a, + onSelected: (l) => selected = l, + onCreateNew: () => createNewCalls++, + ), + ), + ); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('New list')); + await tester.pumpAndSettle(); + + expect(createNewCalls, 1); + expect(selected, isNull); + }); + + testWidgets( + 'after tapping "+ New list" the closed state still shows the current list', + (tester) async { + final a = listA(); + final b = listB(); + + await tester.pumpWidget( + wrapForTest( + ChecklistSelector( + lists: [a, b], + currentList: a, + onSelected: (_) {}, + onCreateNew: () {}, + ), + ), + ); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('New list')); + await tester.pumpAndSettle(); + + // Sentinel must not leak into the closed-state label. + expect(find.text('New list'), findsNothing); + expect(find.text('Groceries'), findsOneWidget); + }, + ); +} diff --git a/test/widgets/photo_add_button_test.dart b/test/widgets/photo_add_button_test.dart new file mode 100644 index 0000000..23f7ebe --- /dev/null +++ b/test/widgets/photo_add_button_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pantry/widgets/photo_add_button.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/test_app.dart'; + +void main() { + testWidgets('renders a single main FAB closed by default', (tester) async { + final controller = FakePhotoBoardController(); + await tester.pumpWidget( + wrapForTest(PhotoAddButton(controller: controller)), + ); + + expect(find.byType(FloatingActionButton), findsOneWidget); + + // None of the action labels should be visible while closed. + expect(find.text('Upload photos'), findsNothing); + expect(find.text('Take photo'), findsNothing); + expect(find.text('New folder'), findsNothing); + }); + + testWidgets('tapping the FAB opens the menu with all three actions', ( + tester, + ) async { + final controller = FakePhotoBoardController(); + await tester.pumpWidget( + wrapForTest(PhotoAddButton(controller: controller)), + ); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + expect(find.text('Upload photos'), findsOneWidget); + expect(find.text('Take photo'), findsOneWidget); + expect(find.text('New folder'), findsOneWidget); + expect(find.byIcon(Icons.add_photo_alternate), findsOneWidget); + expect(find.byIcon(Icons.camera_alt), findsOneWidget); + expect(find.byIcon(Icons.create_new_folder), findsOneWidget); + }); + + testWidgets('tapping the FAB a second time closes the menu', (tester) async { + final controller = FakePhotoBoardController(); + await tester.pumpWidget( + wrapForTest(PhotoAddButton(controller: controller)), + ); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + expect(find.text('Take photo'), findsOneWidget); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + expect(find.text('Take photo'), findsNothing); + }); + + testWidgets('tapping "New folder" opens the create-folder dialog', ( + tester, + ) async { + final controller = FakePhotoBoardController(); + await tester.pumpWidget( + wrapForTest(PhotoAddButton(controller: controller)), + ); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('New folder')); + await tester.pumpAndSettle(); + + // Dialog field label + the action buttons confirm the dialog opened. + expect(find.text('Folder name'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); + expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget); + }); + + testWidgets( + 'submitting the create-folder dialog calls controller.createFolder', + (tester) async { + final controller = FakePhotoBoardController(); + await tester.pumpWidget( + wrapForTest(PhotoAddButton(controller: controller)), + ); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('New folder')); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextField, 'Folder name'), + 'Vacation', + ); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(controller.lastCreatedFolderName, 'Vacation'); + }, + ); + + testWidgets('empty folder name does not invoke createFolder', (tester) async { + final controller = FakePhotoBoardController(); + await tester.pumpWidget( + wrapForTest(PhotoAddButton(controller: controller)), + ); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('New folder')); + await tester.pumpAndSettle(); + + // Don't type anything, just submit. + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(controller.lastCreatedFolderName, isNull); + }); +}