Hauptmenue wieder deutlich sichtbar

Entwarnung vorweg: es fehlt nichts. Alle vier Bereiche (Meine Musik,
Online, Suchen, Favoriten) standen durchgehend in der Bottom-Nav;
lib/main.dart wurde von der UI-Politur gar nicht angefasst.

Aber die Politur hatte das Menue abgeschwaecht — zwei eigene Fehler:
- selectedLabelStyle/unselectedLabelStyle bekamen beide fontSize 12. Damit
  verlor der aktive Eintrag den Groessenunterschied (Flutter-Vorgabe 14 zu
  12), an dem man erkennt, wo man ist. Jetzt keine fontSize mehr.
- unselectedItemColor stand auf text3 (Beiwerk-Stufe). Ein Hauptmenue ist
  kein Beiwerk -> text2.

Zusaetzlich deutlicher: Haarlinie ueber der Leiste (auf Schwarz ging sie
sonst im Inhalt auf), gefuellte Symbole fuer den aktiven Bereich (zweites
Merkmal neben der Farbe) und fetterer aktiver Eintrag.

Neu: test/hauptmenue_test.dart — prueft Vorhandensein, Sichtbarkeit auf dem
Bildschirm, Position am unteren Rand, Wechsel per Tipp und die Abhebung des
aktiven Bereichs. Damit kann keine weitere Politur das Menue unbemerkt
abschwaechen.

Nebenbei eine Test-Falle geloest: await db.close() nach einem tester.tap
laesst den Lauf unbegrenzt haengen (drift plant eine Aufraeum-Aufgabe ein,
die der Test-Rahmen nicht mehr abarbeitet). Rezept im Test dokumentiert.
Beim bekannten Haenger in song_detail_sheet_test.dart hilft es NICHT —
gegengeprueft und als eigene Ursache im BACKLOG vermerkt.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
This commit is contained in:
Hermes (Server)
2026-08-21 14:13:11 +02:00
co-authored by Claude Opus 5
parent 1547f43099
commit 5b6fa949a9
5 changed files with 281 additions and 7 deletions
+208
View File
@@ -0,0 +1,208 @@
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', 'Online', '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);
});
});
}