test: watch_together provider, more mixins, services, utils

This commit is contained in:
edde746
2026-04-25 13:44:15 +02:00
parent ce7e936931
commit 303bd406c2
12 changed files with 3351 additions and 0 deletions
@@ -0,0 +1,147 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mixins/server_bound_media_mixin.dart';
import 'package:plezy/models/plex_metadata.dart';
/// Probe widget exposing the mixin's surface so tests can read its getters
/// and call its helpers against a real BuildContext.
class _Probe extends StatefulWidget {
const _Probe({required this.metadata, required this.offline, required this.onState});
final PlexMetadata metadata;
final bool offline;
final void Function(_ProbeState state, BuildContext context) onState;
@override
State<_Probe> createState() => _ProbeState();
}
class _ProbeState extends State<_Probe> with ServerBoundMediaMixin<_Probe> {
@override
PlexMetadata get serverBoundMetadata => widget.metadata;
@override
bool get isServerBoundOffline => widget.offline;
@override
Widget build(BuildContext context) {
// Surface state+context after the first frame settles so callers can
// exercise the mixin against a live BuildContext.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onState(this, context);
});
return const SizedBox.shrink();
}
}
PlexMetadata _meta({String? serverId, String ratingKey = 'rk1'}) =>
PlexMetadata(ratingKey: ratingKey, serverId: serverId);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ServerBoundMediaMixin', () {
testWidgets('serverBoundServerId mirrors metadata.serverId', (tester) async {
late _ProbeState state;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 'srv-A'),
offline: false,
onState: (s, _) => state = s,
),
);
await tester.pump();
expect(state.serverBoundServerId, 'srv-A');
});
testWidgets('serverBoundServerId is null when metadata has no server', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(metadata: _meta(), offline: false, onState: (s, _) => state = s));
await tester.pump();
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: 's1'),
offline: false,
onState: (s, _) => offState = s,
),
);
await tester.pump();
expect(offState.isServerBoundOffline, isFalse);
await tester.pumpWidget(
_Probe(
metadata: _meta(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(
_Probe(
metadata: _meta(serverId: 'srv-A'),
offline: false,
onState: (s, _) => state = s,
),
);
await tester.pump();
// Format is "serverId:ratingKey".
expect(state.toServerBoundGlobalKey('rk-99'), 'srv-A:rk-99');
});
testWidgets('toServerBoundGlobalKey accepts an explicit serverId override', (tester) async {
late _ProbeState state;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 'srv-A'),
offline: false,
onState: (s, _) => state = s,
),
);
await tester.pump();
// Explicit serverId takes precedence over the metadata-bound one.
expect(state.toServerBoundGlobalKey('rk-1', serverId: 'srv-B'), 'srv-B:rk-1');
});
testWidgets('toServerBoundGlobalKey falls back to empty serverId when metadata has none', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(metadata: _meta(), offline: false, onState: (s, _) => state = s));
await tester.pump();
// Empty server prefix is the documented fallback for server-less metadata.
expect(state.toServerBoundGlobalKey('rk-1'), ':rk-1');
});
testWidgets('getServerBoundClient returns null in offline mode regardless of providers', (tester) async {
late _ProbeState state;
late BuildContext ctx;
await tester.pumpWidget(
_Probe(
metadata: _meta(serverId: 'srv-A'),
offline: true,
onState: (s, c) {
state = s;
ctx = c;
},
),
);
await tester.pump();
// The provider extension short-circuits to null when isOffline is true,
// so no MultiServerProvider is required to exercise this branch.
expect(state.getServerBoundClient(ctx), isNull);
});
});
}
+205
View File
@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mixins/tab_navigation_mixin.dart';
import 'package:plezy/services/gamepad_service.dart';
/// Probe widget that mounts the mixin against a real BuildContext + Ticker.
///
/// Tests stage [tabCount] focus nodes and read the resulting controller state
/// after [initTabNavigation] runs.
class _Probe extends StatefulWidget {
const _Probe({required this.tabCount, required this.onState, this.initialIndex = 0});
final int tabCount;
final int initialIndex;
final void Function(_ProbeState state) onState;
@override
State<_Probe> createState() => _ProbeState();
}
class _ProbeState extends State<_Probe> with TickerProviderStateMixin<_Probe>, TabNavigationMixin<_Probe> {
late final List<FocusNode> _nodes;
int onTabChangedCalls = 0;
@override
List<FocusNode> get tabChipFocusNodes => _nodes;
@override
void initState() {
super.initState();
_nodes = List.generate(widget.tabCount, (i) => FocusNode(debugLabel: 'tab_$i'));
initTabNavigation();
if (widget.initialIndex != 0) {
tabController.index = widget.initialIndex;
}
widget.onState(this);
}
@override
void onTabChanged() {
onTabChangedCalls++;
super.onTabChanged();
}
@override
void dispose() {
for (final n in _nodes) {
n.dispose();
}
disposeTabNavigation();
super.dispose();
}
@override
Widget build(BuildContext context) =>
const Directionality(textDirection: TextDirection.ltr, child: SizedBox.shrink());
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('TabNavigationMixin', () {
setUp(() {
// Static gamepad callbacks are global state; isolate each test.
GamepadService.onL1Pressed = null;
GamepadService.onR1Pressed = null;
});
tearDown(() {
GamepadService.onL1Pressed = null;
GamepadService.onR1Pressed = null;
});
testWidgets('initTabNavigation creates a TabController with the right length', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s));
expect(state.tabCount, 3);
expect(state.tabController.length, 3);
// Initial tab is 0 by default.
expect(state.tabController.index, 0);
// Auto-focus suppression flag starts false.
expect(state.suppressAutoFocus, isFalse);
});
testWidgets('initTabNavigation registers L1/R1 gamepad callbacks', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s));
// Mixin wires its private goToPreviousTab/goToNextTab to the static
// callbacks; we can only assert non-null wiring (the closures are the
// mixin's bound methods, not directly comparable).
expect(GamepadService.onL1Pressed, isNotNull);
expect(GamepadService.onR1Pressed, isNotNull);
// Sanity: invoking R1 advances the tab via the mixin's goToNextTab.
GamepadService.onR1Pressed!.call();
await tester.pump();
expect(state.tabController.index, 1);
});
testWidgets('disposeTabNavigation clears the static gamepad callbacks', (tester) async {
await tester.pumpWidget(_Probe(tabCount: 2, onState: (_) {}));
expect(GamepadService.onL1Pressed, isNotNull);
expect(GamepadService.onR1Pressed, isNotNull);
// Replace the widget tree to fire dispose.
await tester.pumpWidget(const SizedBox.shrink());
expect(GamepadService.onL1Pressed, isNull);
expect(GamepadService.onR1Pressed, isNull);
});
testWidgets('tabChipFocusNodes drives tabCount; getTabChipFocusNode returns the right node', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 4, onState: (s) => state = s));
expect(state.tabCount, 4);
// Each indexed lookup returns the same node reference — the mixin must
// not stash its own copies.
for (var i = 0; i < 4; i++) {
expect(identical(state.getTabChipFocusNode(i), state.tabChipFocusNodes[i]), isTrue);
}
});
testWidgets('goToNextTab advances the index and stops at the last tab', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s));
// 0 -> 1
state.goToNextTab();
await tester.pump();
expect(state.tabController.index, 1);
// suppressAutoFocus is set as a side-effect of programmatic navigation.
expect(state.suppressAutoFocus, isTrue);
// 1 -> 2
state.goToNextTab();
await tester.pump();
expect(state.tabController.index, 2);
// 2 (last) -> stays at 2 (mixin guards against overflow).
state.goToNextTab();
await tester.pump();
expect(state.tabController.index, 2);
});
testWidgets('goToPreviousTab decrements the index and stops at the first tab', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 3, initialIndex: 2, onState: (s) => state = s));
// 2 -> 1
state.goToPreviousTab();
await tester.pump();
expect(state.tabController.index, 1);
// 1 -> 0
state.goToPreviousTab();
await tester.pump();
expect(state.tabController.index, 0);
// 0 (first) -> stays at 0.
state.goToPreviousTab();
await tester.pump();
expect(state.tabController.index, 0);
});
testWidgets('onTabChanged fires when tabController.index changes', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s));
final before = state.onTabChangedCalls;
state.tabController.index = 1;
// index= triggers an animation; pump until it settles so the listener
// fires its terminal event.
await tester.pumpAndSettle();
expect(state.onTabChangedCalls, greaterThan(before));
});
testWidgets('focusTabBar sets suppressAutoFocus and calls requestFocus on the active chip', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 3, onState: (s) => state = s));
// 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);
});
testWidgets('onTabBarBack is null-safe outside MainScreenFocusScope (no throw)', (tester) async {
late _ProbeState state;
await tester.pumpWidget(_Probe(tabCount: 2, onState: (s) => state = s));
// Without a MainScreenFocusScope ancestor, the helper hits a null `?.`
// and returns without doing anything; it must not throw.
expect(state.onTabBarBack, returnsNormally);
});
});
}
@@ -0,0 +1,327 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/bif_thumbnail_service.dart';
import 'package:plezy/services/plex_client.dart';
// BIF (Roku Base Index Format) is a binary container for video timeline
// thumbnails. The service exposes [BifThumbnailService] which downloads + parses
// a file (network-bound), but the parser itself is reachable through
// [BifThumbnailService.load] when paired with a fake [PlexClient] that returns
// hand-crafted bytes.
//
// What's NOT covered (by design):
// - The 50MiB size guard — verifying it would mean producing a 50MiB
// `Uint8List`, which is wasteful for unit tests.
// - The download-throws path — `BifThumbnailService.load` swallows errors
// into a "no thumbnails" state, and the only observable difference between
// "download failed" and "valid 0-image BIF" is `isAvailable=false`.
/// Build a minimal valid BIF byte buffer.
///
/// - [entries]: list of (timestamp, jpegBytes). Timestamps are in raw units
/// (not multiplied by [timestampMultiplier]).
/// - [timestampMultiplier]: ms per unit (0 means "use default 1000").
Uint8List _buildBif(List<({int timestamp, List<int> bytes})> entries, {int timestampMultiplier = 1000}) {
// Header (64 bytes) + index table ((count+1)*8 bytes) + image bytes
final imageCount = entries.length;
final indexTableBytes = (imageCount + 1) * 8;
final imageBytesTotal = entries.fold<int>(0, (a, e) => a + e.bytes.length);
final total = 64 + indexTableBytes + imageBytesTotal;
final buf = Uint8List(total);
final view = ByteData.sublistView(buf);
// Magic bytes: 0x89 B I F 0x0D 0x0A 0x1A 0x0A
const magic = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A];
for (var i = 0; i < magic.length; i++) {
buf[i] = magic[i];
}
// version (uint32 LE) at offset 8
view.setUint32(8, 0, Endian.little);
// image count (uint32 LE) at offset 12
view.setUint32(12, imageCount, Endian.little);
// timestamp multiplier (uint32 LE) at offset 16
view.setUint32(16, timestampMultiplier, Endian.little);
// bytes 20..63 are reserved — zero-initialized by Uint8List default
// Index table: (imageCount + 1) entries, each [timestamp:u32 LE, offset:u32 LE]
var dataOffset = 64 + indexTableBytes;
for (var i = 0; i < imageCount; i++) {
final entry = entries[i];
view.setUint32(64 + i * 8, entry.timestamp, Endian.little);
view.setUint32(64 + i * 8 + 4, dataOffset, Endian.little);
dataOffset += entry.bytes.length;
}
// Sentinel entry: timestamp 0xFFFFFFFF, offset = end-of-data
view.setUint32(64 + imageCount * 8, 0xFFFFFFFF, Endian.little);
view.setUint32(64 + imageCount * 8 + 4, dataOffset, Endian.little);
// Image data, contiguous.
var pos = 64 + indexTableBytes;
for (final entry in entries) {
for (var i = 0; i < entry.bytes.length; i++) {
buf[pos + i] = entry.bytes[i];
}
pos += entry.bytes.length;
}
return buf;
}
/// Fake [PlexClient] that satisfies the *only* method [BifThumbnailService.load]
/// invokes — `downloadBifFile`. Every other PlexClient member is unreachable
/// from this path, so [noSuchMethod] is forwarded to the default impl which
/// throws — making any unintended call a loud failure.
class _FakePlexClient implements PlexClient {
_FakePlexClient(this._bytes);
final Uint8List? _bytes;
@override
Future<Uint8List?> downloadBifFile(int partId) async => _bytes;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
// ============================================================
// Initial state
// ============================================================
group('initial state', () {
test('isAvailable is false before load()', () {
final svc = BifThumbnailService();
addTearDown(svc.dispose);
expect(svc.isAvailable, isFalse);
});
test('getThumbnail returns null before load()', () {
final svc = BifThumbnailService();
addTearDown(svc.dispose);
expect(svc.getThumbnail(Duration.zero), isNull);
expect(svc.getThumbnail(const Duration(seconds: 5)), isNull);
});
});
// ============================================================
// Pure parser (via load + getThumbnail)
// ============================================================
group('valid BIF parsing', () {
test('parses a 3-entry BIF with default 1000ms multiplier', () async {
final bytes = _buildBif([
(timestamp: 0, bytes: [0x10, 0x20]),
(timestamp: 10, bytes: [0x30, 0x40, 0x50]),
(timestamp: 20, bytes: [0x60]),
]);
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(bytes), 1);
expect(svc.isAvailable, isTrue);
// Lookup at exactly the first entry's timestamp.
expect(svc.getThumbnail(Duration.zero), Uint8List.fromList([0x10, 0x20]));
// Lookup between entries — should pick the largest <= time.
// 5s < 10s → first entry.
expect(svc.getThumbnail(const Duration(seconds: 5)), Uint8List.fromList([0x10, 0x20]));
// 10s == 2nd entry's timestamp.
expect(svc.getThumbnail(const Duration(seconds: 10)), Uint8List.fromList([0x30, 0x40, 0x50]));
// 15s — second entry still.
expect(svc.getThumbnail(const Duration(seconds: 15)), Uint8List.fromList([0x30, 0x40, 0x50]));
// 25s past the last entry's timestamp — clamps to last.
expect(svc.getThumbnail(const Duration(seconds: 25)), Uint8List.fromList([0x60]));
});
test('honors a non-default timestampMultiplier', () async {
// multiplier=500 → each timestamp unit is 500ms.
final bytes = _buildBif([
(timestamp: 0, bytes: [0xAA]),
(timestamp: 4, bytes: [0xBB]), // 4 * 500ms = 2000ms
], timestampMultiplier: 500);
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(bytes), 1);
expect(svc.getThumbnail(Duration.zero), Uint8List.fromList([0xAA]));
// 1.999s — still first entry.
expect(svc.getThumbnail(const Duration(milliseconds: 1999)), Uint8List.fromList([0xAA]));
// Exactly 2s — second entry.
expect(svc.getThumbnail(const Duration(seconds: 2)), Uint8List.fromList([0xBB]));
});
test('multiplier=0 is treated as 1000ms (per BIF spec)', () async {
final bytes = _buildBif([
(timestamp: 0, bytes: [0x01]),
(timestamp: 7, bytes: [0x02]),
], timestampMultiplier: 0);
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(bytes), 1);
// 7s should hit the second entry (7 * 1000ms).
expect(svc.getThumbnail(const Duration(seconds: 7)), Uint8List.fromList([0x02]));
});
});
// ============================================================
// Malformed input
// ============================================================
group('malformed BIF input', () {
test('rejects bytes shorter than the 64-byte header', () async {
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(Uint8List(50)), 1);
expect(svc.isAvailable, isFalse);
});
test('rejects bytes with an invalid magic header', () async {
// 128-byte buffer with all-zero bytes (no magic).
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(Uint8List(128)), 1);
expect(svc.isAvailable, isFalse);
});
test('rejects bytes shorter than the declared index table', () async {
// Build a valid header that claims 100 images but supply no index data.
final buf = Uint8List(64);
const magic = [0x89, 0x42, 0x49, 0x46, 0x0D, 0x0A, 0x1A, 0x0A];
for (var i = 0; i < magic.length; i++) {
buf[i] = magic[i];
}
final view = ByteData.sublistView(buf);
view.setUint32(12, 100, Endian.little); // imageCount=100 → needs 808 more bytes
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(buf), 1);
expect(svc.isAvailable, isFalse);
});
test('skips entries whose offset window is invalid (next <= current)', () async {
// Build a valid 2-entry BIF, then corrupt the second offset to be inside
// the first range so `nextImgOffset <= imgOffset` triggers the skip.
final bytes = _buildBif([
(timestamp: 0, bytes: [0x11, 0x22]),
(timestamp: 5, bytes: [0x33]),
]);
// Index entry 1 (second image) starts at byte 64+8.
// Set its imgOffset to a value <= entry 0's imgOffset.
final view = ByteData.sublistView(bytes);
// Entry 0's imgOffset is at byte 64+0+4 = 68
final firstImgOffset = view.getUint32(68, Endian.little);
// Corrupt entry 1's imgOffset (at 64+8+4=76) to equal firstImgOffset,
// so for entry 0 the lookahead `nextImgOffset == imgOffset` triggers
// the `<=` skip path.
view.setUint32(76, firstImgOffset, Endian.little);
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(bytes), 1);
// Entry 0 is skipped (next offset == its offset). Entry 1's window is
// (firstImgOffset .. sentinel.offset), which is still valid, so it
// remains. Verify exactly one entry survives.
expect(svc.isAvailable, isTrue);
// The surviving entry maps timestamp=5*1000ms onwards.
expect(svc.getThumbnail(const Duration(seconds: 5)), isNotNull);
// A query before the surviving entry's timestamp clamps to it (since
// there's only one entry, binary search returns entries[0]).
expect(svc.getThumbnail(Duration.zero), isNotNull);
});
});
// ============================================================
// Empty / null input
// ============================================================
group('empty input', () {
test('null download keeps the service in unavailable state', () async {
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(null), 1);
expect(svc.isAvailable, isFalse);
expect(svc.getThumbnail(Duration.zero), isNull);
});
test('empty bytes keep the service in unavailable state', () async {
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(Uint8List(0)), 1);
expect(svc.isAvailable, isFalse);
});
test('a valid BIF with zero images parses but reports unavailable', () async {
final bytes = _buildBif(const []); // no entries, only header + sentinel
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(bytes), 1);
// Loaded successfully but the entries list is empty — `isAvailable`
// requires non-empty entries.
expect(svc.isAvailable, isFalse);
expect(svc.getThumbnail(Duration.zero), isNull);
});
});
// ============================================================
// Reload + dispose
// ============================================================
group('reload + dispose', () {
test('a second load() replaces prior entries', () async {
final first = _buildBif([
(timestamp: 0, bytes: [0xAA]),
]);
final second = _buildBif([
(timestamp: 0, bytes: [0xBB, 0xCC]),
]);
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(first), 1);
expect(svc.getThumbnail(Duration.zero), Uint8List.fromList([0xAA]));
await svc.load(_FakePlexClient(second), 2);
expect(svc.getThumbnail(Duration.zero), Uint8List.fromList([0xBB, 0xCC]));
});
test('a failed reload (null bytes) clears prior entries', () async {
final first = _buildBif([
(timestamp: 0, bytes: [0xAA]),
]);
final svc = BifThumbnailService();
addTearDown(svc.dispose);
await svc.load(_FakePlexClient(first), 1);
expect(svc.isAvailable, isTrue);
// The implementation sets `_entries = null` at the start of `load()`,
// so a null download leaves the service unavailable.
await svc.load(_FakePlexClient(null), 2);
expect(svc.isAvailable, isFalse);
expect(svc.getThumbnail(Duration.zero), isNull);
});
test('dispose() releases entries', () async {
final bytes = _buildBif([
(timestamp: 0, bytes: [0xFF]),
]);
final svc = BifThumbnailService();
await svc.load(_FakePlexClient(bytes), 1);
expect(svc.isAvailable, isTrue);
svc.dispose();
expect(svc.isAvailable, isFalse);
expect(svc.getThumbnail(Duration.zero), isNull);
});
});
}
@@ -0,0 +1,535 @@
import 'package:drift/native.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_mode_source.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
import '../test_helpers/prefs.dart';
// NOTE on coverage scope:
// The actual sync-to-server path (`syncPendingItems`, `syncWatchStatesFromServer`,
// `_performBidirectionalSync`) all reach into a real `PlexClient` via the
// injected `MultiServerManager`. Per the task brief we do NOT exercise those
// paths here — they require either a fake `PlexClient` factory or live HTTP.
//
// What IS covered:
// - Initial state on a fresh service.
// - `queueMarkWatched` / `queueMarkUnwatched` — local DB persistence.
// - `getLocalWatchStatus` / `getLocalViewOffset` — local resolution.
// - `getPendingSyncCount` — DB-side count.
// - `clearAll` — local wipe.
// - `dispose` — listener cleanup on the offline-mode source.
// - Connectivity listener attachment via `startConnectivityMonitoring`.
//
// What is NOT covered (would need a fake PlexClient factory):
// - `_performBidirectionalSync` (online path)
// - `syncPendingItems` outcome map
// - `syncWatchStatesFromServer` cache-write logic
// - `getWatchedThreshold`'s "online client preference" branch — only the
// SettingsService cached + default branches are testable here.
/// Minimal [OfflineModeSource] that lets tests flip the offline flag and
/// observe `addListener`/`removeListener` traffic via the protected
/// [ChangeNotifier.hasListeners] flag.
class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource {
bool _isOffline;
_FakeOfflineModeSource({bool initial = false}) : _isOffline = initial;
@override
bool get isOffline => _isOffline;
void setOffline(bool value) {
if (_isOffline == value) return;
_isOffline = value;
notifyListeners();
}
// ChangeNotifier.hasListeners is `@protected` — re-export for tests.
@override
// ignore: unnecessary_overrides
bool get hasListeners => super.hasListeners;
}
/// Build a service against an in-memory database and a bare-metal
/// [MultiServerManager] (no servers added).
({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr}) _makeService() {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final mgr = MultiServerManager();
final svc = OfflineWatchSyncService(database: db, serverManager: mgr);
return (svc: svc, db: db, mgr: mgr);
}
void main() {
setUp(resetSharedPreferencesForTest);
// ============================================================
// Initial state
// ============================================================
group('initial state', () {
test('a freshly constructed service is not syncing and has no pending count', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
expect(svc.isSyncing, isFalse);
expect(await svc.getPendingSyncCount(), 0);
expect(await svc.getLocalWatchStatus('srv:nonexistent'), isNull);
expect(await svc.getLocalViewOffset('srv:nonexistent'), isNull);
});
test('isWatchedByProgress: pure math (no DB / network)', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
// duration=0 short-circuits to false (avoids divide-by-zero).
expect(svc.isWatchedByProgress(0, 0), isFalse);
expect(svc.isWatchedByProgress(1000, 0), isFalse);
// No serverId → uses 0.9 default threshold.
expect(svc.isWatchedByProgress(89, 100), isFalse);
expect(svc.isWatchedByProgress(90, 100), isTrue);
expect(svc.isWatchedByProgress(95, 100), isTrue);
expect(svc.isWatchedByProgress(100, 100), isTrue);
});
test('getWatchedThreshold falls back to default 0.9 when no client + no settings', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
// No SettingsService initialized, no client registered → default 90/100.
expect(svc.getWatchedThreshold('unknown-server'), 0.9);
});
});
// ============================================================
// queueMarkWatched / queueMarkUnwatched
// ============================================================
group('queueMarkWatched / queueMarkUnwatched', () {
test('queueMarkWatched persists a "watched" action and bumps pending count', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
var notifications = 0;
svc.addListener(() => notifications++);
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '42');
expect(await svc.getPendingSyncCount(), 1);
// ChangeNotifier emission was synchronous in the queue helper.
expect(notifications, 1);
// Latest action has actionType='watched'.
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
expect(action!.actionType, 'watched');
expect(action.serverId, 'srv');
expect(action.ratingKey, '42');
});
test('queueMarkUnwatched persists an "unwatched" action', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '42');
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
expect(action!.actionType, 'unwatched');
});
test('queueing the opposite action replaces the prior action (single row)', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '42');
expect(await svc.getPendingSyncCount(), 1);
// The DB layer's insertWatchAction deletes any prior entries for the
// same globalKey before inserting — so flipping watched/unwatched keeps
// a single row.
await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '42');
expect(await svc.getPendingSyncCount(), 1);
final action = await db.getLatestWatchAction('srv:42');
expect(action!.actionType, 'unwatched');
});
test('different ratingKeys persist independently', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1');
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '2');
await svc.queueMarkUnwatched(serverId: 'other', ratingKey: '1');
expect(await svc.getPendingSyncCount(), 3);
expect((await db.getLatestWatchAction('srv:1'))!.actionType, 'watched');
expect((await db.getLatestWatchAction('srv:2'))!.actionType, 'watched');
expect((await db.getLatestWatchAction('other:1'))!.actionType, 'unwatched');
});
});
// ============================================================
// queueProgressUpdate (also exercised so we can test the progress branches
// of getLocalWatchStatus / getLocalViewOffset).
// ============================================================
group('queueProgressUpdate', () {
test('persists a progress row with shouldMarkWatched=false below threshold', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
// 50% progress → below default 0.9 threshold.
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '42', viewOffset: 50, duration: 100);
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
expect(action!.actionType, 'progress');
expect(action.viewOffset, 50);
expect(action.duration, 100);
expect(action.shouldMarkWatched, isFalse);
});
test('persists shouldMarkWatched=true at/above the default 0.9 threshold', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '42', viewOffset: 95, duration: 100);
final action = await db.getLatestWatchAction('srv:42');
expect(action!.shouldMarkWatched, isTrue);
});
test('repeated progress updates merge into the same row', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '42', viewOffset: 10, duration: 100);
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '42', viewOffset: 20, duration: 100);
// upsertProgressAction merges by globalKey — only ONE row.
expect(await svc.getPendingSyncCount(), 1);
final action = await db.getLatestWatchAction('srv:42');
expect(action!.viewOffset, 20);
});
});
// ============================================================
// getLocalWatchStatus
// ============================================================
group('getLocalWatchStatus', () {
test('returns null when no local action exists', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
expect(await svc.getLocalWatchStatus('srv:none'), isNull);
});
test('returns true for a "watched" action', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1');
expect(await svc.getLocalWatchStatus('srv:1'), isTrue);
});
test('returns false for an "unwatched" action', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '1');
expect(await svc.getLocalWatchStatus('srv:1'), isFalse);
});
test('returns shouldMarkWatched for a "progress" action', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
// Below threshold → shouldMarkWatched=false → status=false.
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 50, duration: 100);
expect(await svc.getLocalWatchStatus('srv:1'), isFalse);
// Above threshold → shouldMarkWatched=true → status=true.
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '2', viewOffset: 99, duration: 100);
expect(await svc.getLocalWatchStatus('srv:2'), isTrue);
});
});
// ============================================================
// getLocalViewOffset
// ============================================================
group('getLocalViewOffset', () {
test('returns null when no local action exists', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
expect(await svc.getLocalViewOffset('srv:none'), isNull);
});
test('returns null for a "watched" or "unwatched" action (no offset)', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1');
expect(await svc.getLocalViewOffset('srv:1'), isNull);
await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '2');
expect(await svc.getLocalViewOffset('srv:2'), isNull);
});
test('returns the stored offset for a "progress" action', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 12345, duration: 60000);
expect(await svc.getLocalViewOffset('srv:1'), 12345);
});
test('progress is replaced by a manual "watched" action — offset becomes null', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 5000, duration: 10000);
expect(await svc.getLocalViewOffset('srv:1'), 5000);
// Manual "watched" wipes the progress row (insertWatchAction deletes
// by globalKey first), so getLocalViewOffset reads the new row whose
// actionType != 'progress' → null.
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1');
expect(await svc.getLocalViewOffset('srv:1'), isNull);
});
});
// ============================================================
// getPendingSyncCount
// ============================================================
group('getPendingSyncCount', () {
test('counts every queued action (manual + progress)', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
expect(await svc.getPendingSyncCount(), 0);
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1');
await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '2');
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '3', viewOffset: 50, duration: 100);
expect(await svc.getPendingSyncCount(), 3);
});
test('progress upsert does NOT increment count', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 10, duration: 100);
await svc.queueProgressUpdate(serverId: 'srv', ratingKey: '1', viewOffset: 20, duration: 100);
expect(await svc.getPendingSyncCount(), 1);
});
});
// ============================================================
// getLocalWatchStatusesBatched
// ============================================================
group('getLocalWatchStatusesBatched', () {
test('empty input returns empty map without touching the DB', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
expect(await svc.getLocalWatchStatusesBatched({}), isEmpty);
});
test('returns null for missing keys, statuses for queued items', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1');
await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '2');
await svc.queueProgressUpdate(
serverId: 'srv',
ratingKey: '3',
viewOffset: 99,
duration: 100, // above threshold
);
final result = await svc.getLocalWatchStatusesBatched({'srv:1', 'srv:2', 'srv:3', 'srv:missing'});
expect(result['srv:1'], isTrue);
expect(result['srv:2'], isFalse);
expect(result['srv:3'], isTrue);
expect(result['srv:missing'], isNull);
// The map MUST contain every requested key, even when null.
expect(result.keys.toSet(), {'srv:1', 'srv:2', 'srv:3', 'srv:missing'});
});
});
// ============================================================
// clearAll
// ============================================================
group('clearAll', () {
test('removes every queued action and notifies listeners', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
await svc.queueMarkWatched(serverId: 'srv', ratingKey: '1');
await svc.queueMarkUnwatched(serverId: 'srv', ratingKey: '2');
expect(await svc.getPendingSyncCount(), 2);
var notifications = 0;
svc.addListener(() => notifications++);
await svc.clearAll();
expect(await svc.getPendingSyncCount(), 0);
expect(notifications, 1);
});
});
// ============================================================
// startConnectivityMonitoring + dispose
// ============================================================
group('startConnectivityMonitoring + dispose', () {
test('attaches a listener to the source', () {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
mgr.dispose();
await db.close();
});
final source = _FakeOfflineModeSource();
expect(source.hasListeners, isFalse);
svc.startConnectivityMonitoring(source);
expect(source.hasListeners, isTrue);
svc.dispose();
// After dispose, the listener is removed.
expect(source.hasListeners, isFalse);
});
test('replacing the source detaches the prior listener', () {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final first = _FakeOfflineModeSource();
final second = _FakeOfflineModeSource();
svc.startConnectivityMonitoring(first);
expect(first.hasListeners, isTrue);
expect(second.hasListeners, isFalse);
svc.startConnectivityMonitoring(second);
// First's listener was removed; second now has one.
expect(first.hasListeners, isFalse);
expect(second.hasListeners, isTrue);
});
test('dispose() before startConnectivityMonitoring is safe', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
mgr.dispose();
await db.close();
});
// Never called startConnectivityMonitoring → both fields are null.
expect(svc.dispose, returnsNormally);
});
});
}
@@ -0,0 +1,539 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/models/plex_metadata.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
import 'package:plezy/services/playback_progress_tracker.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/prefs.dart';
// NOTE on coverage scope:
// `PlaybackProgressTracker` periodically samples the player's position and
// reports it to either an online [PlexClient] or the offline queue. The
// periodic [Timer] is purely a wall-clock concern — instead of trying to
// virtualize it, we exercise the routing/threshold/scrobble logic directly
// through the public [PlaybackProgressTracker.sendProgress].
//
// Coverage:
// - Constructor invariants (offline ↔ offlineWatchService, online ↔ client).
// - Online routing: 'stopped' awaits, 'playing'/'paused' fire-and-forget.
// - Threshold gating: scrobbles once when percent >= server threshold.
// - Scrobble idempotency: a second sendProgress past threshold is a no-op.
// - Offline routing: queues a progress update via the database.
// - Offline progress with null serverId is a no-op (no queue write).
// - 'stopped' event emits a WatchStateNotifier.notifyProgress.
// - dispose() / stopTracking() are idempotent.
//
// What is NOT covered (by design):
// - The periodic [Timer.periodic] tick itself — we'd need to either drive
// real time (flaky) or inject a clock dependency (out of scope).
// - The exponential-backoff state — observable only across multiple ticks
// under wall time.
/// Fake Player whose state is mutable from the test.
class _FakePlayer implements Player {
PlayerState _state;
_FakePlayer({Duration position = Duration.zero, Duration duration = Duration.zero, bool playing = true})
: _state = PlayerState(playing: playing, duration: duration, position: position);
@override
PlayerState get state => _state;
set position(Duration value) {
_state = _state.copyWith(position: value);
}
set duration(Duration value) {
_state = _state.copyWith(duration: value);
}
set playing(bool value) {
_state = _state.copyWith(playing: value);
}
set completed(bool value) {
_state = _state.copyWith(completed: value);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Recording fake [PlexClient] that captures every progress / scrobble call
/// without touching the network.
class _FakePlexClient implements PlexClient {
_FakePlexClient({this.thresholdPercent = 90});
/// Watched-threshold percentage to report. Defaults to 90 (matches
/// production fallback).
final int thresholdPercent;
/// Override [PlexClient.watchedThresholdPercent] without going through
/// `_serverPrefs`.
@override
int get watchedThresholdPercent => thresholdPercent;
/// (ratingKey, time, state, duration) tuples for every updateProgress call.
final List<({String ratingKey, int time, String state, int? duration})> updateProgressCalls = [];
/// Rating keys passed to markAsWatched.
final List<String> markWatchedCalls = [];
/// If non-null, [updateProgress] / [markAsWatched] throw this on the next call.
Object? throwOnNextCall;
@override
Future<void> updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {
if (throwOnNextCall != null) {
final err = throwOnNextCall!;
throwOnNextCall = null;
throw err;
}
updateProgressCalls.add((ratingKey: ratingKey, time: time, state: state, duration: duration));
}
@override
Future<void> markAsWatched(String ratingKey, {PlexMetadata? metadata}) async {
if (throwOnNextCall != null) {
final err = throwOnNextCall!;
throwOnNextCall = null;
throw err;
}
markWatchedCalls.add(ratingKey);
// Production fires a WatchStateNotifier event from markAsWatched. Mirror
// that so tests can observe it through the singleton.
if (metadata != null) {
WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true);
}
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
PlexMetadata _meta({String ratingKey = '42', String? serverId = 'srv', String? type = 'movie'}) =>
PlexMetadata(ratingKey: ratingKey, type: type, title: 'Test Item', serverId: serverId);
void main() {
setUp(resetSharedPreferencesForTest);
// ============================================================
// Constructor assertions
// ============================================================
group('constructor assertions', () {
test('offline=true requires offlineWatchService', () {
expect(
() => PlaybackProgressTracker(client: null, metadata: _meta(), player: _FakePlayer(), isOffline: true),
throwsA(isA<AssertionError>()),
);
});
test('offline=false requires client', () {
expect(
() => PlaybackProgressTracker(client: null, metadata: _meta(), player: _FakePlayer(), isOffline: false),
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);
});
});
// ============================================================
// sendProgress: short-circuit on duration=0
// ============================================================
group('sendProgress: duration guard', () {
test('does NOT send progress when duration is zero (player not yet ready)', () async {
final client = _FakePlexClient();
final player = _FakePlayer(); // duration = Duration.zero
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped');
expect(client.updateProgressCalls, isEmpty);
expect(client.markWatchedCalls, isEmpty);
});
});
// ============================================================
// sendProgress: online routing
// ============================================================
group('sendProgress: online', () {
test('"stopped" awaits the underlying call and reports correct args', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 30), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42'),
player: player,
isOffline: false,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped');
// updateProgress is awaited synchronously when state == 'stopped'.
expect(client.updateProgressCalls, hasLength(1));
final call = client.updateProgressCalls.single;
expect(call.ratingKey, '42');
expect(call.time, 30000); // 30s in ms
expect(call.state, 'stopped');
expect(call.duration, 100000); // 100s in ms
});
test('"playing" fires-and-forgets but eventually invokes updateProgress', () async {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
addTearDown(tracker.dispose);
await tracker.sendProgress('playing');
// The unawaited Future may not have settled yet — drain microtasks.
await Future<void>.delayed(Duration.zero);
expect(client.updateProgressCalls, hasLength(1));
expect(client.updateProgressCalls.single.state, 'playing');
});
});
// ============================================================
// Threshold gating + scrobble
// ============================================================
group('threshold gating', () {
test('does NOT scrobble when percent < watchedThresholdPercent', () async {
// 89% < 90% threshold.
final client = _FakePlexClient(thresholdPercent: 90);
final player = _FakePlayer(position: const Duration(seconds: 89), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped');
expect(client.markWatchedCalls, isEmpty);
});
test('scrobbles when percent >= watchedThresholdPercent', () async {
// 95% >= 90% threshold.
final client = _FakePlexClient(thresholdPercent: 90);
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42'),
player: player,
isOffline: false,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped');
expect(client.markWatchedCalls, ['42']);
});
test('respects a custom server threshold (e.g. 80%)', () async {
// 81% >= 80%, but < 90% default.
final client = _FakePlexClient(thresholdPercent: 80);
final player = _FakePlayer(position: const Duration(seconds: 81), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42'),
player: player,
isOffline: false,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped');
expect(client.markWatchedCalls, ['42']);
});
test('scrobble is idempotent across multiple progress calls', () async {
final client = _FakePlexClient(thresholdPercent: 90);
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
addTearDown(tracker.dispose);
await tracker.sendProgress('stopped');
await tracker.sendProgress('stopped');
await tracker.sendProgress('stopped');
// markAsWatched fired exactly once — _scrobbled stays true.
expect(client.markWatchedCalls, hasLength(1));
});
test('a failed scrobble is retried on the next call (resets _scrobbled)', () async {
final client = _FakePlexClient(thresholdPercent: 90);
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
addTearDown(tracker.dispose);
// First call: updateProgress succeeds, then markAsWatched throws.
// To make the *second* method (markAsWatched) throw, we need a flag that
// only triggers on the 2nd call. The fake's `throwOnNextCall` consumes
// on the first call, which is updateProgress. Workaround: arm the throw
// immediately before sendProgress, so updateProgress fails. The catch
// branch in PlaybackProgressTracker still bumps the failure counter for
// online stopped calls (and skips scrobble). Then arm again — updateProgress
// succeeds (because the throw was consumed) — and assert markAsWatched
// succeeds and scrobbles.
//
// To target ONLY markAsWatched, we instead use a custom client.
final precise = _ScrobblePreciseClient(thresholdPercent: 90, failScrobbleFirstTime: true);
final tracker2 = PlaybackProgressTracker(
client: precise,
metadata: _meta(ratingKey: '42'),
player: player,
isOffline: false,
);
addTearDown(tracker2.dispose);
await tracker2.sendProgress('stopped');
expect(precise.markWatchedAttempts, 1);
// Retry — markAsWatched now succeeds.
await tracker2.sendProgress('stopped');
expect(precise.markWatchedAttempts, 2);
expect(precise.markWatchedSuccesses, 1);
});
});
// ============================================================
// Offline routing
// ============================================================
group('sendProgress: offline', () {
Future<({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr})> makeOfflineService() async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final mgr = MultiServerManager();
final svc = OfflineWatchSyncService(database: db, serverManager: mgr);
return (svc: svc, db: db, mgr: mgr);
}
test('queues a progress update via the offline service', () async {
final (svc: svc, db: db, mgr: mgr) = await makeOfflineService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final player = _FakePlayer(position: const Duration(seconds: 12), duration: const Duration(seconds: 60));
final tracker = PlaybackProgressTracker(
client: null,
metadata: _meta(ratingKey: '42', serverId: 'srv'),
player: player,
isOffline: true,
offlineWatchService: svc,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('playing');
// Local DB now has a progress row for srv:42.
final action = await db.getLatestWatchAction('srv:42');
expect(action, isNotNull);
expect(action!.actionType, 'progress');
expect(action.viewOffset, 12000); // 12s in ms
expect(action.duration, 60000);
});
test('offline + null serverId is a no-op (does NOT throw, does NOT queue)', () async {
final (svc: svc, db: db, mgr: mgr) = await makeOfflineService();
addTearDown(() async {
svc.dispose();
mgr.dispose();
await db.close();
});
final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 60));
final tracker = PlaybackProgressTracker(
client: null,
metadata: _meta(ratingKey: '42', serverId: null), // <— no serverId
player: player,
isOffline: true,
offlineWatchService: svc,
);
addTearDown(tracker.dispose);
await tracker.sendProgress('playing');
expect(await svc.getPendingSyncCount(), 0);
});
});
// ============================================================
// WatchStateNotifier emission on 'stopped'
// ============================================================
group('WatchStateNotifier event on "stopped"', () {
test('emits a progress-update event when stopped past position 0', () async {
final client = _FakePlexClient(thresholdPercent: 90);
final player = _FakePlayer(position: const Duration(seconds: 30), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: '42', serverId: 'srv'),
player: player,
isOffline: false,
);
addTearDown(tracker.dispose);
// Subscribe before triggering the event.
final events = <WatchStateEvent>[];
final sub = WatchStateNotifier().forItem('42').listen(events.add);
addTearDown(sub.cancel);
await tracker.sendProgress('stopped');
// Stream is broadcast — give it a microtask.
await Future<void>.delayed(Duration.zero);
// We expect at least one progressUpdate event for ratingKey=42.
final progressEvents = events.where((e) => e.changeType == WatchStateChangeType.progressUpdate).toList();
expect(progressEvents, isNotEmpty);
expect(progressEvents.first.viewOffset, 30000);
});
test('does NOT emit on "stopped" if position is 0 (no real watch)', () async {
final client = _FakePlexClient(thresholdPercent: 90);
final player = _FakePlayer(position: Duration.zero, duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: 'no-watch', serverId: 'srv'),
player: player,
isOffline: false,
);
addTearDown(tracker.dispose);
final events = <WatchStateEvent>[];
final sub = WatchStateNotifier().forItem('no-watch').listen(events.add);
addTearDown(sub.cancel);
await tracker.sendProgress('stopped');
await Future<void>.delayed(Duration.zero);
// No progressUpdate event.
expect(events.where((e) => e.changeType == WatchStateChangeType.progressUpdate), isEmpty);
});
test('does NOT emit a progress event when scrobble already fired', () async {
// 95% triggers a scrobble (markAsWatched → notifyWatched). The progress
// event must be suppressed by the `_scrobbled` flag.
final client = _FakePlexClient(thresholdPercent: 90);
final player = _FakePlayer(position: const Duration(seconds: 95), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(ratingKey: 'scrobbler', serverId: 'srv'),
player: player,
isOffline: false,
);
addTearDown(tracker.dispose);
final events = <WatchStateEvent>[];
final sub = WatchStateNotifier().forItem('scrobbler').listen(events.add);
addTearDown(sub.cancel);
await tracker.sendProgress('stopped');
await Future<void>.delayed(Duration.zero);
// Watched event from markAsWatched fires; progressUpdate is suppressed.
final watched = events.where((e) => e.changeType == WatchStateChangeType.watched).toList();
final progress = events.where((e) => e.changeType == WatchStateChangeType.progressUpdate).toList();
expect(watched, hasLength(1));
expect(progress, isEmpty);
});
});
// ============================================================
// startTracking / stopTracking / dispose lifecycle
// ============================================================
group('lifecycle', () {
test('startTracking + stopTracking is a clean no-op for an inactive player', () async {
final client = _FakePlexClient();
final player = _FakePlayer(playing: false); // not active
final tracker = PlaybackProgressTracker(client: client, metadata: _meta(), player: player, isOffline: false);
addTearDown(tracker.dispose);
tracker.startTracking();
tracker.stopTracking();
// No initial 'playing' progress was sent because the player wasn't active.
// Drain anyway in case the unawaited future raced.
await Future<void>.delayed(Duration.zero);
expect(client.updateProgressCalls, isEmpty);
});
test('startTracking is idempotent: a second call logs a warning and no-ops', () async {
final client = _FakePlexClient();
final player = _FakePlayer(playing: false); // skip the immediate fire
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: player,
isOffline: false,
updateInterval: const Duration(hours: 1), // long enough that no tick fires in the test window
);
addTearDown(tracker.dispose);
tracker.startTracking();
tracker.startTracking(); // second call should warn and bail
tracker.stopTracking();
// No exception is the contract.
});
test('dispose is idempotent', () {
final client = _FakePlexClient();
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: _FakePlayer(playing: false),
isOffline: false,
);
tracker.dispose();
// Calling dispose again must not throw.
expect(tracker.dispose, returnsNormally);
});
});
}
/// A more precise fake than [_FakePlexClient]: lets the test independently
/// fail markAsWatched without touching updateProgress.
class _ScrobblePreciseClient implements PlexClient {
_ScrobblePreciseClient({this.thresholdPercent = 90, this.failScrobbleFirstTime = false});
final int thresholdPercent;
@override
int get watchedThresholdPercent => thresholdPercent;
bool failScrobbleFirstTime;
int markWatchedAttempts = 0;
int markWatchedSuccesses = 0;
@override
Future<void> updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {}
@override
Future<void> markAsWatched(String ratingKey, {PlexMetadata? metadata}) async {
markWatchedAttempts++;
if (failScrobbleFirstTime) {
failScrobbleFirstTime = false;
throw StateError('simulated scrobble failure');
}
markWatchedSuccesses++;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -0,0 +1,478 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_export_service.dart';
import '../test_helpers/prefs.dart';
// NOTE on coverage scope:
// `SettingsExportService.exportToFile` and `importFromFile` both call into
// platform plumbing (FilePicker, PackageInfo, path_provider, dart:io.File).
// Per the task brief we only round-trip through the *pure* helpers
// `buildExportMap` and `applyImportMap` against an in-memory
// SharedPreferencesWithCache. That covers the user-prefix re-scoping, the
// allow/deny filtering, and the typed value (de)serialization — which is
// where the format-stability risk lives.
void main() {
setUp(resetSharedPreferencesForTest);
// ============================================================
// buildExportMap — header fields
// ============================================================
group('buildExportMap header', () {
test('emits the documented format version, an ISO8601 timestamp, and the platform', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final out = SettingsExportService.buildExportMap(prefs);
expect(out['formatVersion'], SettingsExportService.formatVersion);
expect(out['appVersion'], '');
expect(out['exportedAt'], isA<String>());
// Sanity: the timestamp parses as an ISO-8601 instant.
expect(() => DateTime.parse(out['exportedAt'] as String), returnsNormally);
expect(out['platform'], isA<String>());
expect(out['prefs'], isA<Map>());
});
test('honors the supplied appVersion', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final out = SettingsExportService.buildExportMap(prefs, appVersion: '1.2.3');
expect(out['appVersion'], '1.2.3');
});
});
// ============================================================
// buildExportMap — type encoding round-trip
// ============================================================
group('buildExportMap type encoding', () {
test('encodes bool / int / double / string / stringList with type markers', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('flag_a', true);
await prefs.setInt('count_a', 42);
await prefs.setDouble('volume', 0.75);
await prefs.setString('name', 'plezy');
await prefs.setStringList('list_a', const ['x', 'y']);
final out = SettingsExportService.buildExportMap(prefs);
final p = out['prefs'] as Map<String, dynamic>;
expect(p['flag_a'], {'type': 'bool', 'value': true});
expect(p['count_a'], {'type': 'int', 'value': 42});
expect(p['volume'], {'type': 'double', 'value': 0.75});
expect(p['name'], {'type': 'string', 'value': 'plezy'});
expect(p['list_a'], {
'type': 'stringList',
'value': ['x', 'y'],
});
});
});
// ============================================================
// buildExportMap — denylist filtering
// ============================================================
group('buildExportMap denylist', () {
test('drops exact-deny credential keys', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
// Sample of the credential bucket — should never leak.
await prefs.setString('plex_token', 'abc');
await prefs.setString('client_identifier', 'xyz');
await prefs.setString('current_user_uuid', 'user-1');
await prefs.setString('user_profile', '{}');
// Plus a good-faith key that should stay.
await prefs.setBool('keep_me', true);
final out = SettingsExportService.buildExportMap(prefs);
final p = out['prefs'] as Map<String, dynamic>;
expect(p, isNot(contains('plex_token')));
expect(p, isNot(contains('client_identifier')));
expect(p, isNot(contains('current_user_uuid')));
expect(p, isNot(contains('user_profile')));
expect(p, contains('keep_me'));
});
test('drops prefix-deny keys (server_endpoint_, episode_count_, watched_threshold_, trakt_)', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('server_endpoint_srv1', 'http://x');
await prefs.setInt('episode_count_show42', 24);
await prefs.setInt('watched_threshold_srv1', 95);
await prefs.setString('trakt_access_token', 'secret');
// The trakt feature flag uses a different prefix and SHOULD survive.
await prefs.setBool('enable_trakt_scrobble', true);
final out = SettingsExportService.buildExportMap(prefs);
final p = out['prefs'] as Map<String, dynamic>;
expect(p, isNot(contains('server_endpoint_srv1')));
expect(p, isNot(contains('episode_count_show42')));
expect(p, isNot(contains('watched_threshold_srv1')));
expect(p, isNot(contains('trakt_access_token')));
expect(p, contains('enable_trakt_scrobble'));
});
test('drops the internal migration flag', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('buffer_size_migrated_to_auto', true);
final out = SettingsExportService.buildExportMap(prefs);
expect((out['prefs'] as Map), isNot(contains('buffer_size_migrated_to_auto')));
});
});
// ============================================================
// buildExportMap — user-prefix scoping
// ============================================================
group('buildExportMap user-scoping', () {
test('strips the active user prefix on export', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['a', 'b']);
await prefs.setBool('user_alice_hidden_libraries_does_not_exist', true);
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final p = out['prefs'] as Map<String, dynamic>;
// Active user's keys land under their *base* names.
expect(p, contains('library_order'));
expect(p['library_order'], {
'type': 'stringList',
'value': ['a', 'b'],
});
// Anything else under user_ that *isn't* the active user is excluded —
// the synthetic key above lives under "alice" and so it goes through.
expect(p, contains('hidden_libraries_does_not_exist'));
});
test('skips other users\' scoped keys entirely', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['a']);
await prefs.setStringList('user_bob_library_order', const ['b']);
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final p = out['prefs'] as Map<String, dynamic>;
// alice's value made it through (stripped to base key).
expect(p['library_order'], {
'type': 'stringList',
'value': ['a'],
});
// bob's was filtered out — there's no second pref with that name.
expect(p.values.where((v) => (v as Map)['value'] is List && (v['value'] as List).contains('b')), isEmpty);
});
test('without currentUserUuid: every user_-prefixed key is skipped', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['a']);
await prefs.setBool('global_flag', true);
final out = SettingsExportService.buildExportMap(prefs); // no UUID
final p = out['prefs'] as Map<String, dynamic>;
expect(p, contains('global_flag'));
// Every user_-scoped key is skipped because we have no active user.
expect(p.keys.where((k) => k.startsWith('user_')), isEmpty);
expect(p, isNot(contains('library_order')));
});
});
// ============================================================
// applyImportMap — version + structure validation
// ============================================================
group('applyImportMap validation', () {
test('throws when formatVersion is missing or wrong type', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
// Missing
expect(
() => SettingsExportService.applyImportMap({'prefs': const <String, dynamic>{}}, prefs, currentUserUuid: 'u'),
throwsA(isA<InvalidExportFileException>()),
);
// Wrong type
expect(
() => SettingsExportService.applyImportMap(
{'formatVersion': 'one', 'prefs': const <String, dynamic>{}},
prefs,
currentUserUuid: 'u',
),
throwsA(isA<InvalidExportFileException>()),
);
});
test('throws when formatVersion is newer than the supported one', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
expect(
() => SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion + 1, 'prefs': const <String, dynamic>{}},
prefs,
currentUserUuid: 'u',
),
throwsA(isA<InvalidExportFileException>()),
);
});
test('throws when prefs is missing or not a map', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
expect(
() => SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion},
prefs,
currentUserUuid: 'u',
),
throwsA(isA<InvalidExportFileException>()),
);
expect(
() => SettingsExportService.applyImportMap(
{'formatVersion': SettingsExportService.formatVersion, 'prefs': 'not-a-map'},
prefs,
currentUserUuid: 'u',
),
throwsA(isA<InvalidExportFileException>()),
);
});
});
// ============================================================
// applyImportMap — typed writes
// ============================================================
group('applyImportMap typed writes', () {
test('writes bool / int / double / string / stringList back into prefs', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'a_flag': {'type': 'bool', 'value': true},
'a_int': {'type': 'int', 'value': 7},
'a_double': {'type': 'double', 'value': 1.5},
'a_string': {'type': 'string', 'value': 'hi'},
'a_list': {
'type': 'stringList',
'value': ['x', 'y'],
},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 5);
expect(result.keysSkipped, 0);
expect(prefs.getBool('a_flag'), isTrue);
expect(prefs.getInt('a_int'), 7);
expect(prefs.getDouble('a_double'), 1.5);
expect(prefs.getString('a_string'), 'hi');
expect(prefs.getStringList('a_list'), ['x', 'y']);
});
test('double accepts num input (importing an int as double)', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
// Value is encoded as int but typed as double — should still write.
'speed': {'type': 'double', 'value': 2},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 1);
expect(prefs.getDouble('speed'), 2.0);
});
test('skips entries with mismatched type/value pairs without throwing', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
// bool with non-bool value
'bad_bool': {'type': 'bool', 'value': 'yes'},
// unknown type tag
'bad_type': {'type': 'enum', 'value': 'foo'},
// not a map at all
'not_map': 'whatever',
// missing type key
'no_type': {'value': 1},
// type isn't a string
'type_not_str': {'type': 1, 'value': 1},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 0);
expect(result.keysSkipped, 5);
// None of the bad keys ended up in prefs.
expect(prefs.getBool('bad_bool'), isNull);
expect(prefs.getString('bad_type'), isNull);
expect(prefs.getString('not_map'), isNull);
});
test('skips deny-listed keys even if present in the import payload', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'plex_token': {'type': 'string', 'value': 'malicious'},
'server_endpoint_srv': {'type': 'string', 'value': 'http://attacker.test'},
'good_key': {'type': 'bool', 'value': true},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 1);
expect(result.keysSkipped, 2);
expect(prefs.getString('plex_token'), isNull);
expect(prefs.getString('server_endpoint_srv'), isNull);
expect(prefs.getBool('good_key'), isTrue);
});
});
// ============================================================
// applyImportMap — user-scoped re-scoping
// ============================================================
group('applyImportMap user-scoping', () {
test('re-applies the active user prefix to scoped base keys', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
// exact-match scoped base keys
'library_order': {
'type': 'stringList',
'value': ['a', 'b'],
},
'hidden_libraries': {'type': 'string', 'value': '["lib1"]'},
// prefix-match scoped base keys
'library_filters_section1': {'type': 'string', 'value': '{}'},
'library_sort_section1': {'type': 'string', 'value': 'titleSort'},
'library_grouping_section1': {'type': 'string', 'value': 'shows'},
'library_tab_section1': {'type': 'string', 'value': 'recommended'},
// global key — must NOT be scoped
'enable_hardware_decoding': {'type': 'bool', 'value': true},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 7);
// Scoped keys land under user_alice_*
expect(prefs.getStringList('user_alice_library_order'), ['a', 'b']);
expect(prefs.getString('user_alice_hidden_libraries'), '["lib1"]');
expect(prefs.getString('user_alice_library_filters_section1'), '{}');
expect(prefs.getString('user_alice_library_sort_section1'), 'titleSort');
expect(prefs.getString('user_alice_library_grouping_section1'), 'shows');
expect(prefs.getString('user_alice_library_tab_section1'), 'recommended');
// Global key stays unscoped.
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getBool('user_alice_enable_hardware_decoding'), isNull);
});
});
// ============================================================
// Round-trip
// ============================================================
group('round-trip', () {
test('build → JSON → parse → apply produces the same key/value/type', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
// Seed a representative mix.
await prefs.setBool('enable_hardware_decoding', true);
await prefs.setInt('seek_time_small', 15);
await prefs.setDouble('volume', 0.75);
await prefs.setString('preferred_video_codec', 'h264');
await prefs.setStringList('shader_list', const ['a', 'b', 'c']);
// User-scoped data for "alice".
await prefs.setStringList('user_alice_library_order', const ['lib-1', 'lib-2']);
// Credential we expect to be stripped.
await prefs.setString('plex_token', 'never-this');
// Export.
final exportMap = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice', appVersion: '9.9.9');
final encoded = json.encode(exportMap);
// Wipe prefs to simulate a fresh device.
await prefs.clear();
// Confirm wipe.
expect(prefs.getInt('seek_time_small'), isNull);
expect(prefs.getStringList('user_alice_library_order'), isNull);
// Parse back and import — same alice, so scoped keys round-trip cleanly.
final decoded = json.decode(encoded) as Map<String, dynamic>;
final result = await SettingsExportService.applyImportMap(decoded, prefs, currentUserUuid: 'alice');
// 6 expected keys round-trip; the count includes the unrelated
// `plezy_legacy_prefs_migrated_v1` flag the cache plants. We only assert
// it is at LEAST our expected six keys, not an exact count.
expect(result.keysImported, greaterThanOrEqualTo(6));
expect(result.keysSkipped, 0);
// Values restored under their original keys (with re-applied scoping).
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getInt('seek_time_small'), 15);
expect(prefs.getDouble('volume'), 0.75);
expect(prefs.getString('preferred_video_codec'), 'h264');
expect(prefs.getStringList('shader_list'), ['a', 'b', 'c']);
expect(prefs.getStringList('user_alice_library_order'), ['lib-1', 'lib-2']);
// Credential never came back.
expect(prefs.getString('plex_token'), isNull);
});
test('cross-user round-trip: alice exports → bob imports → keys land under bob', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['lib-a', 'lib-b']);
final exportMap = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
// Wipe alice's data.
await prefs.clear();
// Bob imports. Scoped base key gets re-applied with bob's prefix.
final result = await SettingsExportService.applyImportMap(exportMap, prefs, currentUserUuid: 'bob');
// The cache plants `plezy_legacy_prefs_migrated_v1` on first init, so
// the export count includes that flag too. Just confirm the scoped
// value made it through.
expect(result.keysImported, greaterThanOrEqualTo(1));
// Alice's data is now under bob's namespace.
expect(prefs.getStringList('user_bob_library_order'), ['lib-a', 'lib-b']);
expect(prefs.getStringList('user_alice_library_order'), isNull);
});
});
// ============================================================
// Exception types
// ============================================================
group('exception types', () {
test('NoUserSignedInException is a SettingsExportException', () {
const ex = NoUserSignedInException();
expect(ex, isA<SettingsExportException>());
expect(ex.toString(), contains('No user is signed in'));
});
test('InvalidExportFileException is a SettingsExportException with message', () {
const ex = InvalidExportFileException('bad shape');
expect(ex, isA<SettingsExportException>());
expect(ex.toString(), contains('bad shape'));
});
});
}
+179
View File
@@ -0,0 +1,179 @@
import 'package:flutter/material.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';
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
expect(GridSizeCalculator.getColumnCount(600, 200), 3);
// 601 / 200 = 3.005 -> ceil to 4
expect(GridSizeCalculator.getColumnCount(601, 200), 4);
});
test('clamps to at least 1 column for zero/tiny widths', () {
expect(GridSizeCalculator.getColumnCount(0, 200), 1);
expect(GridSizeCalculator.getColumnCount(10, 200), 1);
});
test('clamps to at most 100 columns', () {
// 100000 / 100 = 1000 -> clamped to 100
expect(GridSizeCalculator.getColumnCount(100000, 100), 100);
});
test('uses GridLayoutConstants.crossAxisSpacing in the formula', () {
// The formula adds crossAxisSpacing to both sides, 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.isFirstRow / isFirstColumn', () {
test('isFirstRow: true for indices < columnCount', () {
expect(GridSizeCalculator.isFirstRow(0, 4), isTrue);
expect(GridSizeCalculator.isFirstRow(3, 4), isTrue);
expect(GridSizeCalculator.isFirstRow(4, 4), isFalse);
expect(GridSizeCalculator.isFirstRow(7, 4), isFalse);
});
test('isFirstColumn: true at column 0 of every row', () {
expect(GridSizeCalculator.isFirstColumn(0, 4), isTrue);
expect(GridSizeCalculator.isFirstColumn(4, 4), isTrue);
expect(GridSizeCalculator.isFirstColumn(8, 4), isTrue);
expect(GridSizeCalculator.isFirstColumn(1, 4), isFalse);
expect(GridSizeCalculator.isFirstColumn(5, 4), isFalse);
});
});
group('GridSizeCalculator.getMaxCrossAxisExtent', () {
Future<void> pumpWithWidth(WidgetTester tester, double width, void Function(BuildContext) onContext) async {
await tester.pumpWidget(
MediaQuery(
data: MediaQueryData(size: Size(width, 800)),
child: Builder(
builder: (ctx) {
onContext(ctx);
return const SizedBox();
},
),
),
);
}
testWidgets('mobile width (< 600): extent in [100, 200] range', (tester) async {
late double extent;
await pumpWithWidth(tester, 360, (ctx) {
extent = GridSizeCalculator.getMaxCrossAxisExtent(ctx, LibraryDensity.defaultValue);
});
// f at default(3) = (3-1)/(5-1) = 0.5 -> 100 + (200-100)*0.5 = 150
expect(extent, 150);
});
testWidgets('tablet width (600-1199): extent in [120, 230] range', (tester) async {
late double extent;
await pumpWithWidth(tester, 800, (ctx) {
extent = GridSizeCalculator.getMaxCrossAxisExtent(ctx, LibraryDensity.defaultValue);
});
// 120 + 110 * 0.5 = 175
expect(extent, 175);
});
testWidgets('desktop width (>=1200): extent in [140, 280] range', (tester) async {
late double extent;
await pumpWithWidth(tester, 1400, (ctx) {
extent = GridSizeCalculator.getMaxCrossAxisExtent(ctx, LibraryDensity.defaultValue);
});
// 140 + 140 * 0.5 = 210
expect(extent, 210);
});
testWidgets('density 1 returns the compact (min) extent', (tester) async {
late double extent;
await pumpWithWidth(tester, 360, (ctx) {
extent = GridSizeCalculator.getMaxCrossAxisExtent(ctx, 1);
});
// f = 0 -> 100
expect(extent, 100);
});
testWidgets('density 5 returns the comfortable (max) extent', (tester) async {
late double extent;
await pumpWithWidth(tester, 360, (ctx) {
extent = GridSizeCalculator.getMaxCrossAxisExtent(ctx, 5);
});
// f = 1 -> 200
expect(extent, 200);
});
testWidgets('extent grows monotonically with density on a fixed width', (tester) async {
final extents = <double>[];
for (final d in [1, 2, 3, 4, 5]) {
late double e;
await pumpWithWidth(tester, 800, (ctx) {
e = GridSizeCalculator.getMaxCrossAxisExtent(ctx, d);
});
extents.add(e);
}
for (var i = 1; i < extents.length; i++) {
expect(
extents[i],
greaterThan(extents[i - 1]),
reason: 'density $i should yield larger extent than density ${i - 1}',
);
}
});
});
group('GridSizeCalculator.getCellWidth', () {
testWidgets('cell width = availableWidth / column count', (tester) async {
late double width;
late double extent;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: Size(800, 800)),
child: Builder(
builder: (ctx) {
extent = GridSizeCalculator.getMaxCrossAxisExtent(ctx, LibraryDensity.defaultValue);
width = GridSizeCalculator.getCellWidth(800, ctx, LibraryDensity.defaultValue);
return const SizedBox();
},
),
),
);
// tablet path: 175 max extent -> ceil(800 / 175) = 5 columns -> 800 / 5 = 160
expect(extent, 175);
expect(width, 160);
});
testWidgets('cell width never exceeds available width', (tester) async {
late double width;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(size: Size(150, 800)),
child: Builder(
builder: (ctx) {
width = GridSizeCalculator.getCellWidth(150, ctx, LibraryDensity.defaultValue);
return const SizedBox();
},
),
),
);
// On 150-wide mobile, max extent is 150, columns clamp to 1, cell = 150.
expect(width, lessThanOrEqualTo(150));
});
});
}
+184
View File
@@ -0,0 +1,184 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/log_redaction_manager.dart';
void main() {
// The manager holds static state; clear between tests so they don't bleed.
setUp(() {
LogRedactionManager.clearTrackedValues();
});
tearDownAll(() {
LogRedactionManager.clearTrackedValues();
});
group('redact (no registered values)', () {
test('passes plain text through unchanged', () {
expect(LogRedactionManager.redact('hello world'), 'hello world');
});
test('redacts X-Plex-Token query parameter without registration', () {
final input = 'https://example.com/api?X-Plex-Token=abc123secret&foo=bar';
final result = LogRedactionManager.redact(input);
expect(result.contains('abc123secret'), isFalse);
expect(result.contains('X-Plex-Token=[REDACTED]'), isTrue);
// Does not eat the next param.
expect(result.contains('foo=bar'), isTrue);
});
test('X-Plex-Token redaction is case-insensitive', () {
final result = LogRedactionManager.redact('x-plex-token=SECRET&other=1');
expect(result.contains('SECRET'), isFalse);
expect(result.contains('[REDACTED]'), isTrue);
});
test('masks IPv4 addresses with dots', () {
final result = LogRedactionManager.redact('connect to 192.168.1.42 now');
expect(result.contains('192.168.1.42'), isFalse);
expect(result, 'connect to 192.x.x.42 now');
});
test('masks IPv4 addresses with dashes (used in *.plex.direct hostnames)', () {
final result = LogRedactionManager.redact('host 10-0-0-5.plex.direct');
expect(result.contains('10-0-0-5'), isFalse);
expect(result.contains('10-x-x-5'), isTrue);
});
test('does not match arbitrary dotted numbers that look unlike IPv4', () {
// Three octets only — not full v4.
final result = LogRedactionManager.redact('version 1.2.3 was released');
expect(result, 'version 1.2.3 was released');
});
});
group('registerToken', () {
test('redacts a registered token verbatim', () {
LogRedactionManager.registerToken('abc-secret-XYZ');
final result = LogRedactionManager.redact('Authorization: Bearer abc-secret-XYZ');
expect(result.contains('abc-secret-XYZ'), isFalse);
expect(result.contains('[REDACTED_TOKEN]'), isTrue);
});
test('redacts URL-encoded form of a token', () {
// This token contains a character that gets encoded.
LogRedactionManager.registerToken('a b/c');
final encoded = Uri.encodeQueryComponent('a b/c');
final result = LogRedactionManager.redact('q=$encoded&z=1');
expect(result.contains(encoded), isFalse);
expect(result.contains('[REDACTED_TOKEN]'), isTrue);
expect(result.contains('z=1'), isTrue);
});
test('null/empty/whitespace tokens are no-ops', () {
LogRedactionManager.registerToken(null);
LogRedactionManager.registerToken('');
LogRedactionManager.registerToken(' ');
// No state registered — redaction won't add tokens, only the IPv4
// and X-Plex-Token catch-alls remain.
expect(LogRedactionManager.redact('plain text'), 'plain text');
});
test('trims whitespace around tokens', () {
LogRedactionManager.registerToken(' TRIMMED ');
final result = LogRedactionManager.redact('value=TRIMMED here');
expect(result.contains('TRIMMED'), isFalse);
expect(result.contains('[REDACTED_TOKEN]'), isTrue);
});
});
group('registerServerUrl', () {
test('masks a registered server URL with start/end preview', () {
LogRedactionManager.registerServerUrl('https://my-cool-plex-server.example.com');
final result = LogRedactionManager.redact('GET https://my-cool-plex-server.example.com/library/sections');
expect(result.contains('my-cool-plex-server'), isFalse);
// start preview length is 12, end preview length is 8
expect(result.contains('...[REDACTED_URL]...'), isTrue);
});
test('skips IPv4-host URLs (regex IP redaction handles them)', () {
LogRedactionManager.registerServerUrl('http://192.168.1.1:32400');
// No URL is registered, so the URL is left as-is except for IPv4 mask.
final result = LogRedactionManager.redact('connecting to http://192.168.1.1:32400/api');
expect(result.contains('192.x.x.1'), isTrue);
// Should not contain any [REDACTED_URL] marker because URL was not registered.
expect(result.contains('[REDACTED_URL]'), isFalse);
});
test('null/empty values are no-ops', () {
LogRedactionManager.registerServerUrl(null);
LogRedactionManager.registerServerUrl('');
expect(LogRedactionManager.redact('plain'), 'plain');
});
test('registers both with and without trailing slash forms', () {
LogRedactionManager.registerServerUrl('https://server.example.com/');
// Both forms appear in real logs.
final r1 = LogRedactionManager.redact('host https://server.example.com');
final r2 = LogRedactionManager.redact('host https://server.example.com/');
expect(r1.contains('server.example.com'), isFalse);
expect(r2.contains('server.example.com'), isFalse);
});
});
group('registerCustomValue', () {
test('redacts a registered custom value with [REDACTED]', () {
LogRedactionManager.registerCustomValue('SuperSecret42');
final result = LogRedactionManager.redact('debug: SuperSecret42 leaked');
expect(result.contains('SuperSecret42'), isFalse);
expect(result.contains('[REDACTED]'), isTrue);
});
test('escapes regex metacharacters in registered values', () {
// If the manager naively built regex without escaping, this would break.
LogRedactionManager.registerCustomValue('a.b+c?d');
final result = LogRedactionManager.redact('found a.b+c?d in stream');
expect(result.contains('a.b+c?d'), isFalse);
expect(result.contains('[REDACTED]'), isTrue);
});
test('null/empty are no-ops', () {
LogRedactionManager.registerCustomValue(null);
LogRedactionManager.registerCustomValue('');
expect(LogRedactionManager.redact('xyz'), 'xyz');
});
});
group('combined redaction behavior', () {
test('longer match preferred over shorter overlapping match', () {
LogRedactionManager.registerCustomValue('abc');
LogRedactionManager.registerCustomValue('abcdef');
final result = LogRedactionManager.redact('value=abcdef');
// Both would match, but the longer literal sorts first in the alternation.
// After replacement, the substring abc within abcdef is consumed.
expect(result, 'value=[REDACTED]');
});
test('multiple kinds redacted in a single pass', () {
LogRedactionManager.registerToken('TOKEN_VALUE');
LogRedactionManager.registerServerUrl('https://plex.example.com');
LogRedactionManager.registerCustomValue('CUSTOM');
final result = LogRedactionManager.redact('TOKEN_VALUE host=https://plex.example.com extra=CUSTOM ip=10.0.0.1');
expect(result.contains('TOKEN_VALUE'), isFalse);
expect(result.contains('plex.example.com'), isFalse);
expect(result.contains('CUSTOM'), isFalse);
expect(result.contains('10.0.0.1'), isFalse);
expect(result.contains('[REDACTED_TOKEN]'), isTrue);
expect(result.contains('[REDACTED_URL]'), isTrue);
expect(result.contains('[REDACTED]'), isTrue);
expect(result.contains('10.x.x.1'), isTrue);
});
});
group('clearTrackedValues', () {
test('removes all previously registered values', () {
LogRedactionManager.registerToken('TOK');
LogRedactionManager.registerCustomValue('VAL');
LogRedactionManager.registerServerUrl('https://example.com');
LogRedactionManager.clearTrackedValues();
// Now nothing should be redacted (other than the always-on patterns).
final result = LogRedactionManager.redact('TOK VAL https://example.com');
expect(result, 'TOK VAL https://example.com');
});
});
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/utils/quality_preset_labels.dart';
void main() {
group('qualityPresetLabel', () {
test('original returns "Original" (default English locale)', () {
expect(qualityPresetLabel(TranscodeQualityPreset.original), 'Original');
});
test('integer-mbps preset renders without decimal', () {
// 2000 kbps -> 2 Mbps (whole number)
expect(qualityPresetLabel(TranscodeQualityPreset.p720_2mbps), '720p 2 Mbps');
});
test('fractional-mbps preset renders with one decimal', () {
// 1500 kbps -> 1.5 Mbps
expect(qualityPresetLabel(TranscodeQualityPreset.p480_1_5mbps), '480p 1.5 Mbps');
});
test('large bitrate (>=10 Mbps) drops decimal', () {
// 10000 kbps -> 10 Mbps
expect(qualityPresetLabel(TranscodeQualityPreset.p1080_10mbps), '1080p 10 Mbps');
// 20000 kbps -> 20 Mbps
expect(qualityPresetLabel(TranscodeQualityPreset.p1080_20mbps), '1080p 20 Mbps');
});
test('low-resolution presets have correct height', () {
expect(qualityPresetLabel(TranscodeQualityPreset.p240_320), startsWith('240p '));
expect(qualityPresetLabel(TranscodeQualityPreset.p320_720), startsWith('320p '));
});
test('all 1080p presets render with 1080p prefix', () {
for (final preset in [
TranscodeQualityPreset.p1080_8mbps,
TranscodeQualityPreset.p1080_10mbps,
TranscodeQualityPreset.p1080_12mbps,
TranscodeQualityPreset.p1080_20mbps,
]) {
expect(qualityPresetLabel(preset), startsWith('1080p '));
}
});
test('all 720p presets render with 720p prefix', () {
for (final preset in [
TranscodeQualityPreset.p720_2mbps,
TranscodeQualityPreset.p720_3mbps,
TranscodeQualityPreset.p720_4mbps,
]) {
expect(qualityPresetLabel(preset), startsWith('720p '));
}
});
test('every non-original preset renders without throwing', () {
for (final preset in TranscodeQualityPreset.values) {
expect(qualityPresetLabel(preset), isNotEmpty);
}
});
});
group('qualityPresetSizeEstimate', () {
test('returns null when sourceDurationMs is null', () {
expect(
qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: 5000,
sourceDurationMs: null,
),
isNull,
);
});
test('returns null when sourceDurationMs is 0 or negative', () {
expect(
qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: 5000,
sourceDurationMs: 0,
),
isNull,
);
expect(
qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: 5000,
sourceDurationMs: -100,
),
isNull,
);
});
test('original returns null when source bitrate is missing or zero', () {
expect(
qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.original,
sourceBitrateKbps: null,
sourceDurationMs: 1000,
),
isNull,
);
expect(
qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.original,
sourceBitrateKbps: 0,
sourceDurationMs: 1000,
),
isNull,
);
});
test('original returns formatted byte size with no percentage', () {
// 1000 kbps * 1000 ms / 8 = 125000 bytes -> "122.1 KB"
final result = qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.original,
sourceBitrateKbps: 1000,
sourceDurationMs: 1000,
);
expect(result, isNotNull);
// Should not contain a percentage marker.
expect(result!.contains('%'), isFalse);
});
test('non-original preset includes percentage when source bitrate is provided', () {
final result = qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: 8000,
sourceDurationMs: 60 * 1000,
);
expect(result, isNotNull);
expect(result!.contains('%'), isTrue);
expect(result.contains('('), isTrue);
expect(result.contains(')'), isTrue);
});
test('non-original preset omits percentage when source bitrate is null', () {
final result = qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: null,
sourceDurationMs: 60 * 1000,
);
expect(result, isNotNull);
expect(result!.contains('%'), isFalse);
});
test('non-original preset omits percentage when source bitrate is zero', () {
final result = qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: 0,
sourceDurationMs: 60 * 1000,
);
expect(result, isNotNull);
expect(result!.contains('%'), isFalse);
});
test('percentage uses video+audio bitrate ratio relative to source', () {
// p720_2mbps -> 2000 video kbps + 192 audio = 2192 kbps total.
// source = 8000 kbps -> 2192 * 100 / 8000 = 27.4 -> rounds to 27%.
final result = qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: 8000,
sourceDurationMs: 60 * 1000,
);
expect(result, isNotNull);
expect(result!.contains('27%'), isTrue);
});
test('larger source bitrate produces smaller percentage', () {
final at4k = qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: 40000,
sourceDurationMs: 1000,
);
final at8k = qualityPresetSizeEstimate(
preset: TranscodeQualityPreset.p720_2mbps,
sourceBitrateKbps: 8000,
sourceDurationMs: 1000,
);
// 4k source -> ~5%; 8k source -> ~27%.
expect(at4k, isNotNull);
expect(at8k, isNotNull);
// Pull just the percentage out for a sanity comparison.
final pctRe = RegExp(r'\((\d+)%\)');
final pct4k = int.parse(pctRe.firstMatch(at4k!)!.group(1)!);
final pct8k = int.parse(pctRe.firstMatch(at8k!)!.group(1)!);
expect(pct4k, lessThan(pct8k));
});
});
}
+273
View File
@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/scroll_utils.dart';
void main() {
group('scrollContextToCenter', () {
test('null context is a no-op (does not throw)', () {
// Calling with null must not register a post-frame callback or throw.
expect(() => scrollContextToCenter(null), returnsNormally);
});
testWidgets('non-null context with no scrollable ancestor does not throw', (tester) async {
late BuildContext capturedContext;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (ctx) {
capturedContext = ctx;
return const SizedBox();
},
),
),
);
// No Scrollable ancestor; ensureVisible has nothing to do but also
// shouldn't crash. Pumping a frame triggers the post-frame callback.
expect(() {
scrollContextToCenter(capturedContext);
}, returnsNormally);
// A pump runs the post-frame callback; it should be a no-op gracefully.
await tester.pump();
});
});
group('scrollToCurrentItem', () {
testWidgets('no-op when controller has no clients', (tester) async {
// Controller never attached -> hasClients is false -> early return.
final controller = ScrollController();
final firstItemKey = GlobalKey();
// Need a binding for addPostFrameCallback to run; build any widget tree.
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
expect(() {
scrollToCurrentItem(controller, firstItemKey, 5);
}, returnsNormally);
// Pump a frame to drain post-frame callbacks.
await tester.pump();
controller.dispose();
});
testWidgets('no-op when GlobalKey has no current context', (tester) async {
final controller = ScrollController();
final firstItemKey = GlobalKey();
// Attach the controller to a real list so hasClients is true,
// but never mount the firstItemKey -> findRenderObject() returns null.
await tester.pumpWidget(
MaterialApp(
home: ListView(controller: controller, children: const [SizedBox(height: 50)]),
),
);
expect(() {
scrollToCurrentItem(controller, firstItemKey, 3);
}, returnsNormally);
await tester.pump();
// Offset must be unchanged because the function bailed out.
expect(controller.offset, 0.0);
});
testWidgets('scrolls to currentIndex * itemHeight, clamped to maxExtent', (tester) async {
final controller = ScrollController();
final firstItemKey = GlobalKey();
const itemHeight = 50.0;
const itemCount = 100;
// Default test viewport is 800x600 — fill it directly.
const viewportHeight = 600.0;
await tester.pumpWidget(
MaterialApp(
home: ListView.builder(
controller: controller,
itemCount: itemCount,
itemBuilder: (_, i) => SizedBox(key: i == 0 ? firstItemKey : null, height: itemHeight, child: Text('$i')),
),
),
);
// Sanity-check viewport assumption.
expect(controller.position.viewportDimension, viewportHeight);
// Tap the function and pump to flush post-frame.
scrollToCurrentItem(controller, firstItemKey, 10);
await tester.pump();
// 10 * 50 = 500, well within max extent (50 * 100 - 600 = 4400).
expect(controller.offset, 500.0);
controller.dispose();
});
testWidgets('clamps target to max scroll extent for huge index', (tester) async {
final controller = ScrollController();
final firstItemKey = GlobalKey();
const itemHeight = 50.0;
const itemCount = 20;
await tester.pumpWidget(
MaterialApp(
home: ListView.builder(
controller: controller,
itemCount: itemCount,
itemBuilder: (_, i) => SizedBox(key: i == 0 ? firstItemKey : null, height: itemHeight, child: Text('$i')),
),
),
);
scrollToCurrentItem(controller, firstItemKey, 10000);
await tester.pump();
// Max extent equals position.maxScrollExtent — read it from the
// controller so the test stays robust against viewport changes.
expect(controller.offset, controller.position.maxScrollExtent);
controller.dispose();
});
});
group('scrollListToIndex', () {
testWidgets('no-op when controller has no clients', (tester) async {
final controller = ScrollController();
// Need a binding before invoking, even if controller is unattached.
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
expect(() {
scrollListToIndex(controller, 5, itemExtent: 100);
}, returnsNormally);
controller.dispose();
});
testWidgets('no-op when itemExtent <= 0', (tester) async {
final controller = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: SizedBox(
height: 100,
child: ListView(
scrollDirection: Axis.horizontal,
controller: controller,
children: List.generate(10, (i) => SizedBox(width: 80, height: 80, child: Text('$i'))),
),
),
),
);
// itemExtent of 0 must short-circuit.
scrollListToIndex(controller, 5, itemExtent: 0);
await tester.pump();
expect(controller.offset, 0.0);
// Negative itemExtent also short-circuits.
scrollListToIndex(controller, 5, itemExtent: -10);
await tester.pump();
expect(controller.offset, 0.0);
controller.dispose();
});
testWidgets('jumpTo (animate=false) centers the indexed item', (tester) async {
final controller = ScrollController();
const itemExtent = 100.0;
const leadingPadding = 12.0;
await tester.pumpWidget(
MaterialApp(
home: ListView(
scrollDirection: Axis.horizontal,
controller: controller,
children: List.generate(20, (i) => SizedBox(width: itemExtent, height: 100, child: Text('$i'))),
),
),
);
// Viewport reflects whatever the test surface assigns; read it.
final viewport = controller.position.viewportDimension;
const index = 10;
scrollListToIndex(controller, index, itemExtent: itemExtent, animate: false);
await tester.pump();
// Expected: leading + index*extent + extent/2 - viewport/2
final expected = (leadingPadding + index * itemExtent + itemExtent / 2 - viewport / 2).clamp(
0.0,
controller.position.maxScrollExtent,
);
expect(controller.offset, expected);
controller.dispose();
});
testWidgets('clamps to 0 when target would be negative', (tester) async {
final controller = ScrollController();
const itemExtent = 100.0;
await tester.pumpWidget(
MaterialApp(
home: ListView(
scrollDirection: Axis.horizontal,
controller: controller,
children: List.generate(20, (i) => SizedBox(width: itemExtent, height: 100, child: Text('$i'))),
),
),
);
// Centering the first item would require negative offset; should clamp.
scrollListToIndex(controller, 0, itemExtent: itemExtent, animate: false);
await tester.pump();
expect(controller.offset, 0.0);
controller.dispose();
});
testWidgets('clamps to maxScrollExtent for index past the end', (tester) async {
final controller = ScrollController();
const itemExtent = 100.0;
await tester.pumpWidget(
MaterialApp(
home: ListView(
scrollDirection: Axis.horizontal,
controller: controller,
children: List.generate(20, (i) => SizedBox(width: itemExtent, height: 100, child: Text('$i'))),
),
),
);
scrollListToIndex(controller, 9999, itemExtent: itemExtent, animate: false);
await tester.pump();
expect(controller.offset, controller.position.maxScrollExtent);
controller.dispose();
});
testWidgets('animate=true reaches the same final offset as jumpTo', (tester) async {
final controller = ScrollController();
const itemExtent = 100.0;
const leadingPadding = 12.0;
await tester.pumpWidget(
MaterialApp(
home: ListView(
scrollDirection: Axis.horizontal,
controller: controller,
children: List.generate(20, (i) => SizedBox(width: itemExtent, height: 100, child: Text('$i'))),
),
),
);
const index = 10;
final viewport = controller.position.viewportDimension;
final expected = (leadingPadding + index * itemExtent + itemExtent / 2 - viewport / 2).clamp(
0.0,
controller.position.maxScrollExtent,
);
scrollListToIndex(controller, index, itemExtent: itemExtent);
// Walk through the 150ms animation.
await tester.pumpAndSettle(const Duration(milliseconds: 200));
expect(controller.offset, closeTo(expected, 0.5));
controller.dispose();
});
});
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/track_label_builder.dart';
void main() {
group('buildTrackLabel', () {
test('joins title, language, and extra parts with " · "', () {
expect(
buildTrackLabel(title: 'Director Cut', language: 'EN', extraParts: const ['AAC', '2ch'], index: 0),
'Director Cut · EN · AAC · 2ch',
);
});
test('drops null/empty title and language', () {
expect(buildTrackLabel(title: null, language: null, extraParts: const ['AAC'], index: 0), 'AAC');
expect(buildTrackLabel(title: '', language: '', extraParts: const ['AAC'], index: 0), 'AAC');
});
test('falls back to "<prefix> <index+1>" when no parts', () {
expect(buildTrackLabel(index: 0), 'Track 1');
expect(buildTrackLabel(index: 4), 'Track 5');
expect(buildTrackLabel(index: 2, fallbackPrefix: 'Audio Track'), 'Audio Track 3');
});
test('preserves ordering: title, language, extras', () {
expect(buildTrackLabel(title: 'A', language: 'B', extraParts: const ['C', 'D'], index: 0), 'A · B · C · D');
});
test('only language', () {
expect(buildTrackLabel(language: 'FR', index: 0), 'FR');
});
test('only title', () {
expect(buildTrackLabel(title: 'Commentary', index: 0), 'Commentary');
});
});
group('TrackLabelBuilder.buildAudioLabel', () {
test('combines title, uppercased language, codec, channels', () {
expect(
TrackLabelBuilder.buildAudioLabel(title: 'Main', language: 'en', codec: 'aac', channelsCount: 2, index: 0),
'Main · EN · AAC · 2ch',
);
});
test('uppercases language', () {
expect(
TrackLabelBuilder.buildAudioLabel(language: 'fr', codec: 'ac3', channelsCount: 6, index: 0),
'FR · AC3 · 6ch',
);
});
test('formats codec via CodecUtils (e.g. eac3 -> E-AC3)', () {
final label = TrackLabelBuilder.buildAudioLabel(codec: 'eac3', index: 0);
expect(label, 'E-AC3');
});
test('omits codec when null/empty', () {
expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: null, index: 0), 'EN');
expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: '', index: 0), 'EN');
});
test('omits channels when null', () {
expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: 'aac', index: 0), 'EN · AAC');
});
test('falls back to "Audio Track N" when nothing supplied', () {
expect(TrackLabelBuilder.buildAudioLabel(index: 0), 'Audio Track 1');
expect(TrackLabelBuilder.buildAudioLabel(index: 3), 'Audio Track 4');
});
test('zero channel count is still rendered (caller decides validity)', () {
// Behavior check: 0 is non-null, so it appears as 0ch.
expect(TrackLabelBuilder.buildAudioLabel(channelsCount: 0, index: 0), '0ch');
});
});
group('TrackLabelBuilder.buildSubtitleLabel', () {
test('combines title, uppercased language, friendly codec', () {
expect(
TrackLabelBuilder.buildSubtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', index: 0),
'Forced · EN · SRT',
);
});
test('uppercases language and formats codec', () {
expect(TrackLabelBuilder.buildSubtitleLabel(language: 'fr', codec: 'webvtt', index: 0), 'FR · VTT');
expect(TrackLabelBuilder.buildSubtitleLabel(language: 'de', codec: 'hdmv_pgs_subtitle', index: 0), 'DE · PGS');
});
test('omits codec when null/empty', () {
expect(TrackLabelBuilder.buildSubtitleLabel(language: 'en', index: 0), 'EN');
expect(TrackLabelBuilder.buildSubtitleLabel(language: 'en', codec: '', index: 0), 'EN');
});
test('falls back to "Track N" with default prefix', () {
expect(TrackLabelBuilder.buildSubtitleLabel(index: 0), 'Track 1');
expect(TrackLabelBuilder.buildSubtitleLabel(index: 7), 'Track 8');
});
});
}
@@ -0,0 +1,196 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/watch_together/models/watch_session.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
// The provider reads SettingsService.instanceOrNull?.read(...) when
// creating/joining sessions; ensure prefs are reset between tests.
resetSharedPreferencesForTest();
});
group('WatchTogetherProvider — initial state', () {
test('starts disconnected with no session, peers, or sync state', () {
final p = WatchTogetherProvider();
expect(p.session, isNull);
expect(p.sessionId, isNull);
expect(p.isInSession, isFalse);
expect(p.isHost, isFalse);
expect(p.isConnected, isFalse);
expect(p.isSyncing, isFalse);
expect(p.isDeferredPlay, isFalse);
expect(p.isWaitingForHostReconnect, isFalse);
expect(p.participants, isEmpty);
expect(p.participantCount, 0);
// Default control mode falls back to hostOnly when there's no session.
expect(p.controlMode, ControlMode.hostOnly);
expect(p.syncManager, isNull);
p.dispose();
});
test('current media getters all return null on a fresh provider', () {
final p = WatchTogetherProvider();
expect(p.currentMediaRatingKey, isNull);
expect(p.currentMediaServerId, isNull);
expect(p.currentMediaTitle, isNull);
expect(p.hasCurrentPlayback, isFalse);
p.dispose();
});
test('participants list is unmodifiable', () {
final p = WatchTogetherProvider();
// Even when empty, the unmodifiable view must reject mutation so
// callers can't smuggle peers in by mutating the returned list.
expect(
() => p.participants.add(const Participant(peerId: 'x', displayName: 'y', isHost: false)),
throwsUnsupportedError,
);
p.dispose();
});
test('canControl returns true outside of a session (no gating)', () {
final p = WatchTogetherProvider();
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', () {
final p = WatchTogetherProvider();
var notified = 0;
p.addListener(() => notified++);
// Without a session, setCurrentMedia logs a warning and bails — no notify.
p.setCurrentMedia(ratingKey: 'rk1', serverId: 's1', mediaTitle: 't1');
expect(notified, 0);
expect(p.currentMediaRatingKey, isNull);
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: '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 manager (logs warning)', () {
final p = WatchTogetherProvider();
// The mpv Player object is platform-tied; skipping it would reach the
// null-syncManager 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 sync manager but is null-safe', () {
final p = WatchTogetherProvider();
expect(() => p.setBackgrounded(true), returnsNormally);
expect(() => p.setBackgrounded(false), returnsNormally);
p.dispose();
});
test('onLocalSeek is null-safe without a sync manager', () {
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 — 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.
final events = <ParticipantEvent>[];
var streamDone = false;
final sub = p.participantEvents.listen(events.add, onDone: () => streamDone = true);
p.dispose();
// Yield so the broadcast controller's close microtask runs.
await Future<void>.delayed(Duration.zero);
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);
});
});
}