todo-app/test/notes_list_screen_test.dart
Krzysztof kuhy Rudnicki 7f84414c87 Add list filters/sort, status, priority rework, export/import, structured template
Notes list & filtering:
- Text-search filter plus independent date-range filters for both created
  and last-updated (AND-combined), a priority filter, and a new status
  filter. Default view hides Done/Abandoned and renders as "unfiltered"
  (no badge for the default state); fixed badge clipping.
- NoteSort options wired into the list UI; watchCount() for the "N saved".

Status & priority:
- New Status enum (toDo/inProgress/Done/Abandoned) as a settable + filterable
  attribute on every note, with capture-screen dropdown.
- Removed "None" priority: every note is Low/Medium/High, default Medium.
  Schema migration v2->v3 rewrites legacy priority 0 -> Medium.

Export / import:
- NotesMarkdown round-trippable single-file format with HTML-comment markers.
- Settings "Export notes" (mobile share sheet / desktop writes ~/todo/BACKLOG.md)
  and "Import notes" (file picker + safe newer-wins merge by id).

Structured template:
- Every new note pre-fills the richer what/where/must/nice/out/done/depends/
  estimate/refs scaffold.

Tests:
- New fast (~5s), deterministic suite via FakeNoteRepository (no DB timers) and
  injected http/file-selector/url-launcher fakes. 86 tests, 96.2% line coverage
  (note.dart & sync_service.dart at 100%, settings 98.7%). Mobile-only share
  branch excluded via coverage:ignore (unreachable on the Linux test host).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:52:59 +02:00

254 lines
8.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/notes_list_screen.dart';
import 'fake_note_repository.dart';
void main() {
Note note(
String id,
String text, {
Priority priority = Priority.medium,
Status status = Status.todo,
}) {
final now = DateTime(2026, 6, 15, 9);
return Note(
id: id,
text: text,
priority: priority,
status: status,
createdAt: now,
updatedAt: now,
);
}
Future<FakeNoteRepository> pumpList(
WidgetTester tester, {
List<Note> seed = const [],
}) async {
final repo = FakeNoteRepository(seed);
addTearDown(repo.close);
await tester.pumpWidget(
MaterialApp(home: NotesListScreen(repository: repo)),
);
await tester.pump(); // flush the initial stream emit
return repo;
}
testWidgets('renders notes with a status · priority · time subtitle', (
tester,
) async {
await pumpList(
tester,
seed: [note('a', 'First note', priority: Priority.high)],
);
expect(find.text('First note'), findsOneWidget);
expect(find.textContaining('To do'), findsOneWidget);
expect(find.textContaining('High'), findsOneWidget);
});
testWidgets('defaults to hiding Done/Abandoned with no filter badge', (
tester,
) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
// The screen's default query hides completed work…
expect(repo.lastFilter!.statuses, {Status.todo, Status.inProgress});
// …but that default is not surfaced as an active-filter badge.
expect(find.byType(Badge), findsOneWidget);
expect(tester.widget<Badge>(find.byType(Badge)).isLabelVisible, isFalse);
});
testWidgets('search box feeds a debounced query into the filter', (
tester,
) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
await tester.enterText(find.byType(TextField), 'diet');
await tester.pump(const Duration(milliseconds: 300)); // > debounce
expect(repo.lastFilter!.query, 'diet');
});
testWidgets('sort menu selection updates the query sort', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
await tester.tap(find.byIcon(Icons.sort));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // menu open
await tester.tap(find.text('Alphabetical').last);
await tester.pump();
expect(repo.lastSort, NoteSort.alphabetical);
});
testWidgets('filter sheet adds a status and shows the badge', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // sheet open
await tester.tap(find.text('Done'));
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // sheet close
expect(repo.lastFilter!.statuses, contains(Status.done));
// A non-default selection now surfaces the badge.
expect(tester.widget<Badge>(find.byType(Badge)).isLabelVisible, isTrue);
});
testWidgets('per-note sheet deletes the note', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'Delete me')]);
await tester.tap(find.text('Delete me'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // sheet open
await tester.tap(find.text('Delete note'));
await tester.pump();
expect(await repo.listNotes(), isEmpty);
});
testWidgets('per-note sheet changes status via a chip', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'Change me')]);
await tester.tap(find.text('Change me'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('In progress'));
await tester.pump();
expect((await repo.listNotes()).single.status, Status.inProgress);
});
testWidgets('shows an empty state when there are no notes', (tester) async {
await pumpList(tester); // no seed
// The default filter hides Done/Abandoned, so it's the "no match"
// variant rather than "No notes yet" — either way, an empty message.
expect(find.textContaining('No notes'), findsOneWidget);
});
testWidgets('a Created date preset sets a created range on the filter', (
tester,
) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// "Today" appears under both Created and Last-updated; the first is
// Created.
await tester.tap(find.text('Today').first);
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(repo.lastFilter!.createdFrom, isNotNull);
expect(repo.lastFilter!.createdTo, isNotNull);
});
testWidgets('clearing the search box resets the query', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
await tester.enterText(find.byType(TextField), 'foo');
await tester.pump(const Duration(milliseconds: 300));
expect(repo.lastFilter!.query, 'foo');
await tester.tap(find.byIcon(Icons.clear));
await tester.pump(const Duration(milliseconds: 300));
expect(repo.lastFilter!.query, isEmpty);
});
testWidgets('renders relative-time labels for varied ages', (tester) async {
final now = DateTime.now();
Note aged(String id, Duration ago) => Note(
id: id,
text: 'note $id',
priority: Priority.medium,
status: Status.todo,
createdAt: now.subtract(ago),
updatedAt: now.subtract(ago),
);
await pumpList(
tester,
seed: [
aged('s', const Duration(seconds: 10)),
aged('m', const Duration(minutes: 5)),
aged('h', const Duration(hours: 2)),
aged('d', const Duration(days: 3)),
],
);
expect(find.textContaining('just now'), findsOneWidget);
expect(find.textContaining('5m ago'), findsOneWidget);
expect(find.textContaining('2h ago'), findsOneWidget);
expect(find.textContaining('3d ago'), findsOneWidget);
});
testWidgets('Clear all resets the filter sheet to the default', (
tester,
) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Done')); // add a non-default status
await tester.pump();
await tester.tap(find.text('Clear all'));
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(repo.lastFilter!.statuses, {Status.todo, Status.inProgress});
});
testWidgets('a date range can be set then cleared with Any', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('7 days').first); // Created: last 7 days
await tester.pump();
await tester.tap(find.text('Any').first); // clear that range
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(repo.lastFilter!.createdFrom, isNull);
});
testWidgets('Custom… opens the range picker (cancel keeps no range)', (
tester,
) async {
final repo = await pumpList(tester, seed: [note('a', 'x')]);
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Custom…').first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // picker opens
// The full-screen range picker dismisses via a close icon, not a label.
await tester.tap(find.byIcon(Icons.close));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Apply'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(repo.lastFilter!.createdFrom, isNull);
});
}