From 3f766b11eeb5b8e2b66aa506dc1a950f752a6df3 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 00:47:52 +0200 Subject: [PATCH 01/10] fix: pin reader interruption review fix --- app/pubspec.lock | 4 ++-- app/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pubspec.lock b/app/pubspec.lock index 243551a..6360465 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: "865e94adffe957042cd2a6107173bd05a78a3e87" - resolved-ref: "865e94adffe957042cd2a6107173bd05a78a3e87" + ref: "e6c59a7eb238b2088b0b92b25f36b617d63181c1" + resolved-ref: "e6c59a7eb238b2088b0b92b25f36b617d63181c1" url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index fff791b..b29f48c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: 865e94adffe957042cd2a6107173bd05a78a3e87 + ref: e6c59a7eb238b2088b0b92b25f36b617d63181c1 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 From 01240132983fd7ae675ee01735ff6c6337f12db4 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 14:07:56 +0200 Subject: [PATCH 02/10] test: replace source assertions and reduce repeated widget journeys --- .github/workflows/ci.yml | 6 + AGENTS.md | 3 +- app/lib/main.dart | 154 +++++---- .../media_profile_switch_contract_test.dart | 305 +++++++++++++----- app/test/models/bookmark_test.dart | 5 - app/test/opds/layout_test.dart | 69 +++- .../book_edit_details_alignment_test.dart | 11 +- .../pages/library_content_alignment_test.dart | 6 +- .../services/book_cover_storage_test.dart | 89 +---- .../services/book_cover_storage_web_test.dart | 103 ++++++ .../services/book_import_session_test.dart | 114 +++++++ app/test/web/book_worker_test.cjs | 79 +++++ .../empty_annotations_state_test.dart | 65 +--- .../empty_bookmarks_state_test.dart | 65 +--- app/test/widgets/shared/empty_state_test.dart | 8 +- .../library_drawer_consistency_test.dart | 14 +- 16 files changed, 753 insertions(+), 343 deletions(-) create mode 100644 app/test/services/book_cover_storage_web_test.dart create mode 100644 app/test/web/book_worker_test.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0912938..7e46baf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,12 @@ jobs: - name: Check report normalization run: python3 -m unittest discover -s ../.github/scripts -p 'test_*.py' + - name: Check browser bootstrap and cover worker + run: node --test test/web/*.cjs + + - name: Check browser cover transport and drag-and-drop + run: flutter test --platform chrome test/services/book_cover_storage_web_test.dart test/platform/book_import_drop_registration_web_test.dart + - name: Run tests with coverage id: tests run: | diff --git a/AGENTS.md b/AGENTS.md index 54a39d1..946d494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,8 @@ the client root, or `../../tools/papyrus` from `app/`. - Analysis: `flutter analyze --no-fatal-warnings --no-fatal-infos` (same policy as CI). - Focused tests: `flutter test test//_test.dart`. - Suite/coverage: `flutter test --coverage`. -- Web bootstrap: `node --test test/web/flutter_bootstrap_test.cjs`. +- Web bootstrap and cover worker: `node --test test/web/*.cjs`. +- Browser cover transport and drag-and-drop: `flutter test --platform chrome test/services/book_cover_storage_web_test.dart test/platform/book_import_drop_registration_web_test.dart`. - Web compile for platform-sensitive changes: `flutter build web --dart-define-from-file=.dart_defines`. Add the narrowest regression for changed behavior. UI changes need a relevant diff --git a/app/lib/main.dart b/app/lib/main.dart index de10a43..3a758d7 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -74,7 +74,33 @@ Future main() async { class Papyrus extends StatefulWidget { final SharedPreferences prefs; - const Papyrus({super.key, required this.prefs}); + const Papyrus({super.key, required this.prefs}) + : _repositoryFactory = null, + _database = null, + _uploads = null, + _importer = null, + _child = null; + + @visibleForTesting + const Papyrus.withServices({ + super.key, + required this.prefs, + required AuthRepository Function(PapyrusApiConfig config, String profileKey) repositoryFactory, + required PapyrusPowerSyncService database, + required MediaUploadQueue uploads, + required BookImportService importer, + required Widget child, + }) : _repositoryFactory = repositoryFactory, + _database = database, + _uploads = uploads, + _importer = importer, + _child = child; + + final AuthRepository Function(PapyrusApiConfig config, String profileKey)? _repositoryFactory; + final PapyrusPowerSyncService? _database; + final MediaUploadQueue? _uploads; + final BookImportService? _importer; + final Widget? _child; @override State createState() => _PapyrusState(); @@ -222,8 +248,8 @@ class _PapyrusState extends State { _dataStore = DataStore(); _preferencesProvider = PreferencesProvider(widget.prefs); - _mediaUploadQueue = MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads); - _bookImportService = BookImportService(); + _mediaUploadQueue = widget._uploads ?? MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads); + _bookImportService = widget._importer ?? BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); _acquisitionAvailabilityProvider = AcquisitionAvailabilityProvider(authProvider: _authProvider); @@ -233,29 +259,31 @@ class _PapyrusState extends State { activeApiConfig: () => _syncSettingsProvider.activeApiConfig, ); - _powerSyncService = PapyrusPowerSyncService( - trackingCapability: () async { - final response = await http - .get(_syncSettingsProvider.activeApiConfig.endpoint('/sync/settings')) - .timeout(const Duration(seconds: 5)); + _powerSyncService = + widget._database ?? + PapyrusPowerSyncService( + trackingCapability: () async { + final response = await http + .get(_syncSettingsProvider.activeApiConfig.endpoint('/sync/settings')) + .timeout(const Duration(seconds: 5)); - if (response.statusCode != 200) { - return 0; - } + if (response.statusCode != 200) { + return 0; + } - return (jsonDecode(response.body) as Map)['tracking_schema_version'] as int? ?? 0; - }, - connectorFactory: () => PapyrusPowerSyncConnector( - supportsTracking: () => _powerSyncService.supportsTracking, - trackingSchemaVersion: () => _powerSyncService.trackingSchemaVersion, - authRepository: _authRepository, - config: _syncSettingsProvider.activeApiConfig, - onUploadComplete: () async { - await _powerSyncService.refreshBookMetadataSyncState(); - await _processMediaUploads(); - }, - ), - ); + return (jsonDecode(response.body) as Map)['tracking_schema_version'] as int? ?? 0; + }, + connectorFactory: () => PapyrusPowerSyncConnector( + supportsTracking: () => _powerSyncService.supportsTracking, + trackingSchemaVersion: () => _powerSyncService.trackingSchemaVersion, + authRepository: _authRepository, + config: _syncSettingsProvider.activeApiConfig, + onUploadComplete: () async { + await _powerSyncService.refreshBookMetadataSyncState(); + await _processMediaUploads(); + }, + ), + ); _bookStorageStatusController = BookStorageStatusController( authProvider: _authProvider, @@ -317,6 +345,12 @@ class _PapyrusState extends State { } AuthRepository _buildAuthRepository(PapyrusApiConfig config, String profileKey) { + final factory = widget._repositoryFactory; + + if (factory != null) { + return factory(config, profileKey); + } + final tokenStore = TokenStore(SecureRefreshTokenStorage.scoped(profileKey)); return AuthRepository( @@ -573,41 +607,43 @@ class _PapyrusState extends State { ), ChangeNotifierProvider.value(value: _preferencesProvider), ], - child: Consumer( - builder: (context, preferencesProvider, child) { - final isEink = preferencesProvider.isEinkMode; - final lightTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastLight : AppTheme.light; - final darkTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastDark : AppTheme.dark; - - return MaterialApp.router( - title: 'Papyrus', - debugShowCheckedModeBanner: false, - theme: AppTheme.withTypography( - isEink ? AppTheme.eink : lightTheme, - dyslexiaFont: preferencesProvider.dyslexiaFont, - ), - darkTheme: AppTheme.withTypography( - isEink ? AppTheme.eink : darkTheme, - dyslexiaFont: preferencesProvider.dyslexiaFont, - ), - highContrastTheme: AppTheme.withTypography( - isEink ? AppTheme.eink : AppTheme.highContrastLight, - dyslexiaFont: preferencesProvider.dyslexiaFont, - ), - highContrastDarkTheme: AppTheme.withTypography( - isEink ? AppTheme.eink : AppTheme.highContrastDark, - dyslexiaFont: preferencesProvider.dyslexiaFont, - ), - themeMode: isEink ? ThemeMode.light : preferencesProvider.themeMode, - themeAnimationDuration: - isEink || preferencesProvider.reduceAnimations || MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : kThemeAnimationDuration, - builder: (context, child) => AppAccessibilityScope(preferences: preferencesProvider, child: child!), - routerConfig: _appRouter.router, - ); - }, - ), + child: + widget._child ?? + Consumer( + builder: (context, preferencesProvider, child) { + final isEink = preferencesProvider.isEinkMode; + final lightTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastLight : AppTheme.light; + final darkTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastDark : AppTheme.dark; + + return MaterialApp.router( + title: 'Papyrus', + debugShowCheckedModeBanner: false, + theme: AppTheme.withTypography( + isEink ? AppTheme.eink : lightTheme, + dyslexiaFont: preferencesProvider.dyslexiaFont, + ), + darkTheme: AppTheme.withTypography( + isEink ? AppTheme.eink : darkTheme, + dyslexiaFont: preferencesProvider.dyslexiaFont, + ), + highContrastTheme: AppTheme.withTypography( + isEink ? AppTheme.eink : AppTheme.highContrastLight, + dyslexiaFont: preferencesProvider.dyslexiaFont, + ), + highContrastDarkTheme: AppTheme.withTypography( + isEink ? AppTheme.eink : AppTheme.highContrastDark, + dyslexiaFont: preferencesProvider.dyslexiaFont, + ), + themeMode: isEink ? ThemeMode.light : preferencesProvider.themeMode, + themeAnimationDuration: + isEink || preferencesProvider.reduceAnimations || MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : kThemeAnimationDuration, + builder: (context, child) => AppAccessibilityScope(preferences: preferencesProvider, child: child!), + routerConfig: _appRouter.router, + ); + }, + ), ); } } diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 3a5f595..c31d2b8 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -1,93 +1,234 @@ -import 'dart:io'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/data/repositories/library_repository.dart'; +import 'package:papyrus/main.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/powersync/book_metadata_sync_state.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../providers/auth_provider_test.dart' show FakeAuthRepository; + +class _Database extends Fake implements PapyrusPowerSyncService { + final activation = Completer(); + final started = Completer(); + + @override + LibraryDatabaseMode? mode; + + @override + Future activateAuthenticated(String userId, {String? profileKey}) async { + if (!started.isCompleted) { + started.complete(); + } + + await activation.future; + mode = LibraryDatabaseMode.authenticated; + } + + @override + Future activateGuest() async { + if (!started.isCompleted) { + started.complete(); + } + + await activation.future; + mode = LibraryDatabaseMode.guest; + } + + @override + Future deactivate({bool clearAuthenticated = false}) async => mode = null; + @override + Future close() async {} + @override + Stream watchLibrary() => const Stream.empty(); + @override + BookMetadataSyncState get bookMetadataSyncState => const BookMetadataSyncState(); + @override + Stream get bookMetadataSyncStates => const Stream.empty(); + @override + SyncState get syncState => const SyncState(); + @override + Stream get syncStates => const Stream.empty(); +} -void main() { - test('OPDS catalogs become available only after the target database activates', () { - final source = File('lib/main.dart').readAsStringSync(); - - final activation = source.substring( - source.indexOf('Future _applyPowerSyncAuthState()'), - source.indexOf('void _clearAuthStateOperation'), - ); - - expect( - activation.indexOf('_opdsCatalogs.setScope(scope.persistenceKey)'), - greaterThan(activation.indexOf('await _powerSyncService.activateAuthenticated')), - ); - - expect( - activation.indexOf('_opdsCatalogs.setScope(MediaStorageScope.localGuest.persistenceKey)'), - greaterThan(activation.indexOf('await _powerSyncService.activateGuest')), - ); - }); - - test('profile switch publishes its key with the replacement repository', () { - final source = File('lib/main.dart').readAsStringSync(); - - final handler = source.substring( - source.indexOf('void _handleSyncSettingsChanged()'), - source.indexOf('Future _refreshMediaUsage()'), - ); - - expect(handler, contains('_switchActiveSyncProfile(nextProfileKey, nextConfig)')); - expect(handler.indexOf('_activeProfileKey = nextProfileKey'), greaterThan(handler.indexOf('_buildAuthRepository'))); - }); - - test('upload processing rechecks profile transition after scope activation', () { - final source = File('lib/main.dart').readAsStringSync(); - - final processor = source.substring( - source.indexOf('Future _processMediaUploads()'), - source.indexOf('@override\n Widget build'), +class _Repository extends FakeAuthRepository { + _Repository(String userId) { + bootstrapResult = AuthTokens.fromJson({ + 'access_token': 'access', + 'refresh_token': 'refresh', + 'expires_in': 3600, + 'user': {'user_id': userId, 'connected_providers': []}, + }); + } + + final uploadStarted = Completer(); + final finishUpload = Completer(); + final uploaded = []; + + @override + Future fetchMediaUsage() async => + const MediaStorageUsage(usedBytes: 0, quotaBytes: 1000, availableBytes: 1000); + + @override + Future uploadMedia(MediaUploadPayload payload) async { + uploaded.add(payload); + uploadStarted.complete(); + await finishUpload.future; + + return MediaAsset( + assetId: 'asset', + ownerUserId: bootstrapResult!.user.userId, + bookId: payload.bookId, + kind: payload.kind, + originalFilename: payload.filename, + contentType: payload.contentType, + extension: 'png', + sizeBytes: payload.bytes.length, + sha256: 'hash', + storagePath: 'private', ); + } +} - expect(RegExp(r'_switchingSyncProfile').allMatches(processor), hasLength(greaterThanOrEqualTo(2))); - expect(processor, contains('!identical(repository, _authRepository)')); - }); - - test('successful cover upload promotes the captured pending cover best effort', () { - final source = File('lib/main.dart').readAsStringSync(); +class _Importer extends BookImportService { + final reads = []; + final promotions = []; + + @override + Future getPendingCoverFile(MediaStorageScope scope, String bookId) async { + reads.add(scope); + return Uint8List.fromList([1, 2, 3]); + } + + @override + Future promotePendingCoverFile( + MediaStorageScope scope, { + required String bookId, + required String mediaId, + }) async { + promotions.add(scope); + } +} - final processor = source.substring( - source.indexOf('Future _processMediaUploads()'), - source.indexOf('@override\n Widget build'), +void main() { + for (final guest in [false, true]) { + testWidgets('catalogs wait for the ${guest ? 'guest' : 'account'} database activation', (tester) async { + SharedPreferences.setMockInitialValues({'offline_mode': guest}); + final prefs = await SharedPreferences.getInstance(); + final database = _Database(); + final uploads = MediaUploadQueue(prefs); + final repository = _Repository('alice'); + late OpdsCatalogs catalogs; + + await tester.pumpWidget( + Papyrus.withServices( + prefs: prefs, + repositoryFactory: (_, _) => repository, + database: database, + uploads: uploads, + importer: _Importer(), + child: Builder( + builder: (context) { + catalogs = context.read(); + return const SizedBox.shrink(); + }, + ), + ), + ); + + await tester.pump(); + expect(database.started.isCompleted, isTrue); + expect(catalogs.scope, isNull); + database.activation.complete(); + await tester.pump(); + expect( + catalogs.scope, + guest ? MediaStorageScope.localGuest.persistenceKey : 'official--alice', + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + uploads.dispose(); + }); + } + + testWidgets('a profile switch waits for upload and promotes only the captured account cover', (tester) async { + SharedPreferences.setMockInitialValues({ + 'sync_custom_servers': jsonEncode([ + {'id': 'other', 'url': 'https://other.test', 'label': 'Other', 'data_sync_url': 'https://sync.other.test'}, + ]), + }); + + final prefs = await SharedPreferences.getInstance(); + final database = _Database()..activation.complete(); + final uploads = MediaUploadQueue(prefs); + final oldScope = MediaStorageScope(profileKey: 'official', userId: 'alice'); + await uploads.activateScope(oldScope); + await uploads.enqueueCover( + book: Book(id: 'book', title: 'Book', author: 'Author', addedAt: DateTime(2026)), + filename: 'cover.png', + contentType: 'image/png', ); - - expect(processor, contains('uploadAndPersistCover(')); - expect(processor, contains('scope: scope')); - expect(processor, contains('promotePendingCover: _bookImportService.promotePendingCoverFile')); - expect(processor, contains("library: 'papyrus cover promotion'")); - }); - - test('production import delegates scoped cover persistence and queueing to the commit boundary', () { - final mainSource = File('lib/main.dart').readAsStringSync(); - - final processor = mainSource.substring( - mainSource.indexOf('Future _processMediaUploads()'), - mainSource.indexOf('@override\n Widget build'), + final oldRepository = _Repository('alice'); + final newRepository = _Repository('bob'); + final importer = _Importer(); + final repositories = []; + late SyncSettingsProvider settings; + late OpdsCatalogs catalogs; + + await tester.pumpWidget( + Papyrus.withServices( + prefs: prefs, + repositoryFactory: (_, profile) { + repositories.add(profile); + return profile == 'official' ? oldRepository : newRepository; + }, + database: database, + uploads: uploads, + importer: importer, + child: Builder( + builder: (context) { + settings = context.read(); + catalogs = context.read(); + return const SizedBox.shrink(); + }, + ), + ), ); - expect(processor, contains('readPendingCover: _bookImportService.getPendingCoverFile')); - final importSource = File('lib/widgets/add_book/book_import_sheet.dart').readAsStringSync(); - final commitStart = importSource.indexOf('static Future _commitResult('); - final commit = importSource.substring(commitStart, importSource.indexOf('@override', commitStart)); - expect(commit, contains('BookImportSession.fromContext(context).commit(result, sourceFilename)')); - final session = File('lib/services/book_import_session.dart').readAsStringSync(); - expect(session, contains('final scope = authenticated ? queue.activeScope : null;')); - expect(session, contains("throw StateError('Cannot import account media without an active media storage scope')")); - expect(session, contains('storePendingCover: importService.storePendingCoverFile')); - expect(session, contains('storeGuestCover: importService.storeGuestCoverFile')); - expect(session, contains('deletePendingCover: importService.deletePendingCoverFile')); - expect(session, contains('deleteGuestCover: importService.deleteGuestCoverFile')); - expect(session, contains('final repository = dataStore.requireBookRepository();')); - expect(session, contains('dataStore.addBookToRepositoryAndWait(repository, book)')); - expect(session, contains('dataStore.deleteBookFromRepositoryAndWait(repository, id)')); - expect(session, contains('enqueueImportedBookMedia: queue.enqueueImportedBookMedia')); - expect(session, contains('isLibraryContextCurrent: isCurrent')); - expect(session, contains('dataStore.isBookRepositoryCurrent(repository)')); - expect(session, contains('queue.activeScope == scope')); - expect(session, contains('accountScope: scope')); - expect(session, isNot(contains('bytesToDataUri'))); + await tester.pump(); + expect(oldRepository.uploadStarted.isCompleted, isTrue); + settings.selectServer('other'); + await tester.pump(); + expect(catalogs.scope, isNull); + expect(repositories, ['official']); + expect(uploads.activeScope, oldScope); + oldRepository.finishUpload.complete(); + await tester.pump(); + await tester.pump(); + expect(importer.reads, [oldScope]); + expect(importer.promotions, [oldScope]); + expect(newRepository.uploaded, isEmpty); + expect(uploads.activeScope, MediaStorageScope(profileKey: settings.activeProfileKey, userId: 'bob')); + expect(catalogs.scope, uploads.activeScope!.persistenceKey); + expect(uploads.pendingTasks, isEmpty); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + uploads.dispose(); }); } diff --git a/app/test/models/bookmark_test.dart b/app/test/models/bookmark_test.dart index c8109e3..5e694f1 100644 --- a/app/test/models/bookmark_test.dart +++ b/app/test/models/bookmark_test.dart @@ -174,10 +174,5 @@ void main() { expect(restored.colorHex, original.colorHex); }); }); - - test('availableColors contains expected number of colors', () { - expect(Bookmark.availableColors.length, 7); - expect(Bookmark.availableColors.first, '#FF5722'); - }); }); } diff --git a/app/test/opds/layout_test.dart b/app/test/opds/layout_test.dart index de600cf..2c327b1 100644 --- a/app/test/opds/layout_test.dart +++ b/app/test/opds/layout_test.dart @@ -125,7 +125,11 @@ void main() { (2507.0, 1.0), (360.0, 2.0), ]) { - testWidgets('$name $width text $scale catalog journey in the app shell', (tester) async { + final fullJourney = _capture || (name == 'light' && scale == 1 && (width == 360 || width == 1280)); + + testWidgets('$name $width text $scale catalog ${fullJourney ? 'journey' : 'layout and keyboard'}', ( + tester, + ) async { tester.view.physicalSize = Size( width, width > 2000 @@ -136,9 +140,9 @@ void main() { ); tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetViewInsets); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetViewInsets); SharedPreferences.setMockInitialValues({}); final store = OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()); @@ -374,6 +378,39 @@ void main() { final label = '$name-${width.toInt()}-${scale.toInt()}x'; await _snapshot(tester, '$label-sources'); + if (!fullJourney) { + expect(find.byType(CatalogSourceTile).hitTestable(), findsWidgets); + + if (width < 840) { + expect(find.byTooltip('Library sections').hitTestable(), findsOneWidget); + } + + await tester.tap( + width < 840 ? find.byTooltip('Add catalog') : find.widgetWithText(FilledButton, 'Add catalog'), + ); + + await _settle(tester); + expect(find.byKey(const Key('opds-name')).hitTestable(), findsOneWidget); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await _settle(tester); + await tester.ensureVisible(find.text('Cancel')); + await tester.tap(find.text('Cancel')); + await _settle(tester); + expect(find.byKey(const Key('opds-name')), findsNothing); + tester.view.resetViewInsets(); + router.go( + Uri( + path: '/library/catalogs/gutenberg/book', + queryParameters: {'feed': 'https://www.gutenberg.org/feed', 'publication': 'pride'}, + ).toString(), + ); + + await _settle(tester); + expect(find.byType(OpdsPublicationDetails), findsOneWidget); + await _expectDetailsLayout(tester, width, scale); + return; + } + if (width < 840) { expect(find.byKey(const Key('catalog-mobile-header')), findsNothing); expect(find.byType(LibraryMobileToolbar), findsOneWidget); @@ -478,18 +515,8 @@ void main() { await tester.tap(find.text('Pride and Prejudice')); await _snapshot(tester, '$label-details'); - if (width < 840) { - final header = find.byKey(const Key('catalog-book-mobile-header')); - expect(tester.getSize(header).height, scale == 1 ? 80 : 96); - expect(find.byKey(const Key('catalog-book-header-divider')), findsNothing); - final rail = find.byType(BookDetailsTabRail); - await tester.dragFrom(Offset(width / 2, 700), const Offset(0, -1600)); - await tester.pumpAndSettle(); - expect(tester.getRect(rail).left, 0); - expect(tester.getRect(rail).width, width); - expect(tester.getTopLeft(rail).dy, tester.getBottomLeft(header).dy); - await _snapshot(tester, '$label-details-scrolled'); - } + await _expectDetailsLayout(tester, width, scale); + await _snapshot(tester, '$label-details-scrolled'); expect(find.text('Details'), findsOneWidget); await tester.ensureVisible(find.text('Description')); @@ -559,3 +586,17 @@ void main() { } } } + +Future _expectDetailsLayout(WidgetTester tester, double width, double scale) async { + if (width < 840) { + final header = find.byKey(const Key('catalog-book-mobile-header')); + expect(tester.getSize(header).height, scale == 1 ? 80 : 96); + expect(find.byKey(const Key('catalog-book-header-divider')), findsNothing); + final rail = find.byType(BookDetailsTabRail); + await tester.dragFrom(Offset(width / 2, 700), const Offset(0, -1600)); + await tester.pumpAndSettle(); + expect(tester.getRect(rail).left, 0); + expect(tester.getRect(rail).width, width); + expect(tester.getTopLeft(rail).dy, tester.getBottomLeft(header).dy); + } +} diff --git a/app/test/pages/book_edit_details_alignment_test.dart b/app/test/pages/book_edit_details_alignment_test.dart index 8807e02..9830e98 100644 --- a/app/test/pages/book_edit_details_alignment_test.dart +++ b/app/test/pages/book_edit_details_alignment_test.dart @@ -12,7 +12,12 @@ import 'package:papyrus/widgets/book_edit/cover_image_picker.dart'; import '../helpers/test_helpers.dart'; void main() { - for (final theme in [AppTheme.dark, AppTheme.highContrastDark, AppTheme.highContrastLight, AppTheme.eink]) { + for (final (themeIndex, theme) in [ + AppTheme.dark, + AppTheme.highContrastDark, + AppTheme.highContrastLight, + AppTheme.eink, + ].indexed) { for (final layout in [ (screen: const Size(3840, 2160), width: 3560.0, scale: 1.0), (screen: const Size(1800, 1200), width: 1520.0, scale: 1.0), @@ -23,6 +28,10 @@ void main() { (screen: const Size(1200, 1200), width: 920.0, scale: 2.0), (screen: const Size(1000, 1200), width: 640.0, scale: 2.0), ]) { + if (themeIndex > 0 && !(layout.width == 560 || layout.scale == 2)) { + continue; + } + testWidgets('desktop details/edit covers match at ${layout.width}, scale ${layout.scale}, ${theme.brightness}', ( tester, ) async { diff --git a/app/test/pages/library_content_alignment_test.dart b/app/test/pages/library_content_alignment_test.dart index c5934f8..3e62889 100644 --- a/app/test/pages/library_content_alignment_test.dart +++ b/app/test/pages/library_content_alignment_test.dart @@ -21,7 +21,7 @@ import 'package:provider/provider.dart'; import '../helpers/test_helpers.dart'; void main() { - for (final theme in [AppTheme.dark, AppTheme.eink]) { + for (final (themeIndex, theme) in [AppTheme.dark, AppTheme.eink].indexed) { for (final layout in [ (screen: const Size(320, 1000), pageWidth: 320.0, scale: 1.0), (screen: const Size(400, 1000), pageWidth: 400.0, scale: 1.0), @@ -30,6 +30,10 @@ void main() { (screen: const Size(1200, 1000), pageWidth: 920.0, scale: 1.0), (screen: const Size(1200, 1000), pageWidth: 920.0, scale: 2.0), ]) { + if (themeIndex > 0 && layout.scale != 2) { + continue; + } + testWidgets('visible books align with search at ${layout.screen.width}/${layout.pageWidth}, ' 'scale ${layout.scale}, ${theme.brightness}', (tester) async { tester.view.devicePixelRatio = 1; diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index e53c19b..db022bc 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -122,11 +122,20 @@ void main() { final secondService = BookImportService(); final candidates = List.generate(12, (index) => Uint8List.fromList(List.filled(4096, index))); - await Future.wait( - candidates.indexed.map( + await service.storeCoverFile(scope, 'asset-1', candidates.first); + final file = File('${root.path}/media-covers/${scope.persistenceKey}/cached/asset-1.bin'); + + await Future.wait([ + ...candidates.indexed.map( (candidate) => (candidate.$1.isEven ? service : secondService).storeCoverFile(scope, 'asset-1', candidate.$2), ), - ); + () async { + for (var attempt = 0; attempt < 20; attempt++) { + final visible = await file.readAsBytes(); + expect(candidates.any((bytes) => _bytesEqual(bytes, visible)), isTrue); + } + }(), + ]); final stored = await service.getCoverFile(scope, 'asset-1'); expect(candidates.any((bytes) => _bytesEqual(bytes, stored)), isTrue); @@ -140,19 +149,6 @@ void main() { ); }); - test('native replacement uses direct rename without moving the existing cache aside', () { - final source = File('lib/services/book_import_service_stub.dart').readAsStringSync(); - - final writer = source.substring( - source.indexOf('Future _writeCoverFile'), - source.indexOf('Future _removeCoverFile'), - ); - - expect(writer, contains('await tempFile.rename(file.path);')); - expect(writer, isNot(contains('backupFile'))); - expect(writer, isNot(contains("await file.rename"))); - }); - test('pending store queued during promotion preserves the newer generation', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([1])); @@ -326,21 +322,6 @@ void main() { expect(() => service.storeGuestCoverFile('../book', Uint8List.fromList([1])), throwsArgumentError); }); - test('book worker declares scoped bucket cover actions and validates buckets', () { - final source = File('web/book_worker.js').readAsStringSync(); - - for (final action in ['getCover', 'storeCover', 'deleteCover', 'promoteCover', 'clearCovers']) { - expect(source, contains("case '$action':")); - } - - expect(source, contains("new Set(['cached', 'pending', 'books'])")); - expect(source, contains("validateCoverBucket(bucket)")); - expect(source, contains("getDirectoryHandle(bucket, { create })")); - expect(source, contains('withCoverLocks(')); - expect(source, contains('navigator.locks.request')); - expect(source, contains('coverLockName(')); - }); - test('book worker derives shared Web Lock names from cover keys', () async { final result = await Process.run('node', const [ '-e', @@ -455,52 +436,6 @@ vm.runInContext(source, context); expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); }); - - test('web cover writes transfer a copy so caller bytes remain renderable', () { - final source = File('lib/services/book_import_service.dart').readAsStringSync(); - - final helper = source.substring( - source.indexOf('Future _sendCoverRequest'), - source.indexOf('BookImportResult _parseImportResult'), - ); - - expect(helper, contains('final actualBytes = Uint8List.fromList(bytes);')); - expect(helper, contains("message['bucket'] = bucket.pathComponent.toJS;")); - expect(helper, isNot(contains('bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes'))); - }); - - test('web cover mutations evict only successfully removed decoded keys', () { - final source = File('lib/services/book_import_service.dart').readAsStringSync(); - - final deleteHelper = source.substring( - source.indexOf('Future _deleteCoverFile'), - source.indexOf('Future clearCoverFiles'), - ); - - final promotion = source.substring( - source.indexOf('Future promotePendingCoverFile'), - source.indexOf('Future _getCoverFile'), - ); - - final storeHelper = source.substring( - source.indexOf('Future _storeCoverFile'), - source.indexOf('Future _deleteCoverFile'), - ); - - expect(source, contains("import 'package:papyrus/media/local_cover_image_provider.dart';")); - - expect( - deleteHelper.indexOf("await _sendCoverRequest(type: 'deleteCover'"), - lessThan(deleteHelper.indexOf('LocalCoverImageProvider.evictKey(')), - ); - - expect(deleteHelper, contains('scopeKey: scope.persistenceKey')); - expect(deleteHelper, contains('bucket: bucket')); - expect(deleteHelper, contains('fileId: id')); - expect(promotion, contains("await _sendCoverRequest(\n type: 'promoteCover'")); - expect(promotion, isNot(contains('LocalCoverImageProvider.evictKey('))); - expect(storeHelper, isNot(contains('LocalCoverImageProvider.evictKey('))); - }); } final Uint8List _pngBytes = base64Decode( diff --git a/app/test/services/book_cover_storage_web_test.dart b/app/test/services/book_cover_storage_web_test.dart new file mode 100644 index 0000000..86d78d0 --- /dev/null +++ b/app/test/services/book_cover_storage_web_test.dart @@ -0,0 +1,103 @@ +@TestOn('browser') +library; + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/cover_storage_bucket.dart'; +import 'package:papyrus/media/local_cover_image_provider.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/services/book_import_service.dart'; + +@JS('eval') +external JSAny? _evaluate(JSString script); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final scope = MediaStorageScope(profileKey: 'server', userId: 'alice'); + late BookImportService service; + + setUp(() { + _evaluate( + r''' + globalThis.originalWorker = globalThis.Worker; + globalThis.coverMessages = []; + globalThis.failCoverRequest = false; + globalThis.Worker = class extends EventTarget { + postMessage(message, transfer = []) { + const copied = structuredClone(message, {transfer}); + coverMessages.push(copied); + queueMicrotask(() => this.dispatchEvent(new MessageEvent('message', {data: { + requestId: copied.requestId, + type: failCoverRequest ? 'error' : 'success', + message: 'storage denied', + }}))); + } + terminate() {} + }; + ''' + .toJS, + ); + service = BookImportService(); + }); + + tearDown(() { + service.dispose(); + PaintingBinding.instance.imageCache.clear(); + _evaluate('globalThis.Worker = originalWorker; delete globalThis.originalWorker;'.toJS); + }); + + test('web cover transfer preserves caller buffers and sends the scoped bucket', () async { + final bytes = Uint8List.fromList([1, 2, 3]); + await service.storePendingCoverFile(scope, 'book', bytes); + expect(bytes, [1, 2, 3]); + + expect( + (_evaluate( + r''' + coverMessages.length === 1 && + coverMessages[0].type === 'storeCover' && + coverMessages[0].scopeKey === 'server--alice' && + coverMessages[0].bucket === 'pending' && + coverMessages[0].mediaId === 'book' && + String(new Uint8Array(coverMessages[0].fileData)) === '1,2,3' + ''' + .toJS, + ) + as JSBoolean) + .toDart, + isTrue, + ); + }); + + test('web mutations evict only the successfully deleted cover key', () async { + LocalCoverImageKey key(String user, CoverStorageBucket bucket, {String fileId = 'book'}) => + LocalCoverImageKey(scopeKey: 'server--$user', bucket: bucket, fileId: fileId); + + final pending = key('alice', CoverStorageBucket.pending); + final cached = key('alice', CoverStorageBucket.cached, fileId: 'asset'); + final otherAccount = key('bob', CoverStorageBucket.pending); + final cache = PaintingBinding.instance.imageCache; + + for (final key in [pending, cached, otherAccount]) { + cache.putIfAbsent(key, () => OneFrameImageStreamCompleter(Completer().future)); + } + + await service.storeCoverFile(scope, 'asset', Uint8List.fromList([2])); + await service.storePendingCoverFile(scope, 'book', Uint8List.fromList([1])); + await service.promotePendingCoverFile(scope, bookId: 'book', mediaId: 'asset'); + expect(cache.containsKey(pending), isTrue); + expect(cache.containsKey(cached), isTrue); + _evaluate('failCoverRequest = true'.toJS); + await expectLater(service.deletePendingCoverFile(scope, 'book'), throwsException); + expect(cache.containsKey(pending), isTrue); + _evaluate('failCoverRequest = false'.toJS); + await service.deletePendingCoverFile(scope, 'book'); + expect(cache.containsKey(pending), isFalse); + expect(cache.containsKey(cached), isTrue); + expect(cache.containsKey(otherAccount), isTrue); + }); +} diff --git a/app/test/services/book_import_session_test.dart b/app/test/services/book_import_session_test.dart index d17bf0d..e50cc40 100644 --- a/app/test/services/book_import_session_test.dart +++ b/app/test/services/book_import_session_test.dart @@ -1,4 +1,8 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; @@ -56,7 +60,117 @@ class _Database extends Fake implements PapyrusPowerSyncService { class _Importer extends Fake implements BookImportService {} +class _ImportStorage extends Fake implements BookImportService { + final covers = {}; + final stored = Completer(); + Completer? pause; + + @override + Future storePendingCoverFile(MediaStorageScope scope, String bookId, Uint8List bytes) async { + covers['${scope.persistenceKey}:$bookId'] = bytes; + if (!stored.isCompleted) { + stored.complete(); + } + + await pause?.future; + } + + @override + Future deletePendingCoverFile(MediaStorageScope scope, String bookId) async { + covers.remove('${scope.persistenceKey}:$bookId'); + } + + @override + Future storeGuestCoverFile(String bookId, Uint8List bytes) async { + covers['guest:$bookId'] = bytes; + } + + @override + Future deleteGuestCoverFile(String bookId) async { + covers.remove('guest:$bookId'); + } +} + +BookImportResult _result() => BookImportResult( + bookId: 'book', + title: 'Imported', + author: 'Author', + fileSize: 3, + fileHash: 'hash', + fileExtension: 'epub', + coverImage: Uint8List.fromList([1, 2, 3]), + coverMimeType: 'image/png', +); + void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final guest in [false, true]) { + test('captured ${guest ? 'guest' : 'account'} session persists metadata and cover to its destination', () async { + final repository = InMemoryBookRepository(); + final store = DataStore(bookRepository: repository); + final queue = MediaUploadQueue(await SharedPreferences.getInstance()); + final storage = _ImportStorage(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'bob'); + + if (!guest) { + await queue.activateScope(scope); + } + + final session = BookImportSession.capture( + dataStore: store, + queue: queue, + importService: storage, + authProvider: _Auth(guest: guest), + powerSyncService: _Database(guest ? LibraryDatabaseMode.guest : LibraryDatabaseMode.authenticated), + ); + + final book = await session.commit(_result(), 'original.epub'); + expect((await repository.getById('book'))?.title, 'Imported'); + expect(book.coverUrl, isNull); + expect(storage.covers[guest ? 'guest:book' : '${scope.persistenceKey}:book'], [1, 2, 3]); + expect(queue.pendingTasks.length, guest ? 0 : 2); + await store.disposeBookRepository(); + store.dispose(); + queue.dispose(); + }); + } + + test('switching repository and media scope during cover persistence cannot import into the new account', () async { + final first = InMemoryBookRepository(); + final second = InMemoryBookRepository(); + final store = DataStore(bookRepository: first); + final queue = MediaUploadQueue(await SharedPreferences.getInstance()); + final oldScope = MediaStorageScope(profileKey: 'official', userId: 'bob'); + final newScope = MediaStorageScope(profileKey: 'other', userId: 'bob'); + await queue.activateScope(oldScope); + final storage = _ImportStorage()..pause = Completer(); + storage.covers['${newScope.persistenceKey}:book'] = Uint8List.fromList([9]); + + final session = BookImportSession.capture( + dataStore: store, + queue: queue, + importService: storage, + authProvider: _Auth(), + powerSyncService: _Database(LibraryDatabaseMode.authenticated), + ); + + final commit = session.commit(_result(), 'original.epub'); + final rejected = expectLater(commit, throwsStateError); + await storage.stored.future; + await store.attachBookRepository(second); + await queue.activateScope(newScope); + storage.pause!.complete(); + await rejected; + expect(await first.getById('book'), isNull); + expect(await second.getById('book'), isNull); + expect(storage.covers['${oldScope.persistenceKey}:book'], isNull); + expect(storage.covers['${newScope.persistenceKey}:book'], [9]); + expect(queue.pendingTasks, isEmpty); + await store.disposeBookRepository(); + store.dispose(); + queue.dispose(); + }); test('guest deletion invalidates imports and prevents new sessions', () { final auth = _Auth(guest: true); diff --git a/app/test/web/book_worker_test.cjs b/app/test/web/book_worker_test.cjs new file mode 100644 index 0000000..7c6eb3a --- /dev/null +++ b/app/test/web/book_worker_test.cjs @@ -0,0 +1,79 @@ +const assert = require('node:assert/strict'); +const {readFileSync} = require('node:fs'); +const {resolve} = require('node:path'); +const {test} = require('node:test'); +const vm = require('node:vm'); + +function directory() { + const entries = new Map(); + const missing = () => Object.assign(new Error('missing'), {name: 'NotFoundError'}); + return { + async getDirectoryHandle(name, {create}) { + if (!entries.has(name)) { + if (!create) throw missing(); + entries.set(name, directory()); + } + return entries.get(name); + }, + async getFileHandle(name, {create}) { + if (!entries.has(name)) { + if (!create) throw missing(); + let contents = new Uint8Array(); + entries.set(name, { + async createWritable() { + let pending; + return {async write(bytes) {pending = new Uint8Array(bytes);}, async close() {contents = pending;}}; + }, + async getFile() {return {async arrayBuffer() {return contents.slice().buffer;}};}, + }); + } + return entries.get(name); + }, + async removeEntry(name) { + if (!entries.delete(name)) throw missing(); + }, + }; +} + +test('cover worker dispatches scoped storage, promotion, deletion and rejects unsafe buckets', async () => { + const root = directory(); + const responses = []; + const locks = []; + const context = vm.createContext({ + self: {}, Uint8Array, + postMessage: message => responses.push(message), + navigator: { + storage: {getDirectory: async () => root}, + locks: {async request(name, callback) {locks.push(name); return callback();}}, + }, + }); + vm.runInContext(readFileSync(resolve(__dirname, '../../web/book_worker.js'), 'utf8'), context); + const send = async (type, fields = {}) => { + await context.self.onmessage({data: { + type, requestId: String(responses.length), scopeKey: 'alice', bucket: 'pending', mediaId: 'book', ...fields, + }}); + return responses.at(-1); + }; + const bytes = new Uint8Array([1, 2, 3]); + assert.equal((await send('storeCover', {fileData: bytes.buffer})).type, 'success'); + assert.deepEqual(new Uint8Array((await send('getCover')).fileData), bytes); + assert.equal((await send('getCover', {scopeKey: 'bob'})).fileData, null); + assert.equal((await send('getCover', {bucket: 'cached'})).fileData, null); + assert.equal((await send('promoteCover', {targetMediaId: 'asset'})).promoted, true); + assert.equal((await send('getCover')).fileData, null); + assert.deepEqual(new Uint8Array((await send('getCover', {bucket: 'cached', mediaId: 'asset'})).fileData), bytes); + assert.equal((await send('deleteCover', {bucket: 'cached', mediaId: 'asset'})).type, 'success'); + assert.equal((await send('getCover', {bucket: 'cached', mediaId: 'asset'})).fileData, null); + for (const bucket of ['pending', 'cached', 'books']) { + assert.equal((await send('storeCover', {bucket, fileData: bytes.buffer})).type, 'success'); + } + await send('storeCover', {scopeKey: 'bob', fileData: bytes.buffer}); + assert.equal((await send('clearCovers')).type, 'success'); + assert.deepEqual(new Uint8Array((await send('getCover', {scopeKey: 'bob'})).fileData), bytes); + for (const bucket of ['pending', 'cached', 'books']) { + assert.equal((await send('getCover', {bucket})).fileData, null); + } + assert.equal((await send('storeCover', {bucket: '../other', fileData: bytes.buffer})).type, 'error'); + assert.equal((await send('getCover', {scopeKey: '../bob'})).type, 'error'); + assert.ok(locks.length > 0); +}); diff --git a/app/test/widgets/book_details/empty_annotations_state_test.dart b/app/test/widgets/book_details/empty_annotations_state_test.dart index 7e1a3e0..3e2959e 100644 --- a/app/test/widgets/book_details/empty_annotations_state_test.dart +++ b/app/test/widgets/book_details/empty_annotations_state_test.dart @@ -1,59 +1,28 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/widgets/book_details/empty_annotations_state.dart'; import '../../helpers/test_helpers.dart'; void main() { - group('EmptyAnnotationsState', () { - Widget buildWidget({bool isPhysical = false, VoidCallback? onAddAnnotation}) { - return createTestApp( - child: EmptyAnnotationsState(isPhysical: isPhysical, onAddAnnotation: onAddAnnotation), - ); - } - - group('rendering', () { - testWidgets('displays highlight icon', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.byIcon(Icons.highlight_outlined), findsOneWidget); - }); - - testWidgets('displays title text', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.text('No annotations yet'), findsOneWidget); - }); - }); - - group('digital book', () { - testWidgets('shows digital book description', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.text('Highlight text while reading to create annotations.'), findsOneWidget); - }); - - testWidgets('does not show add annotation button', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.text('Add annotation'), findsNothing); - }); - }); + testWidgets('digital annotations explain highlighting without offering manual entry', (tester) async { + await tester.pumpWidget(createTestApp(child: const EmptyAnnotationsState())); + expect(find.text('No annotations yet'), findsOneWidget); + expect(find.text('Highlight text while reading to create annotations.'), findsOneWidget); + expect(find.text('Add annotation'), findsNothing); + }); - group('physical book', () { - testWidgets('shows physical book description', (tester) async { - await tester.pumpWidget(buildWidget(isPhysical: true)); - expect(find.text("Add passages you've highlighted or underlined in your book."), findsOneWidget); - }); + testWidgets('physical annotations explain manual entry and invoke the add action', (tester) async { + var additions = 0; - testWidgets('shows add annotation button', (tester) async { - await tester.pumpWidget(buildWidget(isPhysical: true)); - expect(find.text('Add annotation'), findsOneWidget); - expect(find.byIcon(Icons.add), findsOneWidget); - }); + await tester.pumpWidget( + createTestApp( + child: EmptyAnnotationsState(isPhysical: true, onAddAnnotation: () => additions++), + ), + ); - testWidgets('add button calls onAddAnnotation', (tester) async { - var called = false; - await tester.pumpWidget(buildWidget(isPhysical: true, onAddAnnotation: () => called = true)); - await tester.tap(find.text('Add annotation')); - expect(called, true); - }); - }); + expect(find.text('No annotations yet'), findsOneWidget); + expect(find.text("Add passages you've highlighted or underlined in your book."), findsOneWidget); + await tester.tap(find.text('Add annotation')); + expect(additions, 1); }); } diff --git a/app/test/widgets/book_details/empty_bookmarks_state_test.dart b/app/test/widgets/book_details/empty_bookmarks_state_test.dart index e814e4d..666e892 100644 --- a/app/test/widgets/book_details/empty_bookmarks_state_test.dart +++ b/app/test/widgets/book_details/empty_bookmarks_state_test.dart @@ -1,59 +1,28 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/widgets/book_details/empty_bookmarks_state.dart'; import '../../helpers/test_helpers.dart'; void main() { - group('EmptyBookmarksState', () { - Widget buildWidget({bool isPhysical = false, VoidCallback? onAddBookmark}) { - return createTestApp( - child: EmptyBookmarksState(isPhysical: isPhysical, onAddBookmark: onAddBookmark), - ); - } - - group('rendering', () { - testWidgets('displays bookmark icon', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.byIcon(Icons.bookmark_outline), findsOneWidget); - }); - - testWidgets('displays title text', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.text('No bookmarks yet'), findsOneWidget); - }); - }); - - group('digital book', () { - testWidgets('shows digital book description', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.text('Bookmarks you create while reading will appear here.'), findsOneWidget); - }); - - testWidgets('does not show add bookmark button', (tester) async { - await tester.pumpWidget(buildWidget()); - expect(find.text('Add bookmark'), findsNothing); - }); - }); + testWidgets('digital bookmarks explain highlighting without offering manual entry', (tester) async { + await tester.pumpWidget(createTestApp(child: const EmptyBookmarksState())); + expect(find.text('No bookmarks yet'), findsOneWidget); + expect(find.text('Bookmarks you create while reading will appear here.'), findsOneWidget); + expect(find.text('Add bookmark'), findsNothing); + }); - group('physical book', () { - testWidgets('shows physical book description', (tester) async { - await tester.pumpWidget(buildWidget(isPhysical: true)); - expect(find.text('Save pages you want to return to later.'), findsOneWidget); - }); + testWidgets('physical bookmarks explain manual entry and invoke the add action', (tester) async { + var additions = 0; - testWidgets('shows add bookmark button', (tester) async { - await tester.pumpWidget(buildWidget(isPhysical: true)); - expect(find.text('Add bookmark'), findsOneWidget); - expect(find.byIcon(Icons.add), findsOneWidget); - }); + await tester.pumpWidget( + createTestApp( + child: EmptyBookmarksState(isPhysical: true, onAddBookmark: () => additions++), + ), + ); - testWidgets('add button calls onAddBookmark', (tester) async { - var called = false; - await tester.pumpWidget(buildWidget(isPhysical: true, onAddBookmark: () => called = true)); - await tester.tap(find.text('Add bookmark')); - expect(called, true); - }); - }); + expect(find.text('No bookmarks yet'), findsOneWidget); + expect(find.text("Save pages you want to return to later."), findsOneWidget); + await tester.tap(find.text('Add bookmark')); + expect(additions, 1); }); } diff --git a/app/test/widgets/shared/empty_state_test.dart b/app/test/widgets/shared/empty_state_test.dart index fad8b05..bbad14e 100644 --- a/app/test/widgets/shared/empty_state_test.dart +++ b/app/test/widgets/shared/empty_state_test.dart @@ -58,8 +58,12 @@ void main() { testWidgets('is centered', (tester) async { await tester.pumpWidget(buildEmptyState()); - // EmptyState contains a Center widget - expect(find.byType(Center), findsAtLeastNWidgets(1)); + final bounds = tester.getRect(find.byType(EmptyState)); + final title = tester.getRect(find.text('No books found')); + final icon = tester.getRect(find.byIcon(Icons.library_books_outlined)); + expect(title.center.dx, closeTo(bounds.center.dx, 1)); + expect(icon.center.dx, closeTo(bounds.center.dx, 1)); + expect((icon.top + title.bottom) / 2, closeTo(bounds.center.dy, 1)); }); testWidgets('uses custom icon size', (tester) async { diff --git a/app/test/widgets/shell/library_drawer_consistency_test.dart b/app/test/widgets/shell/library_drawer_consistency_test.dart index 21a6e2f..53d5e50 100644 --- a/app/test/widgets/shell/library_drawer_consistency_test.dart +++ b/app/test/widgets/shell/library_drawer_consistency_test.dart @@ -33,10 +33,12 @@ void main() { (path: '/library/notes', label: 'Notes', page: NotesPage()), ]; - for (final theme in [AppTheme.dark, AppTheme.eink]) { + for (final (appearance, theme) in [('dark', AppTheme.dark), ('eink', AppTheme.eink)]) { for (final layout in [(width: 400.0, height: 850.0, scale: 1.0), (width: 320.0, height: 568.0, scale: 2.0)]) { + final fullJourney = appearance == 'dark' && layout.scale == 1; + testWidgets( - 'all Library pages share their drawer at ${layout.width}, scale ${layout.scale}, ${theme.brightness}', + '${fullJourney ? 'all sections' : 'representative sections'} share their drawer at ${layout.width}, scale ${layout.scale}, $appearance', (tester) async { tester.view.physicalSize = Size(layout.width, layout.height); tester.view.devicePixelRatio = 1; @@ -97,9 +99,11 @@ void main() { await tester.pumpAndSettle(); final catalogMenuRect = tester.getRect(find.byTooltip('Library sections')); - // Every page opens the shell drawer, covering the bottom navigation. - // Follow real drawer links through every page and back to Catalogs. - final journey = [sections[2], ...sections.where((section) => section.label != 'Catalogs'), sections[2]]; + final journey = [ + sections[2], + if (fullJourney) ...sections.where((section) => section.label != 'Catalogs') else sections[0], + sections[2], + ]; for (var index = 0; index < journey.length; index++) { final section = journey[index]; From 2bfcba271b288f46bb68baa7277957d3044818ce Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 14:37:36 +0200 Subject: [PATCH 03/10] ci: parallelize browser checks and summarize test durations --- .github/scripts/summarize_test_timing.py | 49 +++++++++++++++ .github/scripts/test_summarize_test_timing.py | 37 +++++++++++ .github/workflows/ci.yml | 61 ++++++++++++++++--- AGENTS.md | 2 + 4 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/summarize_test_timing.py create mode 100644 .github/scripts/test_summarize_test_timing.py diff --git a/.github/scripts/summarize_test_timing.py b/.github/scripts/summarize_test_timing.py new file mode 100644 index 0000000..87d543e --- /dev/null +++ b/.github/scripts/summarize_test_timing.py @@ -0,0 +1,49 @@ +"""Summarize Flutter JSON reporter timings without treating overlapping tests as wall time.""" + +import json +import sys +from pathlib import Path + + +def summarize(events): + starts = {} + durations = [] + elapsed = 0 + complete = False + + for event in events: + elapsed = max(elapsed, event.get("time", 0)) + + if event["type"] == "testStart": + starts[event["test"]["id"]] = (event["test"]["name"], event["time"]) + elif event["type"] == "testDone": + start = starts.pop(event["testID"], None) + + if start and not event.get("hidden") and not event.get("skipped"): + name, begin = start + durations.append((event["time"] - begin, name, event.get("result", "unknown"))) + elif event["type"] == "done": + complete = True + + state = "complete" if complete else "partial" + lines = [ + "### Flutter test durations", + "", + f"Runner elapsed: {elapsed / 1000:.2f}s ({state} report).", + "Durations include per-test setup/teardown. Tests can overlap, so their sum is not elapsed or runner time.", + "Skipped and hidden runner events are excluded from the ranking.", + "", + "| Seconds | Result | Test |", + "| ---: | --- | --- |", + ] + + for duration, name, result in sorted(durations, reverse=True)[:20]: + name = name.replace("|", "\\|").replace("\n", " ").replace("`", "'") + lines.append(f"| {duration / 1000:.2f} | {result} | {name} |") + + return "\n".join(lines) + "\n" + + +if __name__ == "__main__": + with Path(sys.argv[1]).open(encoding="utf-8") as source: + print(summarize(json.loads(line) for line in source if line.strip()), end="") diff --git a/.github/scripts/test_summarize_test_timing.py b/.github/scripts/test_summarize_test_timing.py new file mode 100644 index 0000000..90849ba --- /dev/null +++ b/.github/scripts/test_summarize_test_timing.py @@ -0,0 +1,37 @@ +import unittest + +from summarize_test_timing import summarize + + +class TestTimingTest(unittest.TestCase): + def test_overlapping_tests_do_not_inflate_elapsed_and_failures_are_ranked(self): + report = summarize([ + {"type": "testStart", "time": 0, "test": {"id": 1, "name": "slow"}}, + {"type": "testStart", "time": 1000, "test": {"id": 2, "name": "failed | case"}}, + {"type": "testDone", "time": 3000, "testID": 2, "result": "failure"}, + {"type": "testDone", "time": 4000, "testID": 1, "result": "success"}, + {"type": "done", "time": 4500}, + ]) + self.assertIn("Runner elapsed: 4.50s (complete report)", report) + self.assertIn("| 2.00 | failure | failed \\| case |", report) + self.assertLess(report.index("| 4.00"), report.index("| 2.00")) + + def test_partial_reports_exclude_skipped_hidden_and_unfinished_tests(self): + events = [] + + for test_id, name in enumerate(["skipped case", "hidden load", "unfinished"]): + events.append({"type": "testStart", "time": 0, "test": {"id": test_id, "name": name}}) + + events.extend([ + {"type": "testDone", "time": 100, "testID": 0, "skipped": True}, + {"type": "testDone", "time": 200, "testID": 1, "hidden": True}, + ]) + report = summarize(events) + self.assertIn("0.20s (partial report)", report) + self.assertNotIn("skipped case", report) + self.assertNotIn("hidden load", report) + self.assertNotIn("unfinished", report) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e46baf..0066531 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,8 @@ jobs: - name: Check platform dimensions, artwork and safe zones run: python tools/generate_icons.py --check - quality: - name: Code quality checks + tests: + name: Dart quality and coverage runs-on: ubuntu-latest defaults: run: @@ -71,18 +71,16 @@ jobs: - name: Check report normalization run: python3 -m unittest discover -s ../.github/scripts -p 'test_*.py' - - name: Check browser bootstrap and cover worker - run: node --test test/web/*.cjs - - - name: Check browser cover transport and drag-and-drop - run: flutter test --platform chrome test/services/book_cover_storage_web_test.dart test/platform/book_import_drop_registration_web_test.dart - - name: Run tests with coverage id: tests run: | mkdir -p build/test-results flutter test --coverage --file-reporter json:build/test-results/tests.json + - name: Summarize test durations + if: ${{ !cancelled() && hashFiles('app/build/test-results/tests.json') != '' }} + run: python3 ../.github/scripts/summarize_test_timing.py build/test-results/tests.json >> "$GITHUB_STEP_SUMMARY" + - name: Generate JUnit report if: ${{ !cancelled() && (steps.tests.outcome == 'success' || steps.tests.outcome == 'failure') }} run: | @@ -137,3 +135,50 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true disable_search: true + + browser: + name: Browser behavior checks + runs-on: ubuntu-latest + defaults: + run: + working-directory: app + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + - run: flutter pub get --enforce-lockfile + - name: Check browser bootstrap and cover worker + run: node --test test/web/*.cjs + + - name: Check browser cover transport and drag-and-drop + run: | + mkdir -p build/test-results + flutter test --platform chrome --file-reporter json:build/test-results/browser.json \ + test/services/book_cover_storage_web_test.dart test/platform/book_import_drop_registration_web_test.dart + + - name: Summarize browser test durations + if: ${{ !cancelled() && hashFiles('app/build/test-results/browser.json') != '' }} + run: python3 ../.github/scripts/summarize_test_timing.py build/test-results/browser.json >> "$GITHUB_STEP_SUMMARY" + + - name: Save browser test results + if: ${{ !cancelled() && hashFiles('app/build/test-results/browser.json') != '' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: client-browser-test-results-${{ github.run_attempt }} + path: app/build/test-results/browser.json + if-no-files-found: error + retention-days: 14 + + quality: + name: Code quality checks + if: ${{ always() }} + needs: [tests, browser] + runs-on: ubuntu-latest + steps: + - name: Require both test jobs + env: + TEST_RESULT: ${{ needs.tests.result }} + BROWSER_RESULT: ${{ needs.browser.result }} + run: test "$TEST_RESULT" = success && test "$BROWSER_RESULT" = success diff --git a/AGENTS.md b/AGENTS.md index 946d494..fd72522 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,8 @@ the client root, or `../../tools/papyrus` from `app/`. - Browser cover transport and drag-and-drop: `flutter test --platform chrome test/services/book_cover_storage_web_test.dart test/platform/book_import_drop_registration_web_test.dart`. - Web compile for platform-sensitive changes: `flutter build web --dart-define-from-file=.dart_defines`. +CI runs browser checks alongside the Dart coverage job. The existing Code quality checks gate requires both jobs to pass. Test-duration summaries are generated from the retained JSON reports. + Add the narrowest regression for changed behavior. UI changes need a relevant widget test and visual verification when a running target is available. Analyze and run relevant tests before broadening to full suites. Report warnings and From d6fdbe126f8af753a0c0dbc278ebd99d98b045a1 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 14:42:22 +0200 Subject: [PATCH 04/10] test: distinguish reporter elapsed time from full CI cost --- .github/scripts/summarize_test_timing.py | 3 ++- .github/scripts/test_summarize_test_timing.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/scripts/summarize_test_timing.py b/.github/scripts/summarize_test_timing.py index 87d543e..aed6e23 100644 --- a/.github/scripts/summarize_test_timing.py +++ b/.github/scripts/summarize_test_timing.py @@ -29,7 +29,8 @@ def summarize(events): lines = [ "### Flutter test durations", "", - f"Runner elapsed: {elapsed / 1000:.2f}s ({state} report).", + f"Test reporter elapsed: {elapsed / 1000:.2f}s ({state} report).", + "This excludes work before reporter startup, such as browser compilation. Use job timings for the full CI cost.", "Durations include per-test setup/teardown. Tests can overlap, so their sum is not elapsed or runner time.", "Skipped and hidden runner events are excluded from the ranking.", "", diff --git a/.github/scripts/test_summarize_test_timing.py b/.github/scripts/test_summarize_test_timing.py index 90849ba..292a4dd 100644 --- a/.github/scripts/test_summarize_test_timing.py +++ b/.github/scripts/test_summarize_test_timing.py @@ -12,7 +12,7 @@ def test_overlapping_tests_do_not_inflate_elapsed_and_failures_are_ranked(self): {"type": "testDone", "time": 4000, "testID": 1, "result": "success"}, {"type": "done", "time": 4500}, ]) - self.assertIn("Runner elapsed: 4.50s (complete report)", report) + self.assertIn("Test reporter elapsed: 4.50s (complete report)", report) self.assertIn("| 2.00 | failure | failed \\| case |", report) self.assertLess(report.index("| 4.00"), report.index("| 2.00")) From ad90ee054fbe569e027733c71e2aa724c000d3e4 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 21:15:59 +0200 Subject: [PATCH 05/10] fix: persist reader animation choice and pin consistent loading --- app/lib/reader/reader_book_adapter.dart | 5 +++++ app/pubspec.lock | 4 ++-- app/pubspec.yaml | 2 +- app/test/reader/reader_book_adapter_test.dart | 9 +++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/lib/reader/reader_book_adapter.dart b/app/lib/reader/reader_book_adapter.dart index 5f08876..4a74a4b 100644 --- a/app/lib/reader/reader_book_adapter.dart +++ b/app/lib/reader/reader_book_adapter.dart @@ -126,6 +126,7 @@ final class ReaderBookAdapter { foregroundColor: colors.onSurface, brightness: colors.brightness, layoutMode: preferences.readingMode == 'scroll' ? ReaderLayoutMode.scroll : ReaderLayoutMode.paginated, + pageTurnAnimation: preferences.pageTurnAnimation, ); } @@ -162,6 +163,10 @@ final class ReaderBookAdapter { target.margins = margins; } + if (target.pageTurnAnimation != preferences.pageTurnAnimation) { + target.pageTurnAnimation = preferences.pageTurnAnimation; + } + final readingMode = preferences.layoutMode == ReaderLayoutMode.scroll ? 'scroll' : 'paginated'; if (target.readingMode != readingMode) { diff --git a/app/pubspec.lock b/app/pubspec.lock index 6360465..c7ee7a8 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: "e6c59a7eb238b2088b0b92b25f36b617d63181c1" - resolved-ref: "e6c59a7eb238b2088b0b92b25f36b617d63181c1" + ref: "95ea8ea0a33a2b16dde7e56be384119b1cb246cb" + resolved-ref: "95ea8ea0a33a2b16dde7e56be384119b1cb246cb" url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b29f48c..3bf5cd8 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: e6c59a7eb238b2088b0b92b25f36b617d63181c1 + ref: 95ea8ea0a33a2b16dde7e56be384119b1cb246cb epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 diff --git a/app/test/reader/reader_book_adapter_test.dart b/app/test/reader/reader_book_adapter_test.dart index 3c52c74..80b55da 100644 --- a/app/test/reader/reader_book_adapter_test.dart +++ b/app/test/reader/reader_book_adapter_test.dart @@ -100,6 +100,7 @@ void main() { 'line_spacing': 'relaxed', 'margins': 'large', 'reading_mode': 'scroll', + 'page_turn_animation': false, }); final provider = PreferencesProvider(await SharedPreferences.getInstance()); @@ -110,6 +111,7 @@ void main() { expect(preferences.lineHeight, 1.75); expect(preferences.pageMargins, const EdgeInsets.all(40)); expect(preferences.layoutMode, ReaderLayoutMode.scroll); + expect(preferences.pageTurnAnimation, isFalse); expect(preferences.backgroundColor, colors.surface); expect(preferences.foregroundColor, colors.onSurface); expect(preferences.brightness, Brightness.dark); @@ -125,6 +127,7 @@ void main() { lineHeight: 1.8, pageMargins: EdgeInsets.all(42), layoutMode: ReaderLayoutMode.scroll, + pageTurnAnimation: false, ); ReaderBookAdapter.persistPreferences(provider, readerPreferences); @@ -133,6 +136,12 @@ void main() { expect(provider.lineSpacing, 'relaxed'); expect(provider.margins, 'large'); expect(provider.readingMode, 'scroll'); + final reopened = PreferencesProvider(await SharedPreferences.getInstance()); + expect(reopened.pageTurnAnimation, isFalse); + expect( + ReaderBookAdapter.preferencesFor(reopened, ColorScheme.fromSeed(seedColor: Colors.purple)).pageTurnAnimation, + isFalse, + ); }); }); } From 74e4a62ab8f3c41297b2165968164e670ba2b42a Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 21:30:24 +0200 Subject: [PATCH 06/10] fix: pin compact reader animation setting --- app/pubspec.lock | 4 ++-- app/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pubspec.lock b/app/pubspec.lock index c7ee7a8..27c5abb 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: "95ea8ea0a33a2b16dde7e56be384119b1cb246cb" - resolved-ref: "95ea8ea0a33a2b16dde7e56be384119b1cb246cb" + ref: "01e50dd9142cf79244c3e5b1b8b83f5b0d6ea684" + resolved-ref: "01e50dd9142cf79244c3e5b1b8b83f5b0d6ea684" url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3bf5cd8..69c14aa 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: 95ea8ea0a33a2b16dde7e56be384119b1cb246cb + ref: 01e50dd9142cf79244c3e5b1b8b83f5b0d6ea684 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 From 7ddddcc4abd72357683240474de06fd56aae899d Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 22:23:27 +0200 Subject: [PATCH 07/10] fix: pin bidirectional reader page preloading --- app/pubspec.lock | 4 ++-- app/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pubspec.lock b/app/pubspec.lock index 27c5abb..3814a1a 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: "01e50dd9142cf79244c3e5b1b8b83f5b0d6ea684" - resolved-ref: "01e50dd9142cf79244c3e5b1b8b83f5b0d6ea684" + ref: dc5ea75298644ae7e613616c7aa31e4292c5ffb7 + resolved-ref: dc5ea75298644ae7e613616c7aa31e4292c5ffb7 url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 69c14aa..23b5c1f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: 01e50dd9142cf79244c3e5b1b8b83f5b0d6ea684 + ref: dc5ea75298644ae7e613616c7aa31e4292c5ffb7 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 From eedcf286fc6202967d7744da33b4e01c0f93d205 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 23:31:16 +0200 Subject: [PATCH 08/10] fix: pin latest-destination slider navigation --- app/pubspec.lock | 4 ++-- app/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pubspec.lock b/app/pubspec.lock index 3814a1a..3d2348c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: dc5ea75298644ae7e613616c7aa31e4292c5ffb7 - resolved-ref: dc5ea75298644ae7e613616c7aa31e4292c5ffb7 + ref: ee7737deb9b579199e812d14c580bc1a957f6344 + resolved-ref: ee7737deb9b579199e812d14c580bc1a957f6344 url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 23b5c1f..8f7b097 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: dc5ea75298644ae7e613616c7aa31e4292c5ffb7 + ref: ee7737deb9b579199e812d14c580bc1a957f6344 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 From cc396aec260ae77e2d9be0ab5a8a685652ff8cf3 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 23:42:16 +0200 Subject: [PATCH 09/10] fix: align contents and reading settings headers --- app/lib/reader/reader_library_panel.dart | 6 +- app/pubspec.lock | 4 +- app/pubspec.yaml | 2 +- .../reader/reader_library_panel_test.dart | 99 +++++++++++++++++++ 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/app/lib/reader/reader_library_panel.dart b/app/lib/reader/reader_library_panel.dart index 9d050b9..8d9ae04 100644 --- a/app/lib/reader/reader_library_panel.dart +++ b/app/lib/reader/reader_library_panel.dart @@ -35,11 +35,7 @@ class ReaderLibraryPanel extends StatelessWidget { return Column( children: [ - if (panel.showHeader) - ListTile( - title: const Text('Contents'), - trailing: IconButton(tooltip: 'Close contents', onPressed: panel.close, icon: const Icon(Icons.close)), - ), + if (panel.showHeader) panel.buildHeader!(context, 'Contents'), const TabBar( isScrollable: true, tabAlignment: TabAlignment.start, diff --git a/app/pubspec.lock b/app/pubspec.lock index 3d2348c..33717c9 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: ee7737deb9b579199e812d14c580bc1a957f6344 - resolved-ref: ee7737deb9b579199e812d14c580bc1a957f6344 + ref: c7ce2d62ec6590f0d11467d8b13383ea86915ca5 + resolved-ref: c7ce2d62ec6590f0d11467d8b13383ea86915ca5 url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8f7b097..5390fc3 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: ee7737deb9b579199e812d14c580bc1a957f6344 + ref: c7ce2d62ec6590f0d11467d8b13383ea86915ca5 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 diff --git a/app/test/reader/reader_library_panel_test.dart b/app/test/reader/reader_library_panel_test.dart index b240bd9..2450ea0 100644 --- a/app/test/reader/reader_library_panel_test.dart +++ b/app/test/reader/reader_library_panel_test.dart @@ -76,6 +76,89 @@ void main() { toc: [ReaderTocEntry(title: 'Third chapter', locator: locator)], ); + for (final (name, theme) in [('light', AppTheme.light), ('dark', AppTheme.dark), ('eink', AppTheme.eink)]) { + testWidgets('contents and settings share header geometry in $name theme', ( + tester, + ) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final store = DataStore(bookRepository: _Repository()); + addTearDown(store.dispose); + final library = ReaderLibrary(store: store, bookId: 'book'); + + final controller = ReaderController( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.pdf}, + factory: () => PdfReaderEngine(facadeFactory: (_) async => _PanelPdfFacade()), + ), + ]), + ); + + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: PapyrusReader( + document: ReaderDocument(id: 'book', format: ReaderFormat.pdf, loadBytes: () async => Uint8List(0)), + controller: controller, + theme: ReaderThemeData.fromTheme(theme).copyWith(sidePanelWidth: 420), + builders: ReaderUiBuilders( + tableOfContents: (_, panel) => ReaderLibraryPanel(panel: panel, library: library), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.tapAt(tester.getCenter(find.byKey(const ValueKey('reader-content')))); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Reading settings')); + await tester.pumpAndSettle(); + final settingsTitle = find.text('Reading settings'); + final titleStyle = tester.widget(settingsTitle).style; + final titlePosition = tester.getTopLeft(settingsTitle); + final closeBounds = tester.getRect(find.byTooltip('Close panel')); + + Finder headerBorder(Finder title) => find + .ancestor( + of: title, + matching: find.byWidgetPredicate((widget) { + if (widget is! DecoratedBox || widget.decoration is! BoxDecoration) { + return false; + } + + final border = (widget.decoration as BoxDecoration).border; + return border is Border && border.bottom.style == BorderStyle.solid; + }), + ) + .first; + + final headerBounds = tester.getRect(headerBorder(settingsTitle)); + final headerDecoration = tester.widget(headerBorder(settingsTitle)).decoration; + await tester.tap(find.byTooltip('Close panel')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Table of contents')); + await tester.pumpAndSettle(); + final contentsTitle = find.text('Contents'); + expect(tester.widget(contentsTitle).style, titleStyle); + expect(tester.getTopLeft(contentsTitle), titlePosition); + expect(tester.getRect(find.byTooltip('Close panel')), closeBounds); + expect(tester.getRect(headerBorder(contentsTitle)), headerBounds); + expect(tester.widget(headerBorder(contentsTitle)).decoration, headerDecoration); + expect(find.text('Bookmarks'), findsOneWidget); + await tester.tap(find.byTooltip('Close panel')); + await tester.pumpAndSettle(); + expect(find.text('Contents'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } + test('saved EPUB titles follow the nearest preceding section, including selection starts', () { final store = DataStore(bookRepository: _Repository()); addTearDown(store.dispose); @@ -177,6 +260,7 @@ void main() { controller: controller, close: () {}, showHeader: width >= 720, + buildHeader: (_, title) => Text(title), navigate: (locator) => navigated = locator, buildContents: (_) => const Center(child: Text('Third chapter')), ), @@ -337,6 +421,7 @@ void main() { snapshot: snapshot, controller: controller, close: () {}, + buildHeader: (_, title) => Text(title), navigate: (locator) => navigated = locator, buildContents: (_) => const SizedBox.shrink(), ), @@ -490,3 +575,17 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); } + +final class _PanelPdfFacade implements PdfFacade { + @override + int get pageCount => 3; + + @override + List get outline => const []; + + @override + Future showPage(int pageIndex, double pageOffset) async {} + + @override + Widget buildViewport(PdfViewportConfiguration configuration) => const SizedBox.expand(); +} From c3a0e294650174a187329e134ce93cf509269322 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 23:47:04 +0200 Subject: [PATCH 10/10] fix: pin reader safe-area color correction --- app/pubspec.lock | 4 ++-- app/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pubspec.lock b/app/pubspec.lock index 33717c9..77b5ab2 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -579,8 +579,8 @@ packages: dependency: "direct main" description: path: "." - ref: c7ce2d62ec6590f0d11467d8b13383ea86915ca5 - resolved-ref: c7ce2d62ec6590f0d11467d8b13383ea86915ca5 + ref: c9f566fde4de3bfd1dce6df1fd6d17bae6aaf114 + resolved-ref: c9f566fde4de3bfd1dce6df1fd6d17bae6aaf114 url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5390fc3..246729e 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: c7ce2d62ec6590f0d11467d8b13383ea86915ca5 + ref: c9f566fde4de3bfd1dce6df1fd6d17bae6aaf114 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2