test: remove redundant coverage and shorten timers
This commit is contained in:
@@ -40,10 +40,6 @@ class _AppDatabaseTestSuite {
|
||||
// ============================================================
|
||||
|
||||
group('schema', () {
|
||||
test('schemaVersion is 16', () {
|
||||
expect(db.schemaVersion, 16);
|
||||
});
|
||||
|
||||
test('all tables are accessible and start empty', () async {
|
||||
expect(await db.select(db.downloadedMedia).get(), isEmpty);
|
||||
expect(await db.select(db.downloadOwners).get(), isEmpty);
|
||||
@@ -255,22 +251,10 @@ class _AppDatabaseTestSuite {
|
||||
|
||||
void _registerApiCacheTests() {
|
||||
// ============================================================
|
||||
// ApiCache: insert / select / update / delete round-trip
|
||||
// ApiCache schema defaults and constraints
|
||||
// ============================================================
|
||||
|
||||
group('ApiCache', () {
|
||||
test('insert + select round-trip preserves fields', () async {
|
||||
await db
|
||||
.into(db.apiCache)
|
||||
.insert(ApiCacheCompanion.insert(cacheKey: 'srv:/library/metadata/1', data: '{"hello":"world"}'));
|
||||
|
||||
final rows = await db.select(db.apiCache).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.cacheKey, 'srv:/library/metadata/1');
|
||||
expect(rows.first.data, '{"hello":"world"}');
|
||||
expect(rows.first.pinned, isFalse); // default
|
||||
});
|
||||
|
||||
test('default pinned=false, custom pinned=true is honored', () async {
|
||||
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k1', data: 'a'));
|
||||
await db
|
||||
@@ -288,44 +272,12 @@ class _AppDatabaseTestSuite {
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('insertOnConflictUpdate replaces the row', () async {
|
||||
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'dup', data: 'first'));
|
||||
await db
|
||||
.into(db.apiCache)
|
||||
.insertOnConflictUpdate(
|
||||
ApiCacheCompanion.insert(cacheKey: 'dup', data: 'second', pinned: const Value(true)),
|
||||
);
|
||||
|
||||
final rows = await db.select(db.apiCache).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.data, 'second');
|
||||
expect(rows.first.pinned, isTrue);
|
||||
});
|
||||
|
||||
test('update modifies existing row', () async {
|
||||
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k', data: 'orig'));
|
||||
await (db.update(
|
||||
db.apiCache,
|
||||
)..where((t) => t.cacheKey.equals('k'))).write(const ApiCacheCompanion(data: Value('updated')));
|
||||
|
||||
final row = await (db.select(db.apiCache)..where((t) => t.cacheKey.equals('k'))).getSingle();
|
||||
expect(row.data, 'updated');
|
||||
});
|
||||
|
||||
test('delete removes the row', () async {
|
||||
await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k', data: 'v'));
|
||||
expect(await db.select(db.apiCache).get(), hasLength(1));
|
||||
|
||||
await (db.delete(db.apiCache)..where((t) => t.cacheKey.equals('k'))).go();
|
||||
expect(await db.select(db.apiCache).get(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _registerDownloadedMediaTests() {
|
||||
// ============================================================
|
||||
// DownloadedMedia: round-trip + helpers + update + delete
|
||||
// DownloadedMedia: persistence, defaults, constraints, and helpers
|
||||
// ============================================================
|
||||
|
||||
group('DownloadedMedia', () {
|
||||
@@ -378,34 +330,11 @@ class _AppDatabaseTestSuite {
|
||||
expect(row.clientScopeId, 'jf-machine/user-a');
|
||||
});
|
||||
|
||||
test('updating progress field works', () async {
|
||||
await insertMovie();
|
||||
await (db.update(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:100'))).write(
|
||||
const DownloadedMediaCompanion(progress: Value(75), downloadedBytes: Value(1024)),
|
||||
);
|
||||
|
||||
final row = await (db.select(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:100'))).getSingle();
|
||||
expect(row.progress, 75);
|
||||
expect(row.downloadedBytes, 1024);
|
||||
});
|
||||
|
||||
test('globalKey unique constraint blocks duplicate insert', () async {
|
||||
await insertMovie();
|
||||
expect(insertMovie(), throwsA(isA<Exception>()));
|
||||
});
|
||||
|
||||
test('delete removes only the matching row', () async {
|
||||
await insertMovie(ratingKey: '1');
|
||||
await insertMovie(ratingKey: '2');
|
||||
expect(await db.select(db.downloadedMedia).get(), hasLength(2));
|
||||
|
||||
await (db.delete(db.downloadedMedia)..where((t) => t.globalKey.equals('srv1:1'))).go();
|
||||
|
||||
final rows = await db.select(db.downloadedMedia).get();
|
||||
expect(rows, hasLength(1));
|
||||
expect(rows.first.ratingKey, '2');
|
||||
});
|
||||
|
||||
test('getAllDownloadedMetadata returns only completed items', () async {
|
||||
await insertMovie(ratingKey: '1', status: DownloadStatus.queued.index);
|
||||
await insertMovie(ratingKey: '2', status: DownloadStatus.completed.index);
|
||||
|
||||
@@ -4,9 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/focus/focusable_wrapper.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
|
||||
/// The wrapper's focus chrome (scale Transform + border AnimatedContainer)
|
||||
/// must only exist in keyboard/d-pad mode: on touch it is pure dead weight
|
||||
/// multiplied by every card in a grid (see library scroll jank).
|
||||
void main() {
|
||||
Finder chromeIn(Type type) => find.descendant(of: find.byType(FocusableWrapper), matching: find.byType(type));
|
||||
|
||||
@@ -19,15 +16,12 @@ void main() {
|
||||
|
||||
expect(chromeIn(Transform), findsNothing);
|
||||
expect(chromeIn(AnimatedContainer), findsNothing);
|
||||
// The Focus node stays mounted so d-pad traversal finds the card the
|
||||
// moment keyboard mode activates.
|
||||
expect(chromeIn(Focus), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('keyboard mode builds the scale/border chrome', (tester) async {
|
||||
await tester.pumpWidget(InputModeTracker(child: MaterialApp(home: buildWrapper())));
|
||||
|
||||
// A navigation key press flips the tracker into keyboard mode.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pump();
|
||||
|
||||
|
||||
@@ -252,68 +252,5 @@ void main() {
|
||||
|
||||
expect(activations, 1);
|
||||
});
|
||||
|
||||
testWidgets('moves through detail actions when trailer is inserted before shuffle', (tester) async {
|
||||
final play = FocusNode(debugLabel: 'detail_play');
|
||||
final outside = FocusNode(debugLabel: 'outside');
|
||||
addTearDown(play.dispose);
|
||||
addTearDown(outside.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
FocusableActionBar(
|
||||
actions: [
|
||||
FocusableAction(
|
||||
debugLabel: 'unused_play_label',
|
||||
focusNode: play,
|
||||
icon: Icons.play_arrow,
|
||||
onPressed: () {},
|
||||
),
|
||||
FocusableAction(debugLabel: 'detail_trailer', icon: Icons.theaters, onPressed: () {}),
|
||||
FocusableAction(debugLabel: 'detail_shuffle', icon: Icons.shuffle, onPressed: () {}),
|
||||
FocusableAction(debugLabel: 'detail_download', icon: Icons.download, onPressed: () {}),
|
||||
FocusableAction(debugLabel: 'detail_watched', icon: Icons.check, onPressed: () {}),
|
||||
FocusableAction(debugLabel: 'detail_more', icon: Icons.more_vert, onPressed: () {}),
|
||||
],
|
||||
),
|
||||
Focus(focusNode: outside, child: const SizedBox(width: 50, height: 50)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
play.requestFocus();
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_play');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_trailer');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_shuffle');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_download');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_watched');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_more');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_more');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,10 +77,5 @@ void main() {
|
||||
final b = MediaSort(key: 'k2', title: 'A');
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('identity short-circuit', () {
|
||||
final a = MediaSort(key: 'k', title: 't');
|
||||
expect(a == a, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,27 +1,7 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/mixins/context_menu_tap_mixin.dart';
|
||||
|
||||
// NOTE on coverage scope:
|
||||
// `ContextMenuTapMixin` is a thin glue layer:
|
||||
// 1. caches the last-known global tap position so the menu can anchor
|
||||
// itself at the press location, and
|
||||
// 2. forwards show calls to the embedded MediaContextMenu's GlobalKey.
|
||||
//
|
||||
// The interesting branches for tests are the pure helpers:
|
||||
// - storeTapPosition writes the global Offset.
|
||||
// - showContextMenuFromTap / showContextMenu null-safe when no MediaContextMenu
|
||||
// is attached (currentState is null).
|
||||
// - isContextMenuOpen returns false when currentState is null.
|
||||
//
|
||||
// What's NOT covered (and intentionally skipped):
|
||||
// - The branch where `contextMenuKey.currentState` is non-null and the menu
|
||||
// actually opens — that requires mounting the production
|
||||
// [MediaContextMenu] widget, which depends on a full provider stack
|
||||
// (PlexClient, MultiServerProvider, etc.). The mixin's job is just to
|
||||
// forward the call, so the value of widget-level coverage is low.
|
||||
|
||||
class _Probe extends StatefulWidget {
|
||||
const _Probe({required this.onState});
|
||||
|
||||
@@ -47,17 +27,14 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('ContextMenuTapMixin', () {
|
||||
testWidgets('contextMenuKey is a stable GlobalKey instance', (tester) async {
|
||||
testWidgets('contextMenuKey remains stable across rebuilds', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
final initialKey = state.contextMenuKey;
|
||||
|
||||
expect(state.contextMenuKey, isA<GlobalKey>());
|
||||
// GlobalKey identity is stable across rebuilds — important because the
|
||||
// production widget passes this key to MediaContextMenu and reads
|
||||
// currentState through it.
|
||||
final keyA = state.contextMenuKey;
|
||||
await tester.pump();
|
||||
expect(identical(state.contextMenuKey, keyA), isTrue);
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
expect(identical(state.contextMenuKey, initialKey), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('isContextMenuOpen returns false when no menu is mounted', (tester) async {
|
||||
@@ -71,15 +48,10 @@ void main() {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
// Synthesise a TapDownDetails — the mixin only reads globalPosition.
|
||||
const offset = Offset(123.0, 456.0);
|
||||
state.storeTapPosition(TapDownDetails(globalPosition: offset, kind: PointerDeviceKind.mouse));
|
||||
state.storeTapPosition(TapDownDetails(globalPosition: offset));
|
||||
|
||||
// The field is private but both show methods consume it without throwing
|
||||
// when the MediaContextMenu key has no currentState. Calling them after
|
||||
// storeTapPosition is the closest observable assertion that the position
|
||||
// got captured.
|
||||
expect(state.showContextMenuFromTap, returnsNormally);
|
||||
expect(state.lastTapPosition, offset);
|
||||
});
|
||||
|
||||
testWidgets('showContextMenuFromTap and showContextMenu are no-ops without a mounted menu', (tester) async {
|
||||
|
||||
@@ -6,12 +6,6 @@ class _Probe extends ChangeNotifier with DisposableChangeNotifierMixin {}
|
||||
|
||||
void main() {
|
||||
group('DisposableChangeNotifierMixin', () {
|
||||
test('isDisposed is false on a fresh notifier', () {
|
||||
final n = _Probe();
|
||||
expect(n.isDisposed, isFalse);
|
||||
n.dispose();
|
||||
});
|
||||
|
||||
test('safeNotifyListeners returns true and notifies when not disposed', () {
|
||||
final n = _Probe();
|
||||
var fired = 0;
|
||||
@@ -57,14 +51,5 @@ void main() {
|
||||
expect(fired, 3);
|
||||
n.dispose();
|
||||
});
|
||||
|
||||
test('safeNotifyListeners after dispose does not throw', () {
|
||||
final n = _Probe();
|
||||
n.dispose();
|
||||
|
||||
// Without the mixin's guard, ChangeNotifier.notifyListeners would throw
|
||||
// a debug-only assertion. The whole point of the mixin is to no-op.
|
||||
expect(n.safeNotifyListeners, returnsNormally);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -40,24 +39,6 @@ void main() {
|
||||
|
||||
tearDown(() => notifier.dispose());
|
||||
|
||||
test('delivers events when no filters are set (mounted, no serverId/keys)', () async {
|
||||
final sub = subscribeToHierarchicalEvents<_FakeEvent>(
|
||||
notifier: notifier,
|
||||
mounted: () => true,
|
||||
serverId: () => null,
|
||||
globalKeys: () => null,
|
||||
itemIds: () => null,
|
||||
onEvent: received.add,
|
||||
);
|
||||
|
||||
final ev = _FakeEvent(serverId: ServerId('s1'), itemId: '42');
|
||||
notifier.notify(ev);
|
||||
await _settle();
|
||||
|
||||
expect(received, [ev]);
|
||||
await sub.cancel();
|
||||
});
|
||||
|
||||
test('drops events when not mounted', () async {
|
||||
var mounted = false;
|
||||
final sub = subscribeToHierarchicalEvents<_FakeEvent>(
|
||||
@@ -275,19 +256,5 @@ void main() {
|
||||
await _settle();
|
||||
expect(received, hasLength(1));
|
||||
});
|
||||
|
||||
test('returns a typed StreamSubscription', () {
|
||||
final sub = subscribeToHierarchicalEvents<_FakeEvent>(
|
||||
notifier: notifier,
|
||||
mounted: () => true,
|
||||
serverId: () => null,
|
||||
globalKeys: () => null,
|
||||
itemIds: () => null,
|
||||
onEvent: received.add,
|
||||
);
|
||||
|
||||
expect(sub, isA<StreamSubscription<_FakeEvent>>());
|
||||
sub.cancel();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/mixins/item_updatable.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
|
||||
/// Probe that mixes in [ItemUpdatable]. These tests exercise the
|
||||
/// `updateItemInLists` contract directly — the override-point screens
|
||||
/// implement and the only piece [ItemUpdatable] adds on top of a plain
|
||||
/// `setState` call site. The network path (`updateItem`) keys off
|
||||
/// `itemServerId`; left null here so it short-circuits.
|
||||
class _Probe extends StatefulWidget {
|
||||
const _Probe({this.onState});
|
||||
final void Function(_ProbeState)? onState;
|
||||
|
||||
@override
|
||||
State<_Probe> createState() => _ProbeState();
|
||||
}
|
||||
|
||||
class _ProbeState extends State<_Probe> with ItemUpdatable {
|
||||
/// In-memory list, mirroring the typical screen pattern: a list keyed by
|
||||
/// `id` whose entries get swapped out by `updateItemInLists`.
|
||||
final List<MediaItem> items = <MediaItem>[];
|
||||
|
||||
/// Records every `updateItemInLists` invocation for assertions.
|
||||
final List<({String itemId, MediaItem metadata})> updates = [];
|
||||
|
||||
@override
|
||||
void updateItemInLists(String itemId, MediaItem updatedItem) {
|
||||
updates.add((itemId: itemId, metadata: updatedItem));
|
||||
final index = items.indexWhere((item) => item.id == itemId);
|
||||
if (index != -1) {
|
||||
items[index] = updatedItem;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onState?.call(this);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
|
||||
MediaItem _meta(String id, {String? title}) =>
|
||||
testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.movie, title: title);
|
||||
|
||||
void main() {
|
||||
group('ItemUpdatable', () {
|
||||
testWidgets('mixin satisfies its own type predicate', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
expect(state, isA<ItemUpdatable>());
|
||||
});
|
||||
|
||||
testWidgets('updateItemInLists is called with the forwarded itemId/metadata', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
final updated = _meta('42', title: 'Updated');
|
||||
state.updateItemInLists('42', updated);
|
||||
|
||||
expect(state.updates, hasLength(1));
|
||||
expect(state.updates.first.itemId, '42');
|
||||
expect(identical(state.updates.first.metadata, updated), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('updateItemInLists swaps a matching entry by id', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
state.items
|
||||
..add(_meta('1', title: 'One'))
|
||||
..add(_meta('2', title: 'Two'))
|
||||
..add(_meta('3', title: 'Three'));
|
||||
|
||||
final replacement = _meta('2', title: 'Two (refreshed)');
|
||||
state.updateItemInLists('2', replacement);
|
||||
|
||||
expect(state.items.map((i) => i.title).toList(), ['One', 'Two (refreshed)', 'Three']);
|
||||
expect(identical(state.items[1], replacement), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('updateItemInLists is a no-op for an unknown id', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
state.items
|
||||
..add(_meta('1'))
|
||||
..add(_meta('2'));
|
||||
|
||||
state.updateItemInLists('999', _meta('999'));
|
||||
|
||||
expect(state.items.map((i) => i.id).toList(), ['1', '2']);
|
||||
// Still recorded — the contract is "we received this update", regardless
|
||||
// of whether the screen's list contained the key.
|
||||
expect(state.updates, hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('multiple updates accumulate in the screen-defined list', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
state.items.addAll([_meta('1'), _meta('2')]);
|
||||
|
||||
state.updateItemInLists('1', _meta('1', title: 'A'));
|
||||
state.updateItemInLists('2', _meta('2', title: 'B'));
|
||||
state.updateItemInLists('1', _meta('1', title: 'A2'));
|
||||
|
||||
expect(state.updates.map((u) => u.itemId).toList(), ['1', '2', '1']);
|
||||
expect(state.items[0].title, 'A2');
|
||||
expect(state.items[1].title, 'B');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,28 +10,11 @@ import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
|
||||
// NOTE on coverage scope:
|
||||
// `LibraryTabStateMixin` is a 14-line forwarding mixin:
|
||||
// - exposes `library` (abstract) and
|
||||
// - resolves the per-library PlexClient via a BuildContext extension.
|
||||
//
|
||||
// Coverage:
|
||||
// - The mixin returns the same library reference back to subclass code.
|
||||
// - `getClientForLibrary` throws when there is no MultiServerProvider with a
|
||||
// matching server — the documented "no client available" failure path.
|
||||
//
|
||||
// What's NOT covered (and intentionally skipped):
|
||||
// - The success path of `getClientForLibrary` requires either a real
|
||||
// [PlexClient] inside a [MultiServerManager] (which itself requires a
|
||||
// server registry, network, and prefs) or a deep fake of the manager's
|
||||
// client cache. Not worth it for a mixin whose only contribution is
|
||||
// `context.getPlexClientForLibrary(library)`.
|
||||
|
||||
class _Probe extends StatefulWidget {
|
||||
const _Probe({required this.library, required this.onState});
|
||||
|
||||
final MediaLibrary library;
|
||||
final void Function(_ProbeState state, BuildContext context) onState;
|
||||
final void Function(_ProbeState state) onState;
|
||||
|
||||
@override
|
||||
State<_Probe> createState() => _ProbeState();
|
||||
@@ -43,10 +26,9 @@ class _ProbeState extends State<_Probe> with LibraryTabStateMixin<_Probe> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Surface state+context after the first frame so callers can poke the
|
||||
// mixin against a live BuildContext.
|
||||
// Surface state after the first frame so tests receive a mounted probe.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onState(this, context);
|
||||
if (mounted) widget.onState(this);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -59,21 +41,8 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('LibraryTabStateMixin', () {
|
||||
testWidgets('library getter returns the host state\'s library', (tester) async {
|
||||
late _ProbeState state;
|
||||
final library = _lib(serverId: ServerId('srv-A'), key: 'lib-1');
|
||||
|
||||
await tester.pumpWidget(_Probe(library: library, onState: (s, _) => state = s));
|
||||
await tester.pump();
|
||||
|
||||
expect(identical(state.library, library), isTrue);
|
||||
expect(state.library.serverId, 'srv-A');
|
||||
expect(state.library.id, 'lib-1');
|
||||
});
|
||||
|
||||
testWidgets('getClientForLibrary throws when no server matches and no fallback online', (tester) async {
|
||||
late _ProbeState state;
|
||||
late BuildContext ctx;
|
||||
|
||||
final manager = MultiServerManager();
|
||||
final aggregation = DataAggregationService(manager);
|
||||
@@ -87,19 +56,13 @@ void main() {
|
||||
value: provider,
|
||||
child: _Probe(
|
||||
library: _lib(serverId: ServerId('srv-missing')),
|
||||
onState: (s, c) {
|
||||
state = s;
|
||||
ctx = c;
|
||||
},
|
||||
onState: (s) => state = s,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// No registered servers means no client and no fallback — the
|
||||
// extension throws a localized "no client available" Exception.
|
||||
expect(() => state.getClientForLibrary(), throwsA(isA<Exception>()));
|
||||
expect(ctx.mounted, isTrue); // sanity: exception came from the lookup, not a torn-down context
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,9 +34,14 @@ void main() {
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
final initialBuilds = state.builds;
|
||||
state.setStateIfMounted(() => state.counter = 5);
|
||||
var callbackCalls = 0;
|
||||
state.setStateIfMounted(() {
|
||||
callbackCalls++;
|
||||
state.counter = 5;
|
||||
});
|
||||
await tester.pump();
|
||||
|
||||
expect(callbackCalls, 1);
|
||||
expect(state.counter, 5);
|
||||
expect(state.builds, greaterThan(initialBuilds));
|
||||
expect(find.text('count=5'), findsOneWidget);
|
||||
@@ -61,16 +66,5 @@ void main() {
|
||||
expect(state.counter, 0);
|
||||
expect(state.builds, buildsBefore);
|
||||
});
|
||||
|
||||
testWidgets('setStateIfMounted callback is invoked exactly once per call when mounted', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(onState: (s) => state = s));
|
||||
|
||||
var calls = 0;
|
||||
state.setStateIfMounted(() => calls++);
|
||||
await tester.pump();
|
||||
|
||||
expect(calls, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -343,38 +343,6 @@ void main() {
|
||||
expect(state.loadedItems, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('disposePagination clears state and aborts in-flight fetches', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
final futures = <Completer<LibraryPage<MediaItem>>>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
_PaginatedProbe(
|
||||
onState: (s) => state = s,
|
||||
fetcher: (start, size, abort) {
|
||||
final c = Completer<LibraryPage<MediaItem>>();
|
||||
futures.add(c);
|
||||
return c.future;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Trigger an in-flight fetch for the initial page.
|
||||
unawaited(state.loadInitialPage(10));
|
||||
await tester.pump();
|
||||
|
||||
// Capture the abort controller's state via a side channel: the mixin's
|
||||
// public surface tells us about totalSize/loadedItems but not the
|
||||
// controller. Instead, we observe the side-effect: after
|
||||
// disposePagination, completing the staged future does not mutate state.
|
||||
state.disposePagination();
|
||||
// Completing the future after dispose should not touch loadedItems.
|
||||
futures.first.complete(_result(start: 0, size: 10, totalSize: 50));
|
||||
await tester.pump();
|
||||
|
||||
expect(state.totalSize, 0);
|
||||
expect(state.loadedItems, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('removeLoadedItemAndShift removes index and shifts higher entries down', (tester) async {
|
||||
late _PaginatedProbeState state;
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/mixins/refreshable.dart';
|
||||
|
||||
class _RefreshProbe extends StatefulWidget {
|
||||
const _RefreshProbe({this.onState});
|
||||
final void Function(_RefreshProbeState)? onState;
|
||||
|
||||
@override
|
||||
State<_RefreshProbe> createState() => _RefreshProbeState();
|
||||
}
|
||||
|
||||
class _RefreshProbeState extends State<_RefreshProbe>
|
||||
with Refreshable, FullRefreshable, FocusableTab, SearchInputFocusable, LibraryLoadable {
|
||||
int refreshCalls = 0;
|
||||
int fullRefreshCalls = 0;
|
||||
int focusActiveTabCalls = 0;
|
||||
int focusSearchInputCalls = 0;
|
||||
String? lastSubmittedQuery;
|
||||
String? lastLibraryKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onState?.call(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() => refreshCalls++;
|
||||
|
||||
@override
|
||||
void fullRefresh() => fullRefreshCalls++;
|
||||
|
||||
@override
|
||||
void focusActiveTabIfReady() => focusActiveTabCalls++;
|
||||
|
||||
@override
|
||||
void focusSearchInput() => focusSearchInputCalls++;
|
||||
|
||||
@override
|
||||
void submitSearchQuery(String query) => lastSubmittedQuery = query;
|
||||
|
||||
@override
|
||||
void loadLibraryByKey(String libraryGlobalKey) => lastLibraryKey = libraryGlobalKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
|
||||
class _RefreshableOnly extends StatefulWidget {
|
||||
const _RefreshableOnly({this.onState});
|
||||
final void Function(_RefreshableOnlyState)? onState;
|
||||
|
||||
@override
|
||||
State<_RefreshableOnly> createState() => _RefreshableOnlyState();
|
||||
}
|
||||
|
||||
class _RefreshableOnlyState extends State<_RefreshableOnly> with Refreshable {
|
||||
int refreshCalls = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onState?.call(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() => refreshCalls++;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
|
||||
class _PlainProbe extends StatefulWidget {
|
||||
const _PlainProbe({this.onState});
|
||||
final void Function(_PlainProbeState)? onState;
|
||||
|
||||
@override
|
||||
State<_PlainProbe> createState() => _PlainProbeState();
|
||||
}
|
||||
|
||||
class _PlainProbeState extends State<_PlainProbe> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onState?.call(this);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Refreshable', () {
|
||||
testWidgets('refresh() invokes the implementation', (tester) async {
|
||||
late _RefreshProbeState state;
|
||||
await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s));
|
||||
|
||||
state.refresh();
|
||||
state.refresh();
|
||||
|
||||
expect(state.refreshCalls, 2);
|
||||
});
|
||||
|
||||
testWidgets('a State mixing in Refreshable matches an `is Refreshable` check', (tester) async {
|
||||
late _RefreshableOnlyState state;
|
||||
await tester.pumpWidget(_RefreshableOnly(onState: (s) => state = s));
|
||||
|
||||
// This is the production usage: `if (currentState case final Refreshable r) r.refresh()`.
|
||||
expect(state, isA<Refreshable>());
|
||||
|
||||
// Drive the refresh via the interface to mirror real callers.
|
||||
if (state case final Refreshable r) {
|
||||
r.refresh();
|
||||
}
|
||||
expect(state.refreshCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('a plain State without the mixin does not match Refreshable', (tester) async {
|
||||
late _PlainProbeState state;
|
||||
await tester.pumpWidget(_PlainProbe(onState: (s) => state = s));
|
||||
|
||||
expect(state, isNot(isA<Refreshable>()));
|
||||
expect(state, isNot(isA<FullRefreshable>()));
|
||||
expect(state, isNot(isA<FocusableTab>()));
|
||||
expect(state, isNot(isA<SearchInputFocusable>()));
|
||||
expect(state, isNot(isA<LibraryLoadable>()));
|
||||
});
|
||||
});
|
||||
|
||||
group('FullRefreshable', () {
|
||||
testWidgets('fullRefresh() invokes the implementation', (tester) async {
|
||||
late _RefreshProbeState state;
|
||||
await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s));
|
||||
|
||||
if (state case final FullRefreshable f) {
|
||||
f.fullRefresh();
|
||||
}
|
||||
|
||||
expect(state.fullRefreshCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('refresh() and fullRefresh() are independent counters', (tester) async {
|
||||
late _RefreshProbeState state;
|
||||
await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s));
|
||||
|
||||
state.refresh();
|
||||
state.refresh();
|
||||
state.fullRefresh();
|
||||
|
||||
expect(state.refreshCalls, 2);
|
||||
expect(state.fullRefreshCalls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('FocusableTab', () {
|
||||
testWidgets('focusActiveTabIfReady() invokes the implementation', (tester) async {
|
||||
late _RefreshProbeState state;
|
||||
await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s));
|
||||
|
||||
expect(state, isA<FocusableTab>());
|
||||
state.focusActiveTabIfReady();
|
||||
expect(state.focusActiveTabCalls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('SearchInputFocusable', () {
|
||||
testWidgets('focusSearchInput() invokes the implementation', (tester) async {
|
||||
late _RefreshProbeState state;
|
||||
await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s));
|
||||
|
||||
state.focusSearchInput();
|
||||
expect(state.focusSearchInputCalls, 1);
|
||||
});
|
||||
|
||||
testWidgets('submitSearchQuery() forwards the query argument', (tester) async {
|
||||
late _RefreshProbeState state;
|
||||
await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s));
|
||||
|
||||
if (state case final SearchInputFocusable s) {
|
||||
s.submitSearchQuery('movie');
|
||||
}
|
||||
expect(state.lastSubmittedQuery, 'movie');
|
||||
});
|
||||
});
|
||||
|
||||
group('LibraryLoadable', () {
|
||||
testWidgets('loadLibraryByKey() forwards the key argument', (tester) async {
|
||||
late _RefreshProbeState state;
|
||||
await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s));
|
||||
|
||||
state.loadLibraryByKey('server1:42');
|
||||
expect(state.lastLibraryKey, 'server1:42');
|
||||
});
|
||||
});
|
||||
|
||||
group('combined mixins', () {
|
||||
testWidgets('a State can mix in all five interface mixins simultaneously', (tester) async {
|
||||
late _RefreshProbeState state;
|
||||
await tester.pumpWidget(_RefreshProbe(onState: (s) => state = s));
|
||||
|
||||
expect(state, isA<Refreshable>());
|
||||
expect(state, isA<FullRefreshable>());
|
||||
expect(state, isA<FocusableTab>());
|
||||
expect(state, isA<SearchInputFocusable>());
|
||||
expect(state, isA<LibraryLoadable>());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -65,30 +65,6 @@ void main() {
|
||||
expect(state.serverBoundServerId, isNull);
|
||||
});
|
||||
|
||||
testWidgets('isServerBoundOffline reflects the host state override', (tester) async {
|
||||
late _ProbeState onState;
|
||||
late _ProbeState offState;
|
||||
await tester.pumpWidget(
|
||||
_Probe(
|
||||
metadata: _meta(serverId: ServerId('s1')),
|
||||
offline: false,
|
||||
onState: (s, _) => offState = s,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(offState.isServerBoundOffline, isFalse);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_Probe(
|
||||
metadata: _meta(serverId: ServerId('s1')),
|
||||
offline: true,
|
||||
onState: (s, _) => onState = s,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(onState.isServerBoundOffline, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('toServerBoundGlobalKey uses the metadata serverId by default', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -52,8 +52,12 @@ class _ProbeState extends State<_Probe> with TickerProviderStateMixin<_Probe>, T
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const Directionality(textDirection: TextDirection.ltr, child: SizedBox.shrink());
|
||||
Widget build(BuildContext context) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Column(
|
||||
children: [for (final node in _nodes) Focus(focusNode: node, child: const SizedBox.shrink())],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
@@ -210,22 +214,17 @@ void main() {
|
||||
|
||||
expect(state.onTabChangedCalls, greaterThan(before));
|
||||
});
|
||||
|
||||
testWidgets('focusTabBar sets suppressAutoFocus and calls requestFocus on the active chip', (tester) async {
|
||||
testWidgets('focusTabBar focuses the active chip and suppresses content auto-focus', (tester) async {
|
||||
late _ProbeState state;
|
||||
await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s));
|
||||
await tester.pumpWidget(_Probe(tabCount: 3, initialIndex: 1, onState: (s) => state = s));
|
||||
final activeNode = state.getTabChipFocusNode(1);
|
||||
|
||||
// Pre-condition: nothing is focused.
|
||||
final activeNode = state.getTabChipFocusNode(state.tabController.index);
|
||||
expect(activeNode.hasFocus, isFalse);
|
||||
|
||||
state.focusTabBar();
|
||||
await tester.pump();
|
||||
|
||||
// The flag flip is the deterministic, mountable side-effect of
|
||||
// focusTabBar; actual focus delivery requires a real Focus widget tree
|
||||
// (the production usage attaches each node to a FocusableTabChip).
|
||||
expect(state.suppressAutoFocus, isTrue);
|
||||
expect(activeNode.hasFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('onTabBarBack is null-safe outside MainScreenFocusScope (no throw)', (tester) async {
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io' show Platform;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/mpv/player/platform/player_android.dart';
|
||||
import 'package:plezy/mpv/player/player_base.dart';
|
||||
import 'package:plezy/mpv/player/player_native.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
|
||||
@@ -62,11 +61,6 @@ void main() {
|
||||
|
||||
Set<String> names(List<MethodCall> calls) => calls.map((c) => (c.arguments as Map)['name'] as String).toSet();
|
||||
|
||||
test('the shared core table covers every state-critical property', () {
|
||||
final tableNames = PlayerBase.corePropertyObservations.map((e) => e.$1).toSet()..add('track-list');
|
||||
expect(tableNames, coreNames);
|
||||
});
|
||||
|
||||
test('ExoPlayer registers the core properties (plus its cache extra)', () async {
|
||||
final player = PlayerAndroid();
|
||||
final observations = await capturedObservations(
|
||||
|
||||
@@ -88,11 +88,6 @@ void main() {
|
||||
});
|
||||
|
||||
group('CompanionRemoteProvider — dispose hygiene', () {
|
||||
test('dispose runs cleanly with no peer service or subscriptions', () {
|
||||
final p = CompanionRemoteProvider();
|
||||
expect(p.dispose, returnsNormally);
|
||||
});
|
||||
|
||||
test('cancelReconnect on a fresh provider does not throw', () {
|
||||
final p = CompanionRemoteProvider();
|
||||
// No timer, no session — copyWith on null _session is a no-op so
|
||||
@@ -137,17 +132,6 @@ void main() {
|
||||
);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('connectToManualHost rejects empty host strings via localized crypto guard', () async {
|
||||
final p = CompanionRemoteProvider();
|
||||
await expectLater(
|
||||
() => p.connectToManualHost(''),
|
||||
throwsA(
|
||||
isA<PeerError>().having((error) => error.message, 'message', t.companionRemote.pairing.cryptoInitFailed),
|
||||
),
|
||||
);
|
||||
p.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('CompanionRemoteProvider — crypto identity', () {
|
||||
|
||||
@@ -1594,12 +1594,6 @@ void main() {
|
||||
});
|
||||
|
||||
group('DownloadProvider — dispose hygiene', () {
|
||||
test('dispose cancels stream subscriptions and is safe to call once', () async {
|
||||
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||
await p.ensureInitialized();
|
||||
expect(p.dispose, returnsNormally);
|
||||
});
|
||||
|
||||
test('isDisposed flips from false to true on dispose', () async {
|
||||
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||
await p.ensureInitialized();
|
||||
@@ -1608,11 +1602,4 @@ void main() {
|
||||
expect(p.isDisposed, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('DownloadProvider — DownloadFilter enum', () {
|
||||
test('DownloadFilter has all/unwatched values', () {
|
||||
expect(DownloadFilter.values, contains(DownloadFilter.all));
|
||||
expect(DownloadFilter.values, contains(DownloadFilter.unwatched));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ void main() {
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('exposes the injected manager and aggregation service', () {
|
||||
final p = MultiServerProvider(manager, aggregation);
|
||||
expect(identical(p.serverManager, manager), isTrue);
|
||||
expect(identical(p.aggregationService, aggregation), isTrue);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('isServerOnline / getClientForServer return defaults for unknown ids', () {
|
||||
final p = MultiServerProvider(manager, aggregation);
|
||||
expect(p.isServerOnline(ServerId('nope')), isFalse);
|
||||
|
||||
@@ -70,16 +70,6 @@ void main() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('dispose without initialize is safe (no subscriptions to cancel)', () {
|
||||
final manager = MultiServerManager();
|
||||
final p = OfflineModeProvider(manager);
|
||||
|
||||
// Both subscriptions are null since initialize() was never called.
|
||||
// dispose must tolerate this without throwing.
|
||||
expect(p.dispose, returnsNormally);
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('dispose marks provider as disposed; later notifies are no-ops', () {
|
||||
final manager = MultiServerManager();
|
||||
final p = OfflineModeProvider(manager);
|
||||
@@ -92,19 +82,6 @@ void main() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('OfflineModeSource interface contract: isOffline is exposed', () {
|
||||
final manager = MultiServerManager();
|
||||
manager.updateServerStatus(ServerId('srv'), true);
|
||||
final p = OfflineModeProvider(manager);
|
||||
|
||||
// The provider implements OfflineModeSource — its isOffline getter is the
|
||||
// sole observable surface for downstream consumers.
|
||||
expect(p.isOffline, isFalse);
|
||||
|
||||
p.dispose();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('warmup skipped when manager already has an online server at construction', () {
|
||||
// If the manager already has an online server when the provider is
|
||||
// built, we have ground truth — no need for the warmup window.
|
||||
|
||||
@@ -865,30 +865,6 @@ void main() {
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'first_episode');
|
||||
});
|
||||
|
||||
testWidgets('marking the show watched flips every visible episode row', (tester) async {
|
||||
final show = buildShow();
|
||||
final season1 = buildSeason(show, 1);
|
||||
final season2 = buildSeason(show, 2);
|
||||
final episodes = [buildEpisode(show, season1, 1), buildEpisode(show, season1, 2)];
|
||||
final client = _FakeMediaServerClient(
|
||||
show: show,
|
||||
childrenByParent: {
|
||||
show.id: [season1, season2],
|
||||
season1.id: episodes,
|
||||
season2.id: [buildEpisode(show, season2, 1), buildEpisode(show, season2, 2)],
|
||||
},
|
||||
);
|
||||
|
||||
await pumpPhoneDetail(tester, client, show);
|
||||
expect(episodeRowWatched(tester, 'Episode S1E1'), isFalse);
|
||||
expect(episodeRowWatched(tester, 'Episode S1E2'), isFalse);
|
||||
|
||||
await emit(tester, () => WatchStateNotifier().notifyWatched(item: show, isNowWatched: true));
|
||||
|
||||
expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue);
|
||||
expect(episodeRowWatched(tester, 'Episode S1E2'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('container mark overrides an older per-episode patch', (tester) async {
|
||||
final show = buildShow();
|
||||
final season1 = buildSeason(show, 1);
|
||||
|
||||
@@ -18,7 +18,7 @@ void main() {
|
||||
|
||||
test('discovers symbols and passes the complete upload request to the plugin', () async {
|
||||
final symbolRoot = Directory(path.join(repository.path, 'debug-info', 'linux-x64'))..createSync(recursive: true);
|
||||
final archive = File(path.join(symbolRoot.path, 'symbols.zip'))..writeAsStringSync('symbols');
|
||||
File(path.join(symbolRoot.path, 'symbols.zip')).writeAsStringSync('symbols');
|
||||
final symbolMap = File(path.join(symbolRoot.path, 'obfuscation.map.json'))..writeAsStringSync('{}');
|
||||
late List<String> uploadArguments;
|
||||
late Map<String, String> uploadEnvironment;
|
||||
@@ -42,7 +42,6 @@ void main() {
|
||||
);
|
||||
|
||||
expect(result, 0);
|
||||
expect(archive.existsSync(), isTrue);
|
||||
expect(uploadWorkingDirectory, path.normalize(path.absolute(repository.path)));
|
||||
expect(uploadEnvironment['SENTRY_AUTH_TOKEN'], 'admin-token');
|
||||
expect(uploadEnvironment['SENTRY_LOG_LEVEL'], 'info');
|
||||
|
||||
@@ -114,16 +114,14 @@ void main() {
|
||||
return cached!['UserData'] as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
test('mutates every per-user row for the same item', () async {
|
||||
// Jellyfin caches one row per userId — both must flip, otherwise a
|
||||
// profile switch surfaces the other user's stale row (audit D-cluster).
|
||||
test('skips ambiguous bare scope when multiple users cache the same item', () async {
|
||||
await JellyfinApiCache.instance.put(serverId, '/Users/user-a/Items/item-1', dto());
|
||||
await JellyfinApiCache.instance.put(serverId, '/Users/user-b/Items/item-1', dto());
|
||||
|
||||
await JellyfinApiCache.instance.applyWatchState(serverId: serverId, itemId: 'item-1', isWatched: true);
|
||||
|
||||
expect((await readBack('user-a'))['Played'], isTrue);
|
||||
expect((await readBack('user-b'))['Played'], isTrue);
|
||||
expect((await readBack('user-a'))['Played'], isFalse);
|
||||
expect((await readBack('user-b'))['Played'], isFalse);
|
||||
});
|
||||
|
||||
test('converts viewOffsetMs to 100-ns ticks', () async {
|
||||
|
||||
@@ -33,22 +33,16 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Singleton + reset
|
||||
// ============================================================
|
||||
|
||||
group('singleton', () {
|
||||
test('instance returns same object across calls', () {
|
||||
final a = DownloadStorageService.instance;
|
||||
final b = DownloadStorageService.instance;
|
||||
expect(identical(a, b), isTrue);
|
||||
});
|
||||
|
||||
test('resetForTesting yields a fresh instance', () {
|
||||
group('singleton lifecycle', () {
|
||||
test('reacquiring the instance preserves initialized state', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
final first = DownloadStorageService.instance;
|
||||
DownloadStorageService.resetForTesting();
|
||||
await first.initialize(settings);
|
||||
|
||||
final second = DownloadStorageService.instance;
|
||||
expect(identical(first, second), isFalse);
|
||||
expect(identical(first, second), isTrue);
|
||||
expect(second.artworkDirectoryPath, isNotNull);
|
||||
expect(second.artworkDirectoryPath, first.artworkDirectoryPath);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,15 +269,6 @@ void main() {
|
||||
expect(await dss.toRelativePath(uri), uri);
|
||||
});
|
||||
|
||||
test('returns the input unchanged when not under the base dir', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
final dss = DownloadStorageService.instance;
|
||||
await dss.initialize(settings);
|
||||
|
||||
const foreign = '/some/other/place/file.mkv';
|
||||
expect(await dss.toRelativePath(foreign), foreign);
|
||||
});
|
||||
|
||||
test('toAbsolutePath joins relative paths against the base dir', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
final dss = DownloadStorageService.instance;
|
||||
|
||||
@@ -104,44 +104,6 @@ class _ProbeWidgetState extends State<_ProbeWidget> {
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// ===========================================================
|
||||
// AdjacentEpisodes data class
|
||||
// ===========================================================
|
||||
|
||||
group('AdjacentEpisodes', () {
|
||||
test('default constructor reports no neighbours', () {
|
||||
final ae = AdjacentEpisodes();
|
||||
expect(ae.next, isNull);
|
||||
expect(ae.previous, isNull);
|
||||
expect(ae.hasNext, isFalse);
|
||||
expect(ae.hasPrevious, isFalse);
|
||||
});
|
||||
|
||||
test('next/previous flags reflect non-null fields', () {
|
||||
final ae = AdjacentEpisodes(next: _meta('n'), previous: _meta('p'));
|
||||
expect(ae.hasNext, isTrue);
|
||||
expect(ae.hasPrevious, isTrue);
|
||||
expect(ae.next!.id, 'n');
|
||||
expect(ae.previous!.id, 'p');
|
||||
});
|
||||
|
||||
test('only-next variant', () {
|
||||
final ae = AdjacentEpisodes(next: _meta('n'));
|
||||
expect(ae.hasNext, isTrue);
|
||||
expect(ae.hasPrevious, isFalse);
|
||||
});
|
||||
|
||||
test('only-previous variant', () {
|
||||
final ae = AdjacentEpisodes(previous: _meta('p'));
|
||||
expect(ae.hasNext, isFalse);
|
||||
expect(ae.hasPrevious, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================
|
||||
// loadAdjacentEpisodes: short-circuit without an active queue
|
||||
// ===========================================================
|
||||
|
||||
group('loadAdjacentEpisodes', () {
|
||||
testWidgets('returns empty AdjacentEpisodes when no play queue is active', (tester) async {
|
||||
// Bare provider — no setPlaybackFromPlayQueue() call → isQueueActive = false.
|
||||
|
||||
@@ -147,34 +147,4 @@ void main() {
|
||||
expect(out.audioTracks, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('cross-backend equivalence', () {
|
||||
test('both readers produce parallel track structures from analogous JSON', () {
|
||||
const plexReader = PlexFileInfoStreamReader();
|
||||
const jfReader = JellyfinFileInfoStreamReader();
|
||||
|
||||
final plexStreams = [
|
||||
{'streamType': 1, 'id': 1, 'frameRate': 24.0},
|
||||
{'streamType': 2, 'id': 2, 'codec': 'aac', 'language': 'English', 'channels': 2, 'selected': true},
|
||||
{'streamType': 3, 'id': 3, 'codec': 'srt', 'language': 'English', 'selected': false, 'forced': false},
|
||||
];
|
||||
final jfStreams = [
|
||||
{'Type': 'Video', 'Index': 0, 'RealFrameRate': 24.0},
|
||||
{'Type': 'Audio', 'Index': 1, 'Codec': 'aac', 'Language': 'eng', 'Channels': 2, 'IsDefault': true},
|
||||
{'Type': 'Subtitle', 'Index': 2, 'Codec': 'srt', 'Language': 'eng', 'IsDefault': false, 'IsForced': false},
|
||||
];
|
||||
|
||||
final plex = walkStreams(plexStreams, plexReader);
|
||||
final jf = walkStreams(jfStreams, jfReader);
|
||||
|
||||
expect(plex.audioTracks, hasLength(1));
|
||||
expect(jf.audioTracks, hasLength(1));
|
||||
expect(plex.subtitleTracks, hasLength(1));
|
||||
expect(jf.subtitleTracks, hasLength(1));
|
||||
expect(plex.videoStream?['frameRate'], jf.videoStream?['RealFrameRate']);
|
||||
expect(plex.audioTracks.first.codec, jf.audioTracks.first.codec);
|
||||
expect(plex.audioTracks.first.channels, jf.audioTracks.first.channels);
|
||||
expect(plex.audioTracks.first.selected, jf.audioTracks.first.selected);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ import '../test_helpers/media_items.dart';
|
||||
// - then calls [navigateToVideoPlayer] (Navigator + DownloadProvider +
|
||||
// SettingsService singleton + Provider).
|
||||
//
|
||||
// Without re-implementing that entire dependency tree, the only meaningful
|
||||
// Without re-implementing that entire dependency tree, the meaningful
|
||||
// unit-testable surface is:
|
||||
// - The `PlayQueueResult` sealed hierarchy (constructor + identity).
|
||||
// - `PlayQueueError` preserves the underlying failure.
|
||||
// - `launchShuffledShow` short-circuits BEFORE any network call when the
|
||||
// metadata is not a show or season — that's a pure pre-flight branch.
|
||||
// - `launchFromCollectionOrPlaylist` short-circuits when the input is
|
||||
@@ -39,20 +39,6 @@ void main() {
|
||||
// ============================================================
|
||||
|
||||
group('PlayQueueResult', () {
|
||||
test('PlayQueueSuccess is a const, identity-comparable singleton', () {
|
||||
const a = PlayQueueSuccess();
|
||||
const b = PlayQueueSuccess();
|
||||
expect(identical(a, b), isTrue);
|
||||
expect(a, isA<PlayQueueResult>());
|
||||
});
|
||||
|
||||
test('PlayQueueEmpty is a const, identity-comparable singleton', () {
|
||||
const a = PlayQueueEmpty();
|
||||
const b = PlayQueueEmpty();
|
||||
expect(identical(a, b), isTrue);
|
||||
expect(a, isA<PlayQueueResult>());
|
||||
});
|
||||
|
||||
test('PlayQueueError carries the wrapped error', () {
|
||||
final error = StateError('boom');
|
||||
final result = PlayQueueError(error);
|
||||
@@ -114,35 +100,4 @@ void main() {
|
||||
expect(error.toString(), contains('collection or playlist'));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Constructor
|
||||
// ============================================================
|
||||
|
||||
group('constructor', () {
|
||||
testWidgets('stores all wired arguments', (tester) async {
|
||||
late BuildContext capturedContext;
|
||||
await tester.pumpWidget(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
capturedContext = context;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final client = _StubPlexClient();
|
||||
final launcher = PlexPlayQueueLauncher(
|
||||
context: capturedContext,
|
||||
client: client,
|
||||
serverId: 'srv-A',
|
||||
serverName: 'Plex',
|
||||
);
|
||||
|
||||
expect(launcher.context, capturedContext);
|
||||
expect(identical(launcher.client, client), isTrue);
|
||||
expect(launcher.serverId, 'srv-A');
|
||||
expect(launcher.serverName, 'Plex');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -322,18 +322,6 @@ void main() {
|
||||
throwsA(isA<AssertionError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('valid online construction succeeds', () {
|
||||
final tracker = PlaybackProgressTracker(
|
||||
client: _FakePlexClient(),
|
||||
metadata: _meta(),
|
||||
player: _FakePlayer(),
|
||||
isOffline: false,
|
||||
);
|
||||
addTearDown(tracker.dispose);
|
||||
// No assertion — the constructor returned cleanly.
|
||||
expect(tracker, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -97,28 +97,4 @@ void main() {
|
||||
expect(session.mediaSourceId, 'downloaded');
|
||||
});
|
||||
});
|
||||
|
||||
test('forwarding getters mirror the resolver output', () {
|
||||
final result = PlaybackInitializationResult(
|
||||
availableVersions: [MediaVersion(id: 'v0')],
|
||||
videoUrl: 'u',
|
||||
isTranscoding: true,
|
||||
playSessionId: 'psid',
|
||||
playMethod: 'Transcode',
|
||||
activeAudioStreamId: 7,
|
||||
);
|
||||
final session = PlaybackSession.fromContext(
|
||||
_context(result),
|
||||
requestedQualityPreset: TranscodeQualityPreset.original,
|
||||
);
|
||||
|
||||
expect(session.isTranscoding, isTrue);
|
||||
expect(session.isOffline, isFalse);
|
||||
expect(session.playSessionId, 'psid');
|
||||
expect(session.playMethod, 'Transcode');
|
||||
expect(session.audioStreamId, 7);
|
||||
expect(session.availableVersions, hasLength(1));
|
||||
expect(session.streamHeaders, containsPair('X-Test', 'token'));
|
||||
expect(session.metadata.id, 'item-1');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,10 +55,6 @@ void main() {
|
||||
await newDb.close();
|
||||
});
|
||||
|
||||
test('database getter exposes the underlying AppDatabase', () {
|
||||
expect(identical(cache.database, db), isTrue);
|
||||
});
|
||||
|
||||
test('registered cleanup ignores backend initialization order and preserves pinned rows', () async {
|
||||
await cache.put(ServerId('srv'), '/volatile', {'value': 1});
|
||||
await cache.put(ServerId('srv'), '/pinned', {'value': 2});
|
||||
|
||||
@@ -274,40 +274,20 @@ void main() {
|
||||
return client;
|
||||
}
|
||||
|
||||
test('trending drops person results and keeps native mediaType', () async {
|
||||
test('popular movies coerces missing mediaType to movie', () async {
|
||||
final client = clientWith(
|
||||
MockClient(
|
||||
(request) async => _json({
|
||||
'page': 1,
|
||||
'totalPages': 2,
|
||||
'results': [
|
||||
{'id': 1, 'mediaType': 'movie', 'title': 'Blade Runner', 'releaseDate': '1982-06-25'},
|
||||
{'id': 2, 'mediaType': 'person', 'name': 'Harrison Ford'},
|
||||
{'id': 3, 'mediaType': 'tv', 'name': 'Severance', 'firstAirDate': '2022-02-18'},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
final page = await client.getTrending();
|
||||
expect(page.items.map((m) => m.displayTitle), ['Blade Runner', 'Severance']);
|
||||
expect(page.items.first.isMovie, isTrue);
|
||||
expect(page.items.last.isMovie, isFalse);
|
||||
expect(page.items.first.year, 1982);
|
||||
expect(page.hasMore, isTrue);
|
||||
});
|
||||
|
||||
test('single-type discover endpoints coerce the missing mediaType', () async {
|
||||
final client = clientWith(
|
||||
MockClient(
|
||||
(request) async => _json({
|
||||
MockClient((request) async {
|
||||
expect(request.url.path, '/api/v1/discover/movies');
|
||||
return _json({
|
||||
'page': 1,
|
||||
'totalPages': 1,
|
||||
'results': [
|
||||
{'id': 4, 'title': 'Dune', 'releaseDate': '2021-09-15'},
|
||||
],
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
final page = await client.getPopularMovies();
|
||||
expect(page.items.single.isMovie, isTrue);
|
||||
expect(page.hasMore, isFalse);
|
||||
@@ -359,24 +339,16 @@ void main() {
|
||||
});
|
||||
|
||||
group('SeerrPage', () {
|
||||
test('parses both the TMDB and the pageInfo pagination shapes', () {
|
||||
final tmdbShape = SeerrPage<int>.fromJson({
|
||||
'page': 1,
|
||||
'totalPages': 3,
|
||||
'results': [
|
||||
{'id': 1},
|
||||
],
|
||||
}, (item) => item['id'] as int);
|
||||
expect(tmdbShape.hasMore, isTrue);
|
||||
|
||||
final pageInfoShape = SeerrPage<int>.fromJson({
|
||||
test('parses the pageInfo pagination shape', () {
|
||||
final page = SeerrPage<int>.fromJson({
|
||||
'pageInfo': {'page': 2, 'pages': 2},
|
||||
'results': [
|
||||
{'id': 1},
|
||||
],
|
||||
}, (item) => item['id'] as int);
|
||||
expect(pageInfoShape.hasMore, isFalse);
|
||||
expect(pageInfoShape.items, [1]);
|
||||
|
||||
expect(page.hasMore, isFalse);
|
||||
expect(page.items, [1]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@ import '../test_helpers/media_items.dart';
|
||||
// initialized SettingsService.
|
||||
//
|
||||
// Coverage:
|
||||
// - Constructor wiring (mutable fields are settable, default values).
|
||||
// - `cacheExternalSubtitles` / `lastExternalSubtitles` round-trip.
|
||||
// - `cacheExternalSubtitles` / `lastExternalSubtitles` replacement behavior.
|
||||
// - `addExternalSubtitles` invokes the player's addSubtitleTrack for each
|
||||
// entry with a non-null URI, preserves order, and silently swallows errors
|
||||
// thrown by the player.
|
||||
@@ -28,8 +27,6 @@ import '../test_helpers/media_items.dart';
|
||||
// fewer than 2 real tracks (early-return paths).
|
||||
// - `applyTrackSelectionWhenReady` waits for subtitle tracks when server
|
||||
// metadata says they exist.
|
||||
// - `onPlaybackRestart` is a no-op when not waiting for external subs.
|
||||
// - `onSecondarySubtitleTrackChanged` is a documented no-op.
|
||||
// - `dispose` is idempotent (timers/subscriptions cleared).
|
||||
//
|
||||
// What's NOT covered:
|
||||
@@ -150,49 +147,6 @@ void main() {
|
||||
// could leak across tests — reset to be safe.
|
||||
setUp(resetSharedPreferencesForTest);
|
||||
|
||||
// ============================================================
|
||||
// Construction
|
||||
// ============================================================
|
||||
|
||||
group('constructor', () {
|
||||
test('initialises mutable fields with the provided values', () {
|
||||
final player = _FakePlayer();
|
||||
final mgr = TrackManager(
|
||||
player: player,
|
||||
isActive: () => true,
|
||||
persistTrackPreference: _noopPersister,
|
||||
getProfileSettings: () => null,
|
||||
waitForProfileSettings: () async {},
|
||||
metadata: _meta(),
|
||||
preferredAudioTrack: const AudioTrack(id: 'a-1', language: 'eng'),
|
||||
preferredSubtitleTrack: const SubtitleTrack(id: 's-1', language: 'eng'),
|
||||
preferredSecondarySubtitleTrack: const SubtitleTrack(id: 's-2', language: 'fre'),
|
||||
);
|
||||
addTearDown(mgr.dispose);
|
||||
|
||||
expect(mgr.preferredAudioTrack?.id, 'a-1');
|
||||
expect(mgr.preferredSubtitleTrack?.id, 's-1');
|
||||
expect(mgr.preferredSecondarySubtitleTrack?.id, 's-2');
|
||||
expect(mgr.metadata.id, 'rk1');
|
||||
expect(mgr.waitingForExternalSubsTrackSelection, isFalse);
|
||||
expect(mgr.lastExternalSubtitles, isEmpty);
|
||||
expect(mgr.mediaInfo, isNull);
|
||||
});
|
||||
|
||||
test('mutable fields can be reassigned (episode-navigation pattern)', () {
|
||||
final mgr = _make(player: _FakePlayer());
|
||||
addTearDown(mgr.dispose);
|
||||
|
||||
mgr.metadata = _meta(id: 'next');
|
||||
mgr.preferredAudioTrack = const AudioTrack(id: 'a2', language: 'fre');
|
||||
mgr.waitingForExternalSubsTrackSelection = true;
|
||||
|
||||
expect(mgr.metadata.id, 'next');
|
||||
expect(mgr.preferredAudioTrack?.id, 'a2');
|
||||
expect(mgr.waitingForExternalSubsTrackSelection, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// External subtitle cache
|
||||
// ============================================================
|
||||
@@ -506,15 +460,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('onSecondarySubtitleTrackChanged', () {
|
||||
test('is a documented no-op', () {
|
||||
final mgr = _make(player: _FakePlayer());
|
||||
addTearDown(mgr.dispose);
|
||||
// Just verify it returns normally; nothing else to assert.
|
||||
expect(() => mgr.onSecondarySubtitleTrackChanged(const SubtitleTrack(id: '1')), returnsNormally);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// onSubtitleTrackChanged — same-language stream mapping (#1443)
|
||||
// ============================================================
|
||||
|
||||
@@ -19,13 +19,6 @@ void main() {
|
||||
});
|
||||
|
||||
group('tracker session json codec', () {
|
||||
test('round-trips through provided factory', () {
|
||||
final encoded = encodeTrackerSessionJson({'access_token': 'abc', 'created_at': 123});
|
||||
final decoded = decodeTrackerSessionJson(encoded, (json) => json);
|
||||
|
||||
expect(decoded, {'access_token': 'abc', 'created_at': 123});
|
||||
});
|
||||
|
||||
test('round-trips Trakt sessions with snake-case keys and default scope', () {
|
||||
const session = TrackerSession(
|
||||
accessToken: 'trakt-at',
|
||||
@@ -59,17 +52,6 @@ void main() {
|
||||
expect(decoded.createdAt, 1000);
|
||||
});
|
||||
|
||||
test('round-trips AniList sessions through shared encode mixin', () {
|
||||
const session = TrackerSession(accessToken: 'anilist-at', expiresAt: 2000, username: 'alice', createdAt: 1000);
|
||||
|
||||
final decoded = TrackerSession.decode(session.encode());
|
||||
|
||||
expect(decoded.accessToken, 'anilist-at');
|
||||
expect(decoded.expiresAt, 2000);
|
||||
expect(decoded.username, 'alice');
|
||||
expect(decoded.createdAt, 1000);
|
||||
});
|
||||
|
||||
test('round-trips MAL sessions through shared encode mixin', () {
|
||||
const session = TrackerSession(
|
||||
accessToken: 'mal-at',
|
||||
@@ -88,16 +70,6 @@ void main() {
|
||||
expect(decoded.createdAt, 1000);
|
||||
});
|
||||
|
||||
test('round-trips Simkl sessions through shared encode mixin', () {
|
||||
const session = TrackerSession(accessToken: 'simkl-at', username: 'carol', createdAt: 1000);
|
||||
|
||||
final decoded = TrackerSession.decode(session.encode());
|
||||
|
||||
expect(decoded.accessToken, 'simkl-at');
|
||||
expect(decoded.username, 'carol');
|
||||
expect(decoded.createdAt, 1000);
|
||||
});
|
||||
|
||||
test('builds Trakt token sessions with default scope', () {
|
||||
final session = TrackerSession.fromTokenResponse(TrackerService.trakt, {
|
||||
'access_token': 'trakt-at',
|
||||
|
||||
@@ -130,71 +130,3 @@ MediaItem testMediaItem({
|
||||
raw: raw,
|
||||
);
|
||||
}
|
||||
|
||||
/// Season fixture with canonical show linkage.
|
||||
MediaItem testSeason({
|
||||
String id = 'season-1',
|
||||
MediaItem? show,
|
||||
int index = 1,
|
||||
String? title,
|
||||
MediaBackend? backend,
|
||||
String? serverId,
|
||||
String? libraryId,
|
||||
int? leafCount,
|
||||
int? viewedLeafCount,
|
||||
}) {
|
||||
return testMediaItem(
|
||||
id: id,
|
||||
backend: backend ?? show?.backend ?? MediaBackend.plex,
|
||||
kind: MediaKind.season,
|
||||
title: title,
|
||||
parentId: show?.id,
|
||||
parentTitle: show?.title,
|
||||
index: index,
|
||||
serverId: serverId ?? show?.serverId,
|
||||
serverName: show?.serverName,
|
||||
libraryId: libraryId ?? show?.libraryId,
|
||||
libraryTitle: show?.libraryTitle,
|
||||
leafCount: leafCount,
|
||||
viewedLeafCount: viewedLeafCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// Episode fixture with canonical show and season linkage.
|
||||
MediaItem testEpisode({
|
||||
String id = 'episode-1',
|
||||
MediaItem? show,
|
||||
MediaItem? season,
|
||||
int index = 1,
|
||||
String? title,
|
||||
MediaBackend? backend,
|
||||
String? serverId,
|
||||
String? libraryId,
|
||||
int? durationMs,
|
||||
int? viewOffsetMs,
|
||||
int? viewCount,
|
||||
String? originallyAvailableAt,
|
||||
List<MediaVersion>? mediaVersions,
|
||||
}) {
|
||||
return testMediaItem(
|
||||
id: id,
|
||||
backend: backend ?? season?.backend ?? show?.backend ?? MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: title,
|
||||
parentId: season?.id,
|
||||
parentTitle: season?.title,
|
||||
parentIndex: season?.index,
|
||||
index: index,
|
||||
grandparentId: show?.id,
|
||||
grandparentTitle: show?.title,
|
||||
serverId: serverId ?? season?.serverId ?? show?.serverId,
|
||||
serverName: season?.serverName ?? show?.serverName,
|
||||
libraryId: libraryId ?? season?.libraryId ?? show?.libraryId,
|
||||
libraryTitle: season?.libraryTitle ?? show?.libraryTitle,
|
||||
durationMs: durationMs,
|
||||
viewOffsetMs: viewOffsetMs,
|
||||
viewCount: viewCount,
|
||||
originallyAvailableAt: originallyAvailableAt,
|
||||
mediaVersions: mediaVersions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
|
||||
import 'media_items.dart';
|
||||
|
||||
void main() {
|
||||
test('default fixture is a minimal Plex movie', () {
|
||||
final item = testMediaItem();
|
||||
|
||||
expect(item.id, 'item-1');
|
||||
expect(item.backend, MediaBackend.plex);
|
||||
expect(item.kind, MediaKind.movie);
|
||||
expect(item.serverId, isNull);
|
||||
expect(item.parentId, isNull);
|
||||
expect(item.viewCount, isNull);
|
||||
});
|
||||
|
||||
test('season and episode fixtures preserve canonical hierarchy and scope', () {
|
||||
final show = testMediaItem(
|
||||
id: 'show-1',
|
||||
kind: MediaKind.show,
|
||||
backend: MediaBackend.jellyfin,
|
||||
title: 'Show',
|
||||
serverId: 'server-1',
|
||||
serverName: 'Server',
|
||||
libraryId: 'library-1',
|
||||
libraryTitle: 'Library',
|
||||
);
|
||||
final season = testSeason(id: 'season-2', show: show, index: 2, title: 'Season 2');
|
||||
final episode = testEpisode(id: 'episode-3', show: show, season: season, index: 3, title: 'Episode 3');
|
||||
|
||||
expect(season.backend, show.backend);
|
||||
expect(season.parentId, show.id);
|
||||
expect(season.parentTitle, show.title);
|
||||
expect(episode.parentId, season.id);
|
||||
expect(episode.parentTitle, season.title);
|
||||
expect(episode.parentIndex, season.index);
|
||||
expect(episode.grandparentId, show.id);
|
||||
expect(episode.grandparentTitle, show.title);
|
||||
expect(episode.serverId, show.serverId);
|
||||
expect(episode.libraryId, show.libraryId);
|
||||
});
|
||||
}
|
||||
@@ -186,57 +186,6 @@ class FakeSyncPlayer implements Player {
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
/// Standalone recording fake peer service (no relay behind it).
|
||||
class FakeWatchTogetherPeerService extends WatchTogetherPeerService {
|
||||
FakeWatchTogetherPeerService({required this.peerId}) : super(customBaseUrl: 'http://localhost');
|
||||
|
||||
final String peerId;
|
||||
final _messages = StreamController<SyncMessage>.broadcast();
|
||||
final _peerConnected = StreamController<String>.broadcast();
|
||||
final _peerDisconnected = StreamController<String>.broadcast();
|
||||
|
||||
final List<SyncMessage> broadcasts = [];
|
||||
final Map<String, List<SyncMessage>> sent = {};
|
||||
|
||||
@override
|
||||
String? get myPeerId => peerId;
|
||||
|
||||
@override
|
||||
Stream<SyncMessage> get onMessageReceived => _messages.stream;
|
||||
|
||||
@override
|
||||
Stream<String> get onPeerConnected => _peerConnected.stream;
|
||||
|
||||
@override
|
||||
Stream<String> get onPeerDisconnected => _peerDisconnected.stream;
|
||||
|
||||
@override
|
||||
void broadcast(SyncMessage message) {
|
||||
broadcasts.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
void sendTo(String peerId, SyncMessage message) {
|
||||
sent.putIfAbsent(peerId, () => []).add(message);
|
||||
}
|
||||
|
||||
/// All recorded outgoing messages of [type], broadcast and targeted.
|
||||
Iterable<SyncMessage> outgoing(SyncMessageType type) =>
|
||||
[...broadcasts, ...sent.values.expand((m) => m)].where((m) => m.type == type);
|
||||
|
||||
void emit(SyncMessage message) => _messages.add(message);
|
||||
|
||||
void emitPeerConnected(String peerId) => _peerConnected.add(peerId);
|
||||
|
||||
void emitPeerDisconnected(String peerId) => _peerDisconnected.add(peerId);
|
||||
|
||||
Future<void> close() async {
|
||||
await _messages.close();
|
||||
await _peerConnected.close();
|
||||
await _peerDisconnected.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory relay linking [HubPeerService]s for duplex end-to-end tests.
|
||||
///
|
||||
/// Mirrors the real relay's contract: broadcasts fan out to every other
|
||||
|
||||
@@ -23,10 +23,6 @@ void main() {
|
||||
expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'other-machine/user-a'), isNull);
|
||||
});
|
||||
|
||||
test('resolves a compound user scope', () {
|
||||
expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/user-a'), 'jf-machine/user-a');
|
||||
});
|
||||
|
||||
test('keeps users on the same server in distinct active scopes', () {
|
||||
expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/user-a'), 'jf-machine/user-a');
|
||||
expect(resolveActiveClientScopeId(serverId: serverId, cacheServerId: 'jf-machine/user-b'), 'jf-machine/user-b');
|
||||
|
||||
@@ -7,21 +7,6 @@ class _IntNotifier extends BaseNotifier<int> {}
|
||||
|
||||
void main() {
|
||||
group('BaseNotifier', () {
|
||||
test('single listener receives events', () async {
|
||||
final n = _IntNotifier();
|
||||
final received = <int>[];
|
||||
final sub = n.stream.listen(received.add);
|
||||
|
||||
n.notify(1);
|
||||
n.notify(2);
|
||||
n.notify(3);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(received, [1, 2, 3]);
|
||||
await sub.cancel();
|
||||
n.dispose();
|
||||
});
|
||||
|
||||
test('broadcasts to multiple listeners', () async {
|
||||
final n = _IntNotifier();
|
||||
final a = <int>[];
|
||||
|
||||
@@ -40,21 +40,6 @@ void main() {
|
||||
expect(CodecUtils.getSubtitleExtension('dvb_subtitle'), 'sub');
|
||||
});
|
||||
|
||||
test('every image subtitle codec maps to a non-srt extension', () {
|
||||
for (final codec in [
|
||||
'pgs',
|
||||
'pgssub',
|
||||
'hdmv_pgs_subtitle',
|
||||
'dvd_subtitle',
|
||||
'dvdsub',
|
||||
'vobsub',
|
||||
'dvb_sub',
|
||||
'dvb_subtitle',
|
||||
]) {
|
||||
expect(CodecUtils.getSubtitleExtension(codec), isNot('srt'), reason: codec);
|
||||
}
|
||||
});
|
||||
|
||||
test('defaults to srt for unknown codec', () {
|
||||
expect(CodecUtils.getSubtitleExtension('weirdcodec'), 'srt');
|
||||
expect(CodecUtils.getSubtitleExtension(''), 'srt');
|
||||
|
||||
@@ -2,18 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/utils/external_ids.dart';
|
||||
|
||||
void main() {
|
||||
group('ExternalIds.intersects with Plex Guid arrays', () {
|
||||
test('verifies a raw Plex Guid array against target ids', () {
|
||||
final candidate = ExternalIds.fromGuids(const [
|
||||
{'id': 'imdb://tt15398776'},
|
||||
{'id': 'tmdb://872585'},
|
||||
{'id': 'tvdb://287533'},
|
||||
]);
|
||||
expect(const ExternalIds(tmdb: 872585).intersects(candidate), isTrue);
|
||||
expect(const ExternalIds(imdb: 'tt0000001').intersects(candidate), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ExternalIds.intersects', () {
|
||||
test('matches when any shared id form is equal', () {
|
||||
const trakt = ExternalIds(imdb: 'tt0133093', tmdb: 603);
|
||||
|
||||
@@ -175,11 +175,5 @@ void main() {
|
||||
expect(formatFullDate('not-a-date'), 'not-a-date');
|
||||
expect(formatFullDate(''), '');
|
||||
});
|
||||
|
||||
test('does not throw for a valid ISO date', () {
|
||||
// DateFormat may fall back to raw input if intl date symbols aren't
|
||||
// initialised in the test runner — just verify no crash and string output.
|
||||
expect(formatFullDate('2024-01-15'), isA<String>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,10 +11,6 @@ void main() {
|
||||
test('allows empty ratingKey', () {
|
||||
expect(buildGlobalKey(ServerId('server'), ''), 'server:');
|
||||
});
|
||||
|
||||
test('rejects empty serverId', () {
|
||||
expect(() => ServerId(''), throwsArgumentError);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseGlobalKey', () {
|
||||
@@ -48,14 +44,4 @@ void main() {
|
||||
expect(result.ratingKey, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('round-trip build → parse returns original components', () {
|
||||
for (final pair in const [('s1', '42'), ('serverXYZ', '/library/metadata/123'), ('s', '')]) {
|
||||
final built = buildGlobalKey(ServerId(pair.$1), pair.$2);
|
||||
final parsed = parseGlobalKey(built);
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.serverId, pair.$1);
|
||||
expect(parsed.ratingKey, pair.$2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/settings_service.dart' show LibraryDensity;
|
||||
import 'package:plezy/utils/grid_size_calculator.dart';
|
||||
import 'package:plezy/utils/layout_constants.dart';
|
||||
|
||||
/// Column count the stock [SliverGridDelegateWithMaxCrossAxisExtent] renders for
|
||||
/// [crossAxisExtent]. This is the source of truth that the navigation column
|
||||
@@ -39,13 +38,6 @@ void main() {
|
||||
group('GridSizeCalculator.getColumnCount', () {
|
||||
// crossAxisSpacing is 0 in the current layout constants, so the formula
|
||||
// reduces to ceil(crossAxisExtent / maxCrossAxisExtent).
|
||||
test('returns 1 when extent equals maxCrossAxisExtent', () {
|
||||
expect(GridSizeCalculator.getColumnCount(200, 200), 1);
|
||||
});
|
||||
|
||||
test('returns 2 when extent slightly exceeds maxCrossAxisExtent', () {
|
||||
expect(GridSizeCalculator.getColumnCount(201, 200), 2);
|
||||
});
|
||||
|
||||
test('rounds up partial columns', () {
|
||||
// 600 / 200 = 3 exactly
|
||||
@@ -63,15 +55,6 @@ void main() {
|
||||
// 100000 / 100 = 1000 -> clamped to 100
|
||||
expect(GridSizeCalculator.getColumnCount(100000, 100), 100);
|
||||
});
|
||||
|
||||
test('uses GridLayoutConstants.crossAxisSpacing in the formula', () {
|
||||
// The formula adds crossAxisSpacing to the denominator only (matching the
|
||||
// stock grid delegate), and that constant is currently 0. If it ever
|
||||
// becomes non-zero, this test forces a rethink.
|
||||
expect(GridLayoutConstants.crossAxisSpacing, 0);
|
||||
// Identity-ish: extent = max -> 1 column.
|
||||
expect(GridSizeCalculator.getColumnCount(200, 200), 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('GridSizeCalculator.getColumnCount matches the rendered grid', () {
|
||||
@@ -93,13 +76,6 @@ void main() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('regression: #1288 diagonal case (200px cells, 8px spacing, 1040px wide)', () {
|
||||
// The old formula gave ceil((1040 + 8) / 208) = 6, but the grid renders
|
||||
// ceil(1040 / 208) = 5, so "down" jumped a column right. Must be 5.
|
||||
expect(GridSizeCalculator.getColumnCount(1040, 200, crossAxisSpacing: 8), 5);
|
||||
expect(_renderedColumnCount(1040, 200, 8), 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('GridSizeCalculator.isFirstRow / isFirstColumn', () {
|
||||
|
||||
@@ -57,17 +57,5 @@ void main() {
|
||||
expect(ScreenBreakpoints.desktop, 1200);
|
||||
expect(ScreenBreakpoints.largeDesktop, 1600);
|
||||
});
|
||||
|
||||
test('partitioning: every width matches exactly one of mobile/tablet/desktop/largeDesktop', () {
|
||||
for (final w in const [0.0, 300, 599.9, 600, 899.9, 1199.9, 1200, 1599.9, 1600, 2500]) {
|
||||
final matches = [
|
||||
ScreenBreakpoints.isMobile(w.toDouble()),
|
||||
ScreenBreakpoints.isTablet(w.toDouble()) && !ScreenBreakpoints.isDesktopOrLarger(w.toDouble()),
|
||||
ScreenBreakpoints.isDesktop(w.toDouble()),
|
||||
ScreenBreakpoints.isLargeDesktop(w.toDouble()),
|
||||
].where((b) => b).length;
|
||||
expect(matches, 1, reason: 'width $w should match exactly one tier');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,18 +93,6 @@ void main() {
|
||||
expect(url, contains('h=240'));
|
||||
});
|
||||
|
||||
test('near-minimum slots request a sized transcode', () {
|
||||
final url = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: '/library/metadata/1/thumb/2',
|
||||
maxWidth: 96,
|
||||
maxHeight: 144,
|
||||
devicePixelRatio: 1,
|
||||
);
|
||||
|
||||
expect(url, startsWith('sized:'));
|
||||
});
|
||||
|
||||
test('regular slots request DPR-scaled dimensions', () {
|
||||
final url = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
|
||||
@@ -143,23 +143,6 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves 500 status and raw body when JSON decoding fails', () async {
|
||||
final client = MediaServerHttpClient(
|
||||
baseUrl: 'https://example.test',
|
||||
client: MockClient((_) async => http.Response('{bad json', 500, headers: {'content-type': 'application/json'})),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
await expectLater(
|
||||
client.get('/System/Info'),
|
||||
throwsA(
|
||||
isA<MediaServerHttpException>()
|
||||
.having((e) => e.statusCode, 'statusCode', 500)
|
||||
.having((e) => e.responseData, 'responseData', '{bad json'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves 200 status when successful JSON response is malformed', () async {
|
||||
final client = MediaServerHttpClient(
|
||||
baseUrl: 'https://example.test',
|
||||
|
||||
@@ -91,17 +91,6 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('uses native seek near the start of a buffer range', () {
|
||||
expect(
|
||||
resolvePlexTranscodeSeekAction(
|
||||
currentPosition: const Duration(seconds: 30),
|
||||
target: const Duration(milliseconds: 29500),
|
||||
bufferRanges: const [BufferRange(start: Duration(seconds: 30), end: Duration(seconds: 50))],
|
||||
),
|
||||
PlexTranscodeSeekAction.nativeSeek,
|
||||
);
|
||||
});
|
||||
|
||||
test('restarts near the tail of a buffer range to avoid optimistic cache edges', () {
|
||||
expect(
|
||||
resolvePlexTranscodeSeekAction(
|
||||
@@ -152,17 +141,6 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('does not treat a flat buffer end as a local seekable range', () {
|
||||
expect(
|
||||
resolvePlexTranscodeSeekAction(
|
||||
currentPosition: const Duration(seconds: 30),
|
||||
target: const Duration(seconds: 45),
|
||||
bufferRanges: const [],
|
||||
),
|
||||
PlexTranscodeSeekAction.restartTranscode,
|
||||
);
|
||||
});
|
||||
|
||||
test('restarts large seeks when no buffer information exists', () {
|
||||
expect(
|
||||
resolvePlexTranscodeSeekAction(
|
||||
|
||||
@@ -59,11 +59,6 @@ void main() {
|
||||
expect(info!.assetPath, 'assets/rating_icons/imdb.svg');
|
||||
expect(info.formattedValue, '7.5');
|
||||
});
|
||||
|
||||
test('formats to one decimal (truncation follows toStringAsFixed semantics)', () {
|
||||
final info = parseRatingImage('imdb://title', 7.25);
|
||||
expect(info!.formattedValue, anyOf('7.2', '7.3'));
|
||||
});
|
||||
});
|
||||
|
||||
group('parseRatingImage - TMDB', () {
|
||||
|
||||
@@ -77,10 +77,6 @@ void main() {
|
||||
expect(paused.targetPositionMs(fullState.anchorHostTimeMs + 60000), 90000);
|
||||
});
|
||||
});
|
||||
|
||||
test('mediaKey matches mediaKeyFor', () {
|
||||
expect(fullState.mediaKey, PlaybackState.mediaKeyFor(ratingKey: '12345', serverId: 'srv-1'));
|
||||
});
|
||||
});
|
||||
|
||||
group('PeerStatus', () {
|
||||
|
||||
@@ -2,13 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/watch_together/primitives.dart';
|
||||
|
||||
void main() {
|
||||
test('host and guest derive the same host peer ID', () {
|
||||
final hostPeerId = watchTogetherHostPeerId('ROOM1');
|
||||
final guestExpectedHostPeerId = watchTogetherHostPeerId('room1');
|
||||
|
||||
expect(guestExpectedHostPeerId, hostPeerId);
|
||||
});
|
||||
|
||||
test('stored room codes preserve the established host peer wire format', () {
|
||||
const persistedSessionId = 'Ab12z';
|
||||
|
||||
|
||||
@@ -209,14 +209,14 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('clock sync runs over the relay and converges', () {
|
||||
test('guest controller starts clock-sync pings automatically', () {
|
||||
fakeAsync((async) {
|
||||
final room = _Room(async);
|
||||
// The guest's clock-sync burst pings the host; pongs come back with the
|
||||
// shared fake clock → offset 0.
|
||||
// The guest's clock-sync burst starts immediately and sends pings
|
||||
// through its relay-backed peer service.
|
||||
async.elapse(const Duration(seconds: 2));
|
||||
final pongs = room.guestService.outgoingLog.where((m) => m.type == SyncMessageType.ping);
|
||||
expect(pongs, isNotEmpty);
|
||||
final pings = room.guestService.outgoingLog.where((m) => m.type == SyncMessageType.ping);
|
||||
expect(pings, isNotEmpty);
|
||||
room.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,21 @@ import 'package:plezy/watch_together/models/sync_message.dart';
|
||||
|
||||
typedef _MessageHandler = FutureOr<void> Function(int connection, WebSocket socket, Map<String, dynamic> message);
|
||||
|
||||
Future<T> _withShortenedTimer<T>({
|
||||
required Duration original,
|
||||
required Duration replacement,
|
||||
required Future<T> Function() body,
|
||||
}) {
|
||||
return runZoned(
|
||||
body,
|
||||
zoneSpecification: ZoneSpecification(
|
||||
createTimer: (self, parent, zone, duration, callback) {
|
||||
return parent.createTimer(zone, duration == original ? replacement : duration, callback);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RelayServer {
|
||||
_RelayServer._(this._server, this._handler);
|
||||
|
||||
@@ -147,7 +162,11 @@ void main() {
|
||||
reconnected.complete();
|
||||
};
|
||||
|
||||
await service.createSession(sessionId: 'room2');
|
||||
await _withShortenedTimer(
|
||||
original: const Duration(seconds: 2),
|
||||
replacement: const Duration(milliseconds: 10),
|
||||
body: () => service.createSession(sessionId: 'room2'),
|
||||
);
|
||||
await relay.sockets.single.close();
|
||||
await reconnected.future.timeout(const Duration(seconds: 6));
|
||||
|
||||
@@ -167,7 +186,11 @@ void main() {
|
||||
final timeoutService = serviceFor(timeoutRelay);
|
||||
|
||||
await expectLater(
|
||||
timeoutService.createSession(sessionId: 'slow1'),
|
||||
_withShortenedTimer(
|
||||
original: const Duration(seconds: 10),
|
||||
replacement: const Duration(milliseconds: 10),
|
||||
body: () => timeoutService.createSession(sessionId: 'slow1'),
|
||||
),
|
||||
throwsA(
|
||||
isA<PeerError>()
|
||||
.having((error) => error.type, 'type', PeerErrorType.timeout)
|
||||
|
||||
@@ -61,19 +61,10 @@ void main() {
|
||||
expect(p.canControl(), isTrue);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('participantEvents is a broadcast stream that listeners can attach to', () async {
|
||||
final p = WatchTogetherProvider();
|
||||
// Attach a listener so the stream is observed; on a fresh provider no
|
||||
// events will fire, but the stream must already be live.
|
||||
final sub = p.participantEvents.listen((_) {});
|
||||
await sub.cancel();
|
||||
p.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — listener firing via public API', () {
|
||||
test('setCurrentMedia notifies listeners as host', () {
|
||||
group('WatchTogetherProvider — session guards', () {
|
||||
test('setCurrentMedia is rejected outside a session', () {
|
||||
final p = WatchTogetherProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
@@ -84,64 +75,12 @@ void main() {
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('setDisplayName mutates internal state without notifying', () {
|
||||
final p = WatchTogetherProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
// setDisplayName is a plain assignment with no notify; verify it doesn't
|
||||
// accidentally fire one.
|
||||
p.setDisplayName('Tester');
|
||||
expect(notified, 0);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('markCurrentPlaybackHandled does not throw on a fresh provider', () {
|
||||
final p = WatchTogetherProvider();
|
||||
expect(() => p.markCurrentPlaybackHandled(ratingKey: 'rk1', serverId: ServerId('s1')), returnsNormally);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('requestCurrentPlaybackSnapshot is a no-op when not in session', () {
|
||||
final p = WatchTogetherProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
// Guard fires before any peer service work, so no listener notification.
|
||||
p.requestCurrentPlaybackSnapshot();
|
||||
expect(notified, 0);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('attachPlayer is a no-op without a sync controller (logs warning)', () {
|
||||
final p = WatchTogetherProvider();
|
||||
// The mpv Player object is platform-tied; skipping it would reach the
|
||||
// null-controller guard first and bail. Calling with a null check via
|
||||
// the same path used by the production code: just verify the early
|
||||
// return path on detachPlayer (which is also null-safe).
|
||||
expect(p.detachPlayer, returnsNormally);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('setBackgrounded forwards to the sync controller but is null-safe', () {
|
||||
test('setBackgrounded is null-safe without a sync controller', () {
|
||||
final p = WatchTogetherProvider();
|
||||
expect(() => p.setBackgrounded(true), returnsNormally);
|
||||
expect(() => p.setBackgrounded(false), returnsNormally);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('onLocalSeek is null-safe without a sync controller', () {
|
||||
final p = WatchTogetherProvider();
|
||||
expect(() => p.onLocalSeek(const Duration(seconds: 5)), returnsNormally);
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('notifyHostExitedPlayer is a no-op when not host or not in session', () {
|
||||
final p = WatchTogetherProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
p.notifyHostExitedPlayer();
|
||||
expect(notified, 0);
|
||||
p.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — media switch dispatch', () {
|
||||
@@ -271,28 +210,7 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — leaveSession safety', () {
|
||||
test('leaveSession on a fresh provider is a no-op (no notify)', () async {
|
||||
final p = WatchTogetherProvider();
|
||||
var notified = 0;
|
||||
p.addListener(() => notified++);
|
||||
await p.leaveSession();
|
||||
// Early-return path: no session ever existed, no listener fires.
|
||||
expect(notified, 0);
|
||||
expect(p.session, isNull);
|
||||
p.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('WatchTogetherProvider — dispose hygiene', () {
|
||||
test('dispose runs cleanly with no peer service or subscriptions', () {
|
||||
final p = WatchTogetherProvider();
|
||||
// Fresh provider: 4 stream subscriptions are all null, 1 stream
|
||||
// controller is open, _hostReconnectTimer is null. dispose() must
|
||||
// close the controller and tear down without throwing.
|
||||
expect(p.dispose, returnsNormally);
|
||||
});
|
||||
|
||||
test('participantEvents stream is closed after dispose', () async {
|
||||
final p = WatchTogetherProvider();
|
||||
// Attach a listener; capture done via the stream's done future.
|
||||
@@ -305,23 +223,5 @@ void main() {
|
||||
await sub.cancel();
|
||||
expect(streamDone, isTrue);
|
||||
});
|
||||
|
||||
test('notifyListeners after dispose does not throw (coalescing guard)', () async {
|
||||
// The provider overrides notifyListeners to coalesce into a microtask.
|
||||
// After dispose, the _disposed flag must short-circuit any pending or
|
||||
// late notifications.
|
||||
final p = WatchTogetherProvider();
|
||||
p.dispose();
|
||||
// Even if some pathway tried to notify (it won't from outside, but the
|
||||
// microtask path in the override is the relevant guard), it must not
|
||||
// throw and not call super.notifyListeners() on a disposed instance.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
});
|
||||
|
||||
test('dispose is safe to call after a leaveSession on a fresh provider', () async {
|
||||
final p = WatchTogetherProvider();
|
||||
await p.leaveSession();
|
||||
expect(p.dispose, returnsNormally);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,51 +69,6 @@ void main() {
|
||||
expect(selects, 1);
|
||||
});
|
||||
|
||||
testWidgets('d-pad direction handlers are installed on the text field focus node', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'name_field');
|
||||
final nextFocusNode = FocusNode(debugLabel: 'next_button');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
addTearDown(nextFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
onNavigateDown: nextFocusNode.requestFocus,
|
||||
),
|
||||
FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Next')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
final handler = fieldFocusNode.onKeyEvent;
|
||||
|
||||
expect(handler, isNotNull);
|
||||
final result = handler!(
|
||||
fieldFocusNode,
|
||||
const KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.arrowDown,
|
||||
logicalKey: LogicalKeyboardKey.arrowDown,
|
||||
timeStamp: Duration.zero,
|
||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(result, KeyEventResult.handled);
|
||||
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('existing focus node key handler is preserved before text field navigation', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
final handledKeys = <LogicalKeyboardKey>[];
|
||||
@@ -179,28 +134,6 @@ void main() {
|
||||
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('tvOS focus opens virtual keyboard', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
await _setTvSurfaceSize(tester);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'search_field');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden TV text field does not auto-open virtual keyboard', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
await _setTvSurfaceSize(tester);
|
||||
|
||||
@@ -665,38 +665,6 @@ void main() {
|
||||
expect(find.byType(CompositedTransformFollower), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('detailed card layout can still show media text', (tester) async {
|
||||
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
|
||||
|
||||
final serverManager = MultiServerManager();
|
||||
final movie = testMediaItem(
|
||||
id: 'movie_1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Visible Movie',
|
||||
);
|
||||
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Visible Movie'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('detailed card focus border hugs the poster, captions outside', (tester) async {
|
||||
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
|
||||
@@ -703,46 +703,6 @@ void main() {
|
||||
PlayerBackDisposition.exitPlayer,
|
||||
);
|
||||
});
|
||||
|
||||
test('macOS physical Escape uses the same staged disposition as semantic Back', () {
|
||||
expect(
|
||||
resolvePlayerBackDisposition(
|
||||
navigationKey: PlayerNavigationKey.physicalEscape,
|
||||
contentStripVisible: false,
|
||||
controlsVisible: true,
|
||||
physicalEscapeExitsFullscreen: false,
|
||||
),
|
||||
PlayerBackDisposition.hideControls,
|
||||
);
|
||||
expect(
|
||||
resolvePlayerBackDisposition(
|
||||
navigationKey: PlayerNavigationKey.physicalEscape,
|
||||
contentStripVisible: false,
|
||||
controlsVisible: false,
|
||||
physicalEscapeExitsFullscreen: false,
|
||||
),
|
||||
PlayerBackDisposition.exitPlayer,
|
||||
);
|
||||
});
|
||||
|
||||
test('semantic Back hides visible controls then exits when hidden', () {
|
||||
expect(
|
||||
resolvePlayerBackDisposition(
|
||||
navigationKey: PlayerNavigationKey.back,
|
||||
contentStripVisible: false,
|
||||
controlsVisible: true,
|
||||
),
|
||||
PlayerBackDisposition.hideControls,
|
||||
);
|
||||
expect(
|
||||
resolvePlayerBackDisposition(
|
||||
navigationKey: PlayerNavigationKey.back,
|
||||
contentStripVisible: false,
|
||||
controlsVisible: false,
|
||||
),
|
||||
PlayerBackDisposition.exitPlayer,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('SkipMarkerButton', () {
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:plezy/mpv/player/player_state.dart';
|
||||
import 'package:plezy/mpv/player/player_streams.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/theme/mono_tokens.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
@@ -35,11 +34,6 @@ void main() {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('shows audio passthrough on supported TV-style surfaces', (tester) async {
|
||||
@@ -49,15 +43,6 @@ void main() {
|
||||
|
||||
expect(find.text('Audio Passthrough'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows audio passthrough on Apple TV', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
|
||||
await _pumpSheet(tester);
|
||||
await tester.scrollUntilVisible(find.text('Audio Passthrough'), 500, scrollable: find.byType(Scrollable).first);
|
||||
|
||||
expect(find.text('Audio Passthrough'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpSheet(WidgetTester tester) async {
|
||||
|
||||
Reference in New Issue
Block a user