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.
323 lines
12 KiB
Dart
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);
|
|
}
|