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.
This commit is contained in:
160
test/editor_render_test.dart
Normal file
160
test/editor_render_test.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
246
test/editor_repository_test.dart
Normal file
246
test/editor_repository_test.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
// test/editor_repository_test.dart
|
||||
//
|
||||
// Tests for EditorRepository (MF3 diff-write contract + round-trip).
|
||||
//
|
||||
// Run via:
|
||||
// bash tool/test.sh test/editor_repository_test.dart
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite_common/sqlite_api.dart';
|
||||
import 'package:sqflite_common/utils/utils.dart' as sqflite_utils;
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
import 'package:badnote/editor/persistence/editor_repository.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Open an in-memory sqflite database with the ink + notebook_pages schema.
|
||||
Future<Database> _openTestDb() async {
|
||||
sqfliteFfiInit();
|
||||
final factory = databaseFactoryFfi;
|
||||
|
||||
// Use a temp-file DB so the test is isolated but still exercises real I/O.
|
||||
final dir = await Directory.systemTemp.createTemp('editor_repo_test_');
|
||||
final path = p.join(dir.path, 'test.db');
|
||||
|
||||
return factory.openDatabase(
|
||||
path,
|
||||
options: OpenDatabaseOptions(
|
||||
version: 1,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE ink (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_kind TEXT NOT NULL,
|
||||
host_id TEXT NOT NULL,
|
||||
stroke_json TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
|
||||
);
|
||||
await db.execute('''
|
||||
CREATE TABLE notebook_pages (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
source_page_index INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a minimal [EditorStroke] with a given [id].
|
||||
EditorStroke _stroke(String id) => EditorStroke.create(
|
||||
id: id,
|
||||
points: [
|
||||
const EditorPoint(x: 0.1, y: 0.2),
|
||||
const EditorPoint(x: 0.3, y: 0.4),
|
||||
],
|
||||
);
|
||||
|
||||
/// Build [n] distinct strokes.
|
||||
List<EditorStroke> _strokes(int n) =>
|
||||
List.generate(n, (i) => _stroke('stroke-$i'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
group('EditorRepository', () {
|
||||
late Database db;
|
||||
late EditorRepository repo;
|
||||
|
||||
setUp(() async {
|
||||
db = await _openTestDb();
|
||||
repo = EditorRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// ── Round-trip ─────────────────────────────────────────────────────
|
||||
|
||||
test('round-trip: saveHost then loadDocument returns same strokes', () async {
|
||||
const docId = 'doc-rt';
|
||||
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||
final original = _strokes(5);
|
||||
|
||||
await repo.saveHost('page', hostId, original);
|
||||
|
||||
final loaded = await repo.loadDocument(docId);
|
||||
|
||||
expect(loaded.containsKey(hostId), isTrue);
|
||||
final returned = loaded[hostId]!;
|
||||
expect(returned.length, equals(original.length));
|
||||
for (var i = 0; i < original.length; i++) {
|
||||
expect(returned[i].id, equals(original[i].id));
|
||||
expect(returned[i].points.length, equals(original[i].points.length));
|
||||
expect(returned[i].color, equals(original[i].color));
|
||||
expect(returned[i].width, equals(original[i].width));
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2000-stroke seed + 1-stroke delete ────────────────────────────
|
||||
|
||||
test(
|
||||
'seed 2000 strokes, delete 1: second save issues exactly 1 DELETE and 0 INSERTs',
|
||||
() async {
|
||||
const docId = 'doc-2000';
|
||||
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||
final all = _strokes(2000);
|
||||
|
||||
// First save: all 2000 strokes inserted (not under test here).
|
||||
await repo.saveHost('page', hostId, all);
|
||||
|
||||
// Verify row count is 2000.
|
||||
final countBefore = sqflite_utils.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||
[hostId],
|
||||
),
|
||||
)!;
|
||||
expect(countBefore, equals(2000));
|
||||
|
||||
// Record which ids existed before the deletion.
|
||||
final idsBefore = (await db.query(
|
||||
'ink',
|
||||
columns: ['id'],
|
||||
where: 'host_id = ?',
|
||||
whereArgs: [hostId],
|
||||
))
|
||||
.map((r) => r['id'] as String)
|
||||
.toSet();
|
||||
|
||||
// Remove stroke at index 500 (arbitrary) — simulate 1 erasure.
|
||||
final strokeToRemove = all[500];
|
||||
final reduced = List<EditorStroke>.from(all)..removeAt(500);
|
||||
|
||||
// Second save: diff should produce exactly 1 DELETE, 0 INSERTs.
|
||||
await repo.saveHost('page', hostId, reduced);
|
||||
|
||||
final countAfter = sqflite_utils.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||
[hostId],
|
||||
),
|
||||
)!;
|
||||
|
||||
// Row count must drop by exactly 1.
|
||||
expect(countAfter, equals(1999));
|
||||
|
||||
// The removed stroke must no longer exist.
|
||||
final removedRows = await db.query(
|
||||
'ink',
|
||||
where: 'id = ?',
|
||||
whereArgs: [strokeToRemove.id],
|
||||
);
|
||||
expect(removedRows, isEmpty);
|
||||
|
||||
// All 1999 surviving ids must be unchanged.
|
||||
final idsAfter = (await db.query(
|
||||
'ink',
|
||||
columns: ['id'],
|
||||
where: 'host_id = ?',
|
||||
whereArgs: [hostId],
|
||||
))
|
||||
.map((r) => r['id'] as String)
|
||||
.toSet();
|
||||
|
||||
final expectedSurvivors = Set<String>.from(idsBefore)
|
||||
..remove(strokeToRemove.id);
|
||||
expect(idsAfter, equals(expectedSurvivors));
|
||||
|
||||
// No new ids were created (zero INSERTs for the second save).
|
||||
final newIds = idsAfter.difference(idsBefore);
|
||||
expect(newIds, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Multiple hosts in same document ───────────────────────────────
|
||||
|
||||
test('loadDocument returns strokes for multiple pages', () async {
|
||||
const docId = 'doc-multi';
|
||||
final host0 = EditorRepository.pageHostId(docId, 0);
|
||||
final host1 = EditorRepository.pageHostId(docId, 1);
|
||||
|
||||
final strokes0 = _strokes(3);
|
||||
final strokes1 = _strokes(4).map((s) => _stroke('pg1-${s.id}')).toList();
|
||||
|
||||
await repo.saveHost('page', host0, strokes0);
|
||||
await repo.saveHost('page', host1, strokes1);
|
||||
|
||||
final loaded = await repo.loadDocument(docId);
|
||||
expect(loaded[host0]!.length, equals(3));
|
||||
expect(loaded[host1]!.length, equals(4));
|
||||
});
|
||||
|
||||
// ── Idempotency ────────────────────────────────────────────────────
|
||||
|
||||
test('saving the same strokes twice is a no-op (0 DB mutations)', () async {
|
||||
const docId = 'doc-idem';
|
||||
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||
final strokes = _strokes(10);
|
||||
|
||||
await repo.saveHost('page', hostId, strokes);
|
||||
|
||||
final countBefore = sqflite_utils.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||
[hostId],
|
||||
),
|
||||
)!;
|
||||
|
||||
// Second save with identical strokes: should be a no-op.
|
||||
await repo.saveHost('page', hostId, strokes);
|
||||
|
||||
final countAfter = sqflite_utils.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||
[hostId],
|
||||
),
|
||||
)!;
|
||||
|
||||
expect(countAfter, equals(countBefore));
|
||||
});
|
||||
});
|
||||
}
|
||||
180
test/editor_stroke_model_test.dart
Normal file
180
test/editor_stroke_model_test.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
// test/editor_stroke_model_test.dart
|
||||
//
|
||||
// Pure unit tests for the engine foundation (NO DB, NO widgets):
|
||||
// (a) EditorStroke -> json -> EditorStroke round-trips losslessly,
|
||||
// including tilt/timestamp/pointerDeviceKind.
|
||||
// (b) EditorStroke <-> InkStroke round-trips losslessly.
|
||||
// (c) buildStrokeOutline returns a non-empty Path for a >=2-point stroke and
|
||||
// an empty Path for 0 points.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:badnote/editor/engine/stroke_geometry.dart';
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
import 'package:badnote/models/ink_point.dart';
|
||||
import 'package:badnote/models/ink_stroke.dart';
|
||||
import 'package:badnote/models/pen_tool.dart';
|
||||
import 'package:badnote/models/pointer_device_kind.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('EditorStroke JSON round-trip', () {
|
||||
test('round-trips losslessly incl. tilt/timestamp/pointerDeviceKind', () {
|
||||
final stroke = EditorStroke(
|
||||
id: 'stroke-1',
|
||||
points: const [
|
||||
EditorPoint(
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
pressure: 0.75,
|
||||
tilt: 0.33,
|
||||
timestamp: 1234567,
|
||||
pointerDeviceKind: InputDeviceKind.stylus,
|
||||
),
|
||||
EditorPoint(
|
||||
x: 0.4,
|
||||
y: 0.5,
|
||||
pressure: 0.5,
|
||||
tilt: 0.0,
|
||||
timestamp: 1234600,
|
||||
pointerDeviceKind: InputDeviceKind.invertedStylus,
|
||||
),
|
||||
],
|
||||
tool: EditorTool.highlighter,
|
||||
color: 0xFFAABBCC,
|
||||
width: 0.0123,
|
||||
filled: true,
|
||||
textContent: 'hello',
|
||||
fontSize: 18.0,
|
||||
);
|
||||
|
||||
// Persisted as a JSON string (DB TEXT column); jsonEncode invokes nested
|
||||
// toJson, jsonDecode rebuilds the maps.
|
||||
final decoded = EditorStroke.fromJson(
|
||||
jsonDecode(jsonEncode(stroke.toJson())) as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
expect(decoded, stroke);
|
||||
// Spot-check the superset fields explicitly.
|
||||
expect(decoded.points.first.tilt, 0.33);
|
||||
expect(decoded.points.first.timestamp, 1234567);
|
||||
expect(
|
||||
decoded.points.first.pointerDeviceKind,
|
||||
InputDeviceKind.stylus,
|
||||
);
|
||||
expect(
|
||||
decoded.points.last.pointerDeviceKind,
|
||||
InputDeviceKind.invertedStylus,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves null superset fields', () {
|
||||
final stroke = EditorStroke(
|
||||
id: 'stroke-null',
|
||||
points: const [
|
||||
EditorPoint(x: 0.0, y: 0.0),
|
||||
EditorPoint(x: 1.0, y: 1.0, pressure: 0.9),
|
||||
],
|
||||
);
|
||||
|
||||
final decoded = EditorStroke.fromJson(
|
||||
jsonDecode(jsonEncode(stroke.toJson())) as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
expect(decoded, stroke);
|
||||
expect(decoded.points.first.pressure, isNull);
|
||||
expect(decoded.points.first.tilt, isNull);
|
||||
expect(decoded.points.first.timestamp, isNull);
|
||||
expect(decoded.points.first.pointerDeviceKind, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('EditorStroke <-> InkStroke round-trip', () {
|
||||
test('InkStroke -> EditorStroke -> InkStroke is lossless', () {
|
||||
final ink = InkStroke(
|
||||
id: 'ink-1',
|
||||
points: const [
|
||||
InkPoint(
|
||||
x: 0.2,
|
||||
y: 0.3,
|
||||
pressure: 0.8,
|
||||
tilt: 0.1,
|
||||
timestamp: 999,
|
||||
pointerDeviceKind: InputDeviceKind.stylus,
|
||||
),
|
||||
InkPoint(
|
||||
x: 0.6,
|
||||
y: 0.7,
|
||||
pressure: 0.4,
|
||||
tilt: 0.2,
|
||||
timestamp: 1050,
|
||||
pointerDeviceKind: InputDeviceKind.touch,
|
||||
),
|
||||
],
|
||||
tool: PenTool.highlighter,
|
||||
color: 0xFF112233,
|
||||
strokeWidth: 0.02,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(42),
|
||||
filled: true,
|
||||
textContent: 'note',
|
||||
fontSize: 22.0,
|
||||
);
|
||||
|
||||
final editor = EditorStroke.fromInkStroke(ink);
|
||||
final back = editor.toInkStroke(createdAt: ink.createdAt);
|
||||
|
||||
expect(back, ink);
|
||||
});
|
||||
|
||||
test('EditorStroke (non-null fields) -> InkStroke -> EditorStroke', () {
|
||||
final editor = EditorStroke(
|
||||
id: 'ink-2',
|
||||
points: const [
|
||||
EditorPoint(
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
pressure: 0.5,
|
||||
tilt: 0.0,
|
||||
timestamp: 7,
|
||||
pointerDeviceKind: InputDeviceKind.mouse,
|
||||
),
|
||||
],
|
||||
tool: EditorTool.eraser,
|
||||
color: 0xFF000000,
|
||||
width: 0.004,
|
||||
);
|
||||
|
||||
final ink = editor.toInkStroke();
|
||||
final back = EditorStroke.fromInkStroke(ink);
|
||||
|
||||
expect(back, editor);
|
||||
expect(ink.tool, PenTool.eraser);
|
||||
});
|
||||
});
|
||||
|
||||
group('buildStrokeOutline', () {
|
||||
const pageSize = Size(800, 600);
|
||||
|
||||
test('returns a non-empty Path for a >=2-point stroke', () {
|
||||
final stroke = EditorStroke(
|
||||
id: 's',
|
||||
points: const [
|
||||
EditorPoint(x: 0.1, y: 0.1, pressure: 0.6),
|
||||
EditorPoint(x: 0.5, y: 0.4, pressure: 0.6),
|
||||
EditorPoint(x: 0.8, y: 0.9, pressure: 0.6),
|
||||
],
|
||||
width: 0.01,
|
||||
);
|
||||
|
||||
final path = buildStrokeOutline(stroke, pageSize, isComplete: true);
|
||||
expect(path.getBounds().isEmpty, isFalse);
|
||||
});
|
||||
|
||||
test('returns an empty Path for 0 points', () {
|
||||
final stroke = EditorStroke(id: 'empty', points: const []);
|
||||
final path = buildStrokeOutline(stroke, pageSize, isComplete: true);
|
||||
expect(path.getBounds().isEmpty, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user