Files
Melo/test/player/home_shell_expansion_test.dart
Hermes (Server)andClaude Sonnet 5 6687f2f67c Fix: PlayerExpansionController.dragEnd rät Öffnen/Schließen nicht mehr aus Vorzeichen
Bug (Code-Review, P0-nah): dragEnd() wählte die Entscheidungslogik allein
über das Vorzeichen von strecke. Bei bereits vollständig offenem Player
klappte ein schwaches, versehentliches Fingerzittern nach oben (kleine
negative Strecke, weit unter der Wisch-Schwelle) den Player ungewollt zu,
weil das Vorzeichen in den ÖFFNEN-Zweig führte statt in den (korrekten)
Schließen-Zweig, der bei so einer schwachen Geste keine Wirkung hätte.

dragEnd() bekommt jetzt den Pflichtparameter `ausVollbild`, der den
Aufrufer-Kontext explizit benennt statt ihn zu erraten. mini_player.dart
(Öffnen-Geste) ruft mit ausVollbild: false, now_playing_screen.dart
(Schließen-Geste) mit ausVollbild: true.

Test zuerst (RED mit der alten 3-Parameter-Signatur reproduziert, dann
GREEN nach dem Fix): test/player/player_expansion_controller_test.dart.
Bestehende Tests (player_expansion_controller_test.dart,
home_shell_expansion_test.dart) an die neue Signatur angepasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
2026-08-29 15:36:46 +02:00

172 lines
6.5 KiB
Dart

import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:melo/player/player_expansion_controller.dart';
// Minimaler Host, der dieselbe Provider/PopScope-Verdrahtung wie HomeShell
// nachstellt, ohne die volle App (Audio-Handler etc.) hochfahren zu müssen.
// Der eigentliche HomeShell-Umbau wird direkt gegen main.dart getestet,
// sobald Task 4/5 die Stack-Struktur eingeführt haben; dieser erste Test
// sichert nur die PopScope-Erweiterung ab, isoliert an einem Test-Double.
class _TestShell extends StatefulWidget {
const _TestShell({this.tabContent = const SizedBox.shrink()});
final Widget tabContent;
@override
State<_TestShell> createState() => _TestShellState();
}
class _TestShellState extends State<_TestShell> with TickerProviderStateMixin {
late final PlayerExpansionController _controller;
@override
void initState() {
super.initState();
_controller = PlayerExpansionController(vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Eigener AnimatedBuilder um PopScope, damit canPop bei jedem
// _controller-Tick neu ausgewertet wird (Reaktivitäts-Regel aus dem
// Plan) — ohne ihn bliebe canPop auf dem Wert des allerersten Builds
// eingefroren, weil sonst nichts hier ein Rebuild auslöst.
return ChangeNotifierProvider<PlayerExpansionController>.value(
value: _controller,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => PopScope(
canPop: _controller.progress == 0,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _controller.close(context);
},
child: child!,
),
// Derselbe Stack-Aufbau wie HomeShell (Task 4): Blur-Layer und
// Tab-Blocker bekommen jeweils ihren eigenen AnimatedBuilder, NICHT
// nur den äußeren aus dem PopScope-Consumer oben (Reaktivitäts-Regel
// aus dem Plan) — sonst würde dieser Test den genau davor gefundenen
// Bug (Review-Panel) nicht abdecken.
child: Scaffold(
body: Stack(
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) => IgnorePointer(
ignoring: _controller.progress > 0,
child: child,
),
child: widget.tabContent,
),
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final p = _controller.progress;
if (p == 0) return const SizedBox.shrink();
return Positioned.fill(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: p * 20.0, sigmaY: p * 20.0),
child: Container(color: Colors.transparent),
),
);
},
),
],
),
),
),
);
}
}
void main() {
testWidgets('Zurück-Taste schließt den Player statt zu poppen, wenn offen',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
// pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne
// zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) —
// Navigator.maybePop() löst denselben PopScope-Callback aus.
await Navigator.maybePop(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
});
testWidgets('Blur-Layer erscheint erst, wenn progress > 0', (tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
expect(find.byType(BackdropFilter), findsNothing);
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
// Ein zusätzlicher, leerer pump() startet den Ticker — sein erster Tick
// liefert laut Flutter-Konvention elapsed=0 (Baseline), erst der
// folgende pump(duration) rückt die Animation tatsächlich vorwärts.
// Ohne diesen Zwischenschritt bliebe progress nach nur einem
// pump(duration) fälschlich bei 0.0 (per Debug-Ausgabe verifiziert).
await tester.pump();
await tester.pump(const Duration(milliseconds: 50)); // mitten in der Animation
expect(find.byType(BackdropFilter), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('Tab-Inhalt ist bei offenem Player nicht mehr antippbar',
(tester) async {
var getappt = false;
await tester.pumpWidget(MaterialApp(
home: _TestShell(
tabContent: GestureDetector(
onTap: () => getappt = true,
child: const SizedBox(width: 200, height: 200),
),
),
));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.open(tester.element(find.byType(_TestShell)));
await tester.pumpAndSettle();
await tester.tap(find.byType(GestureDetector), warnIfMissed: false);
expect(getappt, isFalse);
});
testWidgets(
'Kompletter Zyklus: Ziehen öffnet, Blur erscheint, Zurück-Taste schließt',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: _TestShell()));
final ctx = tester.element(find.byType(_TestShell));
final controller =
tester.state<_TestShellState>(find.byType(_TestShell))._controller;
controller.dragBy(-250);
await tester.pump();
expect(controller.progress, closeTo(250 / oeffnenReferenzHoehe, 0.01));
expect(find.byType(BackdropFilter), findsOneWidget);
controller.dragEnd(ctx, -250, 0, ausVollbild: false);
await tester.pumpAndSettle();
expect(controller.progress, 1.0);
// pageBack() sucht einen sichtbaren Zurück-Button, den es hier ohne
// zweite Route nicht gibt (siehe Plan-Hinweis für den Umsetzer) —
// Navigator.maybePop() löst denselben PopScope-Callback aus.
await Navigator.maybePop(ctx);
await tester.pumpAndSettle();
expect(controller.progress, 0.0);
expect(find.byType(BackdropFilter), findsNothing);
});
}