fix(media): serialize browsing and metadata mutations

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent 43a8fe020d
commit f8bfecf57d
30 changed files with 3088 additions and 465 deletions
+247 -1
View File
@@ -1,11 +1,30 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/live_tv_support.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/dpad_select_long_press_controller.dart';
import 'package:plezy/models/livetv_channel.dart';
import 'package:plezy/models/livetv_program.dart';
import 'package:plezy/screens/livetv/tabs/guide_tab.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:provider/provider.dart';
const _selectDown = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.enter,
@@ -28,7 +47,16 @@ LiveTvProgram _program({String ratingKey = 'program/42', int beginsAt = 1_800_00
);
void main() {
tearDown(SelectKeyUpSuppressor.clearSuppression);
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() => initializeDateFormatting('en'));
setUp(() {
LocaleSettings.setLocaleSync(AppLocale.en);
TvDetectionService.debugSetAppleTVOverride(false);
});
tearDown(() {
SelectKeyUpSuppressor.clearSuppression();
TvDetectionService.debugSetAppleTVOverride(null);
});
test('SELECT hold survives equivalent fresh guide objects and opens details once', () {
fakeAsync((async) {
@@ -93,4 +121,222 @@ void main() {
controller.dispose();
});
});
testWidgets('superseded guide load keeps one interval and cannot replace current programs', (tester) async {
final harness = _GuideHarness.twoServers();
addTearDown(harness.dispose);
await harness.pump(tester);
await harness.completeInitial(tester);
final rightButton = _rightTimeButton();
expect(rightButton, findsOneWidget);
await tester.tap(rightButton);
await tester.tap(rightButton);
expect(harness.serverA.schedule.requests, hasLength(3));
final older = harness.serverA.schedule.requests[1];
final newer = harness.serverA.schedule.requests[2];
expect(older.to.difference(older.from), const Duration(hours: 6));
expect(newer.to.difference(newer.from), const Duration(hours: 6));
expect(newer.from.difference(older.from), const Duration(hours: 2));
harness.serverA.schedule.complete(2, 'Current A');
await tester.pump();
expect(harness.serverB!.schedule.requests, hasLength(2));
final currentB = harness.serverB!.schedule.requests[1];
expect(currentB.from, newer.from);
expect(currentB.to, newer.to);
harness.serverB!.schedule.complete(1, 'Current B');
await tester.pumpAndSettle();
expect(find.text('Current A'), findsOneWidget);
expect(find.text('Current B'), findsOneWidget);
harness.serverA.schedule.complete(1, 'Obsolete A');
await tester.pump();
await tester.pump();
expect(harness.serverB!.schedule.requests, hasLength(2));
expect(find.text('Obsolete A'), findsNothing);
expect(find.text('Current A'), findsOneWidget);
expect(find.text('Current B'), findsOneWidget);
});
testWidgets('obsolete completion cannot clear loading owned by a newer guide request', (tester) async {
final harness = _GuideHarness.oneServer();
addTearDown(harness.dispose);
await harness.pump(tester);
await harness.completeInitial(tester);
final rightButton = _rightTimeButton();
await tester.tap(rightButton);
await tester.tap(rightButton);
expect(harness.serverA.schedule.requests, hasLength(3));
harness.serverA.schedule.complete(1, 'Obsolete');
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Obsolete'), findsNothing);
harness.serverA.schedule.complete(2, 'Current');
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Current'), findsOneWidget);
expect(find.text('Obsolete'), findsNothing);
});
}
Finder _rightTimeButton() {
final icon = find.byWidgetPredicate((widget) => widget is AppIcon && widget.icon == Symbols.chevron_right_rounded);
return find.ancestor(of: icon, matching: find.byType(IconButton));
}
final class _GuideHarness {
_GuideHarness._({required this.serverA, required this.serverB, required this.provider, required this.channels});
factory _GuideHarness.oneServer() => _GuideHarness._create(includeServerB: false);
factory _GuideHarness.twoServers() => _GuideHarness._create(includeServerB: true);
factory _GuideHarness._create({required bool includeServerB}) {
final serverA = _FakeMediaServerClient(serverId: 'server-a', stationId: 'station-a');
final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null;
final manager = MultiServerManager()..debugRegisterClientForTesting(serverA);
if (serverB != null) manager.debugRegisterClientForTesting(serverB);
final provider = MultiServerProvider(manager, DataAggregationService(manager))
..debugSetLiveTvServersForTesting([
LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a'),
if (serverB != null) LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b'),
]);
return _GuideHarness._(
serverA: serverA,
serverB: serverB,
provider: provider,
channels: [
_guideChannel(serverId: 'server-a', stationId: 'station-a', callSign: 'A'),
if (serverB != null) _guideChannel(serverId: 'server-b', stationId: 'station-b', callSign: 'B'),
],
);
}
final _FakeMediaServerClient serverA;
final _FakeMediaServerClient? serverB;
final MultiServerProvider provider;
final List<LiveTvChannel> channels;
Future<void> pump(WidgetTester tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(body: GuideTab(channels: channels)),
),
),
),
),
);
expect(serverA.schedule.requests, hasLength(1));
}
Future<void> completeInitial(WidgetTester tester) async {
serverA.schedule.complete(0, 'Initial A');
await tester.pump();
final serverB = this.serverB;
if (serverB != null) {
expect(serverB.schedule.requests, hasLength(1));
serverB.schedule.complete(0, 'Initial B');
}
await tester.pumpAndSettle();
expect(find.text('Initial A'), findsOneWidget);
if (serverB != null) expect(find.text('Initial B'), findsOneWidget);
}
void dispose() => provider.dispose();
}
LiveTvChannel _guideChannel({required String serverId, required String stationId, required String callSign}) =>
LiveTvChannel(
key: 'channel-$stationId',
identifier: stationId,
callSign: callSign,
serverId: serverId,
liveDvrKey: 'dvr-$serverId',
);
final class _FakeMediaServerClient implements MediaServerClient {
_FakeMediaServerClient({required String serverId, required String stationId})
: serverId = ServerId(serverId),
schedule = _ControllableLiveTvSupport(serverId: serverId, stationId: stationId);
@override
final ServerId serverId;
final _ControllableLiveTvSupport schedule;
@override
LiveTvSupport get liveTv => schedule;
@override
String get serverName => serverId.value;
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _ControllableLiveTvSupport implements LiveTvSupport {
_ControllableLiveTvSupport({required this.serverId, required this.stationId});
final String serverId;
final String stationId;
final List<_ScheduleRequest> requests = [];
@override
LiveTvDvrSupport? get dvr => null;
@override
Future<List<LiveTvProgram>> fetchSchedule({DateTime? from, DateTime? to}) {
final request = _ScheduleRequest(from: from!, to: to!);
requests.add(request);
return request.completer.future;
}
void complete(int index, String title) {
final request = requests[index];
final beginsAt = request.from.millisecondsSinceEpoch ~/ 1000 + 600;
request.completer.complete([
LiveTvProgram(
ratingKey: '$serverId-$index',
title: title,
beginsAt: beginsAt,
endsAt: beginsAt + 3600,
channelIdentifier: stationId,
serverId: serverId,
),
]);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _ScheduleRequest {
_ScheduleRequest({required this.from, required this.to});
final DateTime from;
final DateTime to;
final Completer<List<LiveTvProgram>> completer = Completer<List<LiveTvProgram>>();
}