// test/editor_repository_test.dart // // Tests for EditorRepository (MF3 diff-write contract + round-trip). // // Run via: // bash tool/test.sh test/editor_repository_test.dart import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:sqflite_common/sqlite_api.dart'; import 'package:sqflite_common/utils/utils.dart' as sqflite_utils; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:badnote/editor/engine/stroke_model.dart'; import 'package:badnote/editor/persistence/editor_repository.dart'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Open an in-memory sqflite database with the ink + notebook_pages schema. Future _openTestDb() async { sqfliteFfiInit(); final factory = databaseFactoryFfi; // Use a temp-file DB so the test is isolated but still exercises real I/O. final dir = await Directory.systemTemp.createTemp('editor_repo_test_'); final path = p.join(dir.path, 'test.db'); return factory.openDatabase( path, options: OpenDatabaseOptions( version: 1, onCreate: (db, version) async { await db.execute(''' CREATE TABLE ink ( id TEXT PRIMARY KEY, host_kind TEXT NOT NULL, host_id TEXT NOT NULL, stroke_json TEXT NOT NULL, ordinal INTEGER NOT NULL, updated_at INTEGER NOT NULL ) '''); await db.execute( 'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)', ); await db.execute(''' CREATE TABLE notebook_pages ( id TEXT PRIMARY KEY, document_id TEXT NOT NULL, ordinal INTEGER NOT NULL, source_page_index INTEGER NOT NULL, kind TEXT NOT NULL, created_at INTEGER NOT NULL ) '''); }, ), ); } /// Build a minimal [EditorStroke] with a given [id]. EditorStroke _stroke(String id) => EditorStroke.create( id: id, points: [ const EditorPoint(x: 0.1, y: 0.2), const EditorPoint(x: 0.3, y: 0.4), ], ); /// Build [n] distinct strokes. List _strokes(int n) => List.generate(n, (i) => _stroke('stroke-$i')); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- void main() { setUpAll(() { sqfliteFfiInit(); databaseFactory = databaseFactoryFfi; }); group('EditorRepository', () { late Database db; late EditorRepository repo; setUp(() async { db = await _openTestDb(); repo = EditorRepository(db); }); tearDown(() async { await db.close(); }); // ── Round-trip ───────────────────────────────────────────────────── test('round-trip: saveHost then loadDocument returns same strokes', () async { const docId = 'doc-rt'; final hostId = EditorRepository.pageHostId(docId, 0); final original = _strokes(5); await repo.saveHost('page', hostId, original); final loaded = await repo.loadDocument(docId); expect(loaded.containsKey(hostId), isTrue); final returned = loaded[hostId]!; expect(returned.length, equals(original.length)); for (var i = 0; i < original.length; i++) { expect(returned[i].id, equals(original[i].id)); expect(returned[i].points.length, equals(original[i].points.length)); expect(returned[i].color, equals(original[i].color)); expect(returned[i].width, equals(original[i].width)); } }); // ── 2000-stroke seed + 1-stroke delete ──────────────────────────── test( 'seed 2000 strokes, delete 1: second save issues exactly 1 DELETE and 0 INSERTs', () async { const docId = 'doc-2000'; final hostId = EditorRepository.pageHostId(docId, 0); final all = _strokes(2000); // First save: all 2000 strokes inserted (not under test here). await repo.saveHost('page', hostId, all); // Verify row count is 2000. final countBefore = sqflite_utils.firstIntValue( await db.rawQuery( 'SELECT COUNT(*) FROM ink WHERE host_id = ?', [hostId], ), )!; expect(countBefore, equals(2000)); // Record which ids existed before the deletion. final idsBefore = (await db.query( 'ink', columns: ['id'], where: 'host_id = ?', whereArgs: [hostId], )) .map((r) => r['id'] as String) .toSet(); // Remove stroke at index 500 (arbitrary) — simulate 1 erasure. final strokeToRemove = all[500]; final reduced = List.from(all)..removeAt(500); // Second save: diff should produce exactly 1 DELETE, 0 INSERTs. await repo.saveHost('page', hostId, reduced); final countAfter = sqflite_utils.firstIntValue( await db.rawQuery( 'SELECT COUNT(*) FROM ink WHERE host_id = ?', [hostId], ), )!; // Row count must drop by exactly 1. expect(countAfter, equals(1999)); // The removed stroke must no longer exist. final removedRows = await db.query( 'ink', where: 'id = ?', whereArgs: [strokeToRemove.id], ); expect(removedRows, isEmpty); // All 1999 surviving ids must be unchanged. final idsAfter = (await db.query( 'ink', columns: ['id'], where: 'host_id = ?', whereArgs: [hostId], )) .map((r) => r['id'] as String) .toSet(); final expectedSurvivors = Set.from(idsBefore) ..remove(strokeToRemove.id); expect(idsAfter, equals(expectedSurvivors)); // No new ids were created (zero INSERTs for the second save). final newIds = idsAfter.difference(idsBefore); expect(newIds, isEmpty); }, ); // ── Multiple hosts in same document ─────────────────────────────── test('loadDocument returns strokes for multiple pages', () async { const docId = 'doc-multi'; final host0 = EditorRepository.pageHostId(docId, 0); final host1 = EditorRepository.pageHostId(docId, 1); final strokes0 = _strokes(3); final strokes1 = _strokes(4).map((s) => _stroke('pg1-${s.id}')).toList(); await repo.saveHost('page', host0, strokes0); await repo.saveHost('page', host1, strokes1); final loaded = await repo.loadDocument(docId); expect(loaded[host0]!.length, equals(3)); expect(loaded[host1]!.length, equals(4)); }); // ── Idempotency ──────────────────────────────────────────────────── test('saving the same strokes twice is a no-op (0 DB mutations)', () async { const docId = 'doc-idem'; final hostId = EditorRepository.pageHostId(docId, 0); final strokes = _strokes(10); await repo.saveHost('page', hostId, strokes); final countBefore = sqflite_utils.firstIntValue( await db.rawQuery( 'SELECT COUNT(*) FROM ink WHERE host_id = ?', [hostId], ), )!; // Second save with identical strokes: should be a no-op. await repo.saveHost('page', hostId, strokes); final countAfter = sqflite_utils.firstIntValue( await db.rawQuery( 'SELECT COUNT(*) FROM ink WHERE host_id = ?', [hostId], ), )!; expect(countAfter, equals(countBefore)); }); }); }