import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:noo/models/saved_account.dart'; import 'package:noo/providers/connectivity_controller.dart'; import 'package:noo/providers/files_controller.dart'; import 'package:noo/providers/session_controller.dart'; import 'package:noo/providers/settings_controller.dart'; import 'package:noo/providers/sync_status_controller.dart'; import 'package:noo/theme/app_theme.dart'; import 'package:noo/views/account_view.dart'; import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; /// Covers the mobile Settings navigation restructure: a top-level menu of /// category rows (`account_view.dart`'s `_MobileMenu`) that pushes a /// single-section screen per row, replacing the old design where every /// section rendered inline in one long scrolling column. This only covers /// the menu/push/back mechanics - each section's own content already has /// (or doesn't need) its own coverage elsewhere. void main() { const secureStorageChannel = MethodChannel( 'plugins.it_nomads.com/flutter_secure_storage', ); const connectivityChannel = MethodChannel( 'dev.fluttercommunity.plus/connectivity', ); const connectivityStatusChannel = EventChannel( 'dev.fluttercommunity.plus/connectivity_status', ); const accountId = 'server_example_com__alice'; const passwordKey = 'nc_app_password_$accountId'; const password = 'app-password'; setUpAll(() { GoogleFonts.config.allowRuntimeFetching = false; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(secureStorageChannel, (call) async { if (call.method == 'readAll') { return {passwordKey: password}; } if (call.method == 'read') { final key = (call.arguments as Map)['key'] as String?; return key == passwordKey ? password : null; } return null; }); // Offline, like `session_controller_test.dart`'s setup: keeps // SessionController in a provisional login (no real HTTP calls) and // stops FilesController/SyncStatusController from starting their // network-fetch/periodic-refresh machinery, which would otherwise // leave timers pending forever in a test. TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(connectivityChannel, (call) async { if (call.method == 'check') return ['none']; return null; }); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockStreamHandler( connectivityStatusChannel, MockStreamHandler.inline(onListen: (arguments, events) {}), ); }); setUp(() { SharedPreferences.setMockInitialValues({ 'account_migration_v1_done': true, 'accounts_list': jsonEncode([ const SavedAccount( id: accountId, serverUrl: 'https://server.example.com', username: 'alice', ).toJson(), ]), 'active_account_id': accountId, }); }); Future pumpSettings(WidgetTester tester) async { await tester.binding.setSurfaceSize(const Size(400, 800)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => ConnectivityController()), ChangeNotifierProvider( create: (context) => SessionController(context.read()), ), ChangeNotifierProvider(create: (_) => SettingsController()), ChangeNotifierProvider( create: (context) => FilesController(context.read()), ), ChangeNotifierProvider( create: (context) => SyncStatusController(context.read(), context.read()), ), ], child: MaterialApp( theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false), home: const AccountView(), ), ), ); // Not `pumpAndSettle`: once the saved account is ready, // FilesController/SyncStatusController start their own periodic // refresh timers, which `pumpAndSettle` would spin on forever. A // bounded number of small pumps is enough to flush the async gaps in // SessionController's prefs restore and the dependent controllers' // one-shot account-ready reactions (mirrors // `session_controller_test.dart`'s `pumpUntil` loop). for (var i = 0; i < 30; i++) { await tester.pump(const Duration(milliseconds: 10)); } } /// Advances exactly far enough to finish a push/pop transition /// (`MaterialPageRoute`'s default is 300ms) without risking /// `pumpAndSettle` picking up a pending periodic timer. Future settleNav(WidgetTester tester) async { await tester.pump(); for (var i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 50)); } } const categoryTitles = [ 'Accounts', 'Security', 'File sync', 'Files cache', 'Appearance', 'Tabs', 'Action bar', 'Swipe on a file', ]; testWidgets( 'shows a top-level menu of category rows, not every section inline', (tester) async { await pumpSettings(tester); // The account card (pinned, not a category row of its own) ... expect(find.text('alice'), findsOneWidget); // ... plus exactly one row per section. for (final title in categoryTitles) { expect(find.text(title), findsOneWidget); } // A section's own content isn't rendered until its row is tapped - // this is a menu, not the old all-sections-inline column. expect( find.text("Require this device's PIN or biometric to open Noo"), findsNothing, ); }, ); testWidgets( 'tapping a category row pushes just that section, with a way back', (tester) async { await pumpSettings(tester); await tester.tap(find.text('Security')); await settleNav(tester); // Landed on a separate, pushed Security screen: its own content - // not shown anywhere on the top-level menu - is now visible. (The // menu screen below it may stay mounted per `PageRoute.maintainState`, // so this checks for the pushed screen's content rather than the // menu's absence.) expect( find.text("Require this device's PIN or biometric to open Noo"), findsOneWidget, ); // At least one `NooTopBarBack` now leads back - the pushed screen's // own, on top of `AccountView`'s own (for returning to the shell). expect(find.byType(NooTopBarBack), findsAtLeastNWidgets(1)); // Tapping the topmost back button pops the pushed screen back off, // taking its content with it - unlike the underlying menu, a popped // route is actually removed, so this absence check is meaningful. await tester.tap(find.byType(NooTopBarBack).last); await settleNav(tester); expect( find.text("Require this device's PIN or biometric to open Noo"), findsNothing, ); expect(find.text('Security'), findsOneWidget); expect(find.text('alice'), findsOneWidget); }, ); }