test(player): cover embedded-VO ownership, colour sanitization and styling refusals

Three new Linux startup cases, one isolate each (a second VideoPlayerScreen
in one isolate never reaches initialize):

- A custom mpv config naming vo/gpu-context/gpu-api cannot detach the
  embedded renderer: the writes are withheld by name while ordinary entries
  (sub-scale) still land.
- Unparseable stored subtitle colours (named, 3-digit hex) reach the wire
  canonicalized to the defaults, proving the OPT_COLOR sanitization.
- A refused sub-color write no longer aborts initialization: the write is
  attempted, contained, and playback continues past the styling block.
This commit is contained in:
edde746
2026-08-14 18:18:09 +02:00
parent 9cdfe7591e
commit f56264dcce
4 changed files with 230 additions and 0 deletions
@@ -0,0 +1,23 @@
import 'package:flutter_test/flutter_test.dart';
import '../../test_helpers/hdr_startup.dart';
/// The user's free-form mpv config is applied as runtime mpv_set_property
/// writes, and the embedded renderer owns the windowed-VO family: a
/// `vo=gpu-next` line (with the gpu-context/gpu-api it rides on) makes mpv
/// re-create its output as a separate, uncontrollable window and orphans the
/// embedded plane. Startup therefore skips those three names in the custom
/// pass and logs the skip with the real reason.
///
/// Removing the filter makes this fail on the write lists: `vo` reaches mpv
/// and the plane is orphaned. See installHdrStartupHarness for why this case
/// needs an isolate of its own.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(installHdrStartupHarness);
testWidgets('a custom config naming the embedded VO family cannot detach the renderer', (tester) async {
await expectCustomConfigCannotOverrideEmbeddedVo(tester);
});
}
@@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/settings_service.dart';
import '../../test_helpers/hdr_startup.dart';
/// mpv 0.40's OPT_COLOR parser accepts only #RRGGBB/#AARRGGBB, so a stored
/// subtitle colour that does not parse (named colour, 3-digit hex) would make
/// the `sub-color`/`sub-border-color`/`sub-back-color` writes fail with
/// MPV_ERROR_PROPERTY_FORMAT. Startup canonicalizes the values to hex and
/// replaces unparseable ones with the defaults, so a bad preference degrades
/// to the default colour instead of failing playback.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
await installHdrStartupHarness();
// Deliberately unparseable stored values: a named colour, a 3-digit hex,
// and another named colour for the background (with a full opacity so the
// composed sub-back-color is deterministic).
await SettingsService.instance.write(SettingsService.subtitleTextColor, 'white');
await SettingsService.instance.write(SettingsService.subtitleBorderColor, '#FFF');
await SettingsService.instance.write(SettingsService.subtitleBackgroundColor, 'red');
await SettingsService.instance.write(SettingsService.subtitleBackgroundOpacity, 100);
});
testWidgets('unparseable subtitle colours are canonicalized to defaults on the wire', (tester) async {
await expectSanitizedSubtitleColorsOnTheWire(tester);
});
}
@@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import '../../test_helpers/hdr_startup.dart';
/// A refused subtitle-styling write used to escape _runPlayerInitializationAttempt
/// into the initialization error screen on every open (mpv refuses a colour
/// value its OPT_COLOR parser cannot read with MPV_ERROR_PROPERTY_FORMAT).
/// Styling is a preference, so the refusal is now contained: the write is
/// attempted, logged, and playback continues with mpv's own default styling.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(installHdrStartupHarness);
testWidgets('a refused subtitle style write does not abort initialization', (tester) async {
await expectSubtitleStyleRefusalDoesNotAbortStartup(tester);
});
}
+160
View File
@@ -281,6 +281,166 @@ Future<void> expectCustomConfigCannotOverrideHdrPreferences(WidgetTester tester)
);
}
/// The custom mpv config is applied as runtime mpv_set_property writes, and
/// vo/gpu-context/gpu-api are owned by the embedded renderer: a vo=gpu-next
/// line would make mpv re-create its output as a separate window and orphan
/// the plane (the render API is OpenGL-only, so gpu-next cannot be embedded).
/// Startup must skip the whole VO family by name - with a key-aware log, not
/// the HDR-settings pointer - and let ordinary entries through.
Future<void> expectCustomConfigCannotOverrideEmbeddedVo(WidgetTester tester) async {
await SettingsService.instance.write(
SettingsService.mpvConfigText,
'vo=gpu-next\n'
'gpu-context=waylandvk\n'
'gpu-api=vulkan\n'
'sub-scale=1.5\n',
);
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
calls.add(call);
return call.method == 'initialize' ? true : null;
},
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
await _mountPlayerScreen(tester, 'Linux embedded VO override video');
// `volume-max` is the write immediately after the custom-config pass, so
// its arrival is what makes the lists below complete.
await pumpUntil(
tester,
() => _propertyWrites(calls).contains('volume-max'),
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
);
// None of the VO family reached mpv: a vo switch would detach the
// embedded render context into a separate window.
for (final owned in ['vo', 'gpu-context', 'gpu-api']) {
expect(_valueWrites(calls, owned), isEmpty, reason: '$owned is owned by the embedded renderer');
}
// The rest of the config still reaches mpv: the skip goes by name.
expect(_valueWrites(calls, 'sub-scale'), ['1.5']);
// Same deterministic teardown as [expectCustomConfigCannotOverrideHdrPreferences].
await tester.pumpWidget(const SizedBox.shrink());
await pumpUntil(
tester,
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
describe: () =>
'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}',
);
},
);
}
/// A refused subtitle-styling write must not abort initialization: styling is
/// a preference, and the same refusal that used to land as
/// SET_PROPERTY_FAILED on the channel used to escape
/// _runPlayerInitializationAttempt into the error screen. The mock plane
/// refuses `sub-color`; initialization must run through to `volume-max` and
/// no error screen may appear.
Future<void> expectSubtitleStyleRefusalDoesNotAbortStartup(WidgetTester tester) async {
final refusal = PlatformException(
code: 'SET_PROPERTY_FAILED',
message: "setProperty 'sub-color'='#FFFFFF' failed: unsupported format for accessing property",
);
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
calls.add(call);
if (call.method == 'setProperty' && (call.arguments as Map)['name'] == 'sub-color') {
return Future<Object?>.error(refusal);
}
return call.method == 'initialize' ? true : null;
},
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
await _mountPlayerScreen(tester, 'Linux subtitle style refusal video');
// `volume-max` follows the styling block; reaching it proves the refusal
// was contained and initialization ran on.
await pumpUntil(
tester,
() => _propertyWrites(calls).contains('volume-max'),
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
);
// The write was attempted (not skipped) and the refusal was swallowed:
// playback must not die over styling.
expect(_propertyWrites(calls), contains('sub-color'));
expect(find.text('Retry'), findsNothing, reason: 'a styling refusal must not show the error screen');
// Same deterministic teardown as the HDR cases.
await tester.pumpWidget(const SizedBox.shrink());
await pumpUntil(
tester,
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
describe: () =>
'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}',
);
},
);
}
/// The stored subtitle colours are free-form strings and mpv 0.40's OPT_COLOR
/// parser rejects anything but #RRGGBB/#AARRGGBB, so startup sanitizes the
/// values before writing: parseable hex passes through, everything else is
/// replaced by the preference default. The caller seeds the unparseable
/// values; this asserts what reaches the wire.
Future<void> expectSanitizedSubtitleColorsOnTheWire(WidgetTester tester) async {
final calls = <MethodCall>[];
final eventCalls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) async {
calls.add(call);
return call.method == 'initialize' ? true : null;
},
eventHandler: (call) async {
eventCalls.add(call);
return null;
},
testBody: () async {
await _mountPlayerScreen(tester, 'Linux subtitle colour sanitization video');
// `volume-max` follows the subtitle styling block, so its arrival makes
// the styling writes complete.
await pumpUntil(
tester,
() => _propertyWrites(calls).contains('volume-max'),
describe: () => 'writes=${_propertyWrites(calls)} calls=${calls.map((c) => c.method).toList()}',
);
// The defaults, not the unparseable seeds.
expect(_valueWrites(calls, 'sub-color'), ['#FFFFFF']);
expect(_valueWrites(calls, 'sub-border-color'), ['#000000']);
expect(_valueWrites(calls, 'sub-back-color'), ['#FF000000']);
// Same deterministic teardown as the HDR cases.
await tester.pumpWidget(const SizedBox.shrink());
await pumpUntil(
tester,
() => calls.any((call) => call.method == 'dispose') && eventCalls.any((call) => call.method == 'cancel'),
describe: () =>
'calls=${calls.map((c) => c.method).toList()} events=${eventCalls.map((c) => c.method).toList()}',
);
},
);
}
/// The negative side of [expectStartupSurvivesHdrRefusal]: with the Linux video
/// path forced off, the same refusal must abort initialization rather than be
/// swallowed, so `audio-delay` never follows it and the stored preference is