Files
BadNote/test/editor_render_test.dart

243 lines
9.0 KiB
Dart
Raw Normal View History

// 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;
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';
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);
});
});
}