mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 15:34:05 +01:00
Glyph + spoken label replaces every app-written string the guard found: empty states, tile meta, sheet dates/fields, date presets, template/section headings, status footer, snackbars, GitHub and cloud-image results, upload/download badges, Material tooltips. Guard tests (text_free_*_test) walk the painted tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
5.5 KiB
Dart
173 lines
5.5 KiB
Dart
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';
|
|
import 'text_free_guard.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(spoken('Synced at'), findsNothing);
|
|
expect(spoken('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(spoken('Sync failed at 12:00:00'), findsOneWidget);
|
|
expect(spoken('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'));
|
|
});
|
|
}
|