feat(pdf): anchored scratch links replace board
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Replace the rejected standalone sticky-card board with the real feature: place a link anchor anywhere on a PDF page, tap it to open split view whose right pane is THAT anchor's own infinite scratchpad (keyed by anchor id) — like a paper sticky-note tab. - ScratchLink model + scratch_links table (id, doc, page, nx, ny). - PDF editor: "place link" tool drops/loads/shows tappable markers; tap opens SplitViewScreen for that anchor; long-press deletes. - SplitViewScreen rebuilt on pdfrx (was syncfusion), right scratchpad keyed by scratchLinkId, new brush palette (was AnnotationToolbar). - Remove board_screen + its test + the home board entry. analyze clean, tests green.
This commit is contained in:
@@ -1,150 +0,0 @@
|
||||
// test/board_screen_test.dart
|
||||
//
|
||||
// Screen-level + persistence guards for F7 (双链 + 无限便利贴):
|
||||
// 1. BoardScreen pumps, "Add card" inserts a card, typing text with a
|
||||
// [[link]] renders the card body and the link chip.
|
||||
// 2. saveBoardCards / loadBoard round-trip a board through DatabaseService
|
||||
// (cards survive a reload — proves the screen's persistence is real).
|
||||
//
|
||||
// DatabaseService.getInstance() needs getApplicationDocumentsDirectory(); we
|
||||
// mock PathProviderPlatform to a temp dir so the singleton opens a real (ffi)
|
||||
// sqlite DB on disk.
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'package:badnote/editor/board/board.dart';
|
||||
import 'package:badnote/l10n/app_localizations.dart';
|
||||
import 'package:badnote/screens/board_screen.dart';
|
||||
import 'package:badnote/services/database_service.dart';
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_FakePathProvider(this.dir);
|
||||
final String dir;
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => dir;
|
||||
}
|
||||
|
||||
Future<void> _pumpBoard(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const BoardScreen(boardId: 'test-board'),
|
||||
),
|
||||
),
|
||||
);
|
||||
// The board loads from a real (ffi) sqlite file in initState. That I/O only
|
||||
// completes on the real event loop, so drive it via runAsync, then pump to
|
||||
// surface the loaded state. (pumpAndSettle is unusable here: the loading
|
||||
// CircularProgressIndicator animates forever and never settles.)
|
||||
await tester.runAsync(() async {
|
||||
// Give the DB open + loadBoard future real wall-clock time to resolve.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
});
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
|
||||
late Directory tmp;
|
||||
|
||||
setUp(() async {
|
||||
tmp = await Directory.systemTemp.createTemp('board_screen_test_');
|
||||
// Fresh DB file per test → clean board state.
|
||||
final dbFile = File('${tmp.path}/badnote.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmp.path);
|
||||
DatabaseService.resetForTest();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
try {
|
||||
tmp.deleteSync(recursive: true);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
testWidgets('add a card, type [[link]] text, card + link chip render',
|
||||
(tester) async {
|
||||
await _pumpBoard(tester);
|
||||
|
||||
// Empty board: no cards yet.
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
|
||||
// "Add card" FAB → inserts a card that opens straight into inline edit.
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byType(TextField), findsOneWidget,
|
||||
reason: 'a new card opens in inline-edit mode');
|
||||
|
||||
// Type a body containing a [[link]] to a (dangling) target.
|
||||
await tester.enterText(find.byType(TextField), 'hello [[other]]');
|
||||
await tester.pump();
|
||||
|
||||
// Tap empty canvas (top-left, away from the centered card) to leave edit
|
||||
// mode and render the body.
|
||||
await tester.tapAt(const Offset(10, 100));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// The non-link text and the link chip both render.
|
||||
expect(find.textContaining('hello'), findsWidgets);
|
||||
expect(find.text('other'), findsOneWidget,
|
||||
reason: 'the [[other]] link renders as a tappable chip');
|
||||
|
||||
// Tear the screen down so its debounced save timer is cancelled and any
|
||||
// pending flush lands while the DB is still open (avoids a post-test write
|
||||
// against a torn-down DB). Disposal runs synchronously on pumpWidget.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.runAsync(() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
});
|
||||
});
|
||||
|
||||
test('persistence round-trips a board through DatabaseService', () async {
|
||||
final db = await DatabaseService.getInstance();
|
||||
|
||||
final board = Board.empty
|
||||
.add(BoardCard(
|
||||
id: 'a',
|
||||
position: const Offset(10, 20),
|
||||
size: const Size(180, 140),
|
||||
text: 'see [[b]]',
|
||||
))
|
||||
.add(BoardCard(
|
||||
id: 'b',
|
||||
position: const Offset(300, 50),
|
||||
size: const Size(180, 140),
|
||||
text: 'leaf',
|
||||
));
|
||||
|
||||
await db.saveBoardCards('test-board', board.cards);
|
||||
|
||||
final reloaded = await db.loadBoard('test-board');
|
||||
expect(reloaded.length, 2);
|
||||
expect(reloaded.cardById('a')!.position, const Offset(10, 20));
|
||||
expect(reloaded.cardById('a')!.text, 'see [[b]]');
|
||||
expect(reloaded.cardById('b')!.size, const Size(180, 140));
|
||||
// Backlinks survive because the [[b]] link is preserved in text.
|
||||
expect(reloaded.backlinksOf('b'), {'a'});
|
||||
|
||||
// A board id without rows loads as empty (default-board fallback path).
|
||||
expect((await db.loadBoard('nonexistent')).length, 0);
|
||||
});
|
||||
}
|
||||
166
test/scratch_link_test.dart
Normal file
166
test/scratch_link_test.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
// test/scratch_link_test.dart
|
||||
//
|
||||
// Guards the PDF-anchored scratch-link feature:
|
||||
// 1. ScratchLink toJson/fromJson round-trip (pure model).
|
||||
// 2. saveScratchLink / loadScratchLinks / deleteScratchLink round-trip a set
|
||||
// of anchors through DatabaseService (anchors survive a reload).
|
||||
// 3. A per-anchor scratchpad keyed by the anchor id is private to that anchor
|
||||
// and is removed when the anchor is deleted (proves the reuse of the
|
||||
// existing scratchpad storage keyed by anchor id, not document id).
|
||||
//
|
||||
// DatabaseService.getInstance() needs getApplicationDocumentsDirectory(); we
|
||||
// mock PathProviderPlatform to a temp dir so the singleton opens a real (ffi)
|
||||
// sqlite DB on disk.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.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/scratch_link.dart';
|
||||
import 'package:badnote/services/database_service.dart';
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform
|
||||
with MockPlatformInterfaceMixin {
|
||||
_FakePathProvider(this.dir);
|
||||
final String dir;
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => dir;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
|
||||
group('ScratchLink model', () {
|
||||
test('toJson/fromJson round-trips', () {
|
||||
const link = ScratchLink(
|
||||
id: 'anchor-1',
|
||||
documentId: 'doc-abc',
|
||||
pageIndex: 3,
|
||||
nx: 0.25,
|
||||
ny: 0.8,
|
||||
);
|
||||
final restored = ScratchLink.fromJson(jsonDecode(jsonEncode(link.toJson()))
|
||||
as Map<String, dynamic>);
|
||||
expect(restored, link);
|
||||
expect(restored.pageIndex, 3);
|
||||
expect(restored.nx, 0.25);
|
||||
expect(restored.ny, 0.8);
|
||||
});
|
||||
});
|
||||
|
||||
group('ScratchLink persistence', () {
|
||||
late Directory tmp;
|
||||
|
||||
setUp(() async {
|
||||
tmp = await Directory.systemTemp.createTemp('scratch_link_test_');
|
||||
final dbFile = File('${tmp.path}/badnote.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
PathProviderPlatform.instance = _FakePathProvider(tmp.path);
|
||||
await DatabaseService.resetForTest();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await DatabaseService.resetForTest();
|
||||
try {
|
||||
tmp.deleteSync(recursive: true);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
test('save / load / delete anchors round-trip', () async {
|
||||
final db = await DatabaseService.getInstance();
|
||||
|
||||
const a = ScratchLink(
|
||||
id: 'a',
|
||||
documentId: 'doc-1',
|
||||
pageIndex: 0,
|
||||
nx: 0.1,
|
||||
ny: 0.2,
|
||||
);
|
||||
const b = ScratchLink(
|
||||
id: 'b',
|
||||
documentId: 'doc-1',
|
||||
pageIndex: 4,
|
||||
nx: 0.7,
|
||||
ny: 0.9,
|
||||
);
|
||||
// A different document's anchor must not leak into doc-1's list.
|
||||
const other = ScratchLink(
|
||||
id: 'c',
|
||||
documentId: 'doc-2',
|
||||
pageIndex: 1,
|
||||
nx: 0.5,
|
||||
ny: 0.5,
|
||||
);
|
||||
|
||||
await db.saveScratchLink(a);
|
||||
await db.saveScratchLink(b);
|
||||
await db.saveScratchLink(other);
|
||||
|
||||
final loaded = await db.loadScratchLinks('doc-1');
|
||||
expect(loaded.length, 2);
|
||||
expect(loaded.map((l) => l.id).toSet(), {'a', 'b'});
|
||||
final reloadedA = loaded.firstWhere((l) => l.id == 'a');
|
||||
expect(reloadedA.pageIndex, 0);
|
||||
expect(reloadedA.nx, 0.1);
|
||||
expect(reloadedA.ny, 0.2);
|
||||
|
||||
// doc-2 keeps its own anchor.
|
||||
expect((await db.loadScratchLinks('doc-2')).single.id, 'c');
|
||||
|
||||
await db.deleteScratchLink('a');
|
||||
final after = await db.loadScratchLinks('doc-1');
|
||||
expect(after.map((l) => l.id), ['b']);
|
||||
});
|
||||
|
||||
test('each anchor has its own private scratchpad keyed by anchor id',
|
||||
() async {
|
||||
final db = await DatabaseService.getInstance();
|
||||
|
||||
InkStroke stroke(String id, double x) => InkStroke(
|
||||
id: id,
|
||||
points: [
|
||||
InkPoint(x: x, y: x, pressure: 0.5, timestamp: 0),
|
||||
],
|
||||
tool: PenTool.pen,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
// Two anchors, two different private scratchpads (keyed by anchor id).
|
||||
final inkA = [stroke('s1', 10)];
|
||||
final inkB = [stroke('s2', 20)];
|
||||
await db.saveScratchpad(
|
||||
'anchor-A', jsonEncode(inkA.map((s) => s.toJson()).toList()));
|
||||
await db.saveScratchpad(
|
||||
'anchor-B', jsonEncode(inkB.map((s) => s.toJson()).toList()));
|
||||
|
||||
final loadedA = await db.loadScratchpad('anchor-A');
|
||||
final loadedB = await db.loadScratchpad('anchor-B');
|
||||
expect(loadedA.single.points.single.x, 10);
|
||||
expect(loadedB.single.points.single.x, 20);
|
||||
|
||||
// Deleting the anchor removes its private scratchpad too.
|
||||
await db.saveScratchLink(const ScratchLink(
|
||||
id: 'anchor-A',
|
||||
documentId: 'doc-9',
|
||||
pageIndex: 0,
|
||||
nx: 0.0,
|
||||
ny: 0.0,
|
||||
));
|
||||
await db.deleteScratchLink('anchor-A');
|
||||
expect(await db.loadScratchpad('anchor-A'), isEmpty);
|
||||
// anchor-B is untouched.
|
||||
expect((await db.loadScratchpad('anchor-B')).single.points.single.x, 20);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user