Bug (Code-Review, P0 — blockierte den Push): PlayerExpansionController wurde bisher nur INNERHALB von HomeShell.build() bereitgestellt. Per Navigator.push geöffnete Screens (PlaylistDetailScreen, ServerTitelScreen) sind im Widget-Baum aber Geschwister von HomeShell im selben Navigator/Overlay, keine Nachfahren — sie sahen den Provider nicht. context.read<PlayerExpansionController>().open(context) warf dort zur Laufzeit ProviderNotFoundException, vom bestehenden try/catch abgefangen zu einer "Wiedergabe fehlgeschlagen"-Snackbar — der Player öffnete sich nie. MeloApp ist jetzt ein StatefulWidget (TickerProviderStateMixin), erzeugt PlayerExpansionController in initState() und stellt ihn in seiner MultiProvider-Liste bereit — oberhalb von MaterialApp/Navigator, wie alle anderen App-weiten Provider. HomeShell erzeugt keinen eigenen mehr, sondern liest die Referenz per context.read<PlayerExpansionController>() einmalig (keine Subscription) und reicht sie an seine AnimatedBuilder weiter, die weiterhin direkt am Controller lauschen — die bewusste "Consumer nur um PopScope"-Optimierung (Scaffold wird nicht bei jedem progress-Tick neu gebaut) bleibt dabei erhalten. Test zuerst (RED mit der heutigen, echten ProviderNotFoundException reproduziert — via echtem HomeShell + echtem Navigator.push, nicht dem bisherigen Kurzschluss-Testaufbau, der den Provider manuell direkt neben das Test-Widget klebt): test/player/provider_scope_navigator_push_test.dart. Bestehende Tests, die HomeShell direkt aufbauen (hauptmenue_test.dart, home_shell_test.dart, home_shell_real_expansion_test.dart), an die neue Verdrahtung angepasst — der Controller kommt jetzt aus ihrer eigenen MultiProvider-Liste statt aus HomeShell selbst. Volle Suite (625 Tests) grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
234 lines
9.0 KiB
Dart
234 lines
9.0 KiB
Dart
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/player/player_expansion_controller.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/services/yt_search_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<String, String> werte = {};
|
|
@override
|
|
Future<String?> lesen(String key) async => werte[key];
|
|
@override
|
|
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
|
|
@override
|
|
Future<void> loeschen(String key) async => werte.remove(key);
|
|
}
|
|
|
|
/// Die fünf Bereiche des Hauptmenüs, in dieser Reihenfolge.
|
|
const hauptmenue = [
|
|
'Meine Musik',
|
|
'Suchen',
|
|
'Download',
|
|
'Favoriten',
|
|
'YT-Suche'
|
|
];
|
|
|
|
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 YtSearchService ytSearch;
|
|
late DownloadService downloads;
|
|
late SyncService sync;
|
|
MeloAudioHandler? handler;
|
|
PlayerExpansionController? expansion;
|
|
|
|
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);
|
|
ytSearch = YtSearchService(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(WidgetTester tester) {
|
|
// Der Audio-Handler erst hier: sein Zwischenspeicher greift auf einen
|
|
// Plattform-Kanal zu, den es im setUp noch nicht gibt.
|
|
handler ??= MeloAudioHandler(db: db);
|
|
// PlayerExpansionController sitzt seit dem Provider-Scope-Fix (main.dart)
|
|
// oberhalb von MaterialApp/HomeShell, nicht mehr innerhalb von
|
|
// HomeShell.build() — hier deshalb dieselbe Verdrahtung nachgebaut.
|
|
expansion ??= PlayerExpansionController(vsync: tester);
|
|
return MultiProvider(
|
|
providers: [
|
|
Provider<MeloDb>.value(value: db),
|
|
ChangeNotifierProvider<LibraryService>.value(value: bibliothek),
|
|
ChangeNotifierProvider<PlaylistService>.value(value: listen),
|
|
Provider<MeloAudioHandler>.value(value: handler!),
|
|
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
|
|
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
|
|
ChangeNotifierProvider<OfflineMode>.value(value: offline),
|
|
ChangeNotifierProvider<BakaAuth>.value(value: auth),
|
|
ChangeNotifierProvider<YtDownloadService>.value(value: ytDownload),
|
|
ChangeNotifierProvider<YtSearchService>.value(value: ytSearch),
|
|
ChangeNotifierProvider<DownloadService>.value(value: downloads),
|
|
ChangeNotifierProvider<SyncService>.value(value: sync),
|
|
ChangeNotifierProvider<PlayerExpansionController>.value(
|
|
value: expansion!),
|
|
],
|
|
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<void> abbauen(WidgetTester tester) async {
|
|
await tester.pumpWidget(const SizedBox());
|
|
handler?.dispose();
|
|
handler = null;
|
|
expansion?.dispose();
|
|
expansion = 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(tester));
|
|
await tester.pump();
|
|
|
|
final leiste =
|
|
tester.widget<BottomNavigationBar>(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(tester));
|
|
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(tester));
|
|
await tester.pump();
|
|
|
|
await tester.tap(eintrag(hauptmenue[ziel]));
|
|
await tester.pump();
|
|
|
|
expect(
|
|
tester
|
|
.widget<BottomNavigationBar>(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(tester));
|
|
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);
|
|
});
|
|
});
|
|
}
|