Some checks failed
CI / Windows build (push) Has been cancelled
A blank note can show a page-background template painted behind the ink, picked from the toolbar and persisted per notebook. - NoteBackground: blank / dots / ruled / grid / cornell, drawn in page space (scales with zoom), subtle grey. Cornell = left margin + bottom summary rule over a ruled body. - Stored as the enum name in the notebook sidecar (back-compat: missing/unknown -> blank), saved/loaded via SidecarRepository so it restores on reopen. - Picker added to the note tool palette. PDF backgrounds skipped (PDFs have their own page content). analyze clean, 386 tests green.
267 lines
8.9 KiB
Dart
267 lines
8.9 KiB
Dart
// test/badnote_sidecar_test.dart
|
|
//
|
|
// Round-trips a BadnoteSidecar containing per-page strokes + highlights +
|
|
// bookmarks + scratch-links (each with its own scratchpad of InkStrokes),
|
|
// asserting the re-parsed model equals the original. Verifies the sidecar reuses
|
|
// the existing EditorStroke / InkStroke / ScratchLink / Bookmark JSON shapes and
|
|
// that the schema `version` field survives.
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:badnote/editor/engine/brush.dart';
|
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
|
import 'package:badnote/models/bookmark.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:badnote/models/scratch_link.dart';
|
|
import 'package:badnote/storage/badnote_sidecar.dart';
|
|
|
|
EditorStroke _editorStroke(String id, EditorTool tool) => EditorStroke(
|
|
id: id,
|
|
points: [
|
|
const EditorPoint(x: 0.1, y: 0.2, pressure: 0.5, tilt: 0.1),
|
|
EditorPoint(
|
|
x: 0.3,
|
|
y: 0.4,
|
|
pressure: 0.9,
|
|
tilt: 0.2,
|
|
timestamp: 1234,
|
|
pointerDeviceKind: InputDeviceKind.stylus,
|
|
),
|
|
],
|
|
tool: tool,
|
|
color: 0xFF112233,
|
|
width: 0.005,
|
|
);
|
|
|
|
InkStroke _inkStroke(String id) => InkStroke(
|
|
id: id,
|
|
points: [
|
|
const InkPoint(
|
|
x: 100.0,
|
|
y: 200.0,
|
|
pressure: 0.7,
|
|
tilt: 0.3,
|
|
timestamp: 99,
|
|
pointerDeviceKind: InputDeviceKind.stylus,
|
|
),
|
|
const InkPoint(x: 300.0, y: 400.0, timestamp: 100),
|
|
],
|
|
tool: PenTool.pen,
|
|
color: 0xFF445566,
|
|
strokeWidth: 3.0,
|
|
createdAt: DateTime.utc(2026, 6, 24, 10, 0, 0),
|
|
);
|
|
|
|
BadnoteSidecar _fullSidecar() => BadnoteSidecar(
|
|
sourceFile: 'Calculus Lecture 3.pdf',
|
|
docType: 'pdf',
|
|
pageCount: 42,
|
|
rotation: 90,
|
|
createdAt: DateTime.utc(2026, 6, 24, 10, 0, 0),
|
|
updatedAt: DateTime.utc(2026, 6, 24, 10, 32, 11),
|
|
strokes: {
|
|
0: [_editorStroke('s0', EditorTool.pen)],
|
|
3: [
|
|
_editorStroke('s1', EditorTool.highlighter),
|
|
_editorStroke('s2', EditorTool.pen),
|
|
],
|
|
},
|
|
highlights: {
|
|
0: const [
|
|
SidecarHighlight(l: 0.12, t: 0.20, r: 0.88, b: 0.235, color: 0xFFFFEB3B),
|
|
],
|
|
},
|
|
bookmarks: [
|
|
Bookmark(
|
|
id: 'bm1',
|
|
documentId: 'doc1',
|
|
pageNumber: 5,
|
|
label: 'Proof',
|
|
color: 0xFF2196F3,
|
|
createdAt: DateTime.utc(2026, 6, 24, 9, 0, 0),
|
|
),
|
|
],
|
|
scratchLinks: [
|
|
SidecarScratchLink(
|
|
link: const ScratchLink(
|
|
id: 'anchor1',
|
|
documentId: 'doc1',
|
|
pageIndex: 7,
|
|
nx: 0.83,
|
|
ny: 0.41,
|
|
),
|
|
scratchpad: SidecarScratchpad(
|
|
canvasWidth: 5000,
|
|
canvasHeight: 6000,
|
|
strokes: [_inkStroke('ink1'), _inkStroke('ink2')],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
/// Serializes through `jsonEncode`/`jsonDecode` exactly as `SidecarStore`
|
|
/// persists/loads on disk (nested freezed objects only flatten via the encoder's
|
|
/// toEncodable hook, so an in-memory map round-trip would not).
|
|
BadnoteSidecar _roundTrip(BadnoteSidecar sidecar) => BadnoteSidecar.fromJson(
|
|
jsonDecode(jsonEncode(sidecar.toJson())) as Map<String, dynamic>);
|
|
|
|
void main() {
|
|
test('full round-trip preserves strokes/highlights/bookmarks/links', () {
|
|
final original = _fullSidecar();
|
|
final reparsed = _roundTrip(original);
|
|
|
|
expect(reparsed.version, kBadnoteSidecarVersion);
|
|
expect(reparsed.sourceFile, 'Calculus Lecture 3.pdf');
|
|
expect(reparsed.docType, 'pdf');
|
|
expect(reparsed.pageCount, 42);
|
|
expect(reparsed.rotation, 90);
|
|
expect(reparsed.createdAt, DateTime.utc(2026, 6, 24, 10, 0, 0));
|
|
expect(reparsed.updatedAt, DateTime.utc(2026, 6, 24, 10, 32, 11));
|
|
|
|
// Strokes (EditorStroke value equality via freezed).
|
|
expect(reparsed.strokes.keys.toSet(), {0, 3});
|
|
expect(reparsed.strokes[0], original.strokes[0]);
|
|
expect(reparsed.strokes[3], original.strokes[3]);
|
|
|
|
// Highlights.
|
|
expect(reparsed.highlights[0], original.highlights[0]);
|
|
|
|
// Bookmarks (Bookmark value equality via freezed).
|
|
expect(reparsed.bookmarks, original.bookmarks);
|
|
|
|
// Scratch links + embedded scratchpads.
|
|
expect(reparsed.scratchLinks, original.scratchLinks);
|
|
final sp = reparsed.scratchLinks.single.scratchpad;
|
|
expect(sp.canvasWidth, 5000);
|
|
expect(sp.canvasHeight, 6000);
|
|
expect(sp.strokes, original.scratchLinks.single.scratchpad.strokes);
|
|
});
|
|
|
|
test('sidecar with mixed-brush strokes round-trips each brush', () {
|
|
EditorStroke brushStroke(String id, BrushKind brush) => EditorStroke(
|
|
id: id,
|
|
points: const [
|
|
EditorPoint(x: 0.1, y: 0.2, pressure: 0.6),
|
|
EditorPoint(x: 0.4, y: 0.5, pressure: 0.6),
|
|
],
|
|
color: 0xFF223344,
|
|
width: 0.004,
|
|
brush: brush,
|
|
);
|
|
|
|
final original = BadnoteSidecar(strokes: {
|
|
0: [
|
|
brushStroke('b-fountain', BrushKind.fountainPen),
|
|
brushStroke('b-ballpoint', BrushKind.ballpoint),
|
|
],
|
|
1: [
|
|
brushStroke('b-pencil', BrushKind.pencil),
|
|
brushStroke('b-highlighter', BrushKind.highlighter),
|
|
],
|
|
});
|
|
final reparsed = _roundTrip(original);
|
|
|
|
expect(
|
|
reparsed.strokes[0]!.map((s) => s.brush).toList(),
|
|
[BrushKind.fountainPen, BrushKind.ballpoint],
|
|
);
|
|
expect(
|
|
reparsed.strokes[1]!.map((s) => s.brush).toList(),
|
|
[BrushKind.pencil, BrushKind.highlighter],
|
|
);
|
|
// Full value equality (brush is part of EditorStroke's freezed equality).
|
|
expect(reparsed.strokes[0], original.strokes[0]);
|
|
expect(reparsed.strokes[1], original.strokes[1]);
|
|
});
|
|
|
|
test('strokes JSON is byte-compatible with EditorStroke.toJson', () {
|
|
final stroke = _editorStroke('s0', EditorTool.pen);
|
|
final sidecar = BadnoteSidecar(strokes: {2: [stroke]});
|
|
final json = sidecar.toJson();
|
|
final pageList = (json['strokes'] as Map)['2'] as List;
|
|
expect(pageList.single, stroke.toJson());
|
|
});
|
|
|
|
test('scratchpad strokes JSON is byte-compatible with InkStroke.toJson', () {
|
|
final ink = _inkStroke('ink1');
|
|
final scratchpad = SidecarScratchpad(strokes: [ink]);
|
|
final json = scratchpad.toJson();
|
|
expect((json['strokes'] as List).single, ink.toJson());
|
|
});
|
|
|
|
test('scratch link JSON includes ScratchLink fields plus scratchpad', () {
|
|
const link = ScratchLink(
|
|
id: 'a',
|
|
documentId: 'd',
|
|
pageIndex: 3,
|
|
nx: 0.5,
|
|
ny: 0.5,
|
|
);
|
|
final json = SidecarScratchLink(link: link).toJson();
|
|
// Reuses ScratchLink.toJson keys verbatim.
|
|
for (final key in link.toJson().keys) {
|
|
expect(json.containsKey(key), isTrue, reason: 'missing key $key');
|
|
}
|
|
expect(json.containsKey('scratchpad'), isTrue);
|
|
});
|
|
|
|
test('empty sidecar round-trips to empty containers', () {
|
|
final reparsed = _roundTrip(BadnoteSidecar());
|
|
expect(reparsed.strokes, isEmpty);
|
|
expect(reparsed.highlights, isEmpty);
|
|
expect(reparsed.bookmarks, isEmpty);
|
|
expect(reparsed.scratchLinks, isEmpty);
|
|
expect(reparsed.version, kBadnoteSidecarVersion);
|
|
});
|
|
|
|
test('unknown fields are ignored (forward-compat)', () {
|
|
final encoded = jsonEncode(BadnoteSidecar(
|
|
strokes: {0: [_editorStroke('s0', EditorTool.pen)]},
|
|
).toJson());
|
|
final json = jsonDecode(encoded) as Map<String, dynamic>;
|
|
json['futureFieldWeDoNotKnow'] = {'anything': true};
|
|
((json['strokes'] as Map)['0'] as List)[0]['brush'] =
|
|
'marker'; // future per-stroke field
|
|
final reparsed = BadnoteSidecar.fromJson(json);
|
|
expect(reparsed.strokes[0]!.single.id, 's0');
|
|
});
|
|
|
|
test('background name round-trips through the sidecar', () {
|
|
final original = BadnoteSidecar(docType: 'notebook', background: 'cornell');
|
|
final reparsed = _roundTrip(original);
|
|
expect(reparsed.background, 'cornell');
|
|
// Omitted when null (no key bloat for blank/legacy sidecars).
|
|
expect(BadnoteSidecar().toJson().containsKey('background'), isFalse);
|
|
});
|
|
|
|
test('missing background decodes to null (back-compat → blank)', () {
|
|
final json =
|
|
jsonDecode(jsonEncode(BadnoteSidecar().toJson())) as Map<String, dynamic>;
|
|
expect(json.containsKey('background'), isFalse);
|
|
final reparsed = BadnoteSidecar.fromJson(json);
|
|
expect(reparsed.background, isNull);
|
|
});
|
|
|
|
test('missing scratchpad defaults to 4000x4000 empty pad', () {
|
|
final json = SidecarScratchLink(
|
|
link: const ScratchLink(
|
|
id: 'a',
|
|
documentId: 'd',
|
|
pageIndex: 0,
|
|
nx: 0,
|
|
ny: 0,
|
|
),
|
|
).toJson();
|
|
json.remove('scratchpad');
|
|
final reparsed = SidecarScratchLink.fromJson(json);
|
|
expect(reparsed.scratchpad.canvasWidth, 4000.0);
|
|
expect(reparsed.scratchpad.canvasHeight, 4000.0);
|
|
expect(reparsed.scratchpad.strokes, isEmpty);
|
|
});
|
|
}
|