247 lines
7.9 KiB
Dart
247 lines
7.9 KiB
Dart
|
|
// 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<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));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|