Files
plezy/test/screens/livetv/guide_tab_test.dart
T

390 lines
14 KiB
Dart

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,
logicalKey: LogicalKeyboardKey.enter,
timeStamp: Duration.zero,
);
LiveTvChannel _channel({String key = 'channel/7'}) =>
LiveTvChannel(key: key, identifier: 'station-7', callSign: 'SEVEN', serverId: 'server-a', liveDvrKey: 'dvr-a');
LiveTvProgram _program({String ratingKey = 'program/42', int beginsAt = 1_800_000_000, int endsAt = 1_800_003_600}) =>
LiveTvProgram(
ratingKey: ratingKey,
title: 'Evening News',
beginsAt: beginsAt,
endsAt: endsAt,
channelIdentifier: 'station-7',
serverId: 'server-a',
liveDvrKey: 'dvr-a',
);
void main() {
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) {
final controller = DpadSelectLongPressController();
var focusedChannel = _channel();
var focusedProgram = _program();
final pressedIdentity = guideAiringIdentity(focusedChannel, focusedProgram);
var detailsOpened = 0;
controller.handleKeyEvent(
_selectDown,
isOwnerActive: () => guideAiringIdentity(focusedChannel, focusedProgram) == pressedIdentity,
onShortPress: () {},
onLongPress: () {
controller.reset();
detailsOpened++;
},
);
async.elapse(const Duration(milliseconds: 250));
final replacementChannel = _channel();
final replacementProgram = _program();
expect(identical(replacementChannel, focusedChannel), isFalse);
expect(identical(replacementProgram, focusedProgram), isFalse);
focusedChannel = replacementChannel;
focusedProgram = replacementProgram;
async.elapse(const Duration(milliseconds: 249));
expect(detailsOpened, 0);
async.elapse(const Duration(milliseconds: 1));
expect(detailsOpened, 1);
async.elapse(const Duration(seconds: 1));
expect(detailsOpened, 1);
controller.dispose();
});
});
test('SELECT hold does not open details after focus moves to a different airing', () {
fakeAsync((async) {
final controller = DpadSelectLongPressController();
final focusedChannel = _channel();
var focusedProgram = _program();
final pressedIdentity = guideAiringIdentity(focusedChannel, focusedProgram);
var detailsOpened = 0;
controller.handleKeyEvent(
_selectDown,
isOwnerActive: () => guideAiringIdentity(focusedChannel, focusedProgram) == pressedIdentity,
onShortPress: () {},
onLongPress: () => detailsOpened++,
);
async.elapse(const Duration(milliseconds: 250));
focusedProgram = _program(beginsAt: 1_800_003_600, endsAt: 1_800_007_200);
expect(guideAiringIdentity(focusedChannel, focusedProgram), isNot(pressedIdentity));
async.elapse(const Duration(milliseconds: 250));
expect(detailsOpened, 0);
async.elapse(const Duration(seconds: 1));
expect(detailsOpened, 0);
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);
});
testWidgets('horizontal guide virtualization keeps the D-pad focus target rendered', (tester) async {
final harness = _GuideHarness.oneServer();
addTearDown(harness.dispose);
await harness.pump(tester);
harness.serverA.schedule.completeSlots(0, 12);
await tester.pumpAndSettle();
expect(find.text('Slot 12'), findsNothing);
final guideFocus = tester.widget<Focus>(
find.byWidgetPredicate((widget) => widget is Focus && widget.focusNode?.debugLabel == 'guide_tab'),
);
guideFocus.focusNode!.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
for (var index = 0; index < 12; index++) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
}
final primary = Theme.of(tester.element(find.byType(GuideTab))).colorScheme.primary;
final focusedMaterial = find.byWidgetPredicate((widget) => widget is Material && widget.color == primary);
expect(find.text('Slot 1'), findsNothing);
expect(find.text('Slot 12'), findsOneWidget);
expect(find.ancestor(of: find.text('Slot 12'), matching: focusedMaterial), findsOneWidget);
});
}
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,
),
]);
}
void completeSlots(int index, int count) {
final request = requests[index];
final startEpoch = request.from.millisecondsSinceEpoch ~/ 1000;
request.completer.complete([
for (var slot = 0; slot < count; slot++)
LiveTvProgram(
ratingKey: '$serverId-$index-$slot',
title: 'Slot ${slot + 1}',
beginsAt: startEpoch + slot * 30 * 60,
endsAt: startEpoch + (slot + 1) * 30 * 60,
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>>();
}