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); }