feat: host-agnostic StrokeHost (CoordinateSpaceHost, plan principle #2)
Some checks failed
CI / Windows build (push) Has been cancelled

Ties the engine together: a StrokeHost is anything ink attaches to — a PDF page,
an infinite-board region, or a (P5) CAS overlay — with a stable hostId (cache +
persistence key), a contentSize (normalized↔px mapping), and a revision-tracked
StrokeStore. strokesIn(viewport) broad-phase-culls via stroke_bounds for the
board. The viewport mounts one AnnotationLayer per host; nothing in the engine
knows page vs board (the one host-agnostic ink engine).

Makes StrokeStore (P0) + stroke_bounds load-bearing together. Pure; unit-tested.

flutter analyze lib/editor clean; 208/208 tests (+4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:36:44 +08:00
parent b1f131814e
commit 77b6ee415d
2 changed files with 97 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
// Tests for the host-agnostic ink host (plan principle #2).
import 'dart:ui' show Rect, Size;
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/engine/stroke_host.dart';
import 'package:badnote/editor/engine/stroke_model.dart';
EditorStroke _at(String id, double x, double y) => EditorStroke.create(
id: id,
points: [EditorPoint(x: x, y: y), EditorPoint(x: x + 0.05, y: y + 0.05)],
);
void main() {
test('exposes identity, content size, and a fresh store', () {
final host = StrokeHost(hostId: 'doc:a:page:0', contentSize: const Size(595, 842));
expect(host.hostId, 'doc:a:page:0');
expect(host.contentSize, const Size(595, 842));
expect(host.revision, 0);
});
test('revision tracks the store', () {
final host = StrokeHost(hostId: 'h', contentSize: const Size(100, 100));
host.store.add(_at('s1', 0.1, 0.1));
expect(host.revision, 1);
});
test('strokesIn culls strokes outside the viewport (board broad-phase)', () {
final host = StrokeHost(hostId: 'board', contentSize: const Size(1000, 1000));
host.store
..add(_at('in', 0.1, 0.1)) // inside [0,0.5]
..add(_at('out', 0.8, 0.8)) // outside
..add(_at('edge', 0.48, 0.48)); // partial overlap with [0,0.5]
final visible = host.strokesIn(const Rect.fromLTRB(0, 0, 0.5, 0.5));
expect(visible.map((s) => s.id).toSet(), {'in', 'edge'});
});
test('an injected store is adopted (load path)', () {
final host = StrokeHost(hostId: 'h', contentSize: const Size(10, 10));
host.store.replaceAll([_at('a', 0.1, 0.1), _at('b', 0.2, 0.2)]);
expect(host.store.committed.length, 2);
expect(host.strokesIn(const Rect.fromLTRB(0, 0, 1, 1)).length, 2);
});
}