Listen zeigen, welcher Titel gerade laeuft
Bisher scrollte man durch die Bibliothek und musste raten, wo man ist — die einzige Rueckmeldung war die Leiste ganz unten. Drei tanzende Balken ueber dem Coverbild des laufenden Titels, sein Name in Rot. Die Balken liegen auf dem Cover statt daneben: eine vierte Spalte haette dem Titel Platz genommen und die Zeile bei jedem Titelwechsel in der Breite springen lassen. Pausiert bleibt die Markierung stehen, die Balken stehen still. Bei abgeschalteten Systemanimationen ebenfalls — eine Endlosschleife ist genau das, was diese Einstellung vermeiden soll. Die Markierung sitzt in SongZeile und gilt damit in einem Rutsch fuer Meine Musik, Favoriten, Suche, Kuenstler, Kategorie und "Zuletzt hinzugefuegt". Warteschlange und Wiedergabeliste bauen ihre Zeilen selbst (dort steht rechts Entfernen statt Herz und Menue) und haben dasselbe Zeichen einzeln bekommen. Nur die laufende Zeile hoert auf den Wiedergabe-Zustand: der meldet auch den Pufferstand und feuert mehrmals pro Sekunde. Haenge jede sichtbare Zeile daran, baute sich die halbe Liste staendig neu auf. Aus dem Code-Review nachgebessert: Startwert fuer den Strom, sonst erschien die Markierung beim Zurueckscrollen einen Frame zu spaet; die Zeile aendert beim Wechsel nicht mehr die Form ihres Aufbaus, sonst verlor das Herz daneben sein Abo und sah kurz leer aus; die Wiedergabeliste bekam die Markierung ueberhaupt erst (der Changelog hatte sie faelschlich schon behauptet); der Abdunkel-Schleier nimmt den Eckenradius jetzt entgegen statt ihn fest zu verdrahten. Testluecke geschlossen, die der Review benannt hat: geprueft waren nur die Regel und die Balken je fuer sich, die Verdrahtung dazwischen nicht — genau dort sassen alle drei Fehler. 411 Tests gruen (vorher 396), flutter analyze ohne Befund, Release-APK gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
co-authored by
Claude Opus 5
parent
ca28264c0e
commit
80948f1ad5
@@ -0,0 +1,178 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:melo/library/category_service.dart';
|
||||
import 'package:melo/library/database.dart';
|
||||
import 'package:melo/library/playlist_service.dart';
|
||||
import 'package:melo/library/song_list.dart';
|
||||
import 'package:melo/library/song_media.dart';
|
||||
import 'package:melo/player/audio_handler.dart';
|
||||
import 'package:melo/settings/app_settings.dart';
|
||||
import 'package:melo/shared/lauf_balken.dart';
|
||||
|
||||
/// Die Verdrahtung zwischen Player und Songzeile — der Teil zwischen der
|
||||
/// reinen Regel [istAmLaufen] und dem isolierten [LaufMarkierung]. Genau
|
||||
/// hier saßen die Fehler: fehlender Startwert (die Markierung erschien einen
|
||||
/// Frame zu spät) und ein Formwechsel des Widget-Baums, der das Herz daneben
|
||||
/// sein Abo verlieren ließ.
|
||||
void main() {
|
||||
late MeloDb db;
|
||||
late CategoryService kategorien;
|
||||
late PlaylistService playlists;
|
||||
late AppSettings einstellungen;
|
||||
late MeloAudioHandler handler;
|
||||
|
||||
/// Die Plugin-Kanäle, die `MeloAudioHandler` beim Bauen anfasst. Ohne diese
|
||||
/// Antworten werfen sie asynchron und der Testlauf meldet Fremdfehler.
|
||||
void stelleKanaeleRuhig() {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(call) async => '/tmp/melo-test',
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||
(call) async => null,
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
stelleKanaeleRuhig();
|
||||
db = MeloDb(NativeDatabase.memory());
|
||||
kategorien = CategoryService(db);
|
||||
playlists = PlaylistService(db);
|
||||
einstellungen = AppSettings();
|
||||
await einstellungen.init();
|
||||
handler = MeloAudioHandler(db: db);
|
||||
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||
id: 'song-1',
|
||||
path: '/music/roses.mp3',
|
||||
title: 'Roses',
|
||||
artist: const Value('Neoni'),
|
||||
dateAddedMs: 0,
|
||||
updatedAtMs: 0,
|
||||
));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
handler.dispose();
|
||||
kategorien.dispose();
|
||||
playlists.dispose();
|
||||
});
|
||||
|
||||
Song derSong() => Song(
|
||||
id: 'song-1',
|
||||
path: '/music/roses.mp3',
|
||||
title: 'Roses',
|
||||
artist: 'Neoni',
|
||||
dateAddedMs: 0,
|
||||
updatedAtMs: 0,
|
||||
deleted: false,
|
||||
playCount: 0,
|
||||
categoriesEdited: false,
|
||||
metadataEdited: false,
|
||||
);
|
||||
|
||||
Future<void> pumpe(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<MeloDb>.value(value: db),
|
||||
Provider<MeloAudioHandler>.value(value: handler),
|
||||
ChangeNotifierProvider<CategoryService>.value(value: kategorien),
|
||||
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SongZeile(
|
||||
song: derSong(),
|
||||
warteschlange: [derSong()],
|
||||
index: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> aufraeumen(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
}
|
||||
|
||||
testWidgets('ohne Wiedergabe bleibt die Zeile unmarkiert', (tester) async {
|
||||
await pumpe(tester);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LaufBalken), findsNothing);
|
||||
|
||||
await aufraeumen(tester);
|
||||
});
|
||||
|
||||
testWidgets('läuft der Titel, ist die Zeile markiert', (tester) async {
|
||||
handler.mediaItem.add(songToMediaItem(derSong()));
|
||||
await pumpe(tester);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LaufBalken), findsOneWidget);
|
||||
|
||||
await aufraeumen(tester);
|
||||
});
|
||||
|
||||
testWidgets('die Markierung steht schon im ersten Frame', (tester) async {
|
||||
// Ohne initialData liefert der BehaviorSubject erst im nächsten Frame —
|
||||
// die Balken ploppten beim Zurückscrollen sichtbar nach.
|
||||
handler.mediaItem.add(songToMediaItem(derSong()));
|
||||
await tester.pump();
|
||||
|
||||
await pumpe(tester);
|
||||
// Genau ein Frame, kein zweiter.
|
||||
expect(find.byType(LaufBalken), findsOneWidget);
|
||||
|
||||
await aufraeumen(tester);
|
||||
});
|
||||
|
||||
testWidgets('ein anderer Titel markiert diese Zeile nicht', (tester) async {
|
||||
handler.mediaItem.add(const MediaItem(
|
||||
id: 'file:///music/anderes.mp3',
|
||||
title: 'Anderes',
|
||||
extras: {'songId': 'song-2'},
|
||||
));
|
||||
await pumpe(tester);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LaufBalken), findsNothing);
|
||||
|
||||
await aufraeumen(tester);
|
||||
});
|
||||
|
||||
testWidgets('der Wechsel zu markiert wirft die Zeile nicht weg',
|
||||
(tester) async {
|
||||
// Ein Formwechsel des Widget-Baums hätte das Herz daneben sein Abo
|
||||
// verlieren lassen; es sähe für einen Frame leer aus.
|
||||
await pumpe(tester);
|
||||
await tester.pump();
|
||||
final vorher = tester.element(find.byType(ListTile));
|
||||
|
||||
handler.mediaItem.add(songToMediaItem(derSong()));
|
||||
// Zwei Frames: der Strom liefert erst nach einem Microtask-Durchlauf.
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(LaufBalken), findsOneWidget);
|
||||
// Dasselbe Element wie vorher — der Baum wurde nicht verworfen.
|
||||
expect(tester.element(find.byType(ListTile)), same(vorher));
|
||||
|
||||
await aufraeumen(tester);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/library/database.dart';
|
||||
import 'package:melo/library/song_media.dart';
|
||||
import 'package:melo/player/audio_handler.dart';
|
||||
|
||||
Song _song(String id) => Song(
|
||||
id: id,
|
||||
path: '/music/$id.mp3',
|
||||
title: 'Titel $id',
|
||||
dateAddedMs: 0,
|
||||
updatedAtMs: 0,
|
||||
deleted: false,
|
||||
playCount: 0,
|
||||
categoriesEdited: false,
|
||||
metadataEdited: false,
|
||||
);
|
||||
|
||||
/// Ohne diese Regel zeigten die Listen nicht, wo man gerade ist: man scrollte
|
||||
/// durch die Bibliothek und musste raten, welcher Titel läuft.
|
||||
void main() {
|
||||
group('istAmLaufen', () {
|
||||
test('erkennt den laufenden Titel an seiner UUID', () {
|
||||
final laufend = songToMediaItem(_song('uuid-1'));
|
||||
|
||||
expect(istAmLaufen(laufend, 'uuid-1'), isTrue);
|
||||
});
|
||||
|
||||
test('ein anderer Titel läuft nicht', () {
|
||||
final laufend = songToMediaItem(_song('uuid-1'));
|
||||
|
||||
expect(istAmLaufen(laufend, 'uuid-2'), isFalse);
|
||||
});
|
||||
|
||||
test('ohne Wiedergabe läuft nichts', () {
|
||||
expect(istAmLaufen(null, 'uuid-1'), isFalse);
|
||||
});
|
||||
|
||||
test('ein Server-Titel markiert keine Zeile der Bibliothek', () {
|
||||
// Er trägt nur eine navidromeId, keine UUID — ohne die Prüfung auf den
|
||||
// leeren String würde er jede Zeile ohne UUID markieren.
|
||||
const serverTitel = MediaItem(
|
||||
id: 'https://musik.example/rest/stream.view?id=abc',
|
||||
title: 'Serverlied',
|
||||
extras: {'navidromeId': 'abc'},
|
||||
);
|
||||
|
||||
expect(istAmLaufen(serverTitel, ''), isFalse);
|
||||
expect(istAmLaufen(serverTitel, 'uuid-1'), isFalse);
|
||||
});
|
||||
|
||||
test('eine Zeile ohne UUID wird nie markiert', () {
|
||||
final laufend = songToMediaItem(_song('uuid-1'));
|
||||
|
||||
expect(istAmLaufen(laufend, ''), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:melo/shared/lauf_balken.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpe(
|
||||
WidgetTester tester, {
|
||||
required bool markiert,
|
||||
required bool spielt,
|
||||
bool ruhig = false,
|
||||
}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(disableAnimations: ruhig),
|
||||
child: Scaffold(
|
||||
body: Center(
|
||||
child: LaufMarkierung(
|
||||
markiert: markiert,
|
||||
spielt: spielt,
|
||||
child: const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Text('Cover'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('ohne Markierung steht nur das Cover da', (tester) async {
|
||||
await pumpe(tester, markiert: false, spielt: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Cover'), findsOneWidget);
|
||||
expect(find.byType(LaufBalken), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('der laufende Titel bekommt die Balken über das Cover',
|
||||
(tester) async {
|
||||
await pumpe(tester, markiert: true, spielt: true);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Cover'), findsOneWidget);
|
||||
expect(find.byType(LaufBalken), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('pausiert bleibt die Markierung, die Balken stehen still',
|
||||
(tester) async {
|
||||
await pumpe(tester, markiert: true, spielt: false);
|
||||
// Steht etwas still, kommt pumpAndSettle zurück. Liefe die Animation
|
||||
// weiter, liefe dieser Aufruf in die Zeitüberschreitung.
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(LaufBalken), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('bei abgeschalteten Systemanimationen läuft nichts endlos',
|
||||
(tester) async {
|
||||
await pumpe(tester, markiert: true, spielt: true, ruhig: true);
|
||||
// Dasselbe Kriterium: eine Endlosschleife brächte pumpAndSettle nie
|
||||
// zurück. Genau davor schützt die Einstellung.
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(LaufBalken), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('die Balken verschwinden, wenn der Titel wechselt',
|
||||
(tester) async {
|
||||
await pumpe(tester, markiert: true, spielt: false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(LaufBalken), findsOneWidget);
|
||||
|
||||
await pumpe(tester, markiert: false, spielt: false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(LaufBalken), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user