// 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 getApplicationDocumentsPath() async => dir; } Future _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.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.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); }); }