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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user