import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:melo/library/category_service.dart'; import 'package:melo/library/database.dart'; import 'package:melo/library/library_service.dart'; import 'package:melo/library/playlist_service.dart'; import 'package:melo/main.dart'; import 'package:melo/player/audio_handler.dart'; import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/download_service.dart'; import 'package:melo/services/melo_cloud_service.dart'; import 'package:melo/services/navidrome_service.dart'; import 'package:melo/services/offline_mode.dart'; import 'package:melo/services/sync_service.dart'; import 'package:melo/services/yt_download_service.dart'; import 'package:melo/settings/app_settings.dart'; import 'package:melo/shared/theme.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _MemorySpeicher implements TokenSpeicher { final Map werte = {}; @override Future lesen(String key) async => werte[key]; @override Future schreiben(String key, String wert) async => werte[key] = wert; @override Future loeschen(String key) async => werte.remove(key); } /// Die vier Bereiche des Hauptmenüs, in dieser Reihenfolge. const hauptmenue = ['Meine Musik', 'Download', 'Suchen', 'Favoriten']; void main() { late MeloDb db; late CategoryService kategorien; late LibraryService bibliothek; late PlaylistService listen; late BakaAuth auth; late AppSettings einstellungen; late OfflineMode offline; late YtDownloadService ytDownload; late DownloadService downloads; late SyncService sync; MeloAudioHandler? handler; setUp(() { SharedPreferences.setMockInitialValues({}); db = MeloDb(NativeDatabase.memory()); kategorien = CategoryService(db); bibliothek = LibraryService(db); listen = PlaylistService(db); auth = BakaAuth(speicher: _MemorySpeicher()); einstellungen = AppSettings(); offline = OfflineMode(); ytDownload = YtDownloadService(auth: auth); downloads = DownloadService(db: db, navidrome: NavidromeService()); sync = SyncService(db: db, cloud: MeloCloudService(auth: auth)); }); tearDown(() { kategorien.dispose(); handler?.dispose(); // Bewusst kein db.close(): drift plant beim Schließen eine Aufräum- // Aufgabe ein, die im Test-Rahmen nach einem tester.tap nie mehr an die // Reihe kommt — der Lauf hinge dann unbegrenzt. Die Datenbank liegt nur // im Arbeitsspeicher und verschwindet mit dem Testprozess. }); // Alle Dienste gehören dem Test und werden per .value gereicht. Würde der // Widget-Baum sie beim Abbauen selbst schließen, bliebe ein Aufräum-Timer // von drift im Test-Rahmen hängen — und ein anschließendes db.close() // käme gar nicht mehr zurück (der Testlauf hinge unbegrenzt). Widget bauen() { // Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen // Plattform-Kanal zu, den es im setUp noch nicht gibt. handler ??= MeloAudioHandler(db: db); return MultiProvider( providers: [ Provider.value(value: db), ChangeNotifierProvider.value(value: bibliothek), ChangeNotifierProvider.value(value: listen), Provider.value(value: handler!), ChangeNotifierProvider.value(value: kategorien), ChangeNotifierProvider.value(value: einstellungen), ChangeNotifierProvider.value(value: offline), ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: ytDownload), ChangeNotifierProvider.value(value: downloads), ChangeNotifierProvider.value(value: sync), ], child: MaterialApp(theme: MeloTheme.dark, home: const HomeShell()), ); } /// Baut den Baum ab und beendet die laufenden Timer. /// /// Muss im Testkörper passieren, nicht im tearDown: der Positions-Timer /// des Audio-Handlers läuft periodisch, und der Test-Rahmen prüft am Ende /// des Körpers, ob noch Timer offen sind. Future abbauen(WidgetTester tester) async { await tester.pumpWidget(const SizedBox()); handler?.dispose(); handler = null; // Die Zeit ein Stück vorstellen: beim Abbauen melden sich die // Datenbank-Ströme ab und planen dafür eine Aufräum-Aufgabe ein. Ein // pump() ohne Dauer lässt sie liegen, und der Test-Rahmen meldet dann // einen offenen Timer. await tester.pump(const Duration(milliseconds: 1)); } Finder eintrag(String name) => find.descendant( of: find.byType(BottomNavigationBar), matching: find.text(name), ); testWidgets('alle vier Bereiche stehen im Hauptmenü', (tester) async { await tester.pumpWidget(bauen()); await tester.pump(); final leiste = tester.widget(find.byType(BottomNavigationBar)); expect(leiste.items.map((i) => i.label).toList(), hauptmenue); await abbauen(tester); }); testWidgets('jeder Bereich ist beschriftet und liegt auf dem Bildschirm', (tester) async { await tester.pumpWidget(bauen()); await tester.pump(); final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio; for (final name in hauptmenue) { expect(eintrag(name), findsOneWidget, reason: '"$name" fehlt im Menü'); // Vorhanden genügt nicht — ein Eintrag außerhalb des Bildschirms oder // ohne Ausdehnung wäre für den Nutzer nicht da. final kasten = tester.getRect(eintrag(name)); expect(kasten.width, greaterThan(0), reason: '"$name" ohne Breite'); expect(kasten.height, greaterThan(0), reason: '"$name" ohne Höhe'); expect(kasten.bottom, lessThanOrEqualTo(bildschirm.height), reason: '"$name" liegt unterhalb des Bildschirms'); expect(kasten.top, greaterThanOrEqualTo(0), reason: '"$name" liegt oberhalb des Bildschirms'); } await abbauen(tester); }); for (var i = 1; i < hauptmenue.length; i++) { final ziel = i; testWidgets('Tipp auf "${hauptmenue[ziel]}" wechselt dorthin', (tester) async { await tester.pumpWidget(bauen()); await tester.pump(); await tester.tap(eintrag(hauptmenue[ziel])); await tester.pump(); expect( tester .widget(find.byType(BottomNavigationBar)) .currentIndex, ziel, ); await abbauen(tester); }); } testWidgets('das Hauptmenü klebt am unteren Rand', (tester) async { // Läge es woanders, wäre es kein Hauptmenü mehr. await tester.pumpWidget(bauen()); await tester.pump(); final menue = tester.getRect(find.byType(BottomNavigationBar)); final bildschirm = tester.view.physicalSize / tester.view.devicePixelRatio; expect(menue.bottom, closeTo(bildschirm.height, 1)); await abbauen(tester); }); group('Darstellung des Hauptmenüs', () { // Reine Theme-Prüfungen — kein Widget-Baum nötig. final menue = MeloTheme.dark.bottomNavigationBarTheme; test('der aktive Bereich hebt sich ab — nicht nur durch Farbe', () { // Wer Rot schlecht sieht, muss trotzdem erkennen, wo er ist. expect(menue.selectedItemColor, MeloTheme.red); expect(menue.selectedLabelStyle!.fontWeight, FontWeight.w700); expect(menue.unselectedLabelStyle!.fontWeight, FontWeight.w500); }); test('keine feste Schriftgröße — sonst verschwindet der Unterschied', () { // Ohne Angabe gelten Flutters 14 (aktiv) und 12 (inaktiv). Beide auf // 12 zu setzen nahm dem aktiven Reiter genau dieses Merkmal. expect(menue.selectedLabelStyle!.fontSize, isNull); expect(menue.unselectedLabelStyle!.fontSize, isNull); }); test('die inaktiven Einträge sind lesbar, nicht nur erahnbar', () { // text3 ist die Beiwerk-Stufe. Ein Hauptmenü ist kein Beiwerk. expect(menue.unselectedItemColor, MeloTheme.text2); }); test('die Beschriftungen bleiben auch bei inaktiven Einträgen stehen', () { // Ohne sie müsste man die Symbole raten. expect(menue.showUnselectedLabels, isTrue); }); }); }