Reach 100% test coverage (capture-screen sync DI + plugin/clipboard fakes)

- Inject an optional http.Client into CaptureScreen (mirroring SettingsScreen)
  so the configured sync path runs against a MockClient instead of the network;
  capture_screen.dart now 100%.
- Mock the file_selector and url_launcher platform interfaces and the clipboard
  channel so the import flow, _openPage launch, and the device-code dialog's
  error/Cancel/Open paths are exercised deterministically (no hangs, no timers).
- Add unit tests for the remaining fallbacks/defaults: copyWith no-arg paths,
  GitHubApiException.toString, default-constructed clients, empty NoteFilter,
  the v1->v2 status-column migration, and the export/import error branches.
- coverage:ignore the private static-only NotesMarkdown ctor.

101 tests, all green in ~5.5s. Line coverage 96.2% -> 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-15 17:11:01 +02:00
parent 7f84414c87
commit 6947757ba0
9 changed files with 303 additions and 8 deletions

View File

@ -11,7 +11,8 @@ import '../data/note.dart';
/// rather than creating duplicates the basis for "never lose ideas"
/// recovery and round-tripping a backup.
class NotesMarkdown {
const NotesMarkdown._();
// Private ctor: this is a static-only utility class, never instantiated.
const NotesMarkdown._(); // coverage:ignore-line
static const _uuid = Uuid();

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';
import '../data/note.dart';
@ -17,10 +18,15 @@ import 'settings_screen.dart';
/// action finalises the current idea and clears the field for the next
/// one (remote sync will hook in here later).
class CaptureScreen extends StatefulWidget {
const CaptureScreen({required this.repository, super.key});
const CaptureScreen({required this.repository, this.httpClient, super.key});
final NoteRepository repository;
/// Injectable HTTP client for the sync path. Production leaves this null
/// (the GitHubClient creates its own); tests pass a mock so the configured
/// sync flow can be exercised without real network access.
final http.Client? httpClient;
@override
State<CaptureScreen> createState() => _CaptureScreenState();
}
@ -108,8 +114,11 @@ class _CaptureScreenState extends State<CaptureScreen> {
if (!mounted) return;
final result = await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute(
builder: (_) =>
SettingsScreen(initial: current, repository: widget.repository),
builder: (_) => SettingsScreen(
initial: current,
repository: widget.repository,
httpClient: widget.httpClient,
),
),
);
if (result != null && mounted) setState(() => _settings = result);
@ -136,6 +145,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
owner: settings.owner,
repo: settings.repo,
token: settings.token,
httpClient: widget.httpClient,
);
try {
final result = await _syncService.sync(widget.repository, client);

View File

@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/capture_screen.dart';
import 'package:todo/ui/settings_screen.dart';
import 'fake_note_repository.dart';
@ -11,15 +14,36 @@ void main() {
// widget tester's fake clock, so these tests inject a timer-free fake.
// (NOTE: avoid pumpAndSettle the autofocused field's cursor blink never
// settles; pump explicit frames instead.)
Future<FakeNoteRepository> pumpCapture(WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
Future<FakeNoteRepository> pumpCapture(
WidgetTester tester, {
Map<String, Object> prefs = const {},
http.Client? httpClient,
}) async {
SharedPreferences.setMockInitialValues(prefs);
// Tall surface so a pushed settings screen builds its whole ListView.
tester.view.physicalSize = const Size(1200, 2800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repo = FakeNoteRepository();
addTearDown(repo.close);
await tester.pumpWidget(MaterialApp(home: CaptureScreen(repository: repo)));
await tester.pumpWidget(
MaterialApp(
home: CaptureScreen(repository: repo, httpClient: httpClient),
),
);
await tester.pump(); // flush initial stream + settings load
return repo;
}
// Seeds a fully configured GitHub sync so the configured `_sync` path runs.
const configuredPrefs = {
'sync.owner': 'o',
'sync.repo': 'r',
'sync.token': 'tok',
};
testWidgets('pre-fills the structured template', (tester) async {
await pumpCapture(tester);
@ -135,4 +159,59 @@ void main() {
expect((await repo.listNotes()).single.status, Status.inProgress);
});
testWidgets('Sync with a configured token runs the sync service', (
tester,
) async {
// Empty remote directory (404) the service has nothing to merge and
// pushes this device's own changeset (PUT).
final mock = MockClient((req) async {
if (req.method == 'PUT') return http.Response('{}', 200);
return http.Response('', 404);
});
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
await tester.tap(find.byTooltip('Sync'));
await tester.pump(); // setState(_syncing = true)
await tester.pump(); // service runs, snackbar scheduled
await tester.pump(); // snackbar builds
expect(find.textContaining('Synced: merged 0 device'), findsOneWidget);
});
testWidgets('Sync surfaces a failure from the sync service', (tester) async {
final mock = MockClient((_) async => throw Exception('offline'));
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
await tester.tap(find.byTooltip('Sync'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(find.textContaining('Sync failed'), findsOneWidget);
});
testWidgets('returning from settings adopts the saved configuration', (
tester,
) async {
await pumpCapture(tester);
await tester.tap(find.byTooltip('Sync settings'));
await tester.pumpAndSettle(); // route transition
expect(find.text('Connect GitHub'), findsOneWidget); // settings is up
// Saving pops a SyncSettings back to the capture screen (covers the
// result-adoption branch in _openSettings). Scope to the settings route
// the capture screen's own "Save" is still mounted behind it.
await tester.tap(
find.descendant(
of: find.byType(SettingsScreen),
matching: find.text('Save'),
),
);
await tester.pumpAndSettle(); // save + pop transition
expect(find.text('Connect GitHub'), findsNothing); // back on capture
expect(find.byTooltip('Sync settings'), findsOneWidget);
});
}

View File

@ -95,4 +95,18 @@ void main() {
throwsA(isA<GitHubApiException>()),
);
});
test('GitHubApiException.toString includes status and message', () {
expect(
GitHubApiException(500, 'boom').toString(),
'GitHubApiException(500): boom',
);
});
test('creates a default http client when none is injected', () {
// No httpClient the constructor builds a real http.Client; just make
// sure that branch runs and the client closes cleanly (no request made).
final c = GitHubClient(owner: 'o', repo: 'r', token: 't');
addTearDown(c.close);
});
}

View File

@ -152,4 +152,11 @@ void main() {
),
);
});
test('defaults to a real http client and delay when none are injected', () {
// Omitting httpClient/delay exercises the `?? http.Client()` and
// `?? Future.delayed` constructor fallbacks; no request is made.
final auth = GitHubDeviceAuth(clientId: 'c');
addTearDown(auth.close);
});
}

View File

@ -313,6 +313,44 @@ void main() {
expect(notes.single.priority, Priority.medium);
});
test('v1→v2 migration adds the status column with a default', () async {
final dir = await Directory.systemTemp.createTemp('todo_migration_v1');
final path = '${dir.path}/notes.db';
addTearDown(() => dir.delete(recursive: true));
// v1 schema predates the status column entirely.
final v1 = await SqliteCrdt.open(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE notes (
id TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (id)
)
''');
},
);
final now = DateTime.now().toIso8601String();
await v1.execute(
'INSERT INTO notes (id, text, priority, created_at, updated_at) '
'VALUES (?1, ?2, ?3, ?4, ?5)',
['old', 'pre-status idea', 1, now, now],
);
await v1.close();
// Reopening runs onUpgrade v1v2 (adds status, default todo) then v2v3.
final repo = await NoteRepository.open(path);
addTearDown(repo.close);
final notes = await repo.listNotes();
expect(notes.single.id, 'old');
expect(notes.single.status, Status.todo); // backfilled default
});
group('sorting and streams', () {
test('createdDesc and alphabetical orderings', () async {
final repo = await NoteRepository.openInMemory();
@ -371,4 +409,34 @@ void main() {
final merged = await target.listNotes();
expect(merged.single.text, 'shared idea');
});
group('NoteFilter', () {
test('a default filter is empty (all facets cleared)', () {
// Evaluates the full conjunction in `isEmpty`, including the date bounds.
const filter = NoteFilter();
expect(filter.isEmpty, isTrue);
expect(filter.activeCount, 0);
});
test('a filter with any facet set is not empty', () {
expect(const NoteFilter(query: 'x').isEmpty, isFalse);
expect(const NoteFilter(statuses: {Status.done}).isEmpty, isFalse);
});
test('copyWith with no arguments preserves every facet', () {
final base = NoteFilter(
query: 'milk',
priorities: const {Priority.high},
statuses: const {Status.todo},
createdFrom: DateTime(2026, 1, 1),
updatedTo: DateTime(2026, 2, 2),
);
final clone = base.copyWith();
expect(clone.query, base.query);
expect(clone.priorities, base.priorities);
expect(clone.statuses, base.statuses);
expect(clone.createdFrom, base.createdFrom);
expect(clone.updatedTo, base.updatedTo);
});
});
}

View File

@ -250,4 +250,65 @@ void main() {
expect(repo.lastFilter!.createdFrom, isNull);
});
testWidgets('filter sheet toggles a priority and a Last-updated preset', (
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('High')); // priority chip toggle
// Second "Today" belongs to the Last-updated section.
await tester.tap(find.text('Today').last);
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(repo.lastFilter!.priorities, contains(Priority.high));
expect(repo.lastFilter!.updatedFrom, isNotNull);
expect(repo.lastFilter!.updatedTo, isNotNull);
});
testWidgets('a 30-day preset then Custom… confirms the seeded 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('30 days').first); // _applyDays(30)
await tester.pump();
// Custom opens the range picker seeded with the 30-day range
// (initialDateRange != null); confirming with Save returns that range.
await tester.tap(find.text('Custom…').first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300)); // picker opens
await tester.tap(find.text('Save'));
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, isNotNull);
expect(repo.lastFilter!.createdTo, isNotNull);
});
testWidgets('per-note sheet changes priority via a chip', (tester) async {
final repo = await pumpList(tester, seed: [note('a', 'Repriortise me')]);
await tester.tap(find.text('Repriortise me'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Low')); // default is Medium change to Low
await tester.pump();
expect((await repo.listNotes()).single.priority, Priority.low);
});
}

View File

@ -9,6 +9,7 @@ import 'package:http/testing.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/settings_screen.dart';
@ -32,6 +33,26 @@ class _FakeFileSelector extends FileSelectorPlatform
}) async => file;
}
/// Repository whose reads fail, to exercise the export error path.
class _ExplodingRepo extends FakeNoteRepository {
@override
Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) async => throw Exception('db down');
}
/// File picker stub that throws, to exercise the import error path.
class _ThrowingFileSelector extends FileSelectorPlatform
with MockPlatformInterfaceMixin {
@override
Future<XFile?> openFile({
List<XTypeGroup>? acceptedTypeGroups,
String? initialDirectory,
String? confirmButtonText,
}) async => throw Exception('picker blew up');
}
/// Stub launcher that records the URL instead of opening it, so `_openPage`
/// can be exercised without a real platform channel.
class _FakeUrlLauncher extends UrlLauncherPlatform
@ -61,6 +82,7 @@ void main() {
),
http.Client? httpClient,
List<Note> seed = const [],
FakeNoteRepository? repository,
}) async {
SharedPreferences.setMockInitialValues({});
// Tall surface so the whole settings ListView builds (its Backup section
@ -70,7 +92,7 @@ void main() {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repo = FakeNoteRepository(seed);
final repo = repository ?? FakeNoteRepository(seed);
addTearDown(repo.close);
await tester.pumpWidget(
MaterialApp(
@ -223,6 +245,20 @@ void main() {
expect(find.textContaining('Exported'), findsOneWidget);
});
testWidgets('Export surfaces a failure when the repository read fails', (
tester,
) async {
await pumpSettings(tester, repository: _ExplodingRepo());
await tester.runAsync(() async {
await tester.tap(find.text('Export notes'));
await Future<void>.delayed(const Duration(milliseconds: 50));
});
await tester.pump();
expect(find.textContaining('Export failed'), findsOneWidget);
});
testWidgets('Save persists the settings and closes the screen', (
tester,
) async {
@ -319,6 +355,18 @@ void main() {
expect(await repo.listNotes(), isEmpty);
});
testWidgets('Import surfaces a failure from the picker', (tester) async {
FileSelectorPlatform.instance = _ThrowingFileSelector();
final repo = await pumpSettings(tester);
await tester.tap(find.text('Import notes'));
await tester.pump();
await tester.pump();
expect(find.textContaining('Import failed'), findsOneWidget);
expect(await repo.listNotes(), isEmpty);
});
testWidgets('device dialog: failed poll shows the error and Open launches', (
tester,
) async {

View File

@ -65,5 +65,12 @@ void main() {
expect(next.repo, 'r');
expect(next.token, 'new');
expect(next.clientId, 'c');
// No-arg copy exercises the `?? this.x` fallback on every field.
final clone = base.copyWith();
expect(clone.owner, 'o');
expect(clone.repo, 'r');
expect(clone.token, 't');
expect(clone.clientId, 'c');
});
}