test: cover list creation, share queues, and photo FAB menu

This commit is contained in:
2026-05-14 17:37:26 +03:00
parent 41e8ac13a0
commit 550027e1bc
5 changed files with 398 additions and 1 deletions

View File

@@ -80,7 +80,28 @@ class FakePhotoBoardController extends PhotoBoardController {
Future<void> deletePhoto(Photo photo) async {}
@override
Future<void> uploadPhotos(List<XFile> files, {int? folderId}) async {}
Future<void> uploadPhotos(List<XFile> files, {int? folderId}) async {
lastUploaded = files;
lastUploadFolderId = folderId;
}
String? lastCreatedFolderName;
List<XFile>? lastUploaded;
int? lastUploadFolderId;
@override
Future<PhotoFolder> 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.

View File

@@ -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);
});
}

View File

@@ -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);
});
}

View File

@@ -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<int>));
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<int>));
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<int>));
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<int>));
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);
},
);
}

View File

@@ -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);
});
}