All checks were successful
CI / Windows build (push) Successful in 20m32s
Wire the F7 双链 + 无限便利贴 model (Board/LinkGraph) into a reachable screen. Previously the model existed but had no UI and no entry point. board_screen.dart: an infinite InteractiveViewer canvas of draggable, editable sticky cards. Card text renders [[links]] as tappable chips that pan to the target card (dangling links styled apart). A backlinks panel lists "linked from" via backlinksOf. "Add card" FAB drops a card at the viewport center. Persistence: a board_cards table (DB v7), one row per card, debounced 800ms like the ink editors, loaded on open — boards survive restart. Entry added to the home screen app bar (dashboard_customize icon). Ink-on-cards, multi-board management and link autocomplete are deferred (TODO board-ink / board-multi / board-link-autocomplete). analyze clean, 285 tests green.
151 lines
5.4 KiB
Dart
151 lines
5.4 KiB
Dart
// 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);
|
|
});
|
|
}
|