Files
BadNote/test/editor_repository_test.dart

246 lines
7.9 KiB
Dart
Raw Permalink Normal View History

// 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/utils/utils.dart' as sqflite_utils; // ignore: depend_on_referenced_packages
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<Database> _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<EditorStroke> _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<EditorStroke>.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<String>.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));
});
});
}