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