todo-app/test/fake_note_repository.dart
Krzysztof kuhy Rudnicki 66143bdfe3 Make the app compile and run for web, behind platform seams
First step of moving the desktop app off Flutter's Linux embedder, which tops
out at ~20fps at 4K (see docs/desktop-performance-findings.md). Android is
unaffected and keeps every existing code path.

The blocker was that `dart:io` was imported by note_repository, capture_screen
and settings_screen. On web it compiles to a stub and then throws at runtime --
the first web build rendered a blank white window. So each `dart:io` use now
sits behind a conditional export, with the shared logic hoisted into pure Dart:

- note_repository loses `dart:io`/sqlite entirely. Platform wiring moves to
  repository_factory{,_io,_web}, and the legacy sqlite import moves to its own
  io-only library. The HLC-from-updated_at seeding is preserved verbatim, so
  independently-migrating devices still resolve pre-cutover edits by real edit
  time.
- Notes persist to IndexedDB on web, not localStorage: the log is the primary
  copy of the backlog and localStorage caps at ~5-10MB and is evicted eagerly.
- BACKLOG.md and the explicit Export keep working through a local wrapper
  (added next). A browser cannot write `~/todo/BACKLOG.md`, and the user's
  tooling plus the todo MCP server read that path, so leaving it to rot was not
  an option. Writes are best-effort so the app still works with no wrapper.
- Notes are also mirrored to the wrapper's on-disk copy, so a wiped Chrome
  profile is not a total-loss event for notes that have not reached GitHub yet.

`openRepository` is split so `openRepositoryIn` keeps the migration tests; the
suite stays at 174 green and 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:14:03 +02:00

114 lines
3.1 KiB
Dart

import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
/// In-memory stand-in for [NoteRepository] used by widget tests.
///
/// It implements the same public API but backs it with a plain list and a
/// broadcast [StreamController] — no SQLite, so no pending sqflite timers to
/// fight the widget tester's fake-async clock. Streams emit synchronously on
/// every change, making tests fast and deterministic.
///
/// It also records the last [NoteSort]/[NoteFilter] passed to [watchNotes],
/// so list-screen tests can assert the UI built the right query without
/// re-testing the repository's SQL (covered separately by unit tests).
class FakeNoteRepository implements NoteRepository {
FakeNoteRepository([List<Note>? initial]) : _notes = [...?initial];
final List<Note> _notes;
final _controller = StreamController<List<Note>>.broadcast();
final _changesController = StreamController<void>.broadcast();
NoteSort? lastSort;
NoteFilter? lastFilter;
/// Emits the current snapshot to a new subscriber first (so late-binding
/// [StreamBuilder]s get the seed), then forwards subsequent changes.
Stream<List<Note>> _snapshots() async* {
yield List.unmodifiable(_notes);
yield* _controller.stream;
}
void _emit() {
if (!_controller.isClosed) _controller.add(List.unmodifiable(_notes));
if (!_changesController.isClosed) _changesController.add(null);
}
@override
Stream<void> get changes => _changesController.stream;
@override
Future<void> upsert(Note note) async {
_notes
..removeWhere((n) => n.id == note.id)
..add(note);
_emit();
}
@override
Future<void> delete(String id) async {
_notes.removeWhere((n) => n.id == id);
_emit();
}
@override
Future<ImportOutcome> importNotes(List<Note> incoming) async {
var added = 0;
var updated = 0;
var skipped = 0;
for (final note in incoming) {
final i = _notes.indexWhere((n) => n.id == note.id);
if (i < 0) {
_notes.add(note);
added++;
} else if (note.updatedAt.isAfter(_notes[i].updatedAt)) {
_notes[i] = note;
updated++;
} else {
skipped++;
}
}
_emit();
return ImportOutcome(added: added, updated: updated, skipped: skipped);
}
@override
Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) async => List.unmodifiable(_notes);
@override
Stream<List<Note>> watchNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) {
lastSort = sort;
lastFilter = filter;
return _snapshots();
}
@override
Stream<int> watchCount() => _snapshots().map((n) => n.length);
@override
String get nodeId => 'fake-node';
@override
Log exportLog() => <String, Record>{};
@override
Future<void> importLog(Log remote) async {}
@override
Future<void> seedFrom(Log log) async {}
@override
Future<void> close() async {
await _controller.close();
await _changesController.close();
}
}