diff --git a/test/mixins/server_bound_media_mixin_test.dart b/test/mixins/server_bound_media_mixin_test.dart new file mode 100644 index 00000000..c2120172 --- /dev/null +++ b/test/mixins/server_bound_media_mixin_test.dart @@ -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); + }); + }); +} diff --git a/test/mixins/tab_navigation_mixin_test.dart b/test/mixins/tab_navigation_mixin_test.dart new file mode 100644 index 00000000..7fbeb030 --- /dev/null +++ b/test/mixins/tab_navigation_mixin_test.dart @@ -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 _nodes; + int onTabChangedCalls = 0; + + @override + List 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); + }); + }); +} diff --git a/test/services/bif_thumbnail_service_test.dart b/test/services/bif_thumbnail_service_test.dart new file mode 100644 index 00000000..0b4ab081 --- /dev/null +++ b/test/services/bif_thumbnail_service_test.dart @@ -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 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(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 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); + }); + }); +} diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart new file mode 100644 index 00000000..920dfc84 --- /dev/null +++ b/test/services/offline_watch_sync_service_test.dart @@ -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); + }); + }); +} diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart new file mode 100644 index 00000000..b12eb88a --- /dev/null +++ b/test/services/playback_progress_tracker_test.dart @@ -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 markWatchedCalls = []; + + /// If non-null, [updateProgress] / [markAsWatched] throw this on the next call. + Object? throwOnNextCall; + + @override + Future 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 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()), + ); + }); + + test('offline=false requires client', () { + expect( + () => PlaybackProgressTracker(client: null, metadata: _meta(), player: _FakePlayer(), isOffline: false), + throwsA(isA()), + ); + }); + + 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.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 = []; + final sub = WatchStateNotifier().forItem('42').listen(events.add); + addTearDown(sub.cancel); + + await tracker.sendProgress('stopped'); + // Stream is broadcast — give it a microtask. + await Future.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 = []; + final sub = WatchStateNotifier().forItem('no-watch').listen(events.add); + addTearDown(sub.cancel); + + await tracker.sendProgress('stopped'); + await Future.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 = []; + final sub = WatchStateNotifier().forItem('scrobbler').listen(events.add); + addTearDown(sub.cancel); + + await tracker.sendProgress('stopped'); + await Future.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.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 updateProgress(String ratingKey, {required int time, required String state, int? duration}) async {} + + @override + Future 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); +} diff --git a/test/services/settings_export_service_test.dart b/test/services/settings_export_service_test.dart new file mode 100644 index 00000000..b58480c8 --- /dev/null +++ b/test/services/settings_export_service_test.dart @@ -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()); + // Sanity: the timestamp parses as an ISO-8601 instant. + expect(() => DateTime.parse(out['exportedAt'] as String), returnsNormally); + expect(out['platform'], isA()); + expect(out['prefs'], isA()); + }); + + 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; + + 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; + + 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; + + 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; + + // 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; + + // 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; + + 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 {}}, prefs, currentUserUuid: 'u'), + throwsA(isA()), + ); + // Wrong type + expect( + () => SettingsExportService.applyImportMap( + {'formatVersion': 'one', 'prefs': const {}}, + prefs, + currentUserUuid: 'u', + ), + throwsA(isA()), + ); + }); + + 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 {}}, + prefs, + currentUserUuid: 'u', + ), + throwsA(isA()), + ); + }); + + 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()), + ); + expect( + () => SettingsExportService.applyImportMap( + {'formatVersion': SettingsExportService.formatVersion, 'prefs': 'not-a-map'}, + prefs, + currentUserUuid: 'u', + ), + throwsA(isA()), + ); + }); + }); + + // ============================================================ + // 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; + 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()); + expect(ex.toString(), contains('No user is signed in')); + }); + + test('InvalidExportFileException is a SettingsExportException with message', () { + const ex = InvalidExportFileException('bad shape'); + expect(ex, isA()); + expect(ex.toString(), contains('bad shape')); + }); + }); +} diff --git a/test/utils/grid_size_calculator_test.dart b/test/utils/grid_size_calculator_test.dart new file mode 100644 index 00000000..7f8c27c0 --- /dev/null +++ b/test/utils/grid_size_calculator_test.dart @@ -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 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 = []; + 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)); + }); + }); +} diff --git a/test/utils/log_redaction_manager_test.dart b/test/utils/log_redaction_manager_test.dart new file mode 100644 index 00000000..3d0b58a4 --- /dev/null +++ b/test/utils/log_redaction_manager_test.dart @@ -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'); + }); + }); +} diff --git a/test/utils/quality_preset_labels_test.dart b/test/utils/quality_preset_labels_test.dart new file mode 100644 index 00000000..d1a641c8 --- /dev/null +++ b/test/utils/quality_preset_labels_test.dart @@ -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)); + }); + }); +} diff --git a/test/utils/scroll_utils_test.dart b/test/utils/scroll_utils_test.dart new file mode 100644 index 00000000..4846a72a --- /dev/null +++ b/test/utils/scroll_utils_test.dart @@ -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(); + }); + }); +} diff --git a/test/utils/track_label_builder_test.dart b/test/utils/track_label_builder_test.dart new file mode 100644 index 00000000..7022c57f --- /dev/null +++ b/test/utils/track_label_builder_test.dart @@ -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 " " 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'); + }); + }); +} diff --git a/test/watch_together/watch_together_provider_test.dart b/test/watch_together/watch_together_provider_test.dart new file mode 100644 index 00000000..cef6b33d --- /dev/null +++ b/test/watch_together/watch_together_provider_test.dart @@ -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 = []; + 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.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.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); + }); + }); +}