2026-06-21 23:41:01 +08:00
|
|
|
// 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.
|
|
|
|
|
|
2026-06-23 02:59:30 +08:00
|
|
|
import 'dart:ui' as ui;
|
|
|
|
|
|
2026-06-21 23:41:01 +08:00
|
|
|
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';
|
2026-06-23 02:59:30 +08:00
|
|
|
import 'package:badnote/editor/render/live_ink_painter.dart';
|
2026-06-21 23:41:01 +08:00
|
|
|
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);
|
|
|
|
|
});
|
2026-06-23 02:59:30 +08:00
|
|
|
|
|
|
|
|
// thinning (PenConfig.pressureSensitivity) participates in the static layer
|
|
|
|
|
// identity: a sensitivity change rebuilds the outline, so it MUST trigger a
|
|
|
|
|
// repaint even when revision + pageSize are unchanged — otherwise the cached
|
|
|
|
|
// Picture (built at the old thinning) would be replayed (stale ink).
|
|
|
|
|
test('returns true when only thinning changes (no stale cached Picture)',
|
|
|
|
|
() {
|
|
|
|
|
final store = StrokeStore()..add(_stroke('s1'));
|
|
|
|
|
final cache = InkPictureCache();
|
|
|
|
|
final pThin = StaticInkPainter(
|
|
|
|
|
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
|
|
|
|
|
thinning: 0.85);
|
|
|
|
|
final pThick = StaticInkPainter(
|
|
|
|
|
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
|
|
|
|
|
thinning: 0.2);
|
|
|
|
|
expect(pThick.shouldRepaint(pThin), isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('returns false when thinning is equal (revision+size unchanged)', () {
|
|
|
|
|
final store = StrokeStore()..add(_stroke('s1'));
|
|
|
|
|
final cache = InkPictureCache();
|
|
|
|
|
StaticInkPainter mk() => StaticInkPainter(
|
|
|
|
|
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
|
|
|
|
|
thinning: 0.6);
|
|
|
|
|
expect(mk().shouldRepaint(mk()), isFalse);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
group('StaticInkPainter.paint (cache + thinning recipe)', () {
|
|
|
|
|
test('paints committed strokes into a Picture without error', () {
|
|
|
|
|
final store = StrokeStore()
|
|
|
|
|
..add(_stroke('a'))
|
|
|
|
|
..add(_stroke('b'));
|
|
|
|
|
final painter = _painter(store, pageSize);
|
|
|
|
|
|
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
|
|
|
painter.paint(Canvas(recorder), pageSize);
|
|
|
|
|
final picture = recorder.endRecording();
|
|
|
|
|
addTearDown(picture.dispose);
|
|
|
|
|
expect(picture, isNotNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a thinning change builds a fresh Picture (cache key includes thinning)',
|
|
|
|
|
() {
|
|
|
|
|
final store = StrokeStore()..add(_stroke('a'));
|
|
|
|
|
final cache = InkPictureCache();
|
|
|
|
|
// Same store/revision/size, different thinning, shared cache.
|
|
|
|
|
for (final t in [0.85, 0.2]) {
|
|
|
|
|
final painter = StaticInkPainter(
|
|
|
|
|
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
|
|
|
|
|
thinning: t);
|
|
|
|
|
final recorder = ui.PictureRecorder();
|
|
|
|
|
painter.paint(Canvas(recorder), pageSize);
|
|
|
|
|
recorder.endRecording().dispose();
|
|
|
|
|
}
|
|
|
|
|
// If the key ignored thinning, the 2nd paint would replay the 1st's
|
|
|
|
|
// Picture; the test simply asserts both paints complete (distinct keys,
|
|
|
|
|
// no aliasing assertion error from drawing a disposed picture).
|
|
|
|
|
expect(true, isTrue);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
group('LiveInkPainter.shouldRepaint', () {
|
|
|
|
|
test('repaints when the live stroke identity changes', () {
|
|
|
|
|
const a = LiveInkPainter(live: null, pageSize: pageSize);
|
|
|
|
|
final b = LiveInkPainter(
|
|
|
|
|
live: _stroke('live'), pageSize: pageSize);
|
|
|
|
|
expect(b.shouldRepaint(a), isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('repaints when thinning changes (width consistent with static layer)',
|
|
|
|
|
() {
|
|
|
|
|
final stroke = _stroke('live');
|
|
|
|
|
final a = LiveInkPainter(live: stroke, pageSize: pageSize, thinning: 0.85);
|
|
|
|
|
final b = LiveInkPainter(live: stroke, pageSize: pageSize, thinning: 0.2);
|
|
|
|
|
expect(b.shouldRepaint(a), isTrue);
|
|
|
|
|
});
|
2026-06-21 23:41:01 +08:00
|
|
|
});
|
|
|
|
|
}
|