Files
BadNote/test/editor_render_test.dart
Akiba So 914951afb7 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.
2026-06-21 23:41:01 +08:00

161 lines
5.6 KiB
Dart

// test/editor_render_test.dart
//
// Unit tests for the P0 RENDER layer:
// (a) StrokeStore: add/removeById/replaceAll/clear all bump revision.
// (b) StaticInkPainter.shouldRepaint: FALSE when revision+pageSize unchanged,
// TRUE when revision changes. (The key perf invariant.)
//
// Tests are intentionally pure-logic: no widget pump, no GPU, no image
// comparisons. StaticInkPainter.shouldRepaint only reads store.revision and
// pageSize so we can exercise it without a real ui.Picture or Canvas.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/engine/stroke_model.dart';
import 'package:badnote/editor/engine/stroke_store.dart';
import 'package:badnote/editor/render/ink_picture_cache.dart';
import 'package:badnote/editor/render/static_ink_painter.dart';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
EditorStroke _stroke(String id) => EditorStroke.create(
id: id,
points: const [
EditorPoint(x: 0.1, y: 0.1, pressure: 0.5),
EditorPoint(x: 0.5, y: 0.5, pressure: 0.5),
],
);
/// Builds a [StaticInkPainter] that can be interrogated via [shouldRepaint]
/// without ever calling [paint] (avoids needing a real Canvas / Picture).
StaticInkPainter _painter(StrokeStore store, Size pageSize) =>
StaticInkPainter(
hostId: 'test-host',
store: store,
pageSize: pageSize,
cache: InkPictureCache(),
);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
const pageSize = Size(800.0, 600.0);
// -------------------------------------------------------------------------
group('StrokeStore revision', () {
test('starts at 0', () {
final store = StrokeStore();
expect(store.revision, 0);
});
test('add bumps revision', () {
final store = StrokeStore();
store.add(_stroke('s1'));
expect(store.revision, 1);
store.add(_stroke('s2'));
expect(store.revision, 2);
expect(store.committed.length, 2);
});
test('removeById bumps revision when stroke is found', () {
final store = StrokeStore()..add(_stroke('s1'));
final revBefore = store.revision;
store.removeById('s1');
expect(store.revision, greaterThan(revBefore));
expect(store.committed, isEmpty);
});
test('removeById does NOT bump revision when id is absent', () {
final store = StrokeStore()..add(_stroke('s1'));
final revBefore = store.revision;
store.removeById('nonexistent');
expect(store.revision, revBefore);
});
test('replaceAll bumps revision', () {
final store = StrokeStore();
store.replaceAll([_stroke('a'), _stroke('b')]);
expect(store.revision, 1);
expect(store.committed.length, 2);
store.replaceAll([_stroke('c')]);
expect(store.revision, 2);
expect(store.committed.length, 1);
});
test('clear bumps revision', () {
final store = StrokeStore()..add(_stroke('x'));
final revBefore = store.revision;
store.clear();
expect(store.revision, greaterThan(revBefore));
expect(store.committed, isEmpty);
});
test('committed returns unmodifiable list', () {
final store = StrokeStore()..add(_stroke('s'));
expect(() => store.committed.add(_stroke('bad')), throwsUnsupportedError);
});
});
// -------------------------------------------------------------------------
group('StaticInkPainter.shouldRepaint', () {
test('returns false when revision and pageSize are unchanged', () {
final store = StrokeStore()..add(_stroke('s1'));
final p1 = _painter(store, pageSize);
final p2 = _painter(store, pageSize);
// Both painters wrap the same store at the same revision.
expect(p2.shouldRepaint(p1), isFalse);
});
test('returns true when revision changes', () {
final store = StrokeStore()..add(_stroke('s1'));
final p1 = _painter(store, pageSize);
// Mutate the store — revision bumps.
store.add(_stroke('s2'));
final p2 = _painter(store, pageSize);
expect(p2.shouldRepaint(p1), isTrue);
});
test('returns true when only pageSize changes', () {
final store = StrokeStore()..add(_stroke('s1'));
final p1 = _painter(store, pageSize);
final p2 = _painter(store, const Size(1024.0, 768.0));
expect(p2.shouldRepaint(p1), isTrue);
});
test('returns false after replaceAll with same content (revision differs '
'but separate store instances — uses store.revision not identity)', () {
// This test confirms shouldRepaint uses the revision INTEGER, not object
// identity, so a brand-new store at revision 0 == another at revision 0.
final storeA = StrokeStore(); // revision 0
final storeB = StrokeStore(); // revision 0
final pA = _painter(storeA, pageSize);
final pB = _painter(storeB, pageSize);
expect(pB.shouldRepaint(pA), isFalse);
});
test('returns true when old painter had higher revision than new '
'(regression: revision comparison is not directional guard)', () {
final store = StrokeStore()
..add(_stroke('a'))
..add(_stroke('b')); // revision == 2
final pOld = _painter(store, pageSize);
store.clear(); // revision == 3
final pNew = _painter(store, pageSize);
// pNew.revision (3) != pOld.revision (2) → should repaint.
expect(pNew.shouldRepaint(pOld), isTrue);
});
});
}