Files
plezy/test/services/settings_service_test.dart
T
edde746 88ffe0806d test(automotive): assert the picture-in-picture vetoes on every host
41ffaa7f2 gated picture-in-picture on FEATURE_AUTOMOTIVE and added a
settings case for it, but the assertion that case leads with — a stored
auto-PiP true surviving a read — needs supportsPictureInPicture() to be
true, and that gate ends in Platform.isAndroid || isIOS || isMacOS. The
term is false and unmockable on the Linux and Windows runners, so the
case passed on a macOS host and could never pass in CI: sanity checks
have been red for six commits on this one failure out of 4723. f13f5af6e
recorded it as a pre-existing Windows-host failure, but it entered in
this window and is red on Linux too.

Extract the gate's decision into a pure pictureInPictureAllowed that
takes the host's own capability as a parameter, the way
driver_distraction.dart already splits automotivePlaybackAllowed from its
ambient wrapper. The boolean algebra is unchanged, so the three callers
keep their behaviour; what changes is that the automotive and TV vetoes
become observable where every Platform branch is false, instead of being
vacuous on the host that gates the release.

The settings case keeps the pref-level contract on both host classes: a
stored true survives where the host supports PiP, and the gate pins it
off where it does not.

Verified with the host term forced false to emulate a Linux runner: both
files stay green, as does the full suite on macOS.
2026-07-30 03:16:49 +02:00

405 lines
16 KiB
Dart

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<void>();
final firstStarted = Completer<void>();
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<void>? onInitStarted;
final Completer<void>? onInitGate;
@override
Future<void> onInit() async {
onInitStarted?.complete();
await onInitGate?.future;
}
}