feat(engine): P0 stroke engine + persistence
Per the full-refactor plan §9 (input-independent half of P0): - engine: canonical EditorStroke (lossless InkStroke round-trip) + stroke_geometry (single getStroke outline) + revision-gated StrokeStore - render: static/live ink painters + ink_picture_cache (revision-keyed) + annotation_layer (RepaintBoundary) - persistence: DB v6 (ink, notebook_pages) + editor_repository diff-write (UPSERT changed / DELETE removed in one txn; id-set after commit) + save_scheduler - pdf_service export now FILLS the getStroke outline (R7 hairline fix) Not yet wired into the live editor (input relocation pending pen-pressure diagnostic). 28 new tests pass.
This commit is contained in:
246
test/editor_repository_test.dart
Normal file
246
test/editor_repository_test.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
// 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user