diff --git a/lib/sync/notes_markdown.dart b/lib/sync/notes_markdown.dart index ea62365..a611e75 100644 --- a/lib/sync/notes_markdown.dart +++ b/lib/sync/notes_markdown.dart @@ -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(); diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 17fb841..bbbdb93 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -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 createState() => _CaptureScreenState(); } @@ -108,8 +114,11 @@ class _CaptureScreenState extends State { if (!mounted) return; final result = await Navigator.of(context).push( 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 { owner: settings.owner, repo: settings.repo, token: settings.token, + httpClient: widget.httpClient, ); try { final result = await _syncService.sync(widget.repository, client); diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index c1eb12d..2c50fae 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -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 pumpCapture(WidgetTester tester) async { - SharedPreferences.setMockInitialValues({}); + Future pumpCapture( + WidgetTester tester, { + Map 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); + }); } diff --git a/test/github_client_test.dart b/test/github_client_test.dart index 9a641c8..3048228 100644 --- a/test/github_client_test.dart +++ b/test/github_client_test.dart @@ -95,4 +95,18 @@ void main() { throwsA(isA()), ); }); + + 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); + }); } diff --git a/test/github_device_auth_test.dart b/test/github_device_auth_test.dart index ac5318d..88be7a4 100644 --- a/test/github_device_auth_test.dart +++ b/test/github_device_auth_test.dart @@ -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); + }); } diff --git a/test/note_repository_test.dart b/test/note_repository_test.dart index ae61ce0..c9383d9 100644 --- a/test/note_repository_test.dart +++ b/test/note_repository_test.dart @@ -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 v1→v2 (adds status, default todo) then v2→v3. + 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); + }); + }); } diff --git a/test/notes_list_screen_test.dart b/test/notes_list_screen_test.dart index d90cf08..e93b2a4 100644 --- a/test/notes_list_screen_test.dart +++ b/test/notes_list_screen_test.dart @@ -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); + }); } diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 838f22e..868c162 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -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> 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 openFile({ + List? 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 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.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 { diff --git a/test/sync_settings_test.dart b/test/sync_settings_test.dart index cbef6b3..20c7566 100644 --- a/test/sync_settings_test.dart +++ b/test/sync_settings_test.dart @@ -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'); }); }