mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:27:01 +01:00
Split nine oversized test files under the cap
Every split is by test-group/concern, with the shared setup moved to a
sibling helper the splits import. All 313 tests are preserved -- the count
is unchanged and `test('…')` names are byte-identical to the originals
(verified by diffing the sorted name lists).
note_repository_test 677 -> 4 files + note_repository_helpers
capture_screen_test 660 -> 4 files + capture_screen_harness
note_editor_test 502 -> 3 files + note_editor_harness
port_guard_test 405 -> 3 files + port_guard_fakes
wrapper_server_test 402 -> 2 files + wrapper_server_harness
settings_screen_test 391 -> 2 files + settings_screen_harness
github_mirror_screen_test 365 -> 2 files + github_mirror_harness
notes_list_screen_test 347 -> 2 files + notes_list_harness
analytics_service_test 300 -> 2 files + analytics_helpers
Helpers are deliberately NOT named `*_test.dart`; the runner would collect
them and fail on the missing `main()`.
Three things did not survive a mechanical split and were handled explicitly:
* wrapper_server's `enabledOrigin` closes over the `late root` that setUp
assigns, so a top-level function cannot see it. It is now a
WrapperHarness class with an install() that registers the setUp/tearDown
pair -- the only structural change in this commit.
* note_repository's splits each repeat `setUpAll(sqfliteFfiInit)`, and
analytics' each repeat `@TestOn('vm')`. Omitting either fails quietly
(uninitialised sqflite / wrong platform) rather than loudly.
* capture_screen's `recordingMock` was declared mid-file between two
tests, so it moved to the harness rather than being dropped.
Verified: 313 tests pass (same as before the refactor), 100% coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d145f92a07
commit
10a546f6d6
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.15+1
|
||||
version: 1.0.16+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
139
test/analytics_event_test.dart
Normal file
139
test/analytics_event_test.dart
Normal file
@ -0,0 +1,139 @@
|
||||
/// Tests for the interaction-only analytics buffer and Firebase flush.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:todo/analytics/analytics_event.dart';
|
||||
import 'package:todo/analytics/analytics_service.dart';
|
||||
|
||||
import 'analytics_helpers.dart';
|
||||
|
||||
FirebaseRestClient _fakeClient(
|
||||
Future<http.Response> Function(http.Request request) handler,
|
||||
) => FirebaseRestClient(
|
||||
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
|
||||
auth: FirebaseTokenProvider(
|
||||
apiKey: 'AIzaKey',
|
||||
store: InMemoryCredentialStore(
|
||||
FirebaseCredentials(
|
||||
idToken: 'id',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
httpClient: http_testing.MockClient(handler),
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
group('AnalyticsEvent', () {
|
||||
test('round-trips through toJson/tryFromJson', () {
|
||||
final event = AnalyticsEvent(
|
||||
name: 'screen_view',
|
||||
timestamp: DateTime(2026, 1, 1, 12),
|
||||
params: {'screen': 'capture'},
|
||||
);
|
||||
final decoded = AnalyticsEvent.tryFromJson(event.toJson());
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.name, 'screen_view');
|
||||
expect(decoded.timestamp, event.timestamp);
|
||||
expect(decoded.params, {'screen': 'capture'});
|
||||
});
|
||||
|
||||
test('tryFromJson returns null for a non-map value', () {
|
||||
expect(AnalyticsEvent.tryFromJson('not a map'), isNull);
|
||||
expect(AnalyticsEvent.tryFromJson(null), isNull);
|
||||
});
|
||||
|
||||
test('tryFromJson returns null for a missing/invalid name', () {
|
||||
expect(
|
||||
AnalyticsEvent.tryFromJson({'timestamp': '2026-01-01T00:00:00.000'}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('tryFromJson returns null for an unparsable timestamp', () {
|
||||
expect(
|
||||
AnalyticsEvent.tryFromJson({'name': 'x', 'timestamp': 'not a date'}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('tryFromJson defaults params to empty when missing/non-map', () {
|
||||
final decoded = AnalyticsEvent.tryFromJson({
|
||||
'name': 'x',
|
||||
'timestamp': DateTime(2026).toIso8601String(),
|
||||
});
|
||||
expect(decoded!.params, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnalyticsService.logEvent', () {
|
||||
test('appends to an initially empty buffer', () async {
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()),
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString('analytics.buffer');
|
||||
expect(raw, isNotNull);
|
||||
final decoded = jsonDecode(raw!) as List;
|
||||
expect(decoded, hasLength(1));
|
||||
});
|
||||
|
||||
test('drops the oldest event once past the cap', () async {
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
for (var i = 0; i < AnalyticsService.maxBufferedEvents + 5; i++) {
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(
|
||||
name: 'event_$i',
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List;
|
||||
expect(decoded, hasLength(AnalyticsService.maxBufferedEvents));
|
||||
// The oldest 5 events were dropped, so event_0..event_4 are gone.
|
||||
final names = decoded.map((e) => (e as Map)['name']).toList();
|
||||
expect(names, isNot(contains('event_0')));
|
||||
expect(
|
||||
names,
|
||||
contains('event_${AnalyticsService.maxBufferedEvents + 4}'),
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores a malformed pre-existing buffer entry on read', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'analytics.buffer': jsonEncode([
|
||||
{'not': 'an event'},
|
||||
{
|
||||
'name': 'ok_event',
|
||||
'timestamp': DateTime(2026).toIso8601String(),
|
||||
'params': <String, Object?>{},
|
||||
},
|
||||
]),
|
||||
});
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(name: 'new_event', timestamp: DateTime.now()),
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List;
|
||||
// The malformed entry was dropped; only the valid + new event remain.
|
||||
expect(decoded, hasLength(2));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -12,6 +12,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:todo/analytics/analytics_event.dart';
|
||||
import 'package:todo/analytics/analytics_service.dart';
|
||||
|
||||
import 'analytics_helpers.dart';
|
||||
|
||||
FirebaseRestClient _fakeClient(
|
||||
Future<http.Response> Function(http.Request request) handler,
|
||||
) => FirebaseRestClient(
|
||||
@ -32,109 +34,6 @@ FirebaseRestClient _fakeClient(
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
group('AnalyticsEvent', () {
|
||||
test('round-trips through toJson/tryFromJson', () {
|
||||
final event = AnalyticsEvent(
|
||||
name: 'screen_view',
|
||||
timestamp: DateTime(2026, 1, 1, 12),
|
||||
params: {'screen': 'capture'},
|
||||
);
|
||||
final decoded = AnalyticsEvent.tryFromJson(event.toJson());
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.name, 'screen_view');
|
||||
expect(decoded.timestamp, event.timestamp);
|
||||
expect(decoded.params, {'screen': 'capture'});
|
||||
});
|
||||
|
||||
test('tryFromJson returns null for a non-map value', () {
|
||||
expect(AnalyticsEvent.tryFromJson('not a map'), isNull);
|
||||
expect(AnalyticsEvent.tryFromJson(null), isNull);
|
||||
});
|
||||
|
||||
test('tryFromJson returns null for a missing/invalid name', () {
|
||||
expect(
|
||||
AnalyticsEvent.tryFromJson({'timestamp': '2026-01-01T00:00:00.000'}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('tryFromJson returns null for an unparsable timestamp', () {
|
||||
expect(
|
||||
AnalyticsEvent.tryFromJson({'name': 'x', 'timestamp': 'not a date'}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('tryFromJson defaults params to empty when missing/non-map', () {
|
||||
final decoded = AnalyticsEvent.tryFromJson({
|
||||
'name': 'x',
|
||||
'timestamp': DateTime(2026).toIso8601String(),
|
||||
});
|
||||
expect(decoded!.params, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnalyticsService.logEvent', () {
|
||||
test('appends to an initially empty buffer', () async {
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(name: 'app_open', timestamp: DateTime.now()),
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString('analytics.buffer');
|
||||
expect(raw, isNotNull);
|
||||
final decoded = jsonDecode(raw!) as List;
|
||||
expect(decoded, hasLength(1));
|
||||
});
|
||||
|
||||
test('drops the oldest event once past the cap', () async {
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
for (var i = 0; i < AnalyticsService.maxBufferedEvents + 5; i++) {
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(
|
||||
name: 'event_$i',
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List;
|
||||
expect(decoded, hasLength(AnalyticsService.maxBufferedEvents));
|
||||
// The oldest 5 events were dropped, so event_0..event_4 are gone.
|
||||
final names = decoded.map((e) => (e as Map)['name']).toList();
|
||||
expect(names, isNot(contains('event_0')));
|
||||
expect(
|
||||
names,
|
||||
contains('event_${AnalyticsService.maxBufferedEvents + 4}'),
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores a malformed pre-existing buffer entry on read', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'analytics.buffer': jsonEncode([
|
||||
{'not': 'an event'},
|
||||
{
|
||||
'name': 'ok_event',
|
||||
'timestamp': DateTime(2026).toIso8601String(),
|
||||
'params': <String, Object?>{},
|
||||
},
|
||||
]),
|
||||
});
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
await service.logEvent(
|
||||
AnalyticsEvent(name: 'new_event', timestamp: DateTime.now()),
|
||||
);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final decoded = jsonDecode(prefs.getString('analytics.buffer')!) as List;
|
||||
// The malformed entry was dropped; only the valid + new event remain.
|
||||
expect(decoded, hasLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
group('AnalyticsService.flush', () {
|
||||
test('does nothing when client is null', () async {
|
||||
const service = AnalyticsService(nodeId: 'device-a');
|
||||
33
test/analytics_helpers.dart
Normal file
33
test/analytics_helpers.dart
Normal file
@ -0,0 +1,33 @@
|
||||
/// The fake FirebaseRestClient used by the analytics tests.
|
||||
///
|
||||
/// Shared by the files `analytics_service_test.dart` was split into for the
|
||||
/// 250-line cap. Deliberately NOT named `*_test.dart`: the runner would
|
||||
/// collect it and fail on the missing `main()`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart' as http_testing;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:todo/analytics/analytics_event.dart';
|
||||
import 'package:todo/analytics/analytics_service.dart';
|
||||
|
||||
FirebaseRestClient _fakeClient(
|
||||
Future<http.Response> Function(http.Request request) handler,
|
||||
) => FirebaseRestClient(
|
||||
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
|
||||
auth: FirebaseTokenProvider(
|
||||
apiKey: 'AIzaKey',
|
||||
store: InMemoryCredentialStore(
|
||||
FirebaseCredentials(
|
||||
idToken: 'id',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
httpClient: http_testing.MockClient(handler),
|
||||
);
|
||||
146
test/capture_screen_advanced_test.dart
Normal file
146
test/capture_screen_advanced_test.dart
Normal file
@ -0,0 +1,146 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
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/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'auto-sync adopts a newer advancedMode value from Firebase',
|
||||
(tester) async {
|
||||
final remoteTime = DateTime(2026, 6);
|
||||
final firebase = FirebaseRestClient(
|
||||
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
|
||||
auth: FirebaseTokenProvider(
|
||||
apiKey: 'AIzaKey',
|
||||
store: InMemoryCredentialStore(
|
||||
FirebaseCredentials(
|
||||
idToken: 'id',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/settings/advancedMode.json') {
|
||||
return http.Response(
|
||||
'{"value": "true", '
|
||||
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.method == 'PUT') return http.Response(request.body, 200);
|
||||
return http.Response('null', 200);
|
||||
}),
|
||||
);
|
||||
final appSettings = ValueNotifier(
|
||||
AppSettings(
|
||||
advancedMode: false,
|
||||
// Older than remoteTime, and non-null, so the reconciliation
|
||||
// guard's `currentAt != null && !reconciledAt.isAfter(currentAt)`
|
||||
// check actually evaluates both operands.
|
||||
advancedModeUpdatedAt: remoteTime.subtract(
|
||||
const Duration(days: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
firebaseFactory: () async => firebase,
|
||||
appSettings: appSettings,
|
||||
);
|
||||
await tester.pump(); // settings load → auto-sync (pull) …
|
||||
await tester.pump(); // … then push, then reconcile
|
||||
|
||||
expect(appSettings.value.advancedMode, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('logs app_open when analytics is injected', (tester) async {
|
||||
const analytics = AnalyticsService(nodeId: 'device-a');
|
||||
await pumpCapture(tester, analytics: analytics);
|
||||
await tester.pump(); // flush the unawaited logEvent's prefs write
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final buffer = prefs.getString('analytics.buffer');
|
||||
expect(buffer, isNotNull);
|
||||
expect(buffer, contains('app_open'));
|
||||
});
|
||||
|
||||
testWidgets('auto-syncs on launch when configured', (tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
httpClient: recordingMock(methods),
|
||||
);
|
||||
await tester.pump(); // settings load → auto-sync (pull) …
|
||||
await tester.pump(); // … then push
|
||||
|
||||
expect(methods, contains('PUT')); // this device pushed its changeset
|
||||
});
|
||||
|
||||
testWidgets('does not auto-sync when unconfigured', (tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(tester, httpClient: recordingMock(methods)); // no token
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(methods, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('auto-syncs again when the app is backgrounded', (tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
httpClient: recordingMock(methods),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
methods.clear();
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// A sync ran. Not `contains('PUT')`: the launch sync above already pushed
|
||||
// this log, so revision tracking correctly suppresses the second, unchanged
|
||||
// push -- that suppression is the point of the revision cache.
|
||||
expect(methods, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('auto-sync failure shows no snackbar but flags the status line', (
|
||||
tester,
|
||||
) async {
|
||||
final mock = MockClient((_) async => throw Exception('offline'));
|
||||
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Capture must never be interrupted by a snackbar…
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
// …but the failure is no longer swallowed: the status line shows it.
|
||||
expect(find.textContaining('Sync failed at'), findsOneWidget);
|
||||
expect(find.textContaining('offline'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
161
test/capture_screen_editing_test.dart
Normal file
161
test/capture_screen_editing_test.dart
Normal file
@ -0,0 +1,161 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
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/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('defaults to Raw, with Guided available via the entry wizard', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
// Defaults to Raw: a single text field, no note persisted yet.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
|
||||
// Tapping Guided on the empty draft opens the priority+template wizard
|
||||
// rather than jumping straight into the stepper.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pump();
|
||||
|
||||
// Now on step 1 of the full-screen step page.
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(
|
||||
find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('imperative'), findsOneWidget); // title helper
|
||||
});
|
||||
|
||||
testWidgets('saving the untouched template creates no note', (tester) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('typing into the template persists a note with defaults', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
// The first field in the guided stepper is the title section.
|
||||
await tester.enterText(find.byType(TextField).first, 'My idea');
|
||||
await tester.pump();
|
||||
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, contains('My idea'));
|
||||
expect(notes.single.priority, Priority.medium);
|
||||
expect(notes.single.status, Status.todo);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the new-note action resets the template', (tester) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'A real idea');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.listNotes(), hasLength(1));
|
||||
// The editor reset to a fresh, empty Raw draft.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('tapping Sync while unconfigured prompts to connect a backend', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester); // empty prefs → no token → not configured
|
||||
|
||||
await tester.tap(find.byTooltip('Sync'));
|
||||
await tester.pump(); // settings load + snackbar
|
||||
await tester.pump();
|
||||
|
||||
// The behaviour that matters is the routing, not the wording: an
|
||||
// unconfigured device is sent to settings to connect a backend. Asserting
|
||||
// on the snackbar text tied this to one phrasing, and the snackbar is
|
||||
// gone by the time settings has pushed anyway.
|
||||
expect(find.textContaining('Connect Firebase'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('the notes-list button navigates to the list screen', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Notes'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // route transition
|
||||
|
||||
expect(find.text('Notes'), findsOneWidget); // list screen app bar title
|
||||
});
|
||||
|
||||
testWidgets('changing the priority dropdown updates the saved note', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Prioritised idea');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is DropdownButton<Priority>),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||
});
|
||||
|
||||
testWidgets('changing the status dropdown updates the saved note', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Status idea');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is DropdownButton<Status>),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
||||
await tester.tap(find.text('In progress').last);
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.status, Status.inProgress);
|
||||
});
|
||||
}
|
||||
106
test/capture_screen_harness.dart
Normal file
106
test/capture_screen_harness.dart
Normal file
@ -0,0 +1,106 @@
|
||||
/// The shared harness for the capture-screen test files.
|
||||
///
|
||||
/// `capture_screen_test.dart` was split by concern for the 250-line cap.
|
||||
/// [pumpCapture] closes over the `WidgetTester`'s view and addTearDown, so it
|
||||
/// takes the tester as a parameter rather than being a closure inside main().
|
||||
///
|
||||
/// Deliberately NOT named `*_test.dart`: the runner would collect it and fail
|
||||
/// on the missing `main()`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
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/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
// A real CRDT DB schedules sqflite timers that never drain under the
|
||||
// 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, {
|
||||
Map<String, Object> prefs = const {},
|
||||
http.Client? httpClient,
|
||||
List<Note> seed = const [],
|
||||
FakeNoteRepository? repository,
|
||||
LocalBackup? localBackup,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
// Defaults to true: these tests exercise today's full UI (priority/status
|
||||
// dropdowns, sync status text, Guided). Tests for the new default-off
|
||||
// behavior pass advancedMode: false explicitly.
|
||||
ValueNotifier<AppSettings>? appSettings,
|
||||
AnalyticsService? analytics,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(prefs);
|
||||
installFakeSecureStorage();
|
||||
// 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 = repository ?? FakeNoteRepository(seed);
|
||||
addTearDown(repo.close);
|
||||
// Default to an in-memory, no-op backup so tests never touch real disk
|
||||
// (the production backup writes ~/todo/BACKLOG.md on the Linux test host).
|
||||
final backup =
|
||||
localBackup ??
|
||||
LocalBackup(
|
||||
fetch: repo.listNotes,
|
||||
reader: () async => null,
|
||||
writer: (_) async {},
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CaptureScreen(
|
||||
repository: repo,
|
||||
appSettings:
|
||||
appSettings ?? ValueNotifier(const AppSettings(advancedMode: true)),
|
||||
analytics: analytics,
|
||||
httpClient: httpClient,
|
||||
// Both injected so the widget never reaches for the platform: the
|
||||
// real factories want the OS keystore and an application-support
|
||||
// directory, neither of which exists under `flutter test`.
|
||||
firebaseFactory: firebaseFactory ?? () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
localBackup: backup,
|
||||
),
|
||||
),
|
||||
);
|
||||
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',
|
||||
};
|
||||
|
||||
// A MockClient that records request methods and answers the sync flow:
|
||||
// repo-exists GET (not /contents/) → 200, the (empty) log listing → 404,
|
||||
// and the device's own PUT → 200.
|
||||
MockClient recordingMock(List<String> methods) => MockClient((req) async {
|
||||
methods.add(req.method);
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
return http.Response('', 404);
|
||||
});
|
||||
171
test/capture_screen_lifecycle_test.dart
Normal file
171
test/capture_screen_lifecycle_test.dart
Normal file
@ -0,0 +1,171 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
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/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'auto-sync success shows no status text — sync is automatic and silent',
|
||||
(tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
httpClient: recordingMock(methods),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Only a failure is worth surfacing; a routine success is silent.
|
||||
expect(find.textContaining('Synced at'), findsNothing);
|
||||
expect(find.textContaining('merged 0 device(s)'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('restores the last sync outcome on launch', (tester) async {
|
||||
// Unconfigured (no token) so no launch auto-sync overwrites the restored
|
||||
// status. A failure recorded on a previous run must still be visible.
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: {
|
||||
'sync.lastTime': DateTime(2026, 7, 16, 12).toIso8601String(),
|
||||
'sync.lastOk': false,
|
||||
'sync.lastDetail': 'Exception: rate limited',
|
||||
},
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Sync failed at 12:00:00'), findsOneWidget);
|
||||
expect(find.textContaining('rate limited'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('losing focus triggers a debounced auto-sync', (tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
httpClient: recordingMock(methods),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
methods.clear();
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||
// A second signal (hidden also counts as focus loss) resets the debounce
|
||||
// instead of double-firing.
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
||||
await tester.pump(
|
||||
CaptureScreen.autoSyncDebounce - const Duration(seconds: 1),
|
||||
);
|
||||
expect(methods, isEmpty); // still inside the debounce window
|
||||
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// A sync ran. Not `contains('PUT')`: the first sync above already pushed
|
||||
// this log, so revision tracking correctly suppresses the second, unchanged
|
||||
// push -- that suppression is the point of the revision cache.
|
||||
expect(methods, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('recovers notes from the local backup into an empty DB', (
|
||||
tester,
|
||||
) async {
|
||||
final markdown = NotesMarkdown.export([
|
||||
Note(
|
||||
id: 'r1',
|
||||
text: '# Recovered idea',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
),
|
||||
]);
|
||||
final backup = LocalBackup(
|
||||
fetch: () async => const <Note>[],
|
||||
reader: () async => markdown,
|
||||
writer: (_) async {},
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
|
||||
final repo = await pumpCapture(tester, localBackup: backup);
|
||||
await tester.pump(); // recover → import
|
||||
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, contains('Recovered idea'));
|
||||
});
|
||||
|
||||
testWidgets('does not recover when the DB already has notes', (tester) async {
|
||||
final backup = LocalBackup(
|
||||
fetch: () async => const <Note>[],
|
||||
reader: () async => NotesMarkdown.export([
|
||||
Note(
|
||||
id: 'r1',
|
||||
text: '# From backup',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
),
|
||||
]),
|
||||
writer: (_) async {},
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
final seeded = Note(
|
||||
id: 'local',
|
||||
text: '# Existing',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
);
|
||||
|
||||
final repo = await pumpCapture(tester, seed: [seeded], localBackup: backup);
|
||||
await tester.pump();
|
||||
|
||||
// The backup is ignored because the DB was not empty.
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.id, 'local');
|
||||
});
|
||||
|
||||
testWidgets('writes the local backup as notes change', (tester) async {
|
||||
final writes = <String>[];
|
||||
final repo = FakeNoteRepository();
|
||||
// fetch pulls from the repo on write, mirroring production wiring.
|
||||
final backup = LocalBackup(
|
||||
fetch: repo.listNotes,
|
||||
reader: () async => null,
|
||||
writer: (md) async => writes.add(md),
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
|
||||
await pumpCapture(tester, repository: repo, localBackup: backup);
|
||||
await tester.enterText(find.byType(TextField).first, 'Backed up idea');
|
||||
await tester
|
||||
.pump(); // let the change tick → schedule → async fetch/write run
|
||||
|
||||
expect(writes, isNotEmpty);
|
||||
expect(writes.last, contains('Backed up idea'));
|
||||
});
|
||||
}
|
||||
189
test/capture_screen_sync_test.dart
Normal file
189
test/capture_screen_sync_test.dart
Normal file
@ -0,0 +1,189 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
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/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Sync with a configured token runs the sync service', (
|
||||
tester,
|
||||
) async {
|
||||
// Empty remote directory → the service has nothing to merge and pushes
|
||||
// this device's own log (PUT). The bare repo GET (not /contents/) returns
|
||||
// 200 so crdt_sync's client treats the 404 dir as "empty", not missing.
|
||||
final mock = MockClient((req) async {
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) 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
|
||||
|
||||
// No firebaseFactory was injected, so this runs over GitHub alone --
|
||||
// the status line must say so rather than reading identically to a
|
||||
// Firebase-connected sync.
|
||||
expect(
|
||||
find.textContaining(
|
||||
'Synced: GitHub-only (Firebase not connected) — 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();
|
||||
|
||||
// 'Sync failed:' is the snackbar; the status line says 'Sync failed at'.
|
||||
expect(find.textContaining('Sync failed:'), findsOneWidget);
|
||||
expect(find.textContaining('Sync failed at'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('manual sync reports peer files it could not decode', (
|
||||
tester,
|
||||
) async {
|
||||
// One peer file exists but holds garbage → merged 0, skipped 1.
|
||||
final mock = MockClient((req) async {
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
if (req.url.path.endsWith('/todo-sync/notes')) {
|
||||
return http.Response('[{"name": "bad.json"}]', 200);
|
||||
}
|
||||
if (req.url.path.endsWith('/bad.json')) {
|
||||
return http.Response(
|
||||
'{"content": "${base64Encode(utf8.encode('{not a log'))}"}',
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('', 404); // own-file sha probe → create fresh
|
||||
});
|
||||
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('skipped 1 unreadable file(s)'),
|
||||
findsWidgets, // snackbar and status line both call it out
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('returning from settings adopts the saved configuration', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Settings'));
|
||||
await tester.pumpAndSettle(); // route transition
|
||||
expect(find.byType(SettingsScreen), findsOneWidget); // settings is up
|
||||
|
||||
// GitHub sync settings (owner/repo/token) now live on the standalone
|
||||
// GitHubMirrorScreen, reached via a link from SettingsScreen. The pop
|
||||
// value itself is discarded by SettingsScreen; _openSettings always
|
||||
// reloads from storage instead (a device-flow "Connect" saves the token
|
||||
// without popping a result at all, so a stale in-memory value would be
|
||||
// wrong there too). This exercises that reload-after-return path, then
|
||||
// Back returns to capture.
|
||||
await tester.tap(find.text('Advanced sync (GitHub)'));
|
||||
await tester.pumpAndSettle(); // route transition
|
||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle(); // save + pop transition back to Settings
|
||||
expect(find.byType(SettingsScreen), findsOneWidget);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle(); // pop transition back to capture
|
||||
|
||||
expect(find.byType(SettingsScreen), findsNothing); // back on capture
|
||||
expect(find.byTooltip('Settings'), findsOneWidget);
|
||||
});
|
||||
|
||||
// A MockClient that records request methods and answers the sync flow:
|
||||
// repo-exists GET (not /contents/) → 200, the (empty) log listing → 404,
|
||||
// and the device's own PUT → 200.
|
||||
MockClient recordingMock(List<String> methods) => MockClient((req) async {
|
||||
methods.add(req.method);
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
return http.Response('', 404);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'advancedMode off shows just the text field, no chrome or status text',
|
||||
(tester) async {
|
||||
await pumpCapture(
|
||||
tester,
|
||||
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
|
||||
);
|
||||
await tester.enterText(find.byType(TextField), 'A plain idea');
|
||||
await tester.pump();
|
||||
|
||||
// The editor's own template/mode chrome is gone.
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.text('Template'), findsNothing);
|
||||
// The capture screen's priority/status row is gone.
|
||||
expect(find.text('Priority'), findsNothing);
|
||||
expect(find.text('Status'), findsNothing);
|
||||
// Routine status chatter is gone; only a badge count remains.
|
||||
expect(find.textContaining('Autosaves as you type'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
// The note still persisted normally underneath the simplified UI.
|
||||
expect(find.text('A plain idea'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'draft text survives an advancedMode toggle mid-typing',
|
||||
(tester) async {
|
||||
final appSettings = ValueNotifier(
|
||||
const AppSettings(advancedMode: true),
|
||||
);
|
||||
await pumpCapture(tester, appSettings: appSettings);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first,
|
||||
'my important idea',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// The Row above the editor is conditionally present, which shifts the
|
||||
// editor's position in the Column whenever advancedMode changes at
|
||||
// runtime — a key one level too shallow doesn't survive that
|
||||
// reposition and Flutter remounts the editor with an empty draft.
|
||||
appSettings.value = const AppSettings(advancedMode: false);
|
||||
await tester.pump();
|
||||
|
||||
final field = tester.widget<TextField>(find.byType(TextField).first);
|
||||
expect(field.controller?.text, contains('my important idea'));
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1,660 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
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/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
void main() {
|
||||
// A real CRDT DB schedules sqflite timers that never drain under the
|
||||
// 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, {
|
||||
Map<String, Object> prefs = const {},
|
||||
http.Client? httpClient,
|
||||
List<Note> seed = const [],
|
||||
FakeNoteRepository? repository,
|
||||
LocalBackup? localBackup,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
// Defaults to true: these tests exercise today's full UI (priority/status
|
||||
// dropdowns, sync status text, Guided). Tests for the new default-off
|
||||
// behavior pass advancedMode: false explicitly.
|
||||
ValueNotifier<AppSettings>? appSettings,
|
||||
AnalyticsService? analytics,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(prefs);
|
||||
installFakeSecureStorage();
|
||||
// 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 = repository ?? FakeNoteRepository(seed);
|
||||
addTearDown(repo.close);
|
||||
// Default to an in-memory, no-op backup so tests never touch real disk
|
||||
// (the production backup writes ~/todo/BACKLOG.md on the Linux test host).
|
||||
final backup =
|
||||
localBackup ??
|
||||
LocalBackup(
|
||||
fetch: repo.listNotes,
|
||||
reader: () async => null,
|
||||
writer: (_) async {},
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: CaptureScreen(
|
||||
repository: repo,
|
||||
appSettings:
|
||||
appSettings ??
|
||||
ValueNotifier(const AppSettings(advancedMode: true)),
|
||||
analytics: analytics,
|
||||
httpClient: httpClient,
|
||||
// Both injected so the widget never reaches for the platform: the
|
||||
// real factories want the OS keystore and an application-support
|
||||
// directory, neither of which exists under `flutter test`.
|
||||
firebaseFactory: firebaseFactory ?? () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
localBackup: backup,
|
||||
),
|
||||
),
|
||||
);
|
||||
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('defaults to Raw, with Guided available via the entry wizard', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
// Defaults to Raw: a single text field, no note persisted yet.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
|
||||
// Tapping Guided on the empty draft opens the priority+template wizard
|
||||
// rather than jumping straight into the stepper.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pump();
|
||||
|
||||
// Now on step 1 of the full-screen step page.
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(
|
||||
find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('imperative'), findsOneWidget); // title helper
|
||||
});
|
||||
|
||||
testWidgets('saving the untouched template creates no note', (tester) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('typing into the template persists a note with defaults', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
// The first field in the guided stepper is the title section.
|
||||
await tester.enterText(find.byType(TextField).first, 'My idea');
|
||||
await tester.pump();
|
||||
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, contains('My idea'));
|
||||
expect(notes.single.priority, Priority.medium);
|
||||
expect(notes.single.status, Status.todo);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the new-note action resets the template', (tester) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'A real idea');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('New note'));
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.listNotes(), hasLength(1));
|
||||
// The editor reset to a fresh, empty Raw draft.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('tapping Sync while unconfigured prompts to connect a backend', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester); // empty prefs → no token → not configured
|
||||
|
||||
await tester.tap(find.byTooltip('Sync'));
|
||||
await tester.pump(); // settings load + snackbar
|
||||
await tester.pump();
|
||||
|
||||
// The behaviour that matters is the routing, not the wording: an
|
||||
// unconfigured device is sent to settings to connect a backend. Asserting
|
||||
// on the snackbar text tied this to one phrasing, and the snackbar is
|
||||
// gone by the time settings has pushed anyway.
|
||||
expect(find.textContaining('Connect Firebase'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('the notes-list button navigates to the list screen', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Notes'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // route transition
|
||||
|
||||
expect(find.text('Notes'), findsOneWidget); // list screen app bar title
|
||||
});
|
||||
|
||||
testWidgets('changing the priority dropdown updates the saved note', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Prioritised idea');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is DropdownButton<Priority>),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||
});
|
||||
|
||||
testWidgets('changing the status dropdown updates the saved note', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Status idea');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate((w) => w is DropdownButton<Status>),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400)); // menu open
|
||||
await tester.tap(find.text('In progress').last);
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.status, Status.inProgress);
|
||||
});
|
||||
|
||||
testWidgets('Sync with a configured token runs the sync service', (
|
||||
tester,
|
||||
) async {
|
||||
// Empty remote directory → the service has nothing to merge and pushes
|
||||
// this device's own log (PUT). The bare repo GET (not /contents/) returns
|
||||
// 200 so crdt_sync's client treats the 404 dir as "empty", not missing.
|
||||
final mock = MockClient((req) async {
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) 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
|
||||
|
||||
// No firebaseFactory was injected, so this runs over GitHub alone --
|
||||
// the status line must say so rather than reading identically to a
|
||||
// Firebase-connected sync.
|
||||
expect(
|
||||
find.textContaining(
|
||||
'Synced: GitHub-only (Firebase not connected) — 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();
|
||||
|
||||
// 'Sync failed:' is the snackbar; the status line says 'Sync failed at'.
|
||||
expect(find.textContaining('Sync failed:'), findsOneWidget);
|
||||
expect(find.textContaining('Sync failed at'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('manual sync reports peer files it could not decode', (
|
||||
tester,
|
||||
) async {
|
||||
// One peer file exists but holds garbage → merged 0, skipped 1.
|
||||
final mock = MockClient((req) async {
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
if (req.url.path.endsWith('/todo-sync/notes')) {
|
||||
return http.Response('[{"name": "bad.json"}]', 200);
|
||||
}
|
||||
if (req.url.path.endsWith('/bad.json')) {
|
||||
return http.Response(
|
||||
'{"content": "${base64Encode(utf8.encode('{not a log'))}"}',
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('', 404); // own-file sha probe → create fresh
|
||||
});
|
||||
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('skipped 1 unreadable file(s)'),
|
||||
findsWidgets, // snackbar and status line both call it out
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('returning from settings adopts the saved configuration', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Settings'));
|
||||
await tester.pumpAndSettle(); // route transition
|
||||
expect(find.byType(SettingsScreen), findsOneWidget); // settings is up
|
||||
|
||||
// GitHub sync settings (owner/repo/token) now live on the standalone
|
||||
// GitHubMirrorScreen, reached via a link from SettingsScreen. The pop
|
||||
// value itself is discarded by SettingsScreen; _openSettings always
|
||||
// reloads from storage instead (a device-flow "Connect" saves the token
|
||||
// without popping a result at all, so a stale in-memory value would be
|
||||
// wrong there too). This exercises that reload-after-return path, then
|
||||
// Back returns to capture.
|
||||
await tester.tap(find.text('Advanced sync (GitHub)'));
|
||||
await tester.pumpAndSettle(); // route transition
|
||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle(); // save + pop transition back to Settings
|
||||
expect(find.byType(SettingsScreen), findsOneWidget);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle(); // pop transition back to capture
|
||||
|
||||
expect(find.byType(SettingsScreen), findsNothing); // back on capture
|
||||
expect(find.byTooltip('Settings'), findsOneWidget);
|
||||
});
|
||||
|
||||
// A MockClient that records request methods and answers the sync flow:
|
||||
// repo-exists GET (not /contents/) → 200, the (empty) log listing → 404,
|
||||
// and the device's own PUT → 200.
|
||||
MockClient recordingMock(List<String> methods) => MockClient((req) async {
|
||||
methods.add(req.method);
|
||||
if (req.method == 'PUT') return http.Response('{}', 200);
|
||||
if (!req.url.path.contains('/contents/')) return http.Response('{}', 200);
|
||||
return http.Response('', 404);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'advancedMode off shows just the text field, no chrome or status text',
|
||||
(tester) async {
|
||||
await pumpCapture(
|
||||
tester,
|
||||
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
|
||||
);
|
||||
await tester.enterText(find.byType(TextField), 'A plain idea');
|
||||
await tester.pump();
|
||||
|
||||
// The editor's own template/mode chrome is gone.
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.text('Template'), findsNothing);
|
||||
// The capture screen's priority/status row is gone.
|
||||
expect(find.text('Priority'), findsNothing);
|
||||
expect(find.text('Status'), findsNothing);
|
||||
// Routine status chatter is gone; only a badge count remains.
|
||||
expect(find.textContaining('Autosaves as you type'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
// The note still persisted normally underneath the simplified UI.
|
||||
expect(find.text('A plain idea'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'draft text survives an advancedMode toggle mid-typing',
|
||||
(tester) async {
|
||||
final appSettings = ValueNotifier(
|
||||
const AppSettings(advancedMode: true),
|
||||
);
|
||||
await pumpCapture(tester, appSettings: appSettings);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first,
|
||||
'my important idea',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// The Row above the editor is conditionally present, which shifts the
|
||||
// editor's position in the Column whenever advancedMode changes at
|
||||
// runtime — a key one level too shallow doesn't survive that
|
||||
// reposition and Flutter remounts the editor with an empty draft.
|
||||
appSettings.value = const AppSettings(advancedMode: false);
|
||||
await tester.pump();
|
||||
|
||||
final field = tester.widget<TextField>(find.byType(TextField).first);
|
||||
expect(field.controller?.text, contains('my important idea'));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'auto-sync adopts a newer advancedMode value from Firebase',
|
||||
(tester) async {
|
||||
final remoteTime = DateTime(2026, 6);
|
||||
final firebase = FirebaseRestClient(
|
||||
databaseUrl: 'https://x-rtdb.europe-west1.firebasedatabase.app',
|
||||
auth: FirebaseTokenProvider(
|
||||
apiKey: 'AIzaKey',
|
||||
store: InMemoryCredentialStore(
|
||||
FirebaseCredentials(
|
||||
idToken: 'id',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
),
|
||||
),
|
||||
),
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/settings/advancedMode.json') {
|
||||
return http.Response(
|
||||
'{"value": "true", '
|
||||
'"updatedAtMillis": "${remoteTime.millisecondsSinceEpoch}"}',
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.method == 'PUT') return http.Response(request.body, 200);
|
||||
return http.Response('null', 200);
|
||||
}),
|
||||
);
|
||||
final appSettings = ValueNotifier(
|
||||
AppSettings(
|
||||
advancedMode: false,
|
||||
// Older than remoteTime, and non-null, so the reconciliation
|
||||
// guard's `currentAt != null && !reconciledAt.isAfter(currentAt)`
|
||||
// check actually evaluates both operands.
|
||||
advancedModeUpdatedAt: remoteTime.subtract(
|
||||
const Duration(days: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
firebaseFactory: () async => firebase,
|
||||
appSettings: appSettings,
|
||||
);
|
||||
await tester.pump(); // settings load → auto-sync (pull) …
|
||||
await tester.pump(); // … then push, then reconcile
|
||||
|
||||
expect(appSettings.value.advancedMode, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('logs app_open when analytics is injected', (tester) async {
|
||||
const analytics = AnalyticsService(nodeId: 'device-a');
|
||||
await pumpCapture(tester, analytics: analytics);
|
||||
await tester.pump(); // flush the unawaited logEvent's prefs write
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final buffer = prefs.getString('analytics.buffer');
|
||||
expect(buffer, isNotNull);
|
||||
expect(buffer, contains('app_open'));
|
||||
});
|
||||
|
||||
testWidgets('auto-syncs on launch when configured', (tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
httpClient: recordingMock(methods),
|
||||
);
|
||||
await tester.pump(); // settings load → auto-sync (pull) …
|
||||
await tester.pump(); // … then push
|
||||
|
||||
expect(methods, contains('PUT')); // this device pushed its changeset
|
||||
});
|
||||
|
||||
testWidgets('does not auto-sync when unconfigured', (tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(tester, httpClient: recordingMock(methods)); // no token
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(methods, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('auto-syncs again when the app is backgrounded', (tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
httpClient: recordingMock(methods),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
methods.clear();
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// A sync ran. Not `contains('PUT')`: the launch sync above already pushed
|
||||
// this log, so revision tracking correctly suppresses the second, unchanged
|
||||
// push -- that suppression is the point of the revision cache.
|
||||
expect(methods, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('auto-sync failure shows no snackbar but flags the status line', (
|
||||
tester,
|
||||
) async {
|
||||
final mock = MockClient((_) async => throw Exception('offline'));
|
||||
await pumpCapture(tester, prefs: configuredPrefs, httpClient: mock);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Capture must never be interrupted by a snackbar…
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
// …but the failure is no longer swallowed: the status line shows it.
|
||||
expect(find.textContaining('Sync failed at'), findsOneWidget);
|
||||
expect(find.textContaining('offline'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'auto-sync success shows no status text — sync is automatic and silent',
|
||||
(tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
httpClient: recordingMock(methods),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Only a failure is worth surfacing; a routine success is silent.
|
||||
expect(find.textContaining('Synced at'), findsNothing);
|
||||
expect(find.textContaining('merged 0 device(s)'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('restores the last sync outcome on launch', (tester) async {
|
||||
// Unconfigured (no token) so no launch auto-sync overwrites the restored
|
||||
// status. A failure recorded on a previous run must still be visible.
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: {
|
||||
'sync.lastTime': DateTime(2026, 7, 16, 12).toIso8601String(),
|
||||
'sync.lastOk': false,
|
||||
'sync.lastDetail': 'Exception: rate limited',
|
||||
},
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Sync failed at 12:00:00'), findsOneWidget);
|
||||
expect(find.textContaining('rate limited'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('losing focus triggers a debounced auto-sync', (tester) async {
|
||||
final methods = <String>[];
|
||||
await pumpCapture(
|
||||
tester,
|
||||
prefs: configuredPrefs,
|
||||
httpClient: recordingMock(methods),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
methods.clear();
|
||||
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||
// A second signal (hidden also counts as focus loss) resets the debounce
|
||||
// instead of double-firing.
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
|
||||
await tester.pump(
|
||||
CaptureScreen.autoSyncDebounce - const Duration(seconds: 1),
|
||||
);
|
||||
expect(methods, isEmpty); // still inside the debounce window
|
||||
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// A sync ran. Not `contains('PUT')`: the first sync above already pushed
|
||||
// this log, so revision tracking correctly suppresses the second, unchanged
|
||||
// push -- that suppression is the point of the revision cache.
|
||||
expect(methods, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('recovers notes from the local backup into an empty DB', (
|
||||
tester,
|
||||
) async {
|
||||
final markdown = NotesMarkdown.export([
|
||||
Note(
|
||||
id: 'r1',
|
||||
text: '# Recovered idea',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
),
|
||||
]);
|
||||
final backup = LocalBackup(
|
||||
fetch: () async => const <Note>[],
|
||||
reader: () async => markdown,
|
||||
writer: (_) async {},
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
|
||||
final repo = await pumpCapture(tester, localBackup: backup);
|
||||
await tester.pump(); // recover → import
|
||||
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, contains('Recovered idea'));
|
||||
});
|
||||
|
||||
testWidgets('does not recover when the DB already has notes', (tester) async {
|
||||
final backup = LocalBackup(
|
||||
fetch: () async => const <Note>[],
|
||||
reader: () async => NotesMarkdown.export([
|
||||
Note(
|
||||
id: 'r1',
|
||||
text: '# From backup',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
),
|
||||
]),
|
||||
writer: (_) async {},
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
final seeded = Note(
|
||||
id: 'local',
|
||||
text: '# Existing',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
);
|
||||
|
||||
final repo = await pumpCapture(tester, seed: [seeded], localBackup: backup);
|
||||
await tester.pump();
|
||||
|
||||
// The backup is ignored because the DB was not empty.
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.id, 'local');
|
||||
});
|
||||
|
||||
testWidgets('writes the local backup as notes change', (tester) async {
|
||||
final writes = <String>[];
|
||||
final repo = FakeNoteRepository();
|
||||
// fetch pulls from the repo on write, mirroring production wiring.
|
||||
final backup = LocalBackup(
|
||||
fetch: repo.listNotes,
|
||||
reader: () async => null,
|
||||
writer: (md) async => writes.add(md),
|
||||
debounce: Duration.zero,
|
||||
);
|
||||
|
||||
await pumpCapture(tester, repository: repo, localBackup: backup);
|
||||
await tester.enterText(find.byType(TextField).first, 'Backed up idea');
|
||||
await tester
|
||||
.pump(); // let the change tick → schedule → async fetch/write run
|
||||
|
||||
expect(writes, isNotEmpty);
|
||||
expect(writes.last, contains('Backed up idea'));
|
||||
});
|
||||
}
|
||||
219
test/github_mirror_device_flow_test.dart
Normal file
219
test/github_mirror_device_flow_test.dart
Normal file
@ -0,0 +1,219 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
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/app_settings.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:url_launcher_platform_interface/link.dart';
|
||||
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'github_mirror_harness.dart';
|
||||
|
||||
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
||||
/// can be exercised without a real platform channel.
|
||||
class _FakeUrlLauncher extends UrlLauncherPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
String? launched;
|
||||
|
||||
@override
|
||||
final LinkDelegate? linkDelegate = null;
|
||||
|
||||
@override
|
||||
Future<bool> supportsMode(PreferredLaunchMode mode) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> launchUrl(String url, LaunchOptions options) async {
|
||||
launched = url;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('device flow connects but surfaces a post-connect sync failure', (
|
||||
tester,
|
||||
) async {
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path.contains('device/code')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'device_code': 'dev123',
|
||||
'user_code': 'WXYZ-1234',
|
||||
'verification_uri': 'https://github.com/login/device',
|
||||
'interval': 0,
|
||||
'expires_in': 900,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (req.url.path.contains('login/oauth/access_token')) {
|
||||
return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
|
||||
}
|
||||
return http.Response('boom', 500); // the sync's repo calls fail
|
||||
});
|
||||
|
||||
await pumpMirror(
|
||||
tester,
|
||||
initial: const SyncSettings(
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
token: '',
|
||||
clientId: 'cid',
|
||||
),
|
||||
httpClient: mock,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.textContaining('sync failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Save persists the settings and closes the screen', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
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);
|
||||
|
||||
// Push GitHubMirrorScreen over a base route so _save's Navigator.pop has
|
||||
// somewhere to return to (popping the root route is a no-op and hides
|
||||
// the result).
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<SyncSettings>(
|
||||
builder: (_) => GitHubMirrorScreen(
|
||||
initial: const SyncSettings(
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
token: 'tok',
|
||||
),
|
||||
repository: repo,
|
||||
appSettings: ValueNotifier(
|
||||
const AppSettings(advancedMode: false),
|
||||
),
|
||||
firebaseFactory: () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400)); // route transition
|
||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget); // screen is up
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pump(); // run _save (persist + pop)
|
||||
await tester.pump(const Duration(milliseconds: 400)); // pop transition
|
||||
|
||||
expect(find.text('open'), findsOneWidget); // back on the base route
|
||||
final saved = await SyncSettings.load();
|
||||
expect(saved.owner, 'o');
|
||||
expect(saved.token, 'tok');
|
||||
});
|
||||
|
||||
testWidgets('device dialog: failed poll shows the error and Open launches', (
|
||||
tester,
|
||||
) async {
|
||||
final launcher = _FakeUrlLauncher();
|
||||
UrlLauncherPlatform.instance = launcher;
|
||||
|
||||
// _openPage copies the code to the clipboard first; there's no
|
||||
// clipboard plugin in the test host, so stub the channel to succeed.
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async => null,
|
||||
);
|
||||
addTearDown(
|
||||
() => messenger.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path.contains('device/code')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'device_code': 'dev123',
|
||||
'user_code': 'WXYZ-1234',
|
||||
'verification_uri': 'https://github.com/login/device',
|
||||
'interval': 0,
|
||||
'expires_in': 900,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
// Token endpoint: a terminal error ends the poll loop cleanly (no
|
||||
// lingering timer to trip the tester's pending-timer guard).
|
||||
return http.Response(
|
||||
jsonEncode({'error': 'access_denied', 'error_description': 'nope'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
await pumpMirror(
|
||||
tester,
|
||||
initial: const SyncSettings(
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
token: '',
|
||||
clientId: 'cid',
|
||||
),
|
||||
httpClient: mock,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.pump(); // requestDeviceCode
|
||||
await tester.pump(); // dialog builds, poll starts (interval 0)
|
||||
expect(find.text('WXYZ-1234'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires
|
||||
await tester.pump(); // token error throws → _error set
|
||||
expect(find.textContaining('nope'), findsOneWidget); // error rendered
|
||||
|
||||
// Tap the "open on GitHub" action: copies the code and launches the URL.
|
||||
// _openPage awaits Clipboard.setData then the launcher's supportsMode +
|
||||
// launchUrl; pumpAndSettle drains them (no spinner is animating now
|
||||
// that the error is shown, so it settles).
|
||||
await tester.tap(find.byIcon(Icons.open_in_new));
|
||||
await tester.pumpAndSettle();
|
||||
expect(launcher.launched, 'https://github.com/login/device');
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle(); // finish the dialog pop animation
|
||||
expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed
|
||||
});
|
||||
}
|
||||
83
test/github_mirror_harness.dart
Normal file
83
test/github_mirror_harness.dart
Normal file
@ -0,0 +1,83 @@
|
||||
/// The pumpMirror harness and the fake url_launcher.
|
||||
///
|
||||
/// Shared by the files `github_mirror_screen_test.dart` was split into for the
|
||||
/// 250-line cap. Deliberately NOT named `*_test.dart`: the runner would
|
||||
/// collect it and fail on the missing `main()`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
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/app_settings.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:url_launcher_platform_interface/link.dart';
|
||||
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
||||
/// can be exercised without a real platform channel.
|
||||
class _FakeUrlLauncher extends UrlLauncherPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
String? launched;
|
||||
|
||||
@override
|
||||
final LinkDelegate? linkDelegate = null;
|
||||
|
||||
@override
|
||||
Future<bool> supportsMode(PreferredLaunchMode mode) async => true;
|
||||
|
||||
@override
|
||||
Future<bool> launchUrl(String url, LaunchOptions options) async {
|
||||
launched = url;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<FakeNoteRepository> pumpMirror(
|
||||
WidgetTester tester, {
|
||||
SyncSettings initial = const SyncSettings(
|
||||
owner: 'kuhyx',
|
||||
repo: 'syncs',
|
||||
token: 't',
|
||||
),
|
||||
http.Client? httpClient,
|
||||
FakeNoteRepository? repository,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
ValueNotifier<AppSettings>? appSettings,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
tester.view.physicalSize = const Size(1200, 2800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final repo = repository ?? FakeNoteRepository();
|
||||
addTearDown(repo.close);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: GitHubMirrorScreen(
|
||||
initial: initial,
|
||||
repository: repo,
|
||||
appSettings:
|
||||
appSettings ??
|
||||
ValueNotifier(const AppSettings(advancedMode: false)),
|
||||
httpClient: httpClient,
|
||||
firebaseFactory: firebaseFactory ?? () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return repo;
|
||||
}
|
||||
@ -17,6 +17,8 @@ import 'package:url_launcher_platform_interface/url_launcher_platform_interface.
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'github_mirror_harness.dart';
|
||||
|
||||
/// Stub launcher that records the URL instead of opening it, so `_openPage`
|
||||
/// can be exercised without a real platform channel.
|
||||
class _FakeUrlLauncher extends UrlLauncherPlatform
|
||||
@ -37,45 +39,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform
|
||||
}
|
||||
|
||||
void main() {
|
||||
Future<FakeNoteRepository> pumpMirror(
|
||||
WidgetTester tester, {
|
||||
SyncSettings initial = const SyncSettings(
|
||||
owner: 'kuhyx',
|
||||
repo: 'syncs',
|
||||
token: 't',
|
||||
),
|
||||
http.Client? httpClient,
|
||||
FakeNoteRepository? repository,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
ValueNotifier<AppSettings>? appSettings,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
tester.view.physicalSize = const Size(1200, 2800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final repo = repository ?? FakeNoteRepository();
|
||||
addTearDown(repo.close);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: GitHubMirrorScreen(
|
||||
initial: initial,
|
||||
repository: repo,
|
||||
appSettings:
|
||||
appSettings ??
|
||||
ValueNotifier(const AppSettings(advancedMode: false)),
|
||||
httpClient: httpClient,
|
||||
firebaseFactory: firebaseFactory ?? () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return repo;
|
||||
}
|
||||
|
||||
testWidgets('Connect GitHub without a client id shows guidance', (
|
||||
tester,
|
||||
) async {
|
||||
@ -184,182 +147,4 @@ void main() {
|
||||
|
||||
expect(find.textContaining('Connected and synced'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('device flow connects but surfaces a post-connect sync failure', (
|
||||
tester,
|
||||
) async {
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path.contains('device/code')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'device_code': 'dev123',
|
||||
'user_code': 'WXYZ-1234',
|
||||
'verification_uri': 'https://github.com/login/device',
|
||||
'interval': 0,
|
||||
'expires_in': 900,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (req.url.path.contains('login/oauth/access_token')) {
|
||||
return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
|
||||
}
|
||||
return http.Response('boom', 500); // the sync's repo calls fail
|
||||
});
|
||||
|
||||
await pumpMirror(
|
||||
tester,
|
||||
initial: const SyncSettings(
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
token: '',
|
||||
clientId: 'cid',
|
||||
),
|
||||
httpClient: mock,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.textContaining('sync failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Save persists the settings and closes the screen', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
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);
|
||||
|
||||
// Push GitHubMirrorScreen over a base route so _save's Navigator.pop has
|
||||
// somewhere to return to (popping the root route is a no-op and hides
|
||||
// the result).
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<SyncSettings>(
|
||||
builder: (_) => GitHubMirrorScreen(
|
||||
initial: const SyncSettings(
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
token: 'tok',
|
||||
),
|
||||
repository: repo,
|
||||
appSettings: ValueNotifier(
|
||||
const AppSettings(advancedMode: false),
|
||||
),
|
||||
firebaseFactory: () async => null,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400)); // route transition
|
||||
expect(find.byType(GitHubMirrorScreen), findsOneWidget); // screen is up
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pump(); // run _save (persist + pop)
|
||||
await tester.pump(const Duration(milliseconds: 400)); // pop transition
|
||||
|
||||
expect(find.text('open'), findsOneWidget); // back on the base route
|
||||
final saved = await SyncSettings.load();
|
||||
expect(saved.owner, 'o');
|
||||
expect(saved.token, 'tok');
|
||||
});
|
||||
|
||||
testWidgets('device dialog: failed poll shows the error and Open launches', (
|
||||
tester,
|
||||
) async {
|
||||
final launcher = _FakeUrlLauncher();
|
||||
UrlLauncherPlatform.instance = launcher;
|
||||
|
||||
// _openPage copies the code to the clipboard first; there's no
|
||||
// clipboard plugin in the test host, so stub the channel to succeed.
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async => null,
|
||||
);
|
||||
addTearDown(
|
||||
() => messenger.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
|
||||
final mock = MockClient((req) async {
|
||||
if (req.url.path.contains('device/code')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'device_code': 'dev123',
|
||||
'user_code': 'WXYZ-1234',
|
||||
'verification_uri': 'https://github.com/login/device',
|
||||
'interval': 0,
|
||||
'expires_in': 900,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
// Token endpoint: a terminal error ends the poll loop cleanly (no
|
||||
// lingering timer to trip the tester's pending-timer guard).
|
||||
return http.Response(
|
||||
jsonEncode({'error': 'access_denied', 'error_description': 'nope'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
await pumpMirror(
|
||||
tester,
|
||||
initial: const SyncSettings(
|
||||
owner: 'o',
|
||||
repo: 'r',
|
||||
token: '',
|
||||
clientId: 'cid',
|
||||
),
|
||||
httpClient: mock,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Connect GitHub'));
|
||||
await tester.pump(); // requestDeviceCode
|
||||
await tester.pump(); // dialog builds, poll starts (interval 0)
|
||||
expect(find.text('WXYZ-1234'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires
|
||||
await tester.pump(); // token error throws → _error set
|
||||
expect(find.textContaining('nope'), findsOneWidget); // error rendered
|
||||
|
||||
// Tap the "open on GitHub" action: copies the code and launches the URL.
|
||||
// _openPage awaits Clipboard.setData then the launcher's supportsMode +
|
||||
// launchUrl; pumpAndSettle drains them (no spinner is animating now
|
||||
// that the error is shown, so it settles).
|
||||
await tester.tap(find.byIcon(Icons.open_in_new));
|
||||
await tester.pumpAndSettle();
|
||||
expect(launcher.launched, 'https://github.com/login/device');
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle(); // finish the dialog pop animation
|
||||
expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed
|
||||
});
|
||||
}
|
||||
|
||||
135
test/note_editor_guided_test.dart
Normal file
135
test/note_editor_guided_test.dart
Normal file
@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
|
||||
import 'note_editor_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('guided: typing the title emits an assembled # heading', (
|
||||
tester,
|
||||
) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).first, 'Dark mode');
|
||||
await tester.pump();
|
||||
|
||||
expect(emitted.last, startsWith('# Dark mode'));
|
||||
});
|
||||
|
||||
testWidgets('step page Next/Back navigate and progress counter updates', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
expect(find.text('2 / ${spec.sections.length}'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('guided: the stepper walks the seven audited sections', (
|
||||
tester,
|
||||
) async {
|
||||
// The user-visible contract of the 2026-07 template cut. Walks every step
|
||||
// and asserts the label and its guidance are the ones on screen, so a
|
||||
// section resurrected in note_template.dart cannot land unnoticed.
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
for (var i = 0; i < spec.sections.length; i++) {
|
||||
final section = spec.sections[i];
|
||||
expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget);
|
||||
expect(
|
||||
find.text(section.isTitle ? 'title' : section.label),
|
||||
findsWidgets,
|
||||
reason: 'step ${i + 1} should show "${section.label}"',
|
||||
);
|
||||
expect(find.text(section.helper), findsOneWidget);
|
||||
if (i < spec.sections.length - 1) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
// The last step is `read first`; there is no Next past it.
|
||||
expect(spec.sections.last.label, 'read first');
|
||||
expect(find.text('Next'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('pasting a bare link drops the draft to the blank template', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
expect(find.text('LLM design spec'), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first,
|
||||
'https://example.com/x',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Freeform now, so there is no seven-step stepper to walk for a link.
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
|
||||
// Adding prose puts the spec template back — lossless in both directions.
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first,
|
||||
'https://example.com/x — port this to Dart',
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('LLM design spec'), findsOneWidget);
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an explicit template choice survives further typing', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('LLM design spec'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Blank').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
|
||||
// Prose would normally re-select the spec template; an explicit pick wins.
|
||||
await tester.enterText(find.byType(TextField).first, 'build a thing');
|
||||
await tester.pump();
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('switching to View renders the note as Markdown', (tester) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).first, '# Render me');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('View'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(MarkdownView), findsOneWidget);
|
||||
expect(find.text('Render me'), findsOneWidget); // rendered heading text
|
||||
});
|
||||
}
|
||||
50
test/note_editor_harness.dart
Normal file
50
test/note_editor_harness.dart
Normal file
@ -0,0 +1,50 @@
|
||||
/// The shared pumpEditor harness and template constants.
|
||||
///
|
||||
/// Shared by the files `note_editor_test.dart` was split into for the
|
||||
/// 250-line cap. Deliberately NOT named `*_test.dart`: the runner would
|
||||
/// collect it and fail on the missing `main()`.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
|
||||
const spec = NoteTemplate.llmDesignSpec;
|
||||
const blank = NoteTemplate.blank;
|
||||
|
||||
// Pumps an editor and exposes the latest text emitted via onChanged.
|
||||
Future<List<String>> pumpEditor(
|
||||
WidgetTester tester, {
|
||||
String initialText = '',
|
||||
NoteTemplate? initialTemplate,
|
||||
NoteEditorMode initialMode = NoteEditorMode.guided,
|
||||
Priority priority = Priority.defaultValue,
|
||||
ValueChanged<Priority>? onPriorityChanged,
|
||||
ValueChanged<bool>? onChromeVisibleChanged,
|
||||
}) async {
|
||||
final emitted = <String>[];
|
||||
tester.view.physicalSize = const Size(1200, 2400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: NoteEditor(
|
||||
initialText: initialText,
|
||||
initialTemplate: initialTemplate,
|
||||
initialMode: initialMode,
|
||||
priority: priority,
|
||||
onPriorityChanged: onPriorityChanged ?? (_) {},
|
||||
onChromeVisibleChanged: onChromeVisibleChanged ?? (_) {},
|
||||
onChanged: emitted.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return emitted;
|
||||
}
|
||||
172
test/note_editor_modes_test.dart
Normal file
172
test/note_editor_modes_test.dart
Normal file
@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
|
||||
import 'note_editor_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'bare Guided back arrow returns to Raw, materialising the assembled text',
|
||||
(tester) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: spec);
|
||||
await tester.enterText(find.byType(TextField).first, 'T');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.pump();
|
||||
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, startsWith('# T'));
|
||||
|
||||
await tester.enterText(find.byType(TextField), '# T\n\n## what\nedited');
|
||||
await tester.pump();
|
||||
expect(emitted.last, contains('edited'));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Raw → Guided is blocked with a snackbar when non-conforming', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialText: 'totally freeform text',
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
// Structured template + non-conforming raw text → switching is refused.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining("doesn't match the template"), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing); // still raw
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'guided → Raw → Guided round-trips (skipping the wizard) when text still conforms',
|
||||
(tester) async {
|
||||
// Open guided from a conforming note (pre-fills the section controllers
|
||||
// without typing into collapsed steps), so the assembled body conforms.
|
||||
final conforming = assemble(spec, {'title': 'Keep', 'what': 'a body'});
|
||||
await pumpEditor(tester, initialTemplate: spec, initialText: conforming);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
|
||||
// Bare guided -> raw via the back arrow makes the (still conforming)
|
||||
// body the source…
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.pump();
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
|
||||
// …then raw -> guided re-parses the conforming body straight back into
|
||||
// the bare stepper — no wizard, since the content isn't empty.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.text('Step 1 of 2'), findsNothing); // wizard was skipped
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('freeform template offers only View and Raw', (tester) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: blank);
|
||||
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.text('View'), findsOneWidget);
|
||||
expect(find.text('Raw'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'free text');
|
||||
await tester.pump();
|
||||
expect(emitted.last, 'free text');
|
||||
});
|
||||
|
||||
testWidgets('switching template via the dropdown reloads the source', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
expect(find.text('Guided'), findsOneWidget); // structured: Guided offered
|
||||
|
||||
await tester.tap(find.text('LLM design spec').first); // open the menu
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Blank').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Freeform now: Guided is no longer offered.
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('detects a conforming note (no template given) as guided', (
|
||||
tester,
|
||||
) async {
|
||||
final conforming = assemble(spec, {'title': 'Detected', 'what': 'x'});
|
||||
await pumpEditor(tester, initialText: conforming);
|
||||
|
||||
expect(
|
||||
find.byType(LinearProgressIndicator),
|
||||
findsOneWidget,
|
||||
); // guided by default
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('detects a legacy note (no template given) as freeform raw', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(tester, initialText: 'old\n\nwhat — legacy');
|
||||
|
||||
// Non-conforming → blank/raw, no guided stepper offered.
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('initialMode preview opens directly in the rendered view', (
|
||||
tester,
|
||||
) async {
|
||||
final conforming = assemble(spec, {'title': 'Preview me'});
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialText: conforming,
|
||||
initialMode: NoteEditorMode.preview,
|
||||
);
|
||||
|
||||
expect(find.byType(MarkdownView), findsOneWidget);
|
||||
expect(find.text('Preview me'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Preview → Raw materialises the still-guided source', (
|
||||
tester,
|
||||
) async {
|
||||
final conforming = assemble(spec, {'title': 'Preview src', 'what': 'x'});
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialText: conforming,
|
||||
initialMode: NoteEditorMode.preview,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Raw'));
|
||||
await tester.pump();
|
||||
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, contains('Preview src'));
|
||||
});
|
||||
|
||||
testWidgets('initialMode guided falls back to Raw for non-conforming text', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialText: 'cannot be guided',
|
||||
initialMode: NoteEditorMode.guided,
|
||||
);
|
||||
|
||||
// Guided was requested but the text does not conform → opened in Raw.
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, 'cannot be guided');
|
||||
});
|
||||
}
|
||||
@ -1,502 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
|
||||
void main() {
|
||||
const spec = NoteTemplate.llmDesignSpec;
|
||||
const blank = NoteTemplate.blank;
|
||||
|
||||
// Pumps an editor and exposes the latest text emitted via onChanged.
|
||||
Future<List<String>> pumpEditor(
|
||||
WidgetTester tester, {
|
||||
String initialText = '',
|
||||
NoteTemplate? initialTemplate,
|
||||
NoteEditorMode initialMode = NoteEditorMode.guided,
|
||||
Priority priority = Priority.defaultValue,
|
||||
ValueChanged<Priority>? onPriorityChanged,
|
||||
ValueChanged<bool>? onChromeVisibleChanged,
|
||||
}) async {
|
||||
final emitted = <String>[];
|
||||
tester.view.physicalSize = const Size(1200, 2400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: NoteEditor(
|
||||
initialText: initialText,
|
||||
initialTemplate: initialTemplate,
|
||||
initialMode: initialMode,
|
||||
priority: priority,
|
||||
onPriorityChanged: onPriorityChanged ?? (_) {},
|
||||
onChromeVisibleChanged: onChromeVisibleChanged ?? (_) {},
|
||||
onChanged: emitted.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return emitted;
|
||||
}
|
||||
|
||||
testWidgets('guided: typing the title emits an assembled # heading', (
|
||||
tester,
|
||||
) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).first, 'Dark mode');
|
||||
await tester.pump();
|
||||
|
||||
expect(emitted.last, startsWith('# Dark mode'));
|
||||
});
|
||||
|
||||
testWidgets('step page Next/Back navigate and progress counter updates', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
expect(find.text('2 / ${spec.sections.length}'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('1 / ${spec.sections.length}'), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('guided: the stepper walks the seven audited sections', (
|
||||
tester,
|
||||
) async {
|
||||
// The user-visible contract of the 2026-07 template cut. Walks every step
|
||||
// and asserts the label and its guidance are the ones on screen, so a
|
||||
// section resurrected in note_template.dart cannot land unnoticed.
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
for (var i = 0; i < spec.sections.length; i++) {
|
||||
final section = spec.sections[i];
|
||||
expect(find.text('${i + 1} / ${spec.sections.length}'), findsOneWidget);
|
||||
expect(
|
||||
find.text(section.isTitle ? 'title' : section.label),
|
||||
findsWidgets,
|
||||
reason: 'step ${i + 1} should show "${section.label}"',
|
||||
);
|
||||
expect(find.text(section.helper), findsOneWidget);
|
||||
if (i < spec.sections.length - 1) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
// The last step is `read first`; there is no Next past it.
|
||||
expect(spec.sections.last.label, 'read first');
|
||||
expect(find.text('Next'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('pasting a bare link drops the draft to the blank template', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
expect(find.text('LLM design spec'), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first,
|
||||
'https://example.com/x',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Freeform now, so there is no seven-step stepper to walk for a link.
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
|
||||
// Adding prose puts the spec template back — lossless in both directions.
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first,
|
||||
'https://example.com/x — port this to Dart',
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('LLM design spec'), findsOneWidget);
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an explicit template choice survives further typing', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('LLM design spec'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Blank').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
|
||||
// Prose would normally re-select the spec template; an explicit pick wins.
|
||||
await tester.enterText(find.byType(TextField).first, 'build a thing');
|
||||
await tester.pump();
|
||||
expect(find.text('Blank'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('switching to View renders the note as Markdown', (tester) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).first, '# Render me');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('View'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(MarkdownView), findsOneWidget);
|
||||
expect(find.text('Render me'), findsOneWidget); // rendered heading text
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'bare Guided back arrow returns to Raw, materialising the assembled text',
|
||||
(tester) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: spec);
|
||||
await tester.enterText(find.byType(TextField).first, 'T');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.pump();
|
||||
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, startsWith('# T'));
|
||||
|
||||
await tester.enterText(find.byType(TextField), '# T\n\n## what\nedited');
|
||||
await tester.pump();
|
||||
expect(emitted.last, contains('edited'));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Raw → Guided is blocked with a snackbar when non-conforming', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialText: 'totally freeform text',
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
// Structured template + non-conforming raw text → switching is refused.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining("doesn't match the template"), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing); // still raw
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'guided → Raw → Guided round-trips (skipping the wizard) when text still conforms',
|
||||
(tester) async {
|
||||
// Open guided from a conforming note (pre-fills the section controllers
|
||||
// without typing into collapsed steps), so the assembled body conforms.
|
||||
final conforming = assemble(spec, {'title': 'Keep', 'what': 'a body'});
|
||||
await pumpEditor(tester, initialTemplate: spec, initialText: conforming);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
|
||||
// Bare guided -> raw via the back arrow makes the (still conforming)
|
||||
// body the source…
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.pump();
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
|
||||
// …then raw -> guided re-parses the conforming body straight back into
|
||||
// the bare stepper — no wizard, since the content isn't empty.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.text('Step 1 of 2'), findsNothing); // wizard was skipped
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('freeform template offers only View and Raw', (tester) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: blank);
|
||||
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
expect(find.text('View'), findsOneWidget);
|
||||
expect(find.text('Raw'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'free text');
|
||||
await tester.pump();
|
||||
expect(emitted.last, 'free text');
|
||||
});
|
||||
|
||||
testWidgets('switching template via the dropdown reloads the source', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
expect(find.text('Guided'), findsOneWidget); // structured: Guided offered
|
||||
|
||||
await tester.tap(find.text('LLM design spec').first); // open the menu
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Blank').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Freeform now: Guided is no longer offered.
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('detects a conforming note (no template given) as guided', (
|
||||
tester,
|
||||
) async {
|
||||
final conforming = assemble(spec, {'title': 'Detected', 'what': 'x'});
|
||||
await pumpEditor(tester, initialText: conforming);
|
||||
|
||||
expect(
|
||||
find.byType(LinearProgressIndicator),
|
||||
findsOneWidget,
|
||||
); // guided by default
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('detects a legacy note (no template given) as freeform raw', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(tester, initialText: 'old\n\nwhat — legacy');
|
||||
|
||||
// Non-conforming → blank/raw, no guided stepper offered.
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('initialMode preview opens directly in the rendered view', (
|
||||
tester,
|
||||
) async {
|
||||
final conforming = assemble(spec, {'title': 'Preview me'});
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialText: conforming,
|
||||
initialMode: NoteEditorMode.preview,
|
||||
);
|
||||
|
||||
expect(find.byType(MarkdownView), findsOneWidget);
|
||||
expect(find.text('Preview me'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Preview → Raw materialises the still-guided source', (
|
||||
tester,
|
||||
) async {
|
||||
final conforming = assemble(spec, {'title': 'Preview src', 'what': 'x'});
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialText: conforming,
|
||||
initialMode: NoteEditorMode.preview,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Raw'));
|
||||
await tester.pump();
|
||||
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, contains('Preview src'));
|
||||
});
|
||||
|
||||
testWidgets('initialMode guided falls back to Raw for non-conforming text', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialText: 'cannot be guided',
|
||||
initialMode: NoteEditorMode.guided,
|
||||
);
|
||||
|
||||
// Guided was requested but the text does not conform → opened in Raw.
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, 'cannot be guided');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping Guided on an empty draft opens the priority+template wizard',
|
||||
(tester) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
expect(find.byType(DropdownButtonFormField<Priority>), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'the wizard Next/Back moves between priority and template steps',
|
||||
(tester) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 2 of 2'), findsOneWidget);
|
||||
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the wizard template step only offers structured templates', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('LLM design spec').first); // open the menu
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Blank'), findsNothing);
|
||||
|
||||
// Select the (only) offered template, exercising the dropdown's onChanged.
|
||||
await tester.tap(find.text('LLM design spec').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Step 2 of 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'wizard Start commits the chosen priority and enters the bare stepper',
|
||||
(tester) async {
|
||||
Priority? committed;
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
onPriorityChanged: (p) => committed = p,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pump();
|
||||
|
||||
expect(committed, Priority.high);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('wizard Cancel returns to Raw with the chrome restored', (
|
||||
tester,
|
||||
) async {
|
||||
final chromeVisible = <bool>[];
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
onChromeVisibleChanged: chromeVisible.add,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(chromeVisible.last, false);
|
||||
|
||||
await tester.tap(find.byTooltip('Cancel'));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeVisible.last, true);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
expect(find.text('Raw'), findsOneWidget); // chrome's mode selector is back
|
||||
});
|
||||
|
||||
testWidgets('exiting bare Guided via the back arrow restores the chrome', (
|
||||
tester,
|
||||
) async {
|
||||
final chromeVisible = <bool>[];
|
||||
final conforming = assemble(spec, {'title': 'X', 'what': 'a body'});
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialText: conforming,
|
||||
onChromeVisibleChanged: chromeVisible.add,
|
||||
);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeVisible, contains(true));
|
||||
expect(find.text('Raw'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('last step shows Done instead of Next', (tester) async {
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
// Navigate to the final step.
|
||||
for (var i = 0; i < spec.sections.length - 1; i++) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.text('Done'), findsOneWidget);
|
||||
expect(find.text('Next'), findsNothing);
|
||||
expect(
|
||||
find.text('${spec.sections.length} / ${spec.sections.length}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Done on last step exits Guided and materialises text into Raw', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
await tester.enterText(find.byType(TextField).first, 'My title');
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 0; i < spec.sections.length - 1; i++) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, startsWith('# My title'));
|
||||
});
|
||||
}
|
||||
180
test/note_editor_wizard_test.dart
Normal file
180
test/note_editor_wizard_test.dart
Normal file
@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/ui/markdown_view.dart';
|
||||
import 'package:todo/ui/note_editor.dart';
|
||||
|
||||
import 'note_editor_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'tapping Guided on an empty draft opens the priority+template wizard',
|
||||
(tester) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
expect(find.byType(DropdownButtonFormField<Priority>), findsOneWidget);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'the wizard Next/Back moves between priority and template steps',
|
||||
(tester) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 2 of 2'), findsOneWidget);
|
||||
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the wizard template step only offers structured templates', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('LLM design spec').first); // open the menu
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Blank'), findsNothing);
|
||||
|
||||
// Select the (only) offered template, exercising the dropdown's onChanged.
|
||||
await tester.tap(find.text('LLM design spec').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Step 2 of 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'wizard Start commits the chosen priority and enters the bare stepper',
|
||||
(tester) async {
|
||||
Priority? committed;
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
onPriorityChanged: (p) => committed = p,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pump();
|
||||
|
||||
expect(committed, Priority.high);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('wizard Cancel returns to Raw with the chrome restored', (
|
||||
tester,
|
||||
) async {
|
||||
final chromeVisible = <bool>[];
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
onChromeVisibleChanged: chromeVisible.add,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(chromeVisible.last, false);
|
||||
|
||||
await tester.tap(find.byTooltip('Cancel'));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeVisible.last, true);
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
expect(find.text('Raw'), findsOneWidget); // chrome's mode selector is back
|
||||
});
|
||||
|
||||
testWidgets('exiting bare Guided via the back arrow restores the chrome', (
|
||||
tester,
|
||||
) async {
|
||||
final chromeVisible = <bool>[];
|
||||
final conforming = assemble(spec, {'title': 'X', 'what': 'a body'});
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialText: conforming,
|
||||
onChromeVisibleChanged: chromeVisible.add,
|
||||
);
|
||||
expect(find.byType(LinearProgressIndicator), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeVisible, contains(true));
|
||||
expect(find.text('Raw'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('last step shows Done instead of Next', (tester) async {
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
// Navigate to the final step.
|
||||
for (var i = 0; i < spec.sections.length - 1; i++) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
expect(find.text('Done'), findsOneWidget);
|
||||
expect(find.text('Next'), findsNothing);
|
||||
expect(
|
||||
find.text('${spec.sections.length} / ${spec.sections.length}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Done on last step exits Guided and materialises text into Raw', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
await tester.enterText(find.byType(TextField).first, 'My title');
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 0; i < spec.sections.length - 1; i++) {
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LinearProgressIndicator), findsNothing);
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, startsWith('# My title'));
|
||||
});
|
||||
}
|
||||
114
test/note_repository_crud_test.dart
Normal file
114
test/note_repository_crud_test.dart
Normal file
@ -0,0 +1,114 @@
|
||||
/// Core CRUD, tombstones, sorting, and priority defaults.
|
||||
///
|
||||
/// One of four files `note_repository_test.dart` was split into for the
|
||||
/// 250-line cap. Each carries its own `setUpAll(sqfliteFfiInit)`: without it
|
||||
/// sqflite is never initialised and every test in the file fails.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
// SqliteCrdt is needed to seed the legacy DB in migration tests; hide its Hlc
|
||||
// so the crdt_sync Hlc/Record are unambiguous.
|
||||
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repository_factory_io.dart';
|
||||
|
||||
import 'note_repository_helpers.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(sqfliteFfiInit);
|
||||
|
||||
test('upsert then list returns the note', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(noteFixture('a', 'first idea'));
|
||||
final notes = await repo.listNotes();
|
||||
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, 'first idea');
|
||||
});
|
||||
|
||||
test('deleted notes are excluded from reads (tombstone filter)', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(noteFixture('a', 'keep me'));
|
||||
await repo.upsert(noteFixture('b', 'delete me'));
|
||||
await repo.delete('b');
|
||||
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, 'keep me');
|
||||
|
||||
// The tombstone must survive in the exported log so the deletion syncs.
|
||||
final log = repo.exportLog();
|
||||
expect(log['b']!.deleted, isTrue);
|
||||
});
|
||||
|
||||
test('modifiedDesc sort orders most-recently-updated first', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(noteFixture('a', 'older', updatedAt: DateTime(2026)));
|
||||
await repo.upsert(noteFixture('b', 'newer', updatedAt: DateTime(2026, 6)));
|
||||
|
||||
final notes = await repo.listNotes(sort: NoteSort.modifiedDesc);
|
||||
expect(notes.map((n) => n.text), ['newer', 'older']);
|
||||
});
|
||||
|
||||
test('priority sort orders highest first', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(noteFixture('a', 'low', priority: Priority.low));
|
||||
await repo.upsert(noteFixture('b', 'high', priority: Priority.high));
|
||||
|
||||
final notes = await repo.listNotes(sort: NoteSort.priorityDesc);
|
||||
expect(notes.first.text, 'high');
|
||||
expect(notes.last.text, 'low');
|
||||
});
|
||||
|
||||
test('priority sort breaks ties by most-recently-updated', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(
|
||||
noteFixture(
|
||||
'a',
|
||||
'older',
|
||||
priority: Priority.high,
|
||||
updatedAt: DateTime(2026),
|
||||
),
|
||||
);
|
||||
await repo.upsert(
|
||||
noteFixture(
|
||||
'b',
|
||||
'newer',
|
||||
priority: Priority.high,
|
||||
updatedAt: DateTime(2026, 6),
|
||||
),
|
||||
);
|
||||
|
||||
final notes = await repo.listNotes(sort: NoteSort.priorityDesc);
|
||||
expect(notes.map((n) => n.text), ['newer', 'older']);
|
||||
});
|
||||
|
||||
group('priority defaults', () {
|
||||
test('fromValue maps legacy/unknown values to medium', () {
|
||||
expect(Priority.fromValue(0), Priority.medium); // old "none"
|
||||
expect(Priority.fromValue(null), Priority.medium);
|
||||
expect(Priority.fromValue(99), Priority.medium);
|
||||
// Known values still round-trip.
|
||||
expect(Priority.fromValue(1), Priority.low);
|
||||
expect(Priority.fromValue(2), Priority.medium);
|
||||
expect(Priority.fromValue(3), Priority.high);
|
||||
});
|
||||
});
|
||||
}
|
||||
238
test/note_repository_filters_test.dart
Normal file
238
test/note_repository_filters_test.dart
Normal file
@ -0,0 +1,238 @@
|
||||
/// Search, attribute/date filters, and NoteFilter itself.
|
||||
///
|
||||
/// One of four files `note_repository_test.dart` was split into for the
|
||||
/// 250-line cap. Each carries its own `setUpAll(sqfliteFfiInit)`: without it
|
||||
/// sqflite is never initialised and every test in the file fails.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
// SqliteCrdt is needed to seed the legacy DB in migration tests; hide its Hlc
|
||||
// so the crdt_sync Hlc/Record are unambiguous.
|
||||
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repository_factory_io.dart';
|
||||
|
||||
import 'note_repository_helpers.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(sqfliteFfiInit);
|
||||
|
||||
group('text search', () {
|
||||
test('matches a case-insensitive substring', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(noteFixture('a', 'Buy MILK and eggs'));
|
||||
await repo.upsert(noteFixture('b', 'call the dentist'));
|
||||
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(query: 'milk'),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['a']);
|
||||
});
|
||||
|
||||
test('escapes LIKE wildcards so % is matched literally', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(noteFixture('pct', 'a%b'));
|
||||
await repo.upsert(noteFixture('plain', 'axb'));
|
||||
|
||||
// Without escaping, 'a%b' as a LIKE pattern would also match 'axb'.
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(query: 'a%b'),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['pct']);
|
||||
});
|
||||
});
|
||||
|
||||
group('attribute filters', () {
|
||||
test('priority filter includes only the selected priorities', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(noteFixture('lo', 'l', priority: Priority.low));
|
||||
await repo.upsert(noteFixture('me', 'm', priority: Priority.medium));
|
||||
await repo.upsert(noteFixture('hi', 'h', priority: Priority.high));
|
||||
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(priorities: {Priority.low, Priority.high}),
|
||||
);
|
||||
expect(notes.map((n) => n.id).toSet(), {'lo', 'hi'});
|
||||
});
|
||||
|
||||
test('status filter includes only the selected statuses', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(noteFixture('t', 'todo', status: Status.todo));
|
||||
await repo.upsert(noteFixture('d', 'done', status: Status.done));
|
||||
await repo.upsert(noteFixture('x', 'gone', status: Status.abandoned));
|
||||
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(statuses: {Status.todo, Status.inProgress}),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['t']);
|
||||
});
|
||||
});
|
||||
|
||||
group('date range filters', () {
|
||||
final jan = DateTime(2026, 1, 15, 10);
|
||||
final jun = DateTime(2026, 6, 15, 10);
|
||||
|
||||
test('created range bounds are inclusive by calendar day', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(
|
||||
noteFixture('j', 'jan', createdAt: jan, updatedAt: jan),
|
||||
);
|
||||
await repo.upsert(
|
||||
noteFixture('u', 'jun', createdAt: jun, updatedAt: jun),
|
||||
);
|
||||
|
||||
// A single-day range on Jan 15 includes the 10:00 note that day.
|
||||
final notes = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
createdFrom: DateTime(2026, 1, 15),
|
||||
createdTo: DateTime(2026, 1, 15),
|
||||
),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['j']);
|
||||
});
|
||||
|
||||
test('created and updated ranges apply independently', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
// Created in Jan, but last updated in Jun.
|
||||
await repo.upsert(
|
||||
noteFixture('e', 'edited later', createdAt: jan, updatedAt: jun),
|
||||
);
|
||||
|
||||
// Matches on the updated range...
|
||||
final byUpdated = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
updatedFrom: DateTime(2026, 6, 1),
|
||||
updatedTo: DateTime(2026, 6, 30),
|
||||
),
|
||||
);
|
||||
expect(byUpdated.map((n) => n.id), ['e']);
|
||||
|
||||
// ...but not when the created range excludes January.
|
||||
final byCreated = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
createdFrom: DateTime(2026, 6, 1),
|
||||
createdTo: DateTime(2026, 6, 30),
|
||||
),
|
||||
);
|
||||
expect(byCreated, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('filters combine with AND', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(
|
||||
noteFixture(
|
||||
'match',
|
||||
'urgent report',
|
||||
priority: Priority.high,
|
||||
status: Status.inProgress,
|
||||
),
|
||||
);
|
||||
await repo.upsert(
|
||||
noteFixture(
|
||||
'wrongPrio',
|
||||
'urgent report',
|
||||
priority: Priority.low,
|
||||
status: Status.inProgress,
|
||||
),
|
||||
);
|
||||
await repo.upsert(
|
||||
noteFixture(
|
||||
'wrongText',
|
||||
'casual note',
|
||||
priority: Priority.high,
|
||||
status: Status.inProgress,
|
||||
),
|
||||
);
|
||||
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(
|
||||
query: 'urgent',
|
||||
priorities: {Priority.high},
|
||||
statuses: {Status.inProgress},
|
||||
),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['match']);
|
||||
});
|
||||
|
||||
test('date-range filters bound by whole calendar days', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(
|
||||
noteFixture(
|
||||
'jan',
|
||||
'january idea',
|
||||
createdAt: DateTime(2026, 1, 10),
|
||||
updatedAt: DateTime(2026, 1, 10),
|
||||
),
|
||||
);
|
||||
await repo.upsert(
|
||||
noteFixture(
|
||||
'mar',
|
||||
'march idea',
|
||||
createdAt: DateTime(2026, 3, 10),
|
||||
updatedAt: DateTime(2026, 3, 10),
|
||||
),
|
||||
);
|
||||
|
||||
final byCreated = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
createdFrom: DateTime(2026, 2, 1),
|
||||
createdTo: DateTime(2026, 4, 1),
|
||||
),
|
||||
);
|
||||
expect(byCreated.map((n) => n.text), ['march idea']);
|
||||
|
||||
final byUpdated = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
updatedFrom: DateTime(2026, 1, 1),
|
||||
updatedTo: DateTime(2026, 1, 31),
|
||||
),
|
||||
);
|
||||
expect(byUpdated.map((n) => n.text), ['january 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
31
test/note_repository_helpers.dart
Normal file
31
test/note_repository_helpers.dart
Normal file
@ -0,0 +1,31 @@
|
||||
/// Shared fixtures for the note_repository test files.
|
||||
///
|
||||
/// `note_repository_test.dart` was split by concern for file size; these are
|
||||
/// the pieces every split needs. Deliberately NOT named `*_test.dart`: the
|
||||
/// runner would collect it and fail on the missing `main()`.
|
||||
library;
|
||||
|
||||
import 'package:todo/data/note.dart';
|
||||
|
||||
/// A note with sensible defaults, timestamped now unless told otherwise.
|
||||
///
|
||||
/// Each test file builds its own notes rather than sharing a corpus, so a
|
||||
/// change to one file's fixtures cannot silently retune another's assertions.
|
||||
Note noteFixture(
|
||||
String id,
|
||||
String text, {
|
||||
Priority priority = Priority.medium,
|
||||
Status status = Status.todo,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return Note(
|
||||
id: id,
|
||||
text: text,
|
||||
priority: priority,
|
||||
status: status,
|
||||
createdAt: createdAt ?? now,
|
||||
updatedAt: updatedAt ?? now,
|
||||
);
|
||||
}
|
||||
182
test/note_repository_migration_test.dart
Normal file
182
test/note_repository_migration_test.dart
Normal file
@ -0,0 +1,182 @@
|
||||
/// Schema migrations from the legacy sqlite_crdt database.
|
||||
///
|
||||
/// One of four files `note_repository_test.dart` was split into for the
|
||||
/// 250-line cap. Each carries its own `setUpAll(sqfliteFfiInit)`: without it
|
||||
/// sqflite is never initialised and every test in the file fails.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
// SqliteCrdt is needed to seed the legacy DB in migration tests; hide its Hlc
|
||||
// so the crdt_sync Hlc/Record are unambiguous.
|
||||
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repository_factory_io.dart';
|
||||
|
||||
import 'note_repository_helpers.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(sqfliteFfiInit);
|
||||
|
||||
test('v2→v3 migration backfills priority 0 to medium', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final dir = await Directory.systemTemp.createTemp('todo_migration');
|
||||
final path = '${dir.path}/todo.db';
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
|
||||
// Build a v2 database (status column present, no priority backfill) and
|
||||
// insert a legacy note with the old priority 0 ("none").
|
||||
final v2 = await SqliteCrdt.open(
|
||||
path,
|
||||
version: 2,
|
||||
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,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
''');
|
||||
},
|
||||
);
|
||||
final now = DateTime.now().toIso8601String();
|
||||
await v2.execute(
|
||||
'INSERT INTO notes (id, text, priority, status, created_at, updated_at) '
|
||||
'VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
|
||||
['legacy', 'old idea', 0, 0, now, now],
|
||||
);
|
||||
await v2.close();
|
||||
|
||||
// Reopening through the repository runs onUpgrade to v3.
|
||||
final repo = await openRepositoryIn(p.dirname(path));
|
||||
addTearDown(repo.close);
|
||||
final notes = await repo.listNotes();
|
||||
|
||||
expect(notes.single.id, 'legacy');
|
||||
expect(notes.single.priority, Priority.medium);
|
||||
});
|
||||
|
||||
test('v1→v2 migration adds the status column with a default', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final dir = await Directory.systemTemp.createTemp('todo_migration_v1');
|
||||
final path = '${dir.path}/todo.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 openRepositoryIn(p.dirname(path));
|
||||
addTearDown(repo.close);
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes.single.id, 'old');
|
||||
expect(notes.single.status, Status.todo); // backfilled default
|
||||
});
|
||||
|
||||
group('migration from a legacy sqlite_crdt database', () {
|
||||
// Writes a v3 legacy DB at [path] with one live note and one tombstone,
|
||||
// exactly as the pre-crdt_sync app would have left it.
|
||||
Future<String> seedLegacyDb(String dir) async {
|
||||
final path = '$dir/todo.db';
|
||||
final legacy = await SqliteCrdt.open(
|
||||
path,
|
||||
version: 3,
|
||||
onCreate: (db, _) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE notes (
|
||||
id TEXT NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 2,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
''');
|
||||
},
|
||||
);
|
||||
Future<void> insert(String id, String text, int status) => legacy.execute(
|
||||
'INSERT INTO notes (id, text, priority, status, created_at, '
|
||||
'updated_at) VALUES (?1, ?2, 3, ?3, ?4, ?4)',
|
||||
[id, text, status, '2026-01-01T00:00:00.000'],
|
||||
);
|
||||
await insert('live', 'kept idea', Status.inProgress.value);
|
||||
await insert('gone', 'deleted idea', Status.todo.value);
|
||||
await legacy.execute('DELETE FROM notes WHERE id = ?1', ['gone']);
|
||||
await legacy.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
test('imports live notes and preserves tombstones, once', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final dir = await Directory.systemTemp.createTemp('todo_migrate');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final path = await seedLegacyDb(dir.path);
|
||||
|
||||
final repo = await openRepositoryIn(p.dirname(path));
|
||||
addTearDown(repo.close);
|
||||
|
||||
// The live note migrated with its fields intact...
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes.map((n) => n.text), ['kept idea']);
|
||||
expect(notes.single.priority, Priority.high);
|
||||
expect(notes.single.status, Status.inProgress);
|
||||
// ...and the deletion carried over as a sticky tombstone.
|
||||
expect(repo.exportLog()['gone']!.deleted, isTrue);
|
||||
expect(repo.nodeId, isNotEmpty);
|
||||
|
||||
// Reopening does not re-run migration: a note added after the first open
|
||||
// survives, and the (still-present) legacy DB is not re-imported.
|
||||
await repo.upsert(noteFixture('new', 'added post-migration'));
|
||||
await repo.close();
|
||||
final reopened = await openRepositoryIn(p.dirname(path));
|
||||
addTearDown(reopened.close);
|
||||
final texts = (await reopened.listNotes()).map((n) => n.text).toSet();
|
||||
expect(texts, {'kept idea', 'added post-migration'});
|
||||
});
|
||||
|
||||
test('a fresh install with no legacy DB starts empty', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final dir = await Directory.systemTemp.createTemp('todo_fresh');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
|
||||
final repo = await openRepositoryIn(dir.path);
|
||||
addTearDown(repo.close);
|
||||
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
expect(repo.nodeId, isNotEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
234
test/note_repository_streams_test.dart
Normal file
234
test/note_repository_streams_test.dart
Normal file
@ -0,0 +1,234 @@
|
||||
/// Import merging, watch streams, and the changes tick.
|
||||
///
|
||||
/// One of four files `note_repository_test.dart` was split into for the
|
||||
/// 250-line cap. Each carries its own `setUpAll(sqfliteFfiInit)`: without it
|
||||
/// sqflite is never initialised and every test in the file fails.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
// SqliteCrdt is needed to seed the legacy DB in migration tests; hide its Hlc
|
||||
// so the crdt_sync Hlc/Record are unambiguous.
|
||||
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repository_factory_io.dart';
|
||||
|
||||
import 'note_repository_helpers.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(sqfliteFfiInit);
|
||||
|
||||
group('importNotes (safe merge)', () {
|
||||
test('restores at the note\'s own edit time, not "now"', () async {
|
||||
// Regression guard. Recovery used to stamp fresh clocks on every
|
||||
// restored note, so a device that recovered from a backup outranked
|
||||
// every other device under per-field last-writer-wins and silently
|
||||
// overwrote newer edits made elsewhere. Observed for real: a restored
|
||||
// desktop rewrote 245 of 330 field clocks to "now".
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final edited = DateTime(2026, 3, 4, 5, 6, 7);
|
||||
|
||||
await repo.importNotes([
|
||||
noteFixture(
|
||||
'restored',
|
||||
'from a backup',
|
||||
createdAt: edited,
|
||||
updatedAt: edited,
|
||||
),
|
||||
]);
|
||||
|
||||
final field = repo.exportLog()['restored']!.fields['text']!;
|
||||
expect(
|
||||
field.$2.wallTimeMs,
|
||||
edited.millisecondsSinceEpoch,
|
||||
reason:
|
||||
'clock must come from updatedAt so a restore cannot outrank '
|
||||
'genuinely newer data on another device',
|
||||
);
|
||||
});
|
||||
|
||||
test('adds notes whose id is not present locally', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(noteFixture('a', 'local'));
|
||||
|
||||
final outcome = await repo.importNotes([noteFixture('b', 'incoming')]);
|
||||
|
||||
expect(outcome.added, 1);
|
||||
expect(outcome.updated, 0);
|
||||
expect(outcome.skipped, 0);
|
||||
expect((await repo.listNotes()).map((n) => n.id).toSet(), {'a', 'b'});
|
||||
});
|
||||
|
||||
test('overwrites a local note only when the import is newer', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final old = DateTime(2026, 1, 1);
|
||||
final newer = DateTime(2026, 6, 1);
|
||||
await repo.upsert(noteFixture('a', 'local-old', updatedAt: old));
|
||||
|
||||
final outcome = await repo.importNotes([
|
||||
noteFixture('a', 'imported-new', updatedAt: newer),
|
||||
]);
|
||||
|
||||
expect(outcome.updated, 1);
|
||||
final stored = (await repo.listNotes()).single;
|
||||
expect(stored.text, 'imported-new');
|
||||
});
|
||||
|
||||
test('never clobbers a newer local edit with a stale import', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final stale = DateTime(2026, 1, 1);
|
||||
final fresh = DateTime(2026, 6, 1);
|
||||
// Local note is the freshly-edited one.
|
||||
await repo.upsert(noteFixture('a', 'local-fresh', updatedAt: fresh));
|
||||
|
||||
final outcome = await repo.importNotes([
|
||||
noteFixture('a', 'backup-stale', updatedAt: stale),
|
||||
]);
|
||||
|
||||
expect(outcome.skipped, 1);
|
||||
expect(outcome.updated, 0);
|
||||
// The newer local edit survives — "never lose ideas".
|
||||
expect((await repo.listNotes()).single.text, 'local-fresh');
|
||||
});
|
||||
});
|
||||
|
||||
group('sorting and streams', () {
|
||||
test('createdDesc and alphabetical orderings', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final t1 = DateTime(2026, 1, 1);
|
||||
final t2 = DateTime(2026, 2, 1);
|
||||
await repo.upsert(
|
||||
noteFixture('a', 'banana', createdAt: t1, updatedAt: t1),
|
||||
);
|
||||
await repo.upsert(
|
||||
noteFixture('b', 'apple', createdAt: t2, updatedAt: t2),
|
||||
);
|
||||
|
||||
final byCreated = await repo.listNotes(sort: NoteSort.createdDesc);
|
||||
expect(byCreated.first.id, 'b'); // newest created first
|
||||
|
||||
final alpha = await repo.listNotes(sort: NoteSort.alphabetical);
|
||||
expect(alpha.map((n) => n.text), ['apple', 'banana']);
|
||||
});
|
||||
|
||||
test('watchNotes and watchCount emit current state', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(noteFixture('a', 'one'));
|
||||
|
||||
expect(await repo.watchNotes().first, hasLength(1));
|
||||
expect(await repo.watchCount().first, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('nodeId, log export/import merge and close', () async {
|
||||
// Each openInMemory repo owns its own in-memory log, so two of them model
|
||||
// two independent devices directly.
|
||||
final source = await NoteRepository.openInMemory(nodeId: 'source');
|
||||
final target = await NoteRepository.openInMemory(nodeId: 'target');
|
||||
addTearDown(target.close);
|
||||
|
||||
expect(source.nodeId, 'source');
|
||||
await source.upsert(noteFixture('a', 'shared idea'));
|
||||
final log = source.exportLog();
|
||||
await source.close();
|
||||
|
||||
await target.importLog(log);
|
||||
final merged = await target.listNotes();
|
||||
expect(merged.single.text, 'shared idea');
|
||||
});
|
||||
|
||||
test('watchNotes emits the seed then re-emits after each change', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final lengths = <int>[];
|
||||
final sub = repo.watchNotes().listen((notes) => lengths.add(notes.length));
|
||||
|
||||
await repo.upsert(noteFixture('a', 'first'));
|
||||
await repo.upsert(noteFixture('b', 'second'));
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(lengths.first, 0); // seed
|
||||
expect(lengths.last, 2); // after both writes
|
||||
});
|
||||
|
||||
test('watchCount emits the seed then re-emits after a change', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final counts = <int>[];
|
||||
final sub = repo.watchCount().listen(counts.add);
|
||||
|
||||
await repo.upsert(noteFixture('a', 'x'));
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(counts.first, 0);
|
||||
expect(counts.last, 1);
|
||||
});
|
||||
|
||||
test('a foreign record missing timestamps falls back to the epoch', () async {
|
||||
// A note this app writes always has both timestamps; this guards a
|
||||
// malformed record arriving from another device via importLog.
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.importLog({
|
||||
'partial': Record(
|
||||
id: 'partial',
|
||||
fields: {'text': ('no dates', Hlc.newTick('other'))},
|
||||
),
|
||||
});
|
||||
|
||||
final only = (await repo.listNotes()).single;
|
||||
expect(only.text, 'no dates');
|
||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
expect(only.createdAt, epoch);
|
||||
expect(only.updatedAt, epoch);
|
||||
});
|
||||
|
||||
group('changes', () {
|
||||
test('ticks once per write across upsert, delete, and merge', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
var ticks = 0;
|
||||
final sub = repo.changes.listen((_) => ticks++);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await repo.upsert(noteFixture('a', 'x'));
|
||||
await repo.delete('a');
|
||||
|
||||
// A log from another device, merged in, must also tick.
|
||||
final other = await NoteRepository.openInMemory(nodeId: 'other');
|
||||
addTearDown(other.close);
|
||||
await other.upsert(noteFixture('b', 'from other'));
|
||||
await repo.importLog(other.exportLog());
|
||||
|
||||
await pumpEventQueue(); // let the broadcast events dispatch
|
||||
expect(ticks, 3);
|
||||
});
|
||||
|
||||
test('importNotes ticks once per note it writes', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
var ticks = 0;
|
||||
final sub = repo.changes.listen((_) => ticks++);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await repo.importNotes([noteFixture('a', 'x'), noteFixture('b', 'y')]);
|
||||
|
||||
await pumpEventQueue();
|
||||
expect(ticks, 2); // two new notes → two upserts → two ticks
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -1,677 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
// SqliteCrdt is needed to seed the legacy DB in migration tests; hide its Hlc
|
||||
// so the crdt_sync Hlc/Record are unambiguous.
|
||||
import 'package:sqlite_crdt/sqlite_crdt.dart' hide Hlc;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/repository_factory_io.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(sqfliteFfiInit);
|
||||
|
||||
Note note(
|
||||
String id,
|
||||
String text, {
|
||||
Priority priority = Priority.medium,
|
||||
Status status = Status.todo,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return Note(
|
||||
id: id,
|
||||
text: text,
|
||||
priority: priority,
|
||||
status: status,
|
||||
createdAt: createdAt ?? now,
|
||||
updatedAt: updatedAt ?? now,
|
||||
);
|
||||
}
|
||||
|
||||
test('upsert then list returns the note', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(note('a', 'first idea'));
|
||||
final notes = await repo.listNotes();
|
||||
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, 'first idea');
|
||||
});
|
||||
|
||||
test('deleted notes are excluded from reads (tombstone filter)', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(note('a', 'keep me'));
|
||||
await repo.upsert(note('b', 'delete me'));
|
||||
await repo.delete('b');
|
||||
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes, hasLength(1));
|
||||
expect(notes.single.text, 'keep me');
|
||||
|
||||
// The tombstone must survive in the exported log so the deletion syncs.
|
||||
final log = repo.exportLog();
|
||||
expect(log['b']!.deleted, isTrue);
|
||||
});
|
||||
|
||||
test('modifiedDesc sort orders most-recently-updated first', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(note('a', 'older', updatedAt: DateTime(2026)));
|
||||
await repo.upsert(note('b', 'newer', updatedAt: DateTime(2026, 6)));
|
||||
|
||||
final notes = await repo.listNotes(sort: NoteSort.modifiedDesc);
|
||||
expect(notes.map((n) => n.text), ['newer', 'older']);
|
||||
});
|
||||
|
||||
test('priority sort orders highest first', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(note('a', 'low', priority: Priority.low));
|
||||
await repo.upsert(note('b', 'high', priority: Priority.high));
|
||||
|
||||
final notes = await repo.listNotes(sort: NoteSort.priorityDesc);
|
||||
expect(notes.first.text, 'high');
|
||||
expect(notes.last.text, 'low');
|
||||
});
|
||||
|
||||
test('priority sort breaks ties by most-recently-updated', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
|
||||
await repo.upsert(
|
||||
note('a', 'older', priority: Priority.high, updatedAt: DateTime(2026)),
|
||||
);
|
||||
await repo.upsert(
|
||||
note('b', 'newer', priority: Priority.high, updatedAt: DateTime(2026, 6)),
|
||||
);
|
||||
|
||||
final notes = await repo.listNotes(sort: NoteSort.priorityDesc);
|
||||
expect(notes.map((n) => n.text), ['newer', 'older']);
|
||||
});
|
||||
|
||||
group('text search', () {
|
||||
test('matches a case-insensitive substring', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(note('a', 'Buy MILK and eggs'));
|
||||
await repo.upsert(note('b', 'call the dentist'));
|
||||
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(query: 'milk'),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['a']);
|
||||
});
|
||||
|
||||
test('escapes LIKE wildcards so % is matched literally', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(note('pct', 'a%b'));
|
||||
await repo.upsert(note('plain', 'axb'));
|
||||
|
||||
// Without escaping, 'a%b' as a LIKE pattern would also match 'axb'.
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(query: 'a%b'),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['pct']);
|
||||
});
|
||||
});
|
||||
|
||||
group('attribute filters', () {
|
||||
test('priority filter includes only the selected priorities', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(note('lo', 'l', priority: Priority.low));
|
||||
await repo.upsert(note('me', 'm', priority: Priority.medium));
|
||||
await repo.upsert(note('hi', 'h', priority: Priority.high));
|
||||
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(priorities: {Priority.low, Priority.high}),
|
||||
);
|
||||
expect(notes.map((n) => n.id).toSet(), {'lo', 'hi'});
|
||||
});
|
||||
|
||||
test('status filter includes only the selected statuses', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(note('t', 'todo', status: Status.todo));
|
||||
await repo.upsert(note('d', 'done', status: Status.done));
|
||||
await repo.upsert(note('x', 'gone', status: Status.abandoned));
|
||||
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(statuses: {Status.todo, Status.inProgress}),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['t']);
|
||||
});
|
||||
});
|
||||
|
||||
group('date range filters', () {
|
||||
final jan = DateTime(2026, 1, 15, 10);
|
||||
final jun = DateTime(2026, 6, 15, 10);
|
||||
|
||||
test('created range bounds are inclusive by calendar day', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(note('j', 'jan', createdAt: jan, updatedAt: jan));
|
||||
await repo.upsert(note('u', 'jun', createdAt: jun, updatedAt: jun));
|
||||
|
||||
// A single-day range on Jan 15 includes the 10:00 note that day.
|
||||
final notes = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
createdFrom: DateTime(2026, 1, 15),
|
||||
createdTo: DateTime(2026, 1, 15),
|
||||
),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['j']);
|
||||
});
|
||||
|
||||
test('created and updated ranges apply independently', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
// Created in Jan, but last updated in Jun.
|
||||
await repo.upsert(
|
||||
note('e', 'edited later', createdAt: jan, updatedAt: jun),
|
||||
);
|
||||
|
||||
// Matches on the updated range...
|
||||
final byUpdated = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
updatedFrom: DateTime(2026, 6, 1),
|
||||
updatedTo: DateTime(2026, 6, 30),
|
||||
),
|
||||
);
|
||||
expect(byUpdated.map((n) => n.id), ['e']);
|
||||
|
||||
// ...but not when the created range excludes January.
|
||||
final byCreated = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
createdFrom: DateTime(2026, 6, 1),
|
||||
createdTo: DateTime(2026, 6, 30),
|
||||
),
|
||||
);
|
||||
expect(byCreated, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('filters combine with AND', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(
|
||||
note(
|
||||
'match',
|
||||
'urgent report',
|
||||
priority: Priority.high,
|
||||
status: Status.inProgress,
|
||||
),
|
||||
);
|
||||
await repo.upsert(
|
||||
note(
|
||||
'wrongPrio',
|
||||
'urgent report',
|
||||
priority: Priority.low,
|
||||
status: Status.inProgress,
|
||||
),
|
||||
);
|
||||
await repo.upsert(
|
||||
note(
|
||||
'wrongText',
|
||||
'casual note',
|
||||
priority: Priority.high,
|
||||
status: Status.inProgress,
|
||||
),
|
||||
);
|
||||
|
||||
final notes = await repo.listNotes(
|
||||
filter: const NoteFilter(
|
||||
query: 'urgent',
|
||||
priorities: {Priority.high},
|
||||
statuses: {Status.inProgress},
|
||||
),
|
||||
);
|
||||
expect(notes.map((n) => n.id), ['match']);
|
||||
});
|
||||
|
||||
group('priority defaults', () {
|
||||
test('fromValue maps legacy/unknown values to medium', () {
|
||||
expect(Priority.fromValue(0), Priority.medium); // old "none"
|
||||
expect(Priority.fromValue(null), Priority.medium);
|
||||
expect(Priority.fromValue(99), Priority.medium);
|
||||
// Known values still round-trip.
|
||||
expect(Priority.fromValue(1), Priority.low);
|
||||
expect(Priority.fromValue(2), Priority.medium);
|
||||
expect(Priority.fromValue(3), Priority.high);
|
||||
});
|
||||
});
|
||||
|
||||
group('importNotes (safe merge)', () {
|
||||
test('restores at the note\'s own edit time, not "now"', () async {
|
||||
// Regression guard. Recovery used to stamp fresh clocks on every
|
||||
// restored note, so a device that recovered from a backup outranked
|
||||
// every other device under per-field last-writer-wins and silently
|
||||
// overwrote newer edits made elsewhere. Observed for real: a restored
|
||||
// desktop rewrote 245 of 330 field clocks to "now".
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final edited = DateTime(2026, 3, 4, 5, 6, 7);
|
||||
|
||||
await repo.importNotes([
|
||||
note('restored', 'from a backup', createdAt: edited, updatedAt: edited),
|
||||
]);
|
||||
|
||||
final field = repo.exportLog()['restored']!.fields['text']!;
|
||||
expect(
|
||||
field.$2.wallTimeMs,
|
||||
edited.millisecondsSinceEpoch,
|
||||
reason:
|
||||
'clock must come from updatedAt so a restore cannot outrank '
|
||||
'genuinely newer data on another device',
|
||||
);
|
||||
});
|
||||
|
||||
test('adds notes whose id is not present locally', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(note('a', 'local'));
|
||||
|
||||
final outcome = await repo.importNotes([note('b', 'incoming')]);
|
||||
|
||||
expect(outcome.added, 1);
|
||||
expect(outcome.updated, 0);
|
||||
expect(outcome.skipped, 0);
|
||||
expect((await repo.listNotes()).map((n) => n.id).toSet(), {'a', 'b'});
|
||||
});
|
||||
|
||||
test('overwrites a local note only when the import is newer', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final old = DateTime(2026, 1, 1);
|
||||
final newer = DateTime(2026, 6, 1);
|
||||
await repo.upsert(note('a', 'local-old', updatedAt: old));
|
||||
|
||||
final outcome = await repo.importNotes([
|
||||
note('a', 'imported-new', updatedAt: newer),
|
||||
]);
|
||||
|
||||
expect(outcome.updated, 1);
|
||||
final stored = (await repo.listNotes()).single;
|
||||
expect(stored.text, 'imported-new');
|
||||
});
|
||||
|
||||
test('never clobbers a newer local edit with a stale import', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final stale = DateTime(2026, 1, 1);
|
||||
final fresh = DateTime(2026, 6, 1);
|
||||
// Local note is the freshly-edited one.
|
||||
await repo.upsert(note('a', 'local-fresh', updatedAt: fresh));
|
||||
|
||||
final outcome = await repo.importNotes([
|
||||
note('a', 'backup-stale', updatedAt: stale),
|
||||
]);
|
||||
|
||||
expect(outcome.skipped, 1);
|
||||
expect(outcome.updated, 0);
|
||||
// The newer local edit survives — "never lose ideas".
|
||||
expect((await repo.listNotes()).single.text, 'local-fresh');
|
||||
});
|
||||
});
|
||||
|
||||
test('v2→v3 migration backfills priority 0 to medium', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final dir = await Directory.systemTemp.createTemp('todo_migration');
|
||||
final path = '${dir.path}/todo.db';
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
|
||||
// Build a v2 database (status column present, no priority backfill) and
|
||||
// insert a legacy note with the old priority 0 ("none").
|
||||
final v2 = await SqliteCrdt.open(
|
||||
path,
|
||||
version: 2,
|
||||
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,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
''');
|
||||
},
|
||||
);
|
||||
final now = DateTime.now().toIso8601String();
|
||||
await v2.execute(
|
||||
'INSERT INTO notes (id, text, priority, status, created_at, updated_at) '
|
||||
'VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
|
||||
['legacy', 'old idea', 0, 0, now, now],
|
||||
);
|
||||
await v2.close();
|
||||
|
||||
// Reopening through the repository runs onUpgrade to v3.
|
||||
final repo = await openRepositoryIn(p.dirname(path));
|
||||
addTearDown(repo.close);
|
||||
final notes = await repo.listNotes();
|
||||
|
||||
expect(notes.single.id, 'legacy');
|
||||
expect(notes.single.priority, Priority.medium);
|
||||
});
|
||||
|
||||
test('v1→v2 migration adds the status column with a default', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final dir = await Directory.systemTemp.createTemp('todo_migration_v1');
|
||||
final path = '${dir.path}/todo.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 openRepositoryIn(p.dirname(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();
|
||||
addTearDown(repo.close);
|
||||
final t1 = DateTime(2026, 1, 1);
|
||||
final t2 = DateTime(2026, 2, 1);
|
||||
await repo.upsert(note('a', 'banana', createdAt: t1, updatedAt: t1));
|
||||
await repo.upsert(note('b', 'apple', createdAt: t2, updatedAt: t2));
|
||||
|
||||
final byCreated = await repo.listNotes(sort: NoteSort.createdDesc);
|
||||
expect(byCreated.first.id, 'b'); // newest created first
|
||||
|
||||
final alpha = await repo.listNotes(sort: NoteSort.alphabetical);
|
||||
expect(alpha.map((n) => n.text), ['apple', 'banana']);
|
||||
});
|
||||
|
||||
test('watchNotes and watchCount emit current state', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(note('a', 'one'));
|
||||
|
||||
expect(await repo.watchNotes().first, hasLength(1));
|
||||
expect(await repo.watchCount().first, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('nodeId, log export/import merge and close', () async {
|
||||
// Each openInMemory repo owns its own in-memory log, so two of them model
|
||||
// two independent devices directly.
|
||||
final source = await NoteRepository.openInMemory(nodeId: 'source');
|
||||
final target = await NoteRepository.openInMemory(nodeId: 'target');
|
||||
addTearDown(target.close);
|
||||
|
||||
expect(source.nodeId, 'source');
|
||||
await source.upsert(note('a', 'shared idea'));
|
||||
final log = source.exportLog();
|
||||
await source.close();
|
||||
|
||||
await target.importLog(log);
|
||||
final merged = await target.listNotes();
|
||||
expect(merged.single.text, 'shared idea');
|
||||
});
|
||||
|
||||
group('migration from a legacy sqlite_crdt database', () {
|
||||
// Writes a v3 legacy DB at [path] with one live note and one tombstone,
|
||||
// exactly as the pre-crdt_sync app would have left it.
|
||||
Future<String> seedLegacyDb(String dir) async {
|
||||
final path = '$dir/todo.db';
|
||||
final legacy = await SqliteCrdt.open(
|
||||
path,
|
||||
version: 3,
|
||||
onCreate: (db, _) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE notes (
|
||||
id TEXT NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 2,
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
''');
|
||||
},
|
||||
);
|
||||
Future<void> insert(String id, String text, int status) => legacy.execute(
|
||||
'INSERT INTO notes (id, text, priority, status, created_at, '
|
||||
'updated_at) VALUES (?1, ?2, 3, ?3, ?4, ?4)',
|
||||
[id, text, status, '2026-01-01T00:00:00.000'],
|
||||
);
|
||||
await insert('live', 'kept idea', Status.inProgress.value);
|
||||
await insert('gone', 'deleted idea', Status.todo.value);
|
||||
await legacy.execute('DELETE FROM notes WHERE id = ?1', ['gone']);
|
||||
await legacy.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
test('imports live notes and preserves tombstones, once', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final dir = await Directory.systemTemp.createTemp('todo_migrate');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final path = await seedLegacyDb(dir.path);
|
||||
|
||||
final repo = await openRepositoryIn(p.dirname(path));
|
||||
addTearDown(repo.close);
|
||||
|
||||
// The live note migrated with its fields intact...
|
||||
final notes = await repo.listNotes();
|
||||
expect(notes.map((n) => n.text), ['kept idea']);
|
||||
expect(notes.single.priority, Priority.high);
|
||||
expect(notes.single.status, Status.inProgress);
|
||||
// ...and the deletion carried over as a sticky tombstone.
|
||||
expect(repo.exportLog()['gone']!.deleted, isTrue);
|
||||
expect(repo.nodeId, isNotEmpty);
|
||||
|
||||
// Reopening does not re-run migration: a note added after the first open
|
||||
// survives, and the (still-present) legacy DB is not re-imported.
|
||||
await repo.upsert(note('new', 'added post-migration'));
|
||||
await repo.close();
|
||||
final reopened = await openRepositoryIn(p.dirname(path));
|
||||
addTearDown(reopened.close);
|
||||
final texts = (await reopened.listNotes()).map((n) => n.text).toSet();
|
||||
expect(texts, {'kept idea', 'added post-migration'});
|
||||
});
|
||||
|
||||
test('a fresh install with no legacy DB starts empty', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final dir = await Directory.systemTemp.createTemp('todo_fresh');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
|
||||
final repo = await openRepositoryIn(dir.path);
|
||||
addTearDown(repo.close);
|
||||
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
expect(repo.nodeId, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('watchNotes emits the seed then re-emits after each change', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final lengths = <int>[];
|
||||
final sub = repo.watchNotes().listen((notes) => lengths.add(notes.length));
|
||||
|
||||
await repo.upsert(note('a', 'first'));
|
||||
await repo.upsert(note('b', 'second'));
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(lengths.first, 0); // seed
|
||||
expect(lengths.last, 2); // after both writes
|
||||
});
|
||||
|
||||
test('watchCount emits the seed then re-emits after a change', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
final counts = <int>[];
|
||||
final sub = repo.watchCount().listen(counts.add);
|
||||
|
||||
await repo.upsert(note('a', 'x'));
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(counts.first, 0);
|
||||
expect(counts.last, 1);
|
||||
});
|
||||
|
||||
test('a foreign record missing timestamps falls back to the epoch', () async {
|
||||
// A note this app writes always has both timestamps; this guards a
|
||||
// malformed record arriving from another device via importLog.
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.importLog({
|
||||
'partial': Record(
|
||||
id: 'partial',
|
||||
fields: {'text': ('no dates', Hlc.newTick('other'))},
|
||||
),
|
||||
});
|
||||
|
||||
final only = (await repo.listNotes()).single;
|
||||
expect(only.text, 'no dates');
|
||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
expect(only.createdAt, epoch);
|
||||
expect(only.updatedAt, epoch);
|
||||
});
|
||||
|
||||
test('date-range filters bound by whole calendar days', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
await repo.upsert(
|
||||
note(
|
||||
'jan',
|
||||
'january idea',
|
||||
createdAt: DateTime(2026, 1, 10),
|
||||
updatedAt: DateTime(2026, 1, 10),
|
||||
),
|
||||
);
|
||||
await repo.upsert(
|
||||
note(
|
||||
'mar',
|
||||
'march idea',
|
||||
createdAt: DateTime(2026, 3, 10),
|
||||
updatedAt: DateTime(2026, 3, 10),
|
||||
),
|
||||
);
|
||||
|
||||
final byCreated = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
createdFrom: DateTime(2026, 2, 1),
|
||||
createdTo: DateTime(2026, 4, 1),
|
||||
),
|
||||
);
|
||||
expect(byCreated.map((n) => n.text), ['march idea']);
|
||||
|
||||
final byUpdated = await repo.listNotes(
|
||||
filter: NoteFilter(
|
||||
updatedFrom: DateTime(2026, 1, 1),
|
||||
updatedTo: DateTime(2026, 1, 31),
|
||||
),
|
||||
);
|
||||
expect(byUpdated.map((n) => n.text), ['january 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);
|
||||
});
|
||||
});
|
||||
|
||||
group('changes', () {
|
||||
test('ticks once per write across upsert, delete, and merge', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
var ticks = 0;
|
||||
final sub = repo.changes.listen((_) => ticks++);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await repo.upsert(note('a', 'x'));
|
||||
await repo.delete('a');
|
||||
|
||||
// A log from another device, merged in, must also tick.
|
||||
final other = await NoteRepository.openInMemory(nodeId: 'other');
|
||||
addTearDown(other.close);
|
||||
await other.upsert(note('b', 'from other'));
|
||||
await repo.importLog(other.exportLog());
|
||||
|
||||
await pumpEventQueue(); // let the broadcast events dispatch
|
||||
expect(ticks, 3);
|
||||
});
|
||||
|
||||
test('importNotes ticks once per note it writes', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
addTearDown(repo.close);
|
||||
var ticks = 0;
|
||||
final sub = repo.changes.listen((_) => ticks++);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await repo.importNotes([note('a', 'x'), note('b', 'y')]);
|
||||
|
||||
await pumpEventQueue();
|
||||
expect(ticks, 2); // two new notes → two upserts → two ticks
|
||||
});
|
||||
});
|
||||
}
|
||||
203
test/notes_list_filter_test.dart
Normal file
203
test/notes_list_filter_test.dart
Normal file
@ -0,0 +1,203 @@
|
||||
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';
|
||||
|
||||
import 'notes_list_harness.dart';
|
||||
|
||||
void main() {
|
||||
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);
|
||||
});
|
||||
|
||||
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.byTooltip('Quick actions'));
|
||||
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);
|
||||
});
|
||||
|
||||
testWidgets('tapping a note opens the detail screen', (tester) async {
|
||||
await pumpList(tester, seed: [note('a', '# Open me\n\nbody')]);
|
||||
|
||||
await tester.tap(find.text('Open me'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // route transition
|
||||
|
||||
// The detail screen shows the note title in its app bar plus the
|
||||
// Priority/Status meta dropdowns and the editor mode toggle.
|
||||
expect(find.text('Priority'), findsOneWidget);
|
||||
expect(find.text('View'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
44
test/notes_list_harness.dart
Normal file
44
test/notes_list_harness.dart
Normal file
@ -0,0 +1,44 @@
|
||||
/// The shared pumpList harness and its fixed-clock note fixture.
|
||||
///
|
||||
/// Shared by the files `notes_list_screen_test.dart` was split into for the
|
||||
/// 250-line cap. Deliberately NOT named `*_test.dart`: the runner would
|
||||
/// collect it and fail on the missing `main()`.
|
||||
library;
|
||||
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
@ -6,37 +6,9 @@ import 'package:todo/ui/notes_list_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
|
||||
import 'notes_list_harness.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 {
|
||||
@ -152,196 +124,4 @@ void main() {
|
||||
// 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);
|
||||
});
|
||||
|
||||
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.byTooltip('Quick actions'));
|
||||
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);
|
||||
});
|
||||
|
||||
testWidgets('tapping a note opens the detail screen', (tester) async {
|
||||
await pumpList(tester, seed: [note('a', '# Open me\n\nbody')]);
|
||||
|
||||
await tester.tap(find.text('Open me'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300)); // route transition
|
||||
|
||||
// The detail screen shows the note title in its app bar plus the
|
||||
// Priority/Status meta dropdowns and the editor mode toggle.
|
||||
expect(find.text('Priority'), findsOneWidget);
|
||||
expect(find.text('View'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
80
test/port_guard_fakes.dart
Normal file
80
test/port_guard_fakes.dart
Normal file
@ -0,0 +1,80 @@
|
||||
/// The FakeProbe and the ss/proc fixtures.
|
||||
///
|
||||
/// Shared by the files `port_guard_test.dart` was split into for the
|
||||
/// 250-line cap. Deliberately NOT named `*_test.dart`: the runner would
|
||||
/// collect it and fail on the missing `main()`.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/desktop/port_guard.dart';
|
||||
|
||||
/// Real `ss -tlnp` output, including the header row and the run-together
|
||||
/// `Peer Address:PortProcess` column that iproute2 actually prints.
|
||||
const _ssOutput = '''
|
||||
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
|
||||
LISTEN 0 4096 0.0.0.0:5355 0.0.0.0:*
|
||||
LISTEN 0 128 127.0.0.1:8730 0.0.0.0:* users:(("dart:todo_deskt",pid=61687,fd=7))
|
||||
LISTEN 0 511 127.0.0.1:9000 0.0.0.0:* users:(("node",pid=1234,fd=20))
|
||||
''';
|
||||
|
||||
/// A [SystemProbe] whose every answer is scripted by the test.
|
||||
class FakeProbe implements SystemProbe {
|
||||
FakeProbe({
|
||||
this.pid,
|
||||
this.description,
|
||||
this.cmdlines = const [],
|
||||
this.inUse = const [true],
|
||||
this.freeAfter,
|
||||
});
|
||||
|
||||
int? pid;
|
||||
ProcessDescription? description;
|
||||
List<List<String>> cmdlines;
|
||||
|
||||
/// Answers for successive [portInUse] calls; the last one repeats.
|
||||
List<bool> inUse;
|
||||
|
||||
/// When set, the port frees only once this signal has been delivered.
|
||||
///
|
||||
/// Keyed on the signal rather than on elapsed time so the escalation test
|
||||
/// cannot flake on a slow machine.
|
||||
ProcessSignal? freeAfter;
|
||||
|
||||
final List<ProcessSignal> signalsSent = [];
|
||||
int portInUseCalls = 0;
|
||||
|
||||
@override
|
||||
Future<int?> listenerPid(int port) async => pid;
|
||||
|
||||
@override
|
||||
Future<ProcessDescription?> describe(int pid) async => description;
|
||||
|
||||
@override
|
||||
Future<List<List<String>>> processCmdlines() async => cmdlines;
|
||||
|
||||
@override
|
||||
Future<bool> portInUse(int port) async {
|
||||
portInUseCalls++;
|
||||
if (freeAfter != null) return !signalsSent.contains(freeAfter);
|
||||
return inUse[(portInUseCalls - 1).clamp(0, inUse.length - 1)];
|
||||
}
|
||||
|
||||
@override
|
||||
bool signal(int pid, ProcessSignal signal) {
|
||||
signalsSent.add(signal);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const _profile = '/home/kuchy/.local/share/todo-desktop/profile';
|
||||
|
||||
/// Fast timings so the reap tests do not slow the suite down.
|
||||
Future<bool> _reap(PortGuard guard, int pid, int port) => guard.reap(
|
||||
pid,
|
||||
port,
|
||||
termWait: const Duration(milliseconds: 60),
|
||||
killWait: const Duration(milliseconds: 60),
|
||||
pollInterval: const Duration(milliseconds: 5),
|
||||
);
|
||||
136
test/port_guard_linux_probe_test.dart
Normal file
136
test/port_guard_linux_probe_test.dart
Normal file
@ -0,0 +1,136 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/desktop/port_guard.dart';
|
||||
|
||||
import 'port_guard_fakes.dart';
|
||||
|
||||
/// Real `ss -tlnp` output, including the header row and the run-together
|
||||
/// `Peer Address:PortProcess` column that iproute2 actually prints.
|
||||
const _ssOutput = '''
|
||||
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
|
||||
LISTEN 0 4096 0.0.0.0:5355 0.0.0.0:*
|
||||
LISTEN 0 128 127.0.0.1:8730 0.0.0.0:* users:(("dart:todo_deskt",pid=61687,fd=7))
|
||||
LISTEN 0 511 127.0.0.1:9000 0.0.0.0:* users:(("node",pid=1234,fd=20))
|
||||
''';
|
||||
|
||||
/// A [SystemProbe] whose every answer is scripted by the test.
|
||||
class FakeProbe implements SystemProbe {
|
||||
FakeProbe({
|
||||
this.pid,
|
||||
this.description,
|
||||
this.cmdlines = const [],
|
||||
this.inUse = const [true],
|
||||
this.freeAfter,
|
||||
});
|
||||
|
||||
int? pid;
|
||||
ProcessDescription? description;
|
||||
List<List<String>> cmdlines;
|
||||
|
||||
/// Answers for successive [portInUse] calls; the last one repeats.
|
||||
List<bool> inUse;
|
||||
|
||||
/// When set, the port frees only once this signal has been delivered.
|
||||
///
|
||||
/// Keyed on the signal rather than on elapsed time so the escalation test
|
||||
/// cannot flake on a slow machine.
|
||||
ProcessSignal? freeAfter;
|
||||
|
||||
final List<ProcessSignal> signalsSent = [];
|
||||
int portInUseCalls = 0;
|
||||
|
||||
@override
|
||||
Future<int?> listenerPid(int port) async => pid;
|
||||
|
||||
@override
|
||||
Future<ProcessDescription?> describe(int pid) async => description;
|
||||
|
||||
@override
|
||||
Future<List<List<String>>> processCmdlines() async => cmdlines;
|
||||
|
||||
@override
|
||||
Future<bool> portInUse(int port) async {
|
||||
portInUseCalls++;
|
||||
if (freeAfter != null) return !signalsSent.contains(freeAfter);
|
||||
return inUse[(portInUseCalls - 1).clamp(0, inUse.length - 1)];
|
||||
}
|
||||
|
||||
@override
|
||||
bool signal(int pid, ProcessSignal signal) {
|
||||
signalsSent.add(signal);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const _profile = '/home/kuchy/.local/share/todo-desktop/profile';
|
||||
|
||||
/// Fast timings so the reap tests do not slow the suite down.
|
||||
Future<bool> _reap(PortGuard guard, int pid, int port) => guard.reap(
|
||||
pid,
|
||||
port,
|
||||
termWait: const Duration(milliseconds: 60),
|
||||
killWait: const Duration(milliseconds: 60),
|
||||
pollInterval: const Duration(milliseconds: 5),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('LinuxSystemProbe against the real host', () {
|
||||
test('portInUse tracks a real socket', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async => socket.close());
|
||||
expect(await probe.portInUse(socket.port), isTrue);
|
||||
await socket.close();
|
||||
expect(await probe.portInUse(socket.port), isFalse);
|
||||
});
|
||||
|
||||
test('listenerPid finds this process holding a real port', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async => socket.close());
|
||||
// Proves the parser works against genuine `ss` output, not a fixture.
|
||||
expect(await probe.listenerPid(socket.port), pid);
|
||||
});
|
||||
|
||||
test('listenerPid returns null when ss is unavailable', () async {
|
||||
const probe = LinuxSystemProbe(ssExecutable: 'ss-does-not-exist');
|
||||
expect(await probe.listenerPid(8730), isNull);
|
||||
});
|
||||
|
||||
test('describe reads this process', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final description = await probe.describe(pid);
|
||||
expect(description, isNotNull);
|
||||
expect(description!.cmdline, isNotEmpty);
|
||||
expect(description.exeTarget, isNotEmpty);
|
||||
});
|
||||
|
||||
test('describe tolerates an unreadable executable link', () async {
|
||||
// pid 1 belongs to root: its cmdline is world-readable but /proc/1/exe
|
||||
// is not, which is the branch under test.
|
||||
const probe = LinuxSystemProbe();
|
||||
final description = await probe.describe(1);
|
||||
expect(description, isNotNull);
|
||||
expect(description!.exeTarget, isEmpty);
|
||||
});
|
||||
|
||||
test('describe returns null for a process that does not exist', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
expect(await probe.describe(0x7FFFFFFF), isNull);
|
||||
});
|
||||
|
||||
test('processCmdlines lists real processes', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final cmdlines = await probe.processCmdlines();
|
||||
expect(cmdlines, isNotEmpty);
|
||||
});
|
||||
|
||||
test('signal terminates a real child process', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final child = await Process.start('sleep', ['30']);
|
||||
expect(probe.signal(child.pid, ProcessSignal.sigterm), isTrue);
|
||||
expect(await child.exitCode, isNot(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
204
test/port_guard_parsing_test.dart
Normal file
204
test/port_guard_parsing_test.dart
Normal file
@ -0,0 +1,204 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/desktop/port_guard.dart';
|
||||
|
||||
import 'port_guard_fakes.dart';
|
||||
|
||||
/// Real `ss -tlnp` output, including the header row and the run-together
|
||||
/// `Peer Address:PortProcess` column that iproute2 actually prints.
|
||||
const _ssOutput = '''
|
||||
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
|
||||
LISTEN 0 4096 0.0.0.0:5355 0.0.0.0:*
|
||||
LISTEN 0 128 127.0.0.1:8730 0.0.0.0:* users:(("dart:todo_deskt",pid=61687,fd=7))
|
||||
LISTEN 0 511 127.0.0.1:9000 0.0.0.0:* users:(("node",pid=1234,fd=20))
|
||||
''';
|
||||
|
||||
/// A [SystemProbe] whose every answer is scripted by the test.
|
||||
class FakeProbe implements SystemProbe {
|
||||
FakeProbe({
|
||||
this.pid,
|
||||
this.description,
|
||||
this.cmdlines = const [],
|
||||
this.inUse = const [true],
|
||||
this.freeAfter,
|
||||
});
|
||||
|
||||
int? pid;
|
||||
ProcessDescription? description;
|
||||
List<List<String>> cmdlines;
|
||||
|
||||
/// Answers for successive [portInUse] calls; the last one repeats.
|
||||
List<bool> inUse;
|
||||
|
||||
/// When set, the port frees only once this signal has been delivered.
|
||||
///
|
||||
/// Keyed on the signal rather than on elapsed time so the escalation test
|
||||
/// cannot flake on a slow machine.
|
||||
ProcessSignal? freeAfter;
|
||||
|
||||
final List<ProcessSignal> signalsSent = [];
|
||||
int portInUseCalls = 0;
|
||||
|
||||
@override
|
||||
Future<int?> listenerPid(int port) async => pid;
|
||||
|
||||
@override
|
||||
Future<ProcessDescription?> describe(int pid) async => description;
|
||||
|
||||
@override
|
||||
Future<List<List<String>>> processCmdlines() async => cmdlines;
|
||||
|
||||
@override
|
||||
Future<bool> portInUse(int port) async {
|
||||
portInUseCalls++;
|
||||
if (freeAfter != null) return !signalsSent.contains(freeAfter);
|
||||
return inUse[(portInUseCalls - 1).clamp(0, inUse.length - 1)];
|
||||
}
|
||||
|
||||
@override
|
||||
bool signal(int pid, ProcessSignal signal) {
|
||||
signalsSent.add(signal);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const _profile = '/home/kuchy/.local/share/todo-desktop/profile';
|
||||
|
||||
/// Fast timings so the reap tests do not slow the suite down.
|
||||
Future<bool> _reap(PortGuard guard, int pid, int port) => guard.reap(
|
||||
pid,
|
||||
port,
|
||||
termWait: const Duration(milliseconds: 60),
|
||||
killWait: const Duration(milliseconds: 60),
|
||||
pollInterval: const Duration(milliseconds: 5),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('parseListenerPid', () {
|
||||
test('finds the pid holding the requested port', () {
|
||||
expect(parseListenerPid(_ssOutput, 8730), 61687);
|
||||
expect(parseListenerPid(_ssOutput, 9000), 1234);
|
||||
});
|
||||
|
||||
test('returns null when no line listens on the port', () {
|
||||
expect(parseListenerPid(_ssOutput, 7777), isNull);
|
||||
});
|
||||
|
||||
test('returns null when ss hides the owning pid', () {
|
||||
// ss omits the users:(...) column for another user's socket.
|
||||
const hidden = 'LISTEN 0 128 127.0.0.1:8730 0.0.0.0:*';
|
||||
expect(parseListenerPid(hidden, 8730), isNull);
|
||||
});
|
||||
|
||||
test('ignores short and empty lines', () {
|
||||
expect(parseListenerPid('\nLISTEN 0 128\n', 8730), isNull);
|
||||
});
|
||||
|
||||
test('does not match a port that is only a suffix', () {
|
||||
const other =
|
||||
'LISTEN 0 128 127.0.0.1:18730 0.0.0.0:* '
|
||||
'users:(("x",pid=5,fd=7))';
|
||||
expect(parseListenerPid(other, 8730), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('isTodoWrapper', () {
|
||||
test('accepts the installed binary', () {
|
||||
expect(isTodoWrapper('/opt/todo/bin/todo_desktop', []), isTrue);
|
||||
});
|
||||
|
||||
test('accepts a binary replaced by an upgrade', () {
|
||||
expect(
|
||||
isTodoWrapper('/opt/todo/bin/todo_desktop (deleted)', []),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts a development run out of the repo', () {
|
||||
expect(
|
||||
isTodoWrapper('/usr/lib/dart/bin/dart', [
|
||||
'dart',
|
||||
'run',
|
||||
'bin/todo_desktop.dart',
|
||||
'--web-root',
|
||||
'build/web',
|
||||
]),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an unrelated process', () {
|
||||
expect(
|
||||
isTodoWrapper('/usr/bin/python3.14', [
|
||||
'python3',
|
||||
'-m',
|
||||
'http.server',
|
||||
'8730',
|
||||
]),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('hasWindowAttached', () {
|
||||
// Chrome rewrites argv into one space-joined string, so this is the shape
|
||||
// that actually appears in /proc for a browser window.
|
||||
const collapsed = [
|
||||
'/opt/thorium-browser/thorium --app=http://localhost:8730 '
|
||||
'--user-data-dir=$_profile --class=todo --no-first-run',
|
||||
];
|
||||
|
||||
test('matches a browser whose argv collapsed into one token', () {
|
||||
expect(hasWindowAttached([collapsed], _profile), isTrue);
|
||||
});
|
||||
|
||||
test('matches a normally separated argument vector', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=$_profile', '--class=todo'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches when the flag is the final argument', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=$_profile'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match a sibling profile directory', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=${_profile}2'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match when no process mentions the profile', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['bash', '-c', 'sleep 1'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -3,6 +3,8 @@ import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/desktop/port_guard.dart';
|
||||
|
||||
import 'port_guard_fakes.dart';
|
||||
|
||||
/// Real `ss -tlnp` output, including the header row and the run-together
|
||||
/// `Peer Address:PortProcess` column that iproute2 actually prints.
|
||||
const _ssOutput = '''
|
||||
@ -73,133 +75,6 @@ Future<bool> _reap(PortGuard guard, int pid, int port) => guard.reap(
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('parseListenerPid', () {
|
||||
test('finds the pid holding the requested port', () {
|
||||
expect(parseListenerPid(_ssOutput, 8730), 61687);
|
||||
expect(parseListenerPid(_ssOutput, 9000), 1234);
|
||||
});
|
||||
|
||||
test('returns null when no line listens on the port', () {
|
||||
expect(parseListenerPid(_ssOutput, 7777), isNull);
|
||||
});
|
||||
|
||||
test('returns null when ss hides the owning pid', () {
|
||||
// ss omits the users:(...) column for another user's socket.
|
||||
const hidden = 'LISTEN 0 128 127.0.0.1:8730 0.0.0.0:*';
|
||||
expect(parseListenerPid(hidden, 8730), isNull);
|
||||
});
|
||||
|
||||
test('ignores short and empty lines', () {
|
||||
expect(parseListenerPid('\nLISTEN 0 128\n', 8730), isNull);
|
||||
});
|
||||
|
||||
test('does not match a port that is only a suffix', () {
|
||||
const other =
|
||||
'LISTEN 0 128 127.0.0.1:18730 0.0.0.0:* '
|
||||
'users:(("x",pid=5,fd=7))';
|
||||
expect(parseListenerPid(other, 8730), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('isTodoWrapper', () {
|
||||
test('accepts the installed binary', () {
|
||||
expect(isTodoWrapper('/opt/todo/bin/todo_desktop', []), isTrue);
|
||||
});
|
||||
|
||||
test('accepts a binary replaced by an upgrade', () {
|
||||
expect(
|
||||
isTodoWrapper('/opt/todo/bin/todo_desktop (deleted)', []),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts a development run out of the repo', () {
|
||||
expect(
|
||||
isTodoWrapper('/usr/lib/dart/bin/dart', [
|
||||
'dart',
|
||||
'run',
|
||||
'bin/todo_desktop.dart',
|
||||
'--web-root',
|
||||
'build/web',
|
||||
]),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an unrelated process', () {
|
||||
expect(
|
||||
isTodoWrapper('/usr/bin/python3.14', [
|
||||
'python3',
|
||||
'-m',
|
||||
'http.server',
|
||||
'8730',
|
||||
]),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('hasWindowAttached', () {
|
||||
// Chrome rewrites argv into one space-joined string, so this is the shape
|
||||
// that actually appears in /proc for a browser window.
|
||||
const collapsed = [
|
||||
'/opt/thorium-browser/thorium --app=http://localhost:8730 '
|
||||
'--user-data-dir=$_profile --class=todo --no-first-run',
|
||||
];
|
||||
|
||||
test('matches a browser whose argv collapsed into one token', () {
|
||||
expect(hasWindowAttached([collapsed], _profile), isTrue);
|
||||
});
|
||||
|
||||
test('matches a normally separated argument vector', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=$_profile', '--class=todo'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches when the flag is the final argument', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=$_profile'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match a sibling profile directory', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['thorium', '--user-data-dir=${_profile}2'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match when no process mentions the profile', () {
|
||||
expect(
|
||||
hasWindowAttached(
|
||||
[
|
||||
['bash', '-c', 'sleep 1'],
|
||||
],
|
||||
_profile,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('PortGuard.resolve', () {
|
||||
test('retries the bind when the port came free', () async {
|
||||
final guard = PortGuard(
|
||||
@ -343,63 +218,4 @@ void main() {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('LinuxSystemProbe against the real host', () {
|
||||
test('portInUse tracks a real socket', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async => socket.close());
|
||||
expect(await probe.portInUse(socket.port), isTrue);
|
||||
await socket.close();
|
||||
expect(await probe.portInUse(socket.port), isFalse);
|
||||
});
|
||||
|
||||
test('listenerPid finds this process holding a real port', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() async => socket.close());
|
||||
// Proves the parser works against genuine `ss` output, not a fixture.
|
||||
expect(await probe.listenerPid(socket.port), pid);
|
||||
});
|
||||
|
||||
test('listenerPid returns null when ss is unavailable', () async {
|
||||
const probe = LinuxSystemProbe(ssExecutable: 'ss-does-not-exist');
|
||||
expect(await probe.listenerPid(8730), isNull);
|
||||
});
|
||||
|
||||
test('describe reads this process', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final description = await probe.describe(pid);
|
||||
expect(description, isNotNull);
|
||||
expect(description!.cmdline, isNotEmpty);
|
||||
expect(description.exeTarget, isNotEmpty);
|
||||
});
|
||||
|
||||
test('describe tolerates an unreadable executable link', () async {
|
||||
// pid 1 belongs to root: its cmdline is world-readable but /proc/1/exe
|
||||
// is not, which is the branch under test.
|
||||
const probe = LinuxSystemProbe();
|
||||
final description = await probe.describe(1);
|
||||
expect(description, isNotNull);
|
||||
expect(description!.exeTarget, isEmpty);
|
||||
});
|
||||
|
||||
test('describe returns null for a process that does not exist', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
expect(await probe.describe(0x7FFFFFFF), isNull);
|
||||
});
|
||||
|
||||
test('processCmdlines lists real processes', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final cmdlines = await probe.processCmdlines();
|
||||
expect(cmdlines, isNotEmpty);
|
||||
});
|
||||
|
||||
test('signal terminates a real child process', () async {
|
||||
const probe = LinuxSystemProbe();
|
||||
final child = await Process.start('sleep', ['30']);
|
||||
expect(probe.signal(child.pid, ProcessSignal.sigterm), isTrue);
|
||||
expect(await child.exitCode, isNot(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
197
test/settings_screen_backup_test.dart
Normal file
197
test/settings_screen_backup_test.dart
Normal file
@ -0,0 +1,197 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
|
||||
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:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sync_settings_ui/sync_settings_ui.dart';
|
||||
import 'package:todo/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/backlog_export_io.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'settings_screen_harness.dart';
|
||||
|
||||
/// Stub file picker that returns a fixed in-memory file (no disk I/O, so the
|
||||
/// `_import` flow stays timer-free and deterministic under the widget tester).
|
||||
class _FakeFileSelector extends FileSelectorPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_FakeFileSelector(this.file);
|
||||
final XFile? file;
|
||||
|
||||
@override
|
||||
Future<XFile?> openFile({
|
||||
List<XTypeGroup>? acceptedTypeGroups,
|
||||
String? initialDirectory,
|
||||
String? confirmButtonText,
|
||||
}) 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');
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('BackupSlot wiring through Sync settings', () {
|
||||
// These prove the closures SettingsScreen builds for
|
||||
// SyncSettingsScreen's BackupSlot actually call todo's real
|
||||
// export/import — the shared package's own suite covers the generic
|
||||
// BackupSlot UI (button taps, status text), so these focus on the real
|
||||
// behavior behind the closures instead of re-asserting status text.
|
||||
|
||||
testWidgets('Export notes writes the backlog file (desktop)', (
|
||||
tester,
|
||||
) async {
|
||||
// Redirect HOME: this test does real file I/O, and without the
|
||||
// override it overwrites the user's canonical ~/todo/BACKLOG.md with
|
||||
// the fake note below on every test run.
|
||||
final home = Directory.systemTemp.createTempSync('todo_export_home');
|
||||
resolveExportHome = () => home.path;
|
||||
addTearDown(() {
|
||||
resolveExportHome = () =>
|
||||
Platform.environment['HOME'] ?? Directory.current.path;
|
||||
home.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
await pumpSettings(
|
||||
tester,
|
||||
seed: [
|
||||
Note(
|
||||
id: 'n',
|
||||
text: 'an idea',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// _export does real file I/O on desktop, so drive it under runAsync.
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.text('Export notes'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
final file = File('${home.path}/todo/BACKLOG.md');
|
||||
expect(file.existsSync(), isTrue);
|
||||
expect(file.readAsStringSync(), contains('an idea'));
|
||||
});
|
||||
|
||||
testWidgets('Export surfaces a failure when the repository read fails', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpSettings(tester, repository: _ExplodingRepo());
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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('Import notes reads the picked file and merges', (
|
||||
tester,
|
||||
) async {
|
||||
// Round-trip a known note through the export format so the picked
|
||||
// file is valid input the importer can parse and merge.
|
||||
final markdown = NotesMarkdown.export([
|
||||
Note(
|
||||
id: 'imported-1',
|
||||
text: 'an imported idea',
|
||||
priority: Priority.high,
|
||||
status: Status.inProgress,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
),
|
||||
]);
|
||||
FileSelectorPlatform.instance = _FakeFileSelector(
|
||||
XFile.fromData(utf8.encode(markdown), name: 'backlog.md'),
|
||||
);
|
||||
|
||||
final repo = await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.pump(); // openFile resolves (in-memory)
|
||||
await tester.pump(); // read + parse + merge + setState
|
||||
|
||||
expect((await repo.listNotes()).single.text, 'an imported idea');
|
||||
});
|
||||
|
||||
testWidgets('Import shows nothing when the picker is cancelled', (
|
||||
tester,
|
||||
) async {
|
||||
FileSelectorPlatform.instance = _FakeFileSelector(null); // cancelled
|
||||
final repo = await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// The shared screen can't distinguish "cancelled" from "nothing
|
||||
// imported" — it reports "Imported notes." either way, since
|
||||
// BackupSlot.import() returns normally on cancel. The behavior that
|
||||
// actually matters, that nothing was merged, is what's asserted here.
|
||||
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('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Import failed'), findsOneWidget);
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
116
test/settings_screen_harness.dart
Normal file
116
test/settings_screen_harness.dart
Normal file
@ -0,0 +1,116 @@
|
||||
/// The pumpSettings harness and its file-selector fakes.
|
||||
///
|
||||
/// Shared by the files `settings_screen_test.dart` was split into for the
|
||||
/// 250-line cap. Deliberately NOT named `*_test.dart`: the runner would
|
||||
/// collect it and fail on the missing `main()`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
|
||||
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:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sync_settings_ui/sync_settings_ui.dart';
|
||||
import 'package:todo/analytics/analytics_service.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/sync/backlog_export_io.dart';
|
||||
import 'package:todo/sync/notes_markdown.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
import 'package:todo/ui/github_mirror_screen.dart';
|
||||
import 'package:todo/ui/settings_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
/// Stub file picker that returns a fixed in-memory file (no disk I/O, so the
|
||||
/// `_import` flow stays timer-free and deterministic under the widget tester).
|
||||
class _FakeFileSelector extends FileSelectorPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_FakeFileSelector(this.file);
|
||||
final XFile? file;
|
||||
|
||||
@override
|
||||
Future<XFile?> openFile({
|
||||
List<XTypeGroup>? acceptedTypeGroups,
|
||||
String? initialDirectory,
|
||||
String? confirmButtonText,
|
||||
}) 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');
|
||||
}
|
||||
|
||||
Future<FakeNoteRepository> pumpSettings(
|
||||
WidgetTester tester, {
|
||||
SyncSettings initial = const SyncSettings(
|
||||
owner: 'kuhyx',
|
||||
repo: 'syncs',
|
||||
token: 't',
|
||||
),
|
||||
http.Client? httpClient,
|
||||
List<Note> seed = const [],
|
||||
FakeNoteRepository? repository,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
ValueNotifier<AppSettings>? appSettings,
|
||||
AnalyticsService? analytics,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
// Tall surface so the whole settings ListView builds and the pushed
|
||||
// Sync settings screen's Backup section (below the default 800×600
|
||||
// fold) isn't lazy-skipped.
|
||||
tester.view.physicalSize = const Size(1200, 2800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final repo = repository ?? FakeNoteRepository(seed);
|
||||
addTearDown(repo.close);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SettingsScreen(
|
||||
initial: initial,
|
||||
repository: repo,
|
||||
appSettings:
|
||||
appSettings ??
|
||||
ValueNotifier(const AppSettings(advancedMode: false)),
|
||||
analytics: analytics,
|
||||
httpClient: httpClient,
|
||||
// Injected so the widget never reaches for the platform: the real
|
||||
// factory wants the OS keystore, which doesn't exist under
|
||||
// `flutter test`.
|
||||
firebaseFactory: firebaseFactory ?? () async => null,
|
||||
accountLoader: () async => null,
|
||||
sessionProbe: () async => false,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return repo;
|
||||
}
|
||||
@ -23,6 +23,8 @@ import 'package:todo/ui/settings_screen.dart';
|
||||
import 'fake_note_repository.dart';
|
||||
import 'fake_secure_storage.dart';
|
||||
|
||||
import 'settings_screen_harness.dart';
|
||||
|
||||
/// Stub file picker that returns a fixed in-memory file (no disk I/O, so the
|
||||
/// `_import` flow stays timer-free and deterministic under the widget tester).
|
||||
class _FakeFileSelector extends FileSelectorPlatform
|
||||
@ -59,56 +61,6 @@ class _ThrowingFileSelector extends FileSelectorPlatform
|
||||
}
|
||||
|
||||
void main() {
|
||||
Future<FakeNoteRepository> pumpSettings(
|
||||
WidgetTester tester, {
|
||||
SyncSettings initial = const SyncSettings(
|
||||
owner: 'kuhyx',
|
||||
repo: 'syncs',
|
||||
token: 't',
|
||||
),
|
||||
http.Client? httpClient,
|
||||
List<Note> seed = const [],
|
||||
FakeNoteRepository? repository,
|
||||
Future<FirebaseRestClient?> Function()? firebaseFactory,
|
||||
ValueNotifier<AppSettings>? appSettings,
|
||||
AnalyticsService? analytics,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
installFakeSecureStorage();
|
||||
// Tall surface so the whole settings ListView builds and the pushed
|
||||
// Sync settings screen's Backup section (below the default 800×600
|
||||
// fold) isn't lazy-skipped.
|
||||
tester.view.physicalSize = const Size(1200, 2800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final repo = repository ?? FakeNoteRepository(seed);
|
||||
addTearDown(repo.close);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: SettingsScreen(
|
||||
initial: initial,
|
||||
repository: repo,
|
||||
appSettings:
|
||||
appSettings ??
|
||||
ValueNotifier(const AppSettings(advancedMode: false)),
|
||||
analytics: analytics,
|
||||
httpClient: httpClient,
|
||||
// Injected so the widget never reaches for the platform: the real
|
||||
// factory wants the OS keystore, which doesn't exist under
|
||||
// `flutter test`.
|
||||
firebaseFactory: firebaseFactory ?? () async => null,
|
||||
accountLoader: () async => null,
|
||||
sessionProbe: () async => false,
|
||||
stateStore: InMemorySyncStateStore(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return repo;
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'Enable advanced toggles and persists locally with no client',
|
||||
(tester) async {
|
||||
@ -254,138 +206,4 @@ void main() {
|
||||
// suite cannot write to the user's ~/todo.
|
||||
expect(resolveExportHome(), Platform.environment['HOME']);
|
||||
});
|
||||
|
||||
group('BackupSlot wiring through Sync settings', () {
|
||||
// These prove the closures SettingsScreen builds for
|
||||
// SyncSettingsScreen's BackupSlot actually call todo's real
|
||||
// export/import — the shared package's own suite covers the generic
|
||||
// BackupSlot UI (button taps, status text), so these focus on the real
|
||||
// behavior behind the closures instead of re-asserting status text.
|
||||
|
||||
testWidgets('Export notes writes the backlog file (desktop)', (
|
||||
tester,
|
||||
) async {
|
||||
// Redirect HOME: this test does real file I/O, and without the
|
||||
// override it overwrites the user's canonical ~/todo/BACKLOG.md with
|
||||
// the fake note below on every test run.
|
||||
final home = Directory.systemTemp.createTempSync('todo_export_home');
|
||||
resolveExportHome = () => home.path;
|
||||
addTearDown(() {
|
||||
resolveExportHome = () =>
|
||||
Platform.environment['HOME'] ?? Directory.current.path;
|
||||
home.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
await pumpSettings(
|
||||
tester,
|
||||
seed: [
|
||||
Note(
|
||||
id: 'n',
|
||||
text: 'an idea',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// _export does real file I/O on desktop, so drive it under runAsync.
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.text('Export notes'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
final file = File('${home.path}/todo/BACKLOG.md');
|
||||
expect(file.existsSync(), isTrue);
|
||||
expect(file.readAsStringSync(), contains('an idea'));
|
||||
});
|
||||
|
||||
testWidgets('Export surfaces a failure when the repository read fails', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpSettings(tester, repository: _ExplodingRepo());
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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('Import notes reads the picked file and merges', (
|
||||
tester,
|
||||
) async {
|
||||
// Round-trip a known note through the export format so the picked
|
||||
// file is valid input the importer can parse and merge.
|
||||
final markdown = NotesMarkdown.export([
|
||||
Note(
|
||||
id: 'imported-1',
|
||||
text: 'an imported idea',
|
||||
priority: Priority.high,
|
||||
status: Status.inProgress,
|
||||
createdAt: DateTime(2026, 6, 15),
|
||||
updatedAt: DateTime(2026, 6, 15),
|
||||
),
|
||||
]);
|
||||
FileSelectorPlatform.instance = _FakeFileSelector(
|
||||
XFile.fromData(utf8.encode(markdown), name: 'backlog.md'),
|
||||
);
|
||||
|
||||
final repo = await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.pump(); // openFile resolves (in-memory)
|
||||
await tester.pump(); // read + parse + merge + setState
|
||||
|
||||
expect((await repo.listNotes()).single.text, 'an imported idea');
|
||||
});
|
||||
|
||||
testWidgets('Import shows nothing when the picker is cancelled', (
|
||||
tester,
|
||||
) async {
|
||||
FileSelectorPlatform.instance = _FakeFileSelector(null); // cancelled
|
||||
final repo = await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// The shared screen can't distinguish "cancelled" from "nothing
|
||||
// imported" — it reports "Imported notes." either way, since
|
||||
// BackupSlot.import() returns normally on cancel. The behavior that
|
||||
// actually matters, that nothing was merged, is what's asserted here.
|
||||
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('Sync settings'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Import notes'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.textContaining('Import failed'), findsOneWidget);
|
||||
expect(await repo.listNotes(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
99
test/wrapper_server_harness.dart
Normal file
99
test/wrapper_server_harness.dart
Normal file
@ -0,0 +1,99 @@
|
||||
/// The temp web root, the served instance, and enabledOrigin.
|
||||
///
|
||||
/// Shared by the files `wrapper_server_test.dart` was split into for the
|
||||
/// 250-line cap. Deliberately NOT named `*_test.dart`: the runner would
|
||||
/// collect it and fail on the missing `main()`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/desktop/wrapper_server.dart';
|
||||
|
||||
/// One test's temp web root and the [WrapperServer] serving it.
|
||||
///
|
||||
/// A class rather than bare `setUp`/`late` globals because [enabledOrigin]
|
||||
/// needs the temp root, and a top-level function cannot close over a `late`
|
||||
/// that `setUp` assigns. [install] wires the lifecycle; each test file calls
|
||||
/// it once at the top of its `main()`.
|
||||
class WrapperHarness {
|
||||
/// Temp directory holding the web root, the state dir, and the configs.
|
||||
late Directory root;
|
||||
|
||||
/// The server under test, listening on an OS-assigned port.
|
||||
late WrapperServer server;
|
||||
|
||||
/// Origin of [server], e.g. `http://localhost:41234`.
|
||||
late String base;
|
||||
|
||||
/// Registers the setUp/tearDown pair that owns this harness.
|
||||
void install() {
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('wrapper');
|
||||
final webRoot = Directory(p.join(root.path, 'web'))..createSync();
|
||||
File(
|
||||
p.join(webRoot.path, 'index.html'),
|
||||
).writeAsStringSync('<h1>todo</h1>');
|
||||
File(p.join(webRoot.path, 'main.dart.js')).writeAsStringSync('console;');
|
||||
File(p.join(webRoot.path, 'canvaskit.wasm')).writeAsStringSync('binary');
|
||||
|
||||
server = WrapperServer(
|
||||
webRoot: webRoot.path,
|
||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
// Without this, the default falls back to the real
|
||||
// $HOME/.config/todo/firebase_auth.json -- on a machine that has
|
||||
// actually signed in (a real desktop session), that file exists, so
|
||||
// the "no credentials file exists" test below observes 200 instead
|
||||
// of 404. Point it at this test's own throwaway root instead.
|
||||
todoCredentialsPath: p.join(
|
||||
root.path,
|
||||
'todo-config',
|
||||
'firebase_auth.json',
|
||||
),
|
||||
);
|
||||
// Port 0 lets the OS pick, so tests never collide with a running app.
|
||||
await server.start(0);
|
||||
base = 'http://localhost:${server.port}';
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.stop();
|
||||
root.deleteSync(recursive: true);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> enabledOrigin(
|
||||
Map<String, String> files, {
|
||||
String? credentialsJson,
|
||||
}) async {
|
||||
final configDir = Directory(p.join(root.path, 'crdt-sync'))
|
||||
..createSync(recursive: true);
|
||||
files.forEach((name, contents) {
|
||||
File(p.join(configDir.path, name)).writeAsStringSync(contents);
|
||||
});
|
||||
String? credentialsPath;
|
||||
if (credentialsJson != null) {
|
||||
final credentialsDir = Directory(p.join(root.path, 'todo-config'))
|
||||
..createSync(recursive: true);
|
||||
credentialsPath = p.join(credentialsDir.path, 'firebase_auth.json');
|
||||
File(credentialsPath).writeAsStringSync(credentialsJson);
|
||||
}
|
||||
final enabled = WrapperServer(
|
||||
webRoot: p.join(root.path, 'web'),
|
||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
serveSyncAccount: true,
|
||||
syncConfigDir: configDir.path,
|
||||
todoCredentialsPath:
|
||||
credentialsPath ?? p.join(root.path, 'todo-config', 'absent.json'),
|
||||
);
|
||||
await enabled.start(0);
|
||||
addTearDown(enabled.stop);
|
||||
return 'http://localhost:${enabled.port}';
|
||||
}
|
||||
}
|
||||
221
test/wrapper_server_provisioning_test.dart
Normal file
221
test/wrapper_server_provisioning_test.dart
Normal file
@ -0,0 +1,221 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/desktop/wrapper_server.dart';
|
||||
|
||||
import 'wrapper_server_harness.dart';
|
||||
|
||||
void main() {
|
||||
final h = WrapperHarness()..install();
|
||||
|
||||
group('sync-account provisioning', () {
|
||||
test('is 404 when not enabled', () async {
|
||||
// The default, and the whole security posture: a credential route must
|
||||
// not be reachable just because the app is running.
|
||||
final response = await http.get(Uri.parse('${h.base}$kSyncAccountPath'));
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test('serves the account when enabled', () async {
|
||||
final origin = await h.enabledOrigin({
|
||||
'firebase.json': '{"email":"a@b.c"}',
|
||||
'password': 'pw\n',
|
||||
});
|
||||
|
||||
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
|
||||
final account = FirebaseAccount.tryParse(response.body);
|
||||
|
||||
expect(response.statusCode, HttpStatus.ok);
|
||||
expect(account?.email, 'a@b.c');
|
||||
expect(account?.password, 'pw');
|
||||
});
|
||||
|
||||
test('is 404 when the config files are absent', () async {
|
||||
final origin = await h.enabledOrigin({});
|
||||
|
||||
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test('is 404 when firebase.json has no usable email', () async {
|
||||
final origin = await h.enabledOrigin({
|
||||
'firebase.json': '{"apiKey":"x"}',
|
||||
'password': 'pw',
|
||||
});
|
||||
|
||||
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
});
|
||||
|
||||
group('sync-credentials provisioning', () {
|
||||
// Unlike /sync-account, this route needs no CRDT_SYNC_SERVE_ACCOUNT: a
|
||||
// normal launch must self-provision with nothing to remember. [h.base]'s
|
||||
// h.server was built with no credentials file at the default path, so this
|
||||
// exercises the file-absent 404, not a disabled-route 404 -- there is no
|
||||
// "disabled" state for this route anymore.
|
||||
test(
|
||||
'is 404 when no credentials file exists at the default path',
|
||||
() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('${h.base}$kSyncCredentialsPath'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
},
|
||||
);
|
||||
|
||||
test('serves once, then 404s for the rest of the process', () async {
|
||||
final credentialsDir = Directory(p.join(h.root.path, 'todo-config-once'))
|
||||
..createSync(recursive: true);
|
||||
final credentialsPath = p.join(credentialsDir.path, 'firebase_auth.json');
|
||||
File(credentialsPath).writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id_token': 'id',
|
||||
'refresh_token': 'refresh',
|
||||
'expires_at': '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
final enabled = WrapperServer(
|
||||
webRoot: p.join(h.root.path, 'web'),
|
||||
backlogPath: p.join(h.root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(h.root.path, 'state', 'todo_notes.json'),
|
||||
todoCredentialsPath: credentialsPath,
|
||||
);
|
||||
await enabled.start(0);
|
||||
addTearDown(enabled.stop);
|
||||
final origin = 'http://localhost:${enabled.port}';
|
||||
|
||||
final first = await http.get(Uri.parse('$origin$kSyncCredentialsPath'));
|
||||
final second = await http.get(Uri.parse('$origin$kSyncCredentialsPath'));
|
||||
|
||||
expect(first.statusCode, HttpStatus.ok);
|
||||
expect(second.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test(
|
||||
'serves the seeded credentials, with email from firebase.json',
|
||||
() async {
|
||||
final configDir = Directory(p.join(h.root.path, 'crdt-sync'))
|
||||
..createSync(recursive: true);
|
||||
File(
|
||||
p.join(configDir.path, 'firebase.json'),
|
||||
).writeAsStringSync('{"email":"seeded@example.com"}');
|
||||
final credentialsDir = Directory(p.join(h.root.path, 'todo-config'))
|
||||
..createSync(recursive: true);
|
||||
final credentialsPath = p.join(
|
||||
credentialsDir.path,
|
||||
'firebase_auth.json',
|
||||
);
|
||||
File(credentialsPath).writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id_token': 'id',
|
||||
'refresh_token': 'refresh',
|
||||
'expires_at': '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
final enabled = WrapperServer(
|
||||
webRoot: p.join(h.root.path, 'web'),
|
||||
backlogPath: p.join(h.root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(h.root.path, 'state', 'todo_notes.json'),
|
||||
serveSyncAccount: true,
|
||||
syncConfigDir: configDir.path,
|
||||
todoCredentialsPath: credentialsPath,
|
||||
);
|
||||
await enabled.start(0);
|
||||
addTearDown(enabled.stop);
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('http://localhost:${enabled.port}$kSyncCredentialsPath'),
|
||||
);
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, HttpStatus.ok);
|
||||
expect(body['id_token'], 'id');
|
||||
expect(body['refresh_token'], 'refresh');
|
||||
expect(body['expires_at'], '2026-01-01T00:00:00.000Z');
|
||||
expect(body['email'], 'seeded@example.com');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'serves credentials with a null email when firebase.json is absent',
|
||||
() async {
|
||||
final credentialsDir = Directory(p.join(h.root.path, 'todo-config-2'))
|
||||
..createSync(recursive: true);
|
||||
final credentialsPath = p.join(
|
||||
credentialsDir.path,
|
||||
'firebase_auth.json',
|
||||
);
|
||||
File(credentialsPath).writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id_token': 'id',
|
||||
'refresh_token': 'refresh',
|
||||
'expires_at': '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
final enabled = WrapperServer(
|
||||
webRoot: p.join(h.root.path, 'web'),
|
||||
backlogPath: p.join(h.root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(h.root.path, 'state', 'todo_notes.json'),
|
||||
serveSyncAccount: true,
|
||||
syncConfigDir: p.join(h.root.path, 'crdt-sync-absent'),
|
||||
todoCredentialsPath: credentialsPath,
|
||||
);
|
||||
await enabled.start(0);
|
||||
addTearDown(enabled.stop);
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('http://localhost:${enabled.port}$kSyncCredentialsPath'),
|
||||
);
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, HttpStatus.ok);
|
||||
expect(body['email'], isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('is 404 when the credentials file is absent', () async {
|
||||
final origin = await h.enabledOrigin({});
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$origin$kSyncCredentialsPath'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test('is 404 when the credentials file is not valid JSON', () async {
|
||||
final origin = await h.enabledOrigin({}, credentialsJson: 'not json');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$origin$kSyncCredentialsPath'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test(
|
||||
'is 404 when the credentials file is missing required fields',
|
||||
() async {
|
||||
final origin = await h.enabledOrigin(
|
||||
{},
|
||||
credentialsJson: jsonEncode({'id_token': 'id'}),
|
||||
);
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$origin$kSyncCredentialsPath'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
134
test/wrapper_server_static_test.dart
Normal file
134
test/wrapper_server_static_test.dart
Normal file
@ -0,0 +1,134 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/desktop/wrapper_server.dart';
|
||||
|
||||
import 'wrapper_server_harness.dart';
|
||||
|
||||
void main() {
|
||||
final h = WrapperHarness()..install();
|
||||
|
||||
test('serves index.html at the root', () async {
|
||||
final response = await http.get(Uri.parse('${h.base}/'));
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.body, contains('todo'));
|
||||
});
|
||||
|
||||
test('serves nested assets and 404s unknown paths', () async {
|
||||
expect(
|
||||
(await http.get(Uri.parse('${h.base}/main.dart.js'))).statusCode,
|
||||
200,
|
||||
);
|
||||
expect((await http.get(Uri.parse('${h.base}/nope.js'))).statusCode, 404);
|
||||
});
|
||||
|
||||
test('refuses to serve outside the web root', () async {
|
||||
// The served directory sits beside the user's files, so traversal must not
|
||||
// be able to read them. Sent over a raw socket because http.get normalises
|
||||
// `..` away client-side, so the server would never see the attack.
|
||||
File(p.join(h.root.path, 'secret')).writeAsStringSync('do not serve me');
|
||||
|
||||
// Percent-encoded, because both the http client and Dart's HttpServer
|
||||
// collapse a literal `..` before any handler sees it; `%2e%2e` survives
|
||||
// that and is decoded into the path afterwards.
|
||||
final socket = await Socket.connect('localhost', h.server.port);
|
||||
socket.write('GET /%2e%2e/secret HTTP/1.1\r\nHost: localhost\r\n\r\n');
|
||||
await socket.flush();
|
||||
final response = await utf8.decoder.bind(socket).first;
|
||||
await socket.close();
|
||||
|
||||
// Dart's HttpServer decodes and then normalises, so traversal is already
|
||||
// collapsed before any handler runs and this comes back 404 rather than
|
||||
// 403. What matters is the property, not which code rejected it: the file
|
||||
// outside the web h.root is never served.
|
||||
expect(response, isNot(contains('do not serve me')));
|
||||
expect(response, anyOf(contains('403'), contains('404')));
|
||||
});
|
||||
|
||||
test('a failing write reports a server error rather than crashing', () async {
|
||||
// Parent path is a regular file, so creating the directory for the backup
|
||||
// throws. The serve loop must survive it and keep answering requests.
|
||||
final blocked = Directory(p.join(h.root.path, 'blocked'))..createSync();
|
||||
File(p.join(blocked.path, 'wall')).writeAsStringSync('');
|
||||
final wedged = WrapperServer(
|
||||
webRoot: p.join(h.root.path, 'web'),
|
||||
backlogPath: p.join(blocked.path, 'wall', 'nested', 'BACKLOG.md'),
|
||||
logPath: p.join(h.root.path, 'state', 'todo_notes.json'),
|
||||
);
|
||||
await wedged.start(0);
|
||||
addTearDown(wedged.stop);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('http://localhost:${wedged.port}/backup/backlog'),
|
||||
body: 'x',
|
||||
);
|
||||
expect(response.statusCode, 500);
|
||||
|
||||
// Still alive afterwards.
|
||||
final after = await http.get(
|
||||
Uri.parse('http://localhost:${wedged.port}/'),
|
||||
);
|
||||
expect(after.statusCode, 200);
|
||||
});
|
||||
|
||||
test('POST then GET round-trips the backlog', () async {
|
||||
final posted = await http.post(
|
||||
Uri.parse('${h.base}/backup/backlog'),
|
||||
body: '# backlog\n\nnote one\n',
|
||||
);
|
||||
expect(posted.statusCode, 204);
|
||||
|
||||
// Written to the real path, which is what the user's tooling reads.
|
||||
final onDisk = File(p.join(h.root.path, 'todo', 'BACKLOG.md'));
|
||||
expect(onDisk.existsSync(), isTrue);
|
||||
expect(onDisk.readAsStringSync(), contains('note one'));
|
||||
|
||||
final fetched = await http.get(Uri.parse('${h.base}/backup/backlog'));
|
||||
expect(fetched.statusCode, 200);
|
||||
expect(fetched.body, contains('note one'));
|
||||
});
|
||||
|
||||
test('POST then GET round-trips the note log', () async {
|
||||
await http.post(Uri.parse('${h.base}/backup/log'), body: '{"a":1}');
|
||||
final fetched = await http.get(Uri.parse('${h.base}/backup/log'));
|
||||
expect(fetched.body, '{"a":1}');
|
||||
});
|
||||
|
||||
test('GET on a backup that does not exist yet 404s', () async {
|
||||
expect((await http.get(Uri.parse('${h.base}/backup/log'))).statusCode, 404);
|
||||
});
|
||||
|
||||
test('rejects unsupported methods on a backup path', () async {
|
||||
final response = await http.delete(Uri.parse('${h.base}/backup/log'));
|
||||
expect(response.statusCode, 405);
|
||||
});
|
||||
|
||||
test('labels wasm and js correctly', () async {
|
||||
// CanvasKit refuses a .wasm served as anything else, and the app then
|
||||
// renders nothing at all.
|
||||
final wasm = await http.get(Uri.parse('${h.base}/canvaskit.wasm'));
|
||||
expect(wasm.headers['content-type'], contains('application/wasm'));
|
||||
final js = await http.get(Uri.parse('${h.base}/main.dart.js'));
|
||||
expect(js.headers['content-type'], contains('javascript'));
|
||||
});
|
||||
|
||||
test('contentTypeFor covers the asset kinds the build emits', () {
|
||||
String kind(String name) => WrapperServer.contentTypeFor(name).mimeType;
|
||||
|
||||
expect(kind('a.html'), 'text/html');
|
||||
expect(kind('a.json'), 'application/json');
|
||||
expect(kind('a.css'), 'text/css');
|
||||
expect(kind('a.png'), 'image/png');
|
||||
expect(kind('a.svg'), 'image/svg+xml');
|
||||
expect(kind('a.ttf'), 'font/ttf');
|
||||
expect(kind('a.otf'), 'font/otf');
|
||||
expect(kind('a.woff2'), 'font/woff2');
|
||||
expect(kind('a.mjs'), 'text/javascript');
|
||||
expect(kind('a.bin'), 'application/octet-stream');
|
||||
expect(kind('a.unknown'), 'application/octet-stream');
|
||||
});
|
||||
}
|
||||
@ -1,402 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:todo/desktop/wrapper_server.dart';
|
||||
|
||||
void main() {
|
||||
late Directory root;
|
||||
late WrapperServer server;
|
||||
late String base;
|
||||
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('wrapper');
|
||||
final webRoot = Directory(p.join(root.path, 'web'))..createSync();
|
||||
File(p.join(webRoot.path, 'index.html')).writeAsStringSync('<h1>todo</h1>');
|
||||
File(p.join(webRoot.path, 'main.dart.js')).writeAsStringSync('console;');
|
||||
File(p.join(webRoot.path, 'canvaskit.wasm')).writeAsStringSync('binary');
|
||||
|
||||
server = WrapperServer(
|
||||
webRoot: webRoot.path,
|
||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
// Without this, the default falls back to the real
|
||||
// $HOME/.config/todo/firebase_auth.json -- on a machine that has
|
||||
// actually signed in (a real desktop session), that file exists, so
|
||||
// the "no credentials file exists" test below observes 200 instead
|
||||
// of 404. Point it at this test's own throwaway root instead.
|
||||
todoCredentialsPath: p.join(
|
||||
root.path,
|
||||
'todo-config',
|
||||
'firebase_auth.json',
|
||||
),
|
||||
);
|
||||
// Port 0 lets the OS pick, so tests never collide with a running app.
|
||||
await server.start(0);
|
||||
base = 'http://localhost:${server.port}';
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.stop();
|
||||
root.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
/// Starts a second server with provisioning enabled against [files], and
|
||||
/// optionally a `todo`-specific credentials file at [credentialsJson].
|
||||
Future<String> enabledOrigin(
|
||||
Map<String, String> files, {
|
||||
String? credentialsJson,
|
||||
}) async {
|
||||
final configDir = Directory(p.join(root.path, 'crdt-sync'))
|
||||
..createSync(recursive: true);
|
||||
files.forEach((name, contents) {
|
||||
File(p.join(configDir.path, name)).writeAsStringSync(contents);
|
||||
});
|
||||
String? credentialsPath;
|
||||
if (credentialsJson != null) {
|
||||
final credentialsDir = Directory(p.join(root.path, 'todo-config'))
|
||||
..createSync(recursive: true);
|
||||
credentialsPath = p.join(credentialsDir.path, 'firebase_auth.json');
|
||||
File(credentialsPath).writeAsStringSync(credentialsJson);
|
||||
}
|
||||
final enabled = WrapperServer(
|
||||
webRoot: p.join(root.path, 'web'),
|
||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
serveSyncAccount: true,
|
||||
syncConfigDir: configDir.path,
|
||||
todoCredentialsPath:
|
||||
credentialsPath ?? p.join(root.path, 'todo-config', 'absent.json'),
|
||||
);
|
||||
await enabled.start(0);
|
||||
addTearDown(enabled.stop);
|
||||
return 'http://localhost:${enabled.port}';
|
||||
}
|
||||
|
||||
test('serves index.html at the root', () async {
|
||||
final response = await http.get(Uri.parse('$base/'));
|
||||
expect(response.statusCode, 200);
|
||||
expect(response.body, contains('todo'));
|
||||
});
|
||||
|
||||
test('serves nested assets and 404s unknown paths', () async {
|
||||
expect((await http.get(Uri.parse('$base/main.dart.js'))).statusCode, 200);
|
||||
expect((await http.get(Uri.parse('$base/nope.js'))).statusCode, 404);
|
||||
});
|
||||
|
||||
test('refuses to serve outside the web root', () async {
|
||||
// The served directory sits beside the user's files, so traversal must not
|
||||
// be able to read them. Sent over a raw socket because http.get normalises
|
||||
// `..` away client-side, so the server would never see the attack.
|
||||
File(p.join(root.path, 'secret')).writeAsStringSync('do not serve me');
|
||||
|
||||
// Percent-encoded, because both the http client and Dart's HttpServer
|
||||
// collapse a literal `..` before any handler sees it; `%2e%2e` survives
|
||||
// that and is decoded into the path afterwards.
|
||||
final socket = await Socket.connect('localhost', server.port);
|
||||
socket.write('GET /%2e%2e/secret HTTP/1.1\r\nHost: localhost\r\n\r\n');
|
||||
await socket.flush();
|
||||
final response = await utf8.decoder.bind(socket).first;
|
||||
await socket.close();
|
||||
|
||||
// Dart's HttpServer decodes and then normalises, so traversal is already
|
||||
// collapsed before any handler runs and this comes back 404 rather than
|
||||
// 403. What matters is the property, not which code rejected it: the file
|
||||
// outside the web root is never served.
|
||||
expect(response, isNot(contains('do not serve me')));
|
||||
expect(response, anyOf(contains('403'), contains('404')));
|
||||
});
|
||||
|
||||
test('a failing write reports a server error rather than crashing', () async {
|
||||
// Parent path is a regular file, so creating the directory for the backup
|
||||
// throws. The serve loop must survive it and keep answering requests.
|
||||
final blocked = Directory(p.join(root.path, 'blocked'))..createSync();
|
||||
File(p.join(blocked.path, 'wall')).writeAsStringSync('');
|
||||
final wedged = WrapperServer(
|
||||
webRoot: p.join(root.path, 'web'),
|
||||
backlogPath: p.join(blocked.path, 'wall', 'nested', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
);
|
||||
await wedged.start(0);
|
||||
addTearDown(wedged.stop);
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('http://localhost:${wedged.port}/backup/backlog'),
|
||||
body: 'x',
|
||||
);
|
||||
expect(response.statusCode, 500);
|
||||
|
||||
// Still alive afterwards.
|
||||
final after = await http.get(
|
||||
Uri.parse('http://localhost:${wedged.port}/'),
|
||||
);
|
||||
expect(after.statusCode, 200);
|
||||
});
|
||||
|
||||
test('POST then GET round-trips the backlog', () async {
|
||||
final posted = await http.post(
|
||||
Uri.parse('$base/backup/backlog'),
|
||||
body: '# backlog\n\nnote one\n',
|
||||
);
|
||||
expect(posted.statusCode, 204);
|
||||
|
||||
// Written to the real path, which is what the user's tooling reads.
|
||||
final onDisk = File(p.join(root.path, 'todo', 'BACKLOG.md'));
|
||||
expect(onDisk.existsSync(), isTrue);
|
||||
expect(onDisk.readAsStringSync(), contains('note one'));
|
||||
|
||||
final fetched = await http.get(Uri.parse('$base/backup/backlog'));
|
||||
expect(fetched.statusCode, 200);
|
||||
expect(fetched.body, contains('note one'));
|
||||
});
|
||||
|
||||
test('POST then GET round-trips the note log', () async {
|
||||
await http.post(Uri.parse('$base/backup/log'), body: '{"a":1}');
|
||||
final fetched = await http.get(Uri.parse('$base/backup/log'));
|
||||
expect(fetched.body, '{"a":1}');
|
||||
});
|
||||
|
||||
test('GET on a backup that does not exist yet 404s', () async {
|
||||
expect((await http.get(Uri.parse('$base/backup/log'))).statusCode, 404);
|
||||
});
|
||||
|
||||
test('rejects unsupported methods on a backup path', () async {
|
||||
final response = await http.delete(Uri.parse('$base/backup/log'));
|
||||
expect(response.statusCode, 405);
|
||||
});
|
||||
|
||||
test('labels wasm and js correctly', () async {
|
||||
// CanvasKit refuses a .wasm served as anything else, and the app then
|
||||
// renders nothing at all.
|
||||
final wasm = await http.get(Uri.parse('$base/canvaskit.wasm'));
|
||||
expect(wasm.headers['content-type'], contains('application/wasm'));
|
||||
final js = await http.get(Uri.parse('$base/main.dart.js'));
|
||||
expect(js.headers['content-type'], contains('javascript'));
|
||||
});
|
||||
|
||||
test('contentTypeFor covers the asset kinds the build emits', () {
|
||||
String kind(String name) => WrapperServer.contentTypeFor(name).mimeType;
|
||||
|
||||
expect(kind('a.html'), 'text/html');
|
||||
expect(kind('a.json'), 'application/json');
|
||||
expect(kind('a.css'), 'text/css');
|
||||
expect(kind('a.png'), 'image/png');
|
||||
expect(kind('a.svg'), 'image/svg+xml');
|
||||
expect(kind('a.ttf'), 'font/ttf');
|
||||
expect(kind('a.otf'), 'font/otf');
|
||||
expect(kind('a.woff2'), 'font/woff2');
|
||||
expect(kind('a.mjs'), 'text/javascript');
|
||||
expect(kind('a.bin'), 'application/octet-stream');
|
||||
expect(kind('a.unknown'), 'application/octet-stream');
|
||||
});
|
||||
|
||||
group('sync-account provisioning', () {
|
||||
test('is 404 when not enabled', () async {
|
||||
// The default, and the whole security posture: a credential route must
|
||||
// not be reachable just because the app is running.
|
||||
final response = await http.get(Uri.parse('$base$kSyncAccountPath'));
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test('serves the account when enabled', () async {
|
||||
final origin = await enabledOrigin({
|
||||
'firebase.json': '{"email":"a@b.c"}',
|
||||
'password': 'pw\n',
|
||||
});
|
||||
|
||||
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
|
||||
final account = FirebaseAccount.tryParse(response.body);
|
||||
|
||||
expect(response.statusCode, HttpStatus.ok);
|
||||
expect(account?.email, 'a@b.c');
|
||||
expect(account?.password, 'pw');
|
||||
});
|
||||
|
||||
test('is 404 when the config files are absent', () async {
|
||||
final origin = await enabledOrigin({});
|
||||
|
||||
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test('is 404 when firebase.json has no usable email', () async {
|
||||
final origin = await enabledOrigin({
|
||||
'firebase.json': '{"apiKey":"x"}',
|
||||
'password': 'pw',
|
||||
});
|
||||
|
||||
final response = await http.get(Uri.parse('$origin$kSyncAccountPath'));
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
});
|
||||
|
||||
group('sync-credentials provisioning', () {
|
||||
// Unlike /sync-account, this route needs no CRDT_SYNC_SERVE_ACCOUNT: a
|
||||
// normal launch must self-provision with nothing to remember. [base]'s
|
||||
// server was built with no credentials file at the default path, so this
|
||||
// exercises the file-absent 404, not a disabled-route 404 -- there is no
|
||||
// "disabled" state for this route anymore.
|
||||
test(
|
||||
'is 404 when no credentials file exists at the default path',
|
||||
() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('$base$kSyncCredentialsPath'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
},
|
||||
);
|
||||
|
||||
test('serves once, then 404s for the rest of the process', () async {
|
||||
final credentialsDir = Directory(p.join(root.path, 'todo-config-once'))
|
||||
..createSync(recursive: true);
|
||||
final credentialsPath = p.join(credentialsDir.path, 'firebase_auth.json');
|
||||
File(credentialsPath).writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id_token': 'id',
|
||||
'refresh_token': 'refresh',
|
||||
'expires_at': '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
final enabled = WrapperServer(
|
||||
webRoot: p.join(root.path, 'web'),
|
||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
todoCredentialsPath: credentialsPath,
|
||||
);
|
||||
await enabled.start(0);
|
||||
addTearDown(enabled.stop);
|
||||
final origin = 'http://localhost:${enabled.port}';
|
||||
|
||||
final first = await http.get(Uri.parse('$origin$kSyncCredentialsPath'));
|
||||
final second = await http.get(Uri.parse('$origin$kSyncCredentialsPath'));
|
||||
|
||||
expect(first.statusCode, HttpStatus.ok);
|
||||
expect(second.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test(
|
||||
'serves the seeded credentials, with email from firebase.json',
|
||||
() async {
|
||||
final configDir = Directory(p.join(root.path, 'crdt-sync'))
|
||||
..createSync(recursive: true);
|
||||
File(
|
||||
p.join(configDir.path, 'firebase.json'),
|
||||
).writeAsStringSync('{"email":"seeded@example.com"}');
|
||||
final credentialsDir = Directory(p.join(root.path, 'todo-config'))
|
||||
..createSync(recursive: true);
|
||||
final credentialsPath = p.join(
|
||||
credentialsDir.path,
|
||||
'firebase_auth.json',
|
||||
);
|
||||
File(credentialsPath).writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id_token': 'id',
|
||||
'refresh_token': 'refresh',
|
||||
'expires_at': '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
final enabled = WrapperServer(
|
||||
webRoot: p.join(root.path, 'web'),
|
||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
serveSyncAccount: true,
|
||||
syncConfigDir: configDir.path,
|
||||
todoCredentialsPath: credentialsPath,
|
||||
);
|
||||
await enabled.start(0);
|
||||
addTearDown(enabled.stop);
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('http://localhost:${enabled.port}$kSyncCredentialsPath'),
|
||||
);
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, HttpStatus.ok);
|
||||
expect(body['id_token'], 'id');
|
||||
expect(body['refresh_token'], 'refresh');
|
||||
expect(body['expires_at'], '2026-01-01T00:00:00.000Z');
|
||||
expect(body['email'], 'seeded@example.com');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'serves credentials with a null email when firebase.json is absent',
|
||||
() async {
|
||||
final credentialsDir = Directory(p.join(root.path, 'todo-config-2'))
|
||||
..createSync(recursive: true);
|
||||
final credentialsPath = p.join(
|
||||
credentialsDir.path,
|
||||
'firebase_auth.json',
|
||||
);
|
||||
File(credentialsPath).writeAsStringSync(
|
||||
jsonEncode({
|
||||
'id_token': 'id',
|
||||
'refresh_token': 'refresh',
|
||||
'expires_at': '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
final enabled = WrapperServer(
|
||||
webRoot: p.join(root.path, 'web'),
|
||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||
serveSyncAccount: true,
|
||||
syncConfigDir: p.join(root.path, 'crdt-sync-absent'),
|
||||
todoCredentialsPath: credentialsPath,
|
||||
);
|
||||
await enabled.start(0);
|
||||
addTearDown(enabled.stop);
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('http://localhost:${enabled.port}$kSyncCredentialsPath'),
|
||||
);
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
expect(response.statusCode, HttpStatus.ok);
|
||||
expect(body['email'], isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('is 404 when the credentials file is absent', () async {
|
||||
final origin = await enabledOrigin({});
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$origin$kSyncCredentialsPath'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test('is 404 when the credentials file is not valid JSON', () async {
|
||||
final origin = await enabledOrigin({}, credentialsJson: 'not json');
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$origin$kSyncCredentialsPath'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
});
|
||||
|
||||
test(
|
||||
'is 404 when the credentials file is missing required fields',
|
||||
() async {
|
||||
final origin = await enabledOrigin(
|
||||
{},
|
||||
credentialsJson: jsonEncode({'id_token': 'id'}),
|
||||
);
|
||||
|
||||
final response = await http.get(
|
||||
Uri.parse('$origin$kSyncCredentialsPath'),
|
||||
);
|
||||
|
||||
expect(response.statusCode, HttpStatus.notFound);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user