Files
plezy/test/screens/livetv/live_tv_screen_test.dart
T
edde746 ff461d9f71 fix(livetv): explain a guide emptied by the favorites filter
With "Default to Favorite Channels" enabled and no favorites stored —
or only favorites left over from a since-rebuilt lineup — the favorites
filter reduced the guide to zero channels and GuideTab rendered just
the timeline bar: no rows, no message, no sign a filter was active.
Users read it as Live TV being broken; the Aug 8 report in #887 shows
44 channels and 447 grid programs loading in the log while the
screenshot shows a blank guide and 0 favorite channels.

When the filter removes every loaded channel, the guide tab now shows
an empty state naming the cause with a "Show All Channels" action that
clears the filter. The action stays D-pad reachable: the tab-bar focus
handoff falls through to the action's focus node while the empty state
replaces GuideTab, and activating it hands focus back to the restored
guide content. Favorites that match no loaded channel get the same
treatment as an empty favorites list.

Verified: flutter test test/screens/livetv/, analyzer parity,
clean_translations --check --strict, and slang codegen freshness.

Refs #887.
2026-08-09 11:12:08 +02:00

323 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.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/models/livetv_channel.dart';
import 'package:plezy/models/livetv_program.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/livetv/live_tv_screen.dart';
import 'package:plezy/screens/livetv/tabs/guide_tab.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/multi_server_fixtures.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() => initializeDateFormatting('en'));
setUp(() async {
resetSharedPreferencesForTest();
LocaleSettings.setLocaleSync(AppLocale.en);
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.liveTvDefaultFavorites, true);
});
testWidgets('loaded empty favorites shows the favorites empty state and can restore all channels', (tester) async {
final harness = await _pumpLiveTvScreen(tester);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
harness.dispose();
});
expect(find.byIcon(Symbols.star_rounded), findsOneWidget);
expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']);
harness.liveTv.favorites.complete(const []);
await tester.pumpAndSettle();
expect(find.byType(GuideTab), findsNothing);
expect(find.text(t.liveTv.noFavoriteChannels), findsOneWidget);
expect(find.text(t.liveTv.showAllChannels), findsOneWidget);
await tester.tap(find.text(t.liveTv.showAllChannels));
await tester.pumpAndSettle();
expect(find.text(t.liveTv.noFavoriteChannels), findsNothing);
expect(find.byIcon(Symbols.star_outline_rounded), findsOneWidget);
expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']);
});
testWidgets('favorites matching no loaded channel show the favorites empty state', (tester) async {
final harness = await _pumpLiveTvScreen(tester);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
harness.dispose();
});
harness.liveTv.favorites.complete([FavoriteChannel(id: 'channel-gone', source: 'server://server-a/provider-a')]);
await tester.pumpAndSettle();
expect(find.byType(GuideTab), findsNothing);
expect(find.text(t.liveTv.noFavoriteChannels), findsOneWidget);
expect(find.text(t.liveTv.showAllChannels), findsOneWidget);
});
testWidgets('refresh keeps the favorites filter narrow while favorites reload', (tester) async {
final harness = await _pumpLiveTvScreen(tester, channelKeys: const ['channel-a', 'channel-b']);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
harness.dispose();
});
final favorite = FavoriteChannel(id: 'channel-a', source: 'server://server-a/provider-a');
harness.liveTv.favorites.complete([favorite]);
await tester.pumpAndSettle();
expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']);
await tester.tap(find.byIcon(Symbols.refresh_rounded));
await tester.pumpAndSettle();
expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']);
harness.liveTv.favorites.complete([favorite]);
await tester.pumpAndSettle();
expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']);
});
testWidgets('favorite read failure preserves raw Guide channels', (tester) async {
final harness = await _pumpLiveTvScreen(tester);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
harness.dispose();
});
expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']);
harness.liveTv.favorites.completeError(StateError('favorite read failed'));
await tester.pumpAndSettle();
expect(find.byIcon(Symbols.star_rounded), findsOneWidget);
expect(_guideChannels(tester).map((channel) => channel.key), ['channel-a']);
});
testWidgets('favorite failure keeps favorites loaded from healthy stores', (tester) async {
final failedLiveTv = _FakeLiveTvSupport(serverId: 'server-a', storeKey: 'store-a');
final healthyLiveTv = _FakeLiveTvSupport(serverId: 'server-b', storeKey: 'store-b');
final failedClient = _FakeMediaServerClient(failedLiveTv, serverId: ServerId('server-a'));
final healthyClient = _FakeMediaServerClient(healthyLiveTv, serverId: ServerId('server-b'));
final manager = MultiServerManager()
..debugRegisterClientForTesting(failedClient)
..debugRegisterClientForTesting(healthyClient);
final provider = testMultiServerProvider(manager);
provider.debugSetLiveTvServersForTesting([
LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a', lineup: 'provider-a'),
LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b', lineup: 'provider-b'),
]);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
provider.dispose();
manager.dispose();
});
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(theme: monoTheme(dark: true), home: const LiveTvScreen()),
),
),
),
);
failedLiveTv.favorites.completeError(StateError('favorite read failed'));
healthyLiveTv.favorites.complete([FavoriteChannel(id: 'channel-server-b', source: 'server://server-b/provider-b')]);
await tester.pumpAndSettle();
final guide = tester.widget<GuideTab>(find.byType(GuideTab));
final healthyChannel = guide.channels.singleWhere((channel) => channel.serverId == 'server-b');
expect(guide.isFavoriteChannel!(healthyChannel), isTrue);
expect(guide.channels.map((channel) => channel.serverId), ['server-b']);
});
testWidgets('favorite write failure keeps optimistic state, shows feedback, and leaves the queue usable', (
tester,
) async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.liveTvDefaultFavorites, false);
final harness = await _pumpLiveTvScreen(tester);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
harness.dispose();
});
harness.liveTv.writeFailures.add(StateError('favorite write failed'));
harness.liveTv.favorites.complete(const []);
await tester.pumpAndSettle();
await tester.longPress(find.text('Unique Channel A'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
var guide = tester.widget<GuideTab>(find.byType(GuideTab));
expect(guide.isFavoriteChannel!(guide.channels.single), isTrue);
expect(find.text(t.liveTv.favoritesUpdateFailed), findsOneWidget);
expect(harness.liveTv.writes.map((write) => write.map((favorite) => favorite.id).toList()), [
['channel-a'],
]);
await tester.longPress(find.text('Unique Channel A'));
await tester.pumpAndSettle();
guide = tester.widget<GuideTab>(find.byType(GuideTab));
expect(guide.isFavoriteChannel!(guide.channels.single), isFalse);
expect(harness.liveTv.writes.map((write) => write.map((favorite) => favorite.id).toList()), [
['channel-a'],
<String>[],
]);
});
}
List<LiveTvChannel> _guideChannels(WidgetTester tester) => tester.widget<GuideTab>(find.byType(GuideTab)).channels;
Future<_LiveTvHarness> _pumpLiveTvScreen(WidgetTester tester, {List<String>? channelKeys}) async {
final liveTv = _FakeLiveTvSupport(channelKeys: channelKeys);
final client = _FakeMediaServerClient(liveTv);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = testMultiServerProvider(manager);
provider.debugSetLiveTvServersForTesting([
LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr-a', lineup: 'provider-a'),
]);
final harness = _LiveTvHarness(manager: manager, provider: provider, liveTv: liveTv);
await tester.pumpWidget(
TranslationProvider(
child: InputModeTracker(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(theme: monoTheme(dark: true), home: const LiveTvScreen()),
),
),
),
);
await tester.pumpAndSettle();
return harness;
}
class _LiveTvHarness {
const _LiveTvHarness({required this.manager, required this.provider, required this.liveTv});
final MultiServerManager manager;
final MultiServerProvider provider;
final _FakeLiveTvSupport liveTv;
void dispose() {
provider.dispose();
manager.dispose();
}
}
class _FakeMediaServerClient implements MediaServerClient {
_FakeMediaServerClient(this.liveTv, {ServerId? serverId}) : serverId = serverId ?? ServerId('server-a');
@override
final LiveTvSupport liveTv;
@override
final ServerId serverId;
@override
String? get serverName => 'Server ${serverId.value}';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
ServerCapabilities get capabilities => const ServerCapabilities(liveTv: true);
@override
void close() {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeLiveTvSupport implements LiveTvSupport {
_FakeLiveTvSupport({this.serverId = 'server-a', this.storeKey = 'test-store', List<String>? channelKeys})
: channelKeys = channelKeys ?? [serverId == 'server-a' ? 'channel-a' : 'channel-$serverId'];
final String serverId;
final String storeKey;
final List<String> channelKeys;
final List<Completer<List<FavoriteChannel>>> _favoriteRequests = [];
int _servedFavoriteRequests = 0;
Completer<List<FavoriteChannel>> get favorites {
if (_favoriteRequests.length > _servedFavoriteRequests) {
return _favoriteRequests[_servedFavoriteRequests];
}
if (_servedFavoriteRequests > 0 && !_favoriteRequests[_servedFavoriteRequests - 1].isCompleted) {
return _favoriteRequests[_servedFavoriteRequests - 1];
}
final request = Completer<List<FavoriteChannel>>();
_favoriteRequests.add(request);
return request;
}
@override
LiveTvDvrSupport? get dvr => null;
@override
String get favoriteStoreKey => storeKey;
@override
FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.serverSlice;
@override
Future<String> buildFavoriteChannelSource({String? lineup}) async => 'server://$serverId/${lineup ?? 'default'}';
@override
Future<List<LiveTvChannel>> fetchChannels({String? lineup}) async => [
for (final key in channelKeys)
LiveTvChannel(
key: key,
title: key == 'channel-a' ? 'Unique Channel A' : 'Unique Channel $key',
serverId: serverId,
),
];
@override
Future<List<LiveTvProgram>> fetchSchedule({DateTime? from, DateTime? to}) async => const [];
@override
Future<List<FavoriteChannel>> fetchFavoriteChannels() {
if (_favoriteRequests.length == _servedFavoriteRequests) {
_favoriteRequests.add(Completer<List<FavoriteChannel>>());
}
return _favoriteRequests[_servedFavoriteRequests++].future;
}
final List<Object> writeFailures = [];
final List<List<FavoriteChannel>> writes = [];
@override
Future<void> setFavoriteChannels(List<FavoriteChannel> channels) async {
writes.add(List.of(channels));
if (writeFailures.isNotEmpty) throw writeFailures.removeAt(0);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}