mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:43:38 +02:00
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:
parent
7f84414c87
commit
6947757ba0
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user