Files
Melo/test/hauptmenue_test.dart
Hermes (Server)andClaude Opus 5 34d6d8e52a Zweiter Tab heisst wieder "Download" statt "Online"
Auf Wunsch von Dustin. Der Inhalt bleibt unveraendert (YouTube-Downloader
und Server-Bibliothek als Unterreiter), nur die Beschriftung aendert sich.
Die vier Bereiche: Meine Musik, Download, Suchen, Favoriten.

- Symbol ebenfalls zurueck auf Icons.download_outlined/download. Das ist
  kein neuer Entwurf: genau dieses Symbol trug der Tab, bevor er in
  Commit a625d70 in "Online" umbenannt wurde.
- Die Ueberschrift im Bereich selbst stand noch auf "Online" — ein Tab
  "Download", der auf einen Bildschirm "Online" fuehrt, waere verwirrend.
  Zieht jetzt nach.
- Tests und Testbeschreibungen entsprechend angepasst.

328 Tests gruen (1 uebersprungen), flutter analyze ohne Befund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
2026-08-21 14:22:36 +02:00

209 lines
7.9 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/services/baka_auth.dart';
import 'package:melo/services/melo_cloud_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<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 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 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);
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<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<SyncService>.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<void> 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<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());
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<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());
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);
});
});
}