import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/models/audio_quality_preset.dart'; import 'package:plezy/models/hotkey_model.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/trackers/tracker_constants.dart'; import 'package:plezy/utils/platform_detector.dart'; import '../test_helpers/prefs.dart'; void main() { setUp(() { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); }); tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAutomotiveOverride(null); }); group('SettingsService.parseMpvConfigText', () { test('parses plain key=value lines', () { final out = SettingsService.parseMpvConfigText('hwdec=auto\nvolume=100'); expect(out, {'hwdec': 'auto', 'volume': '100'}); }); test('trims whitespace around key and value', () { final out = SettingsService.parseMpvConfigText(' hwdec = auto '); expect(out, {'hwdec': 'auto'}); }); test('skips blank lines', () { final out = SettingsService.parseMpvConfigText('\n\nhwdec=auto\n\n'); expect(out, {'hwdec': 'auto'}); }); test('skips # comment lines (even with leading whitespace)', () { final out = SettingsService.parseMpvConfigText('# this is a comment\n # indented comment\nhwdec=auto'); expect(out, {'hwdec': 'auto'}); }); test('skips lines without an = sign', () { final out = SettingsService.parseMpvConfigText('justakey\nfoo=bar'); expect(out, {'foo': 'bar'}); }); test('skips lines starting with = (empty key)', () { final out = SettingsService.parseMpvConfigText('=value\nfoo=bar'); expect(out, {'foo': 'bar'}); }); test('preserves = signs in value (splits on first only)', () { final out = SettingsService.parseMpvConfigText('params=a=1,b=2'); expect(out, {'params': 'a=1,b=2'}); }); test('allows empty value', () { final out = SettingsService.parseMpvConfigText('flag='); expect(out, {'flag': ''}); }); test('later duplicate key overrides earlier', () { final out = SettingsService.parseMpvConfigText('k=1\nk=2'); expect(out, {'k': '2'}); }); test('empty input yields empty map', () { expect(SettingsService.parseMpvConfigText(''), isEmpty); }); }); group('SettingsService keyboard hotkey defaults', () { test('includes Ctrl+S screenshot shortcut', () { final hotkey = SettingsService.defaultKeyboardHotkeys()['screenshot']; expect(hotkey, isNotNull); expect(hotkey!.key, PhysicalKeyboardKey.keyS); expect(hotkey.modifiers, [HotKeyModifier.control]); }); }); group('SettingsService mute volume restoration', () { test('keeps 37 persisted across mute and restores it on unmute', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.volume, 37.0); final mute = settings.resolveMuteToggle(37); await settings.write(SettingsService.volume, mute.persistedVolume); expect(mute.playerVolume, 0); expect(settings.read(SettingsService.volume), 37); final unmute = settings.resolveMuteToggle(mute.playerVolume); expect(unmute.playerVolume, 37); expect(unmute.persistedVolume, 37); }); test('restores amplified volumes when the configured maximum permits them', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.maxVolume, 250); await settings.write(SettingsService.volume, 175.0); final mute = settings.resolveMuteToggle(175); await settings.write(SettingsService.volume, mute.persistedVolume); final unmute = settings.resolveMuteToggle(mute.playerVolume); expect(mute.playerVolume, 0); expect(mute.persistedVolume, 175); expect(unmute.playerVolume, 175); expect(unmute.persistedVolume, 175); }); test('falls back to 100 when no previous non-zero volume exists', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.maxVolume, 200); await settings.write(SettingsService.volume, 0.0); final unmute = settings.resolveMuteToggle(0); expect(unmute.playerVolume, 100); expect(unmute.persistedVolume, 100); }); }); group('SettingsService TV card defaults', () { test('full card layout starts disabled', () async { final settings = await SettingsService.getInstance(); expect(settings.read(SettingsService.tvFullCardLayout), isFalse); }); }); group('SettingsService episode action', () { test('defaults to play and resets to play', () async { final settings = await SettingsService.getInstance(); expect(settings.read(SettingsService.episodeAction), EpisodeAction.play); await settings.write(SettingsService.episodeAction, EpisodeAction.details); expect(settings.read(SettingsService.episodeAction), EpisodeAction.details); await settings.resetAllSettings(); expect(settings.read(SettingsService.episodeAction), EpisodeAction.play); }); }); group('SettingsService music quality', () { test('defaults to original and persists changes by enum name', () async { var settings = await SettingsService.getInstance(); expect(settings.read(SettingsService.musicQualityPreset), AudioQualityPreset.original); await settings.write(SettingsService.musicQualityPreset, AudioQualityPreset.medium); expect(settings.prefs.getString(SettingsService.musicQualityPreset.key), 'medium'); BaseSharedPreferencesService.resetForTesting(); SettingsService.resetForTesting(); settings = await SettingsService.getInstance(); expect(settings.read(SettingsService.musicQualityPreset), AudioQualityPreset.medium); }); }); group('SettingsService app locale', () { test('persists script-specific locales by enum name', () async { var settings = await SettingsService.getInstance(); await settings.write(SettingsService.appLocale, AppLocale.zhHant); expect(settings.prefs.getString(SettingsService.appLocale.key), 'zhHant'); BaseSharedPreferencesService.resetForTesting(); SettingsService.resetForTesting(); settings = await SettingsService.getInstance(); expect(settings.read(SettingsService.appLocale), AppLocale.zhHant); }); test('continues to read legacy language-code values', () async { var settings = await SettingsService.getInstance(); await settings.prefs.setString(SettingsService.appLocale.key, 'zh'); BaseSharedPreferencesService.resetForTesting(); SettingsService.resetForTesting(); settings = await SettingsService.getInstance(); expect(settings.read(SettingsService.appLocale), AppLocale.zh); }); }); group('SettingsService platform gates', () { test('audio passthrough stays available on desktop and Apple TV', () { expect(PlatformDetector.supportsAudioPassthrough(), isTrue); TvDetectionService.debugSetAppleTVOverride(true); expect(PlatformDetector.supportsAudioPassthrough(), isTrue); }); test('audio passthrough defaults off on a non-Android-TV host and honors explicit writes', () async { final settings = await SettingsService.getInstance(); // The Android-TV-on-ExoPlayer default-on branch depends on Platform.isAndroid, // which is false (and unmockable) on the test host, so the default is off here. expect(settings.read(SettingsService.audioPassthrough), isFalse); await settings.write(SettingsService.audioPassthrough, true); expect(settings.read(SettingsService.audioPassthrough), isTrue); await settings.write(SettingsService.audioPassthrough, false); expect(settings.read(SettingsService.audioPassthrough), isFalse); }); test('forces external player off on Apple TV even when stored enabled', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.useExternalPlayer, true); TvDetectionService.debugSetAppleTVOverride(true); expect(settings.read(SettingsService.useExternalPlayer), isFalse); }); test('forces auto PiP off on Apple TV even when stored enabled', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.autoPip, true); TvDetectionService.debugSetAppleTVOverride(true); expect(settings.read(SettingsService.autoPip), isFalse); }); test('forces auto PiP off on automotive while honoring the stored value elsewhere', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.autoPip, true); // The pref can only surface a stored true where the host itself supports // PiP. That term is Platform.isAndroid/isIOS/isMacOS, unmockable and false // on the Linux and Windows CI hosts, where the gate pins the pref off no // matter what is stored. pictureInPictureAllowed covers the automotive // veto itself on every host. final hostSupportsPip = PlatformDetector.supportsPictureInPicture(); expect(settings.read(SettingsService.autoPip), hostSupportsPip ? isTrue : isFalse); TvDetectionService.debugSetAutomotiveOverride(true); expect(settings.read(SettingsService.autoPip), isFalse); }); }); group('SettingsService companion remote prefs', () { test('last manual host address trims whitespace and drops blanks', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.companionRemoteLastHostAddress, ' 192.168.1.10:48632 '); expect(settings.read(SettingsService.companionRemoteLastHostAddress), '192.168.1.10:48632'); await settings.write(SettingsService.companionRemoteLastHostAddress, ' '); expect(settings.read(SettingsService.companionRemoteLastHostAddress), isNull); }); test('resetAllSettings clears the last manual host address', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.companionRemoteLastHostAddress, '192.168.1.10:48632'); expect(settings.read(SettingsService.companionRemoteLastHostAddress), isNotNull); await settings.resetAllSettings(); expect(settings.read(SettingsService.companionRemoteLastHostAddress), isNull); }); }); group('SettingsService Watch Together relay', () { test('typed writes canonicalize valid bases and reject invalid replacement', () async { final settings = await SettingsService.getInstance(); await settings.write(SettingsService.customRelayUrl, ' HTTPS://Relay.Example.Test/path/// '); expect(settings.read(SettingsService.customRelayUrl), 'https://relay.example.test/path'); await expectLater( settings.write(SettingsService.customRelayUrl, 'ws://relay.example.test'), throwsFormatException, ); expect(settings.read(SettingsService.customRelayUrl), 'https://relay.example.test/path'); await settings.write(SettingsService.customRelayUrl, ' '); expect(settings.read(SettingsService.customRelayUrl), isNull); }); test('startup canonicalizes valid history and removes invalid history', () async { resetSharedPreferencesForTest( initialAsync: {SettingsService.customRelayUrl.key: ' http://Relay.Example.Test:8080/prefix// '}, ); SettingsService.resetForTesting(); var settings = await SettingsService.getInstance(); expect(settings.read(SettingsService.customRelayUrl), 'http://relay.example.test:8080/prefix'); resetSharedPreferencesForTest( initialAsync: {SettingsService.customRelayUrl.key: 'https://relay.example.test/path?wrong=route'}, ); SettingsService.resetForTesting(); settings = await SettingsService.getInstance(); expect(settings.read(SettingsService.customRelayUrl), isNull); }); }); group('SettingsService listenables', () { test('refreshListenables updates active prefs outside the resettable surface', () async { final settings = await SettingsService.getInstance(); final crashReporting = settings.listenable(SettingsService.crashReporting); expect(crashReporting.value, isTrue); await settings.prefs.setBool(SettingsService.crashReporting.key, false); expect(crashReporting.value, isTrue); settings.refreshListenables(); expect(crashReporting.value, isFalse); }); test('resetAllSettings refreshes active dynamic tracker prefs', () async { final settings = await SettingsService.getInstance(); final modePref = SettingsService.trackerFilterModePref(TrackerService.trakt); final idsPref = SettingsService.trackerFilterIdsPref(TrackerService.trakt); await settings.write(modePref, TrackerLibraryFilterMode.whitelist); await settings.write(idsPref, ['library-1']); final mode = settings.listenable(modePref); final ids = settings.listenable(idsPref); expect(mode.value, TrackerLibraryFilterMode.whitelist); expect(ids.value, ['library-1']); await settings.resetAllSettings(); expect(mode.value, TrackerLibraryFilterMode.blacklist); expect(ids.value, isEmpty); }); test('tracker library filter only allows unknown libraries when no filter is configured', () async { final settings = await SettingsService.getInstance(); final modePref = SettingsService.trackerFilterModePref(TrackerService.trakt); final idsPref = SettingsService.trackerFilterIdsPref(TrackerService.trakt); expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isTrue); await settings.write(idsPref, ['server:blocked']); expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isFalse); await settings.write(modePref, TrackerLibraryFilterMode.whitelist); await settings.write(idsPref, ['server:allowed']); expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isFalse); expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, 'server:allowed'), isTrue); }); }); group('BaseSharedPreferencesService initialization generations', () { test('a reset-raced initialization resolves to the replacement backend', () async { resetSharedPreferencesForTest(initialAsync: const {'generation_marker': 'old'}); final firstOnInit = Completer(); final firstStarted = Completer(); var constructions = 0; final raced = BaseSharedPreferencesService.initializeInstance<_GenerationTestPreferences>(() { final construction = constructions++; return _GenerationTestPreferences( construction, onInitStarted: construction == 0 ? firstStarted : null, onInitGate: construction == 0 ? firstOnInit : null, ); }); await firstStarted.future; resetSharedPreferencesForTest(initialAsync: const {'generation_marker': 'new'}); final replacement = await BaseSharedPreferencesService.initializeInstance<_GenerationTestPreferences>( () => _GenerationTestPreferences(constructions++), ); firstOnInit.complete(); final recovered = await raced; expect(recovered, same(replacement)); expect(recovered.construction, 1); expect(recovered.readString('generation_marker'), 'new'); expect(constructions, 2); }); }); } class _GenerationTestPreferences extends BaseSharedPreferencesService { _GenerationTestPreferences(this.construction, {this.onInitStarted, this.onInitGate}); final int construction; final Completer? onInitStarted; final Completer? onInitGate; @override Future onInit() async { onInitStarted?.complete(); await onInitGate?.future; } }