fix(ui): harden settings focus and semantics

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent f8bfecf57d
commit b41fb4fe75
159 changed files with 12054 additions and 2050 deletions
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/data/ducet_order.dart';
void main() {
test('applies CLDR kana ordering', () {
expect(ducetCompare('', ''), isNegative);
expect(ducetCompare('', ''), isNegative);
});
test('orders representative scripts by DUCET rank', () {
expect(ducetCompare('A', 'α'), isNegative);
expect(ducetCompare('α', 'Ж'), isNegative);
expect(ducetCompare('Ж', ''), isNegative);
});
test('decomposes Kangxi radicals to their unified equivalents', () {
expect(ducetCompare('', ''), 0);
expect(ducetCompare('', ''), 0);
});
test('uses codepoint fallback for missing BMP and supplementary characters', () {
expect(ducetCompare('\u0378', '\u0379'), isNegative);
expect(ducetCompare('\u0378', 'A'), isPositive);
expect(ducetCompare('😀', '😁'), isNegative);
expect(ducetCompare('😀', 'A'), isPositive);
});
test('preserves apostrophe, backslash, and dollar ranks in generated literal', () {
expect(ducetCompare("'", r'$'), isNegative);
expect(ducetCompare(r'$', r'\'), isPositive);
});
test('compares only the first rune', () {
expect(ducetCompare('A trailing text', 'A different text'), 0);
expect(ducetCompare('😀 trailing text', '😀 different text'), 0);
});
test('preserves empty-input failure', () {
expect(() => ducetCompare('', 'A'), throwsStateError);
expect(() => ducetCompare('A', ''), throwsStateError);
});
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/data/hid_key_labels.dart';
import 'package:plezy/models/hotkey_model.dart';
void main() {
test('catalog preserves all 325 unique keys in canonical numeric order', () {
final ids = hidKeyLabels.keys.toList();
final sortedIds = [...ids]..sort();
expect(hidKeyLabels, hasLength(325));
expect(ids.toSet(), hasLength(ids.length));
expect(ids, sortedIds);
expect(ids.every((id) => id >= 0 && id <= 0xffffffff), isTrue);
expect(hidKeyLabels.values.every((label) => label.trim().isNotEmpty), isTrue);
});
test('catalog preserves representative labels across usage groups', () {
expect(hidKeyLabels[0x00000012], 'Fn');
expect(hidKeyLabels[0x000100b5], 'Display Toggle');
expect(hidKeyLabels[0x0005ff1f], 'Game Button Z');
expect(hidKeyLabels[0x00070031], r'\');
expect(hidKeyLabels[0x000c00cd], 'Play/Pause');
expect(hidKeyLabels[0x000c029f], 'Show All Windows');
});
test('physicalKeyLabel uses the catalog and formats an unknown HID fallback', () {
expect(physicalKeyLabel(PhysicalKeyboardKey.keyA), 'A');
expect(physicalKeyLabel(const PhysicalKeyboardKey(0xffffffff)), 'Key 0xffffffff');
});
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/data/iso_639_data.dart';
import 'package:plezy/utils/language_codes.dart';
void main() {
test('catalog preserves 184 canonical entries and globally unique codes', () {
final primaryCodes = languageEntries.keys.toList();
final sortedPrimaryCodes = [...primaryCodes]..sort();
final allCodes = <String>{};
expect(languageEntries, hasLength(184));
expect(primaryCodes, sortedPrimaryCodes);
for (final mapEntry in languageEntries.entries) {
final entry = mapEntry.value;
expect(entry.code1, mapEntry.key);
expect(entry.code1, matches(RegExp(r'^[a-z]{2}$')));
expect(entry.code2, matches(RegExp(r'^[a-z]{3}$')));
expect(entry.name.trim(), isNotEmpty);
expect(allCodes.add(entry.code1), isTrue);
expect(allCodes.add(entry.code2), isTrue);
if (entry.code2B case final bibliographic?) {
expect(bibliographic, matches(RegExp(r'^[a-z]{3}$')));
expect(allCodes.add(bibliographic), isTrue);
}
}
});
test('reverse maps are derived completely from terminology and bibliographic aliases', () {
expect(code2ToCode1, hasLength(184));
expect(code2BToCode1, hasLength(20));
for (final entry in languageEntries.values) {
expect(code2ToCode1[entry.code2], entry.code1);
if (entry.code2B case final bibliographic?) {
expect(code2BToCode1[bibliographic], entry.code1);
}
}
});
test('representative terminology and bibliographic aliases preserve behavior', () {
final german = languageEntries['de']!;
expect(german.code2, 'deu');
expect(german.code2B, 'ger');
expect(german.name, 'German');
expect(languageEntries['bn']!.name, 'Bengali, Bangla');
expect(languageEntries['mi']!.name, 'Māori');
expect(LanguageCodes.getIso6391Code(' DEU '), 'de');
expect(LanguageCodes.getIso6391Code('ger'), 'de');
expect(LanguageCodes.getLanguageName('GER'), 'German');
expect(LanguageCodes.getVariations('ger'), unorderedEquals(<String>['ger', 'de', 'deu']));
expect(LanguageCodes.getLanguageName('zzz'), isNull);
});
}
@@ -1,3 +1,5 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -39,4 +41,22 @@ void main() {
expect(longPressed, 0);
});
testWidgets('tab chips expose one operable node with selected state', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTabChip(label: 'Selected tab', isSelected: true, onSelect: () {}),
),
),
);
final node = tester.getSemantics(find.bySemanticsLabel('Selected tab')).getSemanticsData();
expect(node.flagsCollection.isButton, isTrue);
expect(node.flagsCollection.isSelected, Tristate.isTrue);
expect(node.hasAction(SemanticsAction.tap), isTrue);
semantics.dispose();
});
}
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mixins/grid_focus_node_mixin.dart';
void main() {
testWidgets('focused distant nodes retain ownership until a later eviction', (tester) async {
final key = GlobalKey<_GridFocusHarnessState>();
await tester.pumpWidget(MaterialApp(home: _GridFocusHarness(key: key)));
final state = key.currentState!;
final retained = state.getGridItemFocusNode(0);
state.getGridItemFocusNode(10);
final center = state.getGridItemFocusNode(20);
state.refresh();
await tester.pump();
retained.requestFocus();
await tester.pump();
expect(retained.hasFocus, isTrue);
state.evictDistantFocusNodes(20, keepCount: 1);
expect(state.gridItemFocusNodes[0], same(retained));
expect(state.gridItemFocusNodes.containsKey(10), isFalse);
expect(state.getGridItemFocusNode(0), same(retained));
state.refresh();
await tester.pump();
center.requestFocus();
await tester.pump();
expect(retained.hasFocus, isFalse);
state.evictDistantFocusNodes(20, keepCount: 1);
expect(state.gridItemFocusNodes.containsKey(0), isFalse);
expect(state.gridItemFocusNodes[20], same(center));
state.refresh();
await tester.pump();
});
}
class _GridFocusHarness extends StatefulWidget {
const _GridFocusHarness({super.key});
@override
State<_GridFocusHarness> createState() => _GridFocusHarnessState();
}
class _GridFocusHarnessState extends State<_GridFocusHarness> with GridFocusNodeMixin<_GridFocusHarness> {
void refresh() => setState(() {});
@override
void dispose() {
disposeGridFocusNodes();
super.dispose();
}
@override
Widget build(BuildContext context) => Row(
children: [
for (final node in gridItemFocusNodes.values)
Focus(focusNode: node, child: const SizedBox(width: 10, height: 10)),
],
);
}
+62
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/services/gamepad_service.dart';
import 'package:plezy/utils/platform_detector.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -51,4 +52,65 @@ void main() {
expect(oneShotMode, InputMode.pointer);
expect(oneShotBuilds, 1);
});
testWidgets('keyboard-mode cursor shield preserves pointer activation', (tester) async {
var taps = 0;
await tester.pumpWidget(
InputModeTracker(
child: Directionality(
textDirection: TextDirection.ltr,
child: GestureDetector(
key: const Key('target'),
behavior: HitTestBehavior.opaque,
onTap: () => taps++,
child: const SizedBox(width: 120, height: 80),
),
),
),
);
GamepadService.onGamepadInput!.call();
await tester.pump();
expect(tester.widget<MouseRegion>(find.byType(MouseRegion)).cursor, SystemMouseCursors.none);
await tester.tap(find.byKey(const Key('target')));
await tester.pump();
expect(taps, 1);
expect(find.byType(MouseRegion), findsNothing);
});
testWidgets('non-desktop TV path has no cursor shield and remains pointer-reachable', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
PlatformDetector.debugSetIsDesktopOSOverride(false);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
});
var taps = 0;
await tester.pumpWidget(
InputModeTracker(
child: Directionality(
textDirection: TextDirection.ltr,
child: GestureDetector(
key: const Key('tv-target'),
behavior: HitTestBehavior.opaque,
onTap: () => taps++,
child: const SizedBox(width: 120, height: 80),
),
),
),
);
GamepadService.onGamepadInput!.call();
await tester.pump();
expect(find.byType(MouseRegion), findsNothing);
await tester.tap(find.byKey(const Key('tv-target')));
await tester.pump();
expect(taps, 1);
});
}
+121
View File
@@ -75,6 +75,35 @@ void main() {
await tester.pump();
});
test('one-shot select consumes every phase and activates only on key down', () {
var activations = 0;
const down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.select,
logicalKey: LogicalKeyboardKey.select,
timeStamp: Duration.zero,
);
const repeat = KeyRepeatEvent(
physicalKey: PhysicalKeyboardKey.select,
logicalKey: LogicalKeyboardKey.select,
timeStamp: Duration(milliseconds: 100),
);
const up = KeyUpEvent(
physicalKey: PhysicalKeyboardKey.select,
logicalKey: LogicalKeyboardKey.select,
timeStamp: Duration(milliseconds: 200),
);
const unrelated = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.f12,
logicalKey: LogicalKeyboardKey.f12,
timeStamp: Duration.zero,
);
expect(handleOneShotSelect(down, () => activations++), KeyEventResult.handled);
expect(handleOneShotSelect(repeat, () => activations++), KeyEventResult.handled);
expect(handleOneShotSelect(up, () => activations++), KeyEventResult.handled);
expect(handleOneShotSelect(unrelated, () => activations++), KeyEventResult.ignored);
expect(activations, 1);
});
group('BackKeyCoordinator', () {
testWidgets('suppresses one parallel back dispatch in the current frame', (tester) async {
BackKeyCoordinator.markHandled();
@@ -214,6 +243,77 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
});
testWidgets('skips disabled actions for entry and horizontal traversal', (tester) async {
final key = GlobalKey<FocusableActionBarState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableActionBar(
key: key,
actions: [
const FocusableAction(icon: Icons.block),
FocusableAction(icon: Icons.add, onPressed: () {}),
const FocusableAction(icon: Icons.block),
FocusableAction(icon: Icons.remove, onPressed: () {}),
],
),
),
),
);
await tester.pump();
key.currentState!.requestFocusOnFirst();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[1]');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[3]');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[1]');
});
testWidgets('dynamic callback changes remove a disabled action from focus', (tester) async {
final key = GlobalKey<FocusableActionBarState>();
late StateSetter rebuild;
var firstEnabled = true;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
rebuild = setState;
return FocusableActionBar(
key: key,
actions: [
FocusableAction(icon: Icons.add, onPressed: firstEnabled ? () {} : null),
FocusableAction(icon: Icons.remove, onPressed: () {}),
],
);
},
),
),
),
);
await tester.pump();
key.currentState!.requestFocusOnFirst();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
rebuild(() => firstEnabled = false);
await tester.pump();
expect(key.currentState!.getFocusNode(0)!.canRequestFocus, isFalse);
expect(key.currentState!.getFocusNode(0)!.hasFocus, isFalse);
key.currentState!.requestFocusOnFirst();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[1]');
});
testWidgets('still invokes onNavigateLeft at the left edge when wired', (tester) async {
final key = GlobalKey<FocusableActionBarState>();
final leftTarget = FocusNode(debugLabel: 'left-target');
@@ -279,4 +379,25 @@ void main() {
expect(activations, 1);
});
});
group('expandToGraphemeRange', () {
for (final grapheme in ['😀', 'e\u0301', '🇯🇵', '👨‍👩‍👧‍👦']) {
test('expands partial ${grapheme.runes.length}-scalar ranges', () {
final text = 'A${grapheme}B';
final graphemeEnd = 1 + grapheme.length;
final expected = TextRange(start: 1, end: graphemeEnd);
expect(expandToGraphemeRange(text, const TextRange(start: 1, end: 2)), expected);
expect(expandToGraphemeRange(text, TextRange(start: graphemeEnd - 1, end: graphemeEnd)), expected);
expect(expandToGraphemeRange(text, TextSelection(baseOffset: graphemeEnd - 1, extentOffset: 1)), expected);
expect(expandToGraphemeRange(text, TextRange(start: graphemeEnd - 1, end: 1)), expected);
});
}
test('preserves document edges and empty ranges', () {
expect(expandToGraphemeRange('abc', const TextRange(start: 0, end: 1)), const TextRange(start: 0, end: 1));
expect(expandToGraphemeRange('abc', const TextRange(start: 3, end: 3)), const TextRange.collapsed(3));
expect(expandToGraphemeRange('', const TextRange(start: 0, end: 1)), TextRange.empty);
});
});
}
@@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/locked_hub_controller.dart';
void main() {
test('focus memory and last-column hint are isolated per browse owner', () {
final ownerA = HubFocusMemory();
final ownerB = HubFocusMemory();
ownerA.setForHub('detail_episodes', 7);
expect(ownerA.getForHubOnly('detail_episodes', 5), 4);
expect(ownerA.getForHub('detail_episodes', 3), 2);
expect(ownerA.getForHub('detail_extras', 4), 3);
expect(ownerB.getForHubOnly('detail_episodes', 5), 0);
expect(ownerB.getForHub('detail_episodes', 5), 0);
expect(ownerB.getForHub('detail_extras', 5), 0);
});
test('per-hub fallback and empty rows keep their existing clamping behavior', () {
final memory = HubFocusMemory();
expect(memory.getForHubOnly('unseen', 3, fallback: 9), 2);
expect(memory.getForHubOnly('unseen', 0, fallback: 9), 0);
expect(memory.getForHub('unseen', 0), 0);
});
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
void main() {
test('empty overrides fall back in every affected locale', () async {
final english = await AppLocale.en.build();
final commonEnglishValues = <String>[
english.videoControls.showPlaybackControls,
english.videoControls.hidePlaybackControls,
];
final spotlightEnglishValues = <String>[
english.settings.tvCornerSpotlightBackdrop,
english.settings.tvCornerSpotlightBackdropDescription,
];
expect([...commonEnglishValues, ...spotlightEnglishValues], everyElement(isNotEmpty));
const commonFallbackLocales = <AppLocale>[
AppLocale.bg,
AppLocale.da,
AppLocale.de,
AppLocale.es,
AppLocale.fr,
AppLocale.it,
AppLocale.ja,
AppLocale.ko,
AppLocale.nb,
AppLocale.nl,
AppLocale.pl,
AppLocale.pt,
AppLocale.ru,
AppLocale.sv,
AppLocale.zh,
];
const spotlightFallbackLocales = <AppLocale>[
AppLocale.bg,
AppLocale.da,
AppLocale.es,
AppLocale.fr,
AppLocale.it,
AppLocale.ja,
AppLocale.ko,
AppLocale.nb,
AppLocale.nl,
AppLocale.pl,
AppLocale.pt,
AppLocale.ru,
AppLocale.sv,
AppLocale.zh,
];
for (final locale in commonFallbackLocales) {
final translations = await locale.build();
expect(
<String>[translations.videoControls.showPlaybackControls, translations.videoControls.hidePlaybackControls],
commonEnglishValues,
reason: '${locale.languageCode} must inherit the common English values',
);
}
for (final locale in spotlightFallbackLocales) {
final translations = await locale.build();
expect(
<String>[
translations.settings.tvCornerSpotlightBackdrop,
translations.settings.tvCornerSpotlightBackdropDescription,
],
spotlightEnglishValues,
reason: '${locale.languageCode} must inherit the English spotlight values',
);
}
});
}
@@ -0,0 +1,47 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('recovery and error locale entries are absent or non-empty', () {
const recoveryPaths = <List<String>>[
['auth', 'localDataRecoveryRequired'],
['settings', 'watchTogetherRelayInvalid'],
['settings', 'saveFailed'],
['messages', 'playbackAuthenticationRequired'],
['messages', 'playbackServerUnavailable'],
['messages', 'playbackDataInvalid'],
['messages', 'playbackCancelled'],
['messages', 'playbackFailed'],
['profiles', 'borrowLoadFailed'],
['liveTv', 'favoritesUpdateFailed'],
['settings', 'downloadLocationPickerUnavailable'],
];
final sourceDirectory = Directory('lib/i18n');
final english = _decodeLocale(File('${sourceDirectory.path}/en.i18n.json'));
final localeFiles = sourceDirectory.listSync().whereType<File>().where(
(file) => file.path.endsWith('.i18n.json') && !file.path.endsWith('en.i18n.json'),
);
for (final path in recoveryPaths) {
expect(_valueAt(english, path), isNotEmpty, reason: 'English base value ${path.join('.')} must be usable');
for (final localeFile in localeFiles) {
final locale = _decodeLocale(localeFile);
final value = _valueAt(locale, path);
expect(value, anyOf(isNull, isNotEmpty), reason: '${localeFile.path} must omit or translate ${path.join('.')}');
}
}
});
}
Map<String, dynamic> _decodeLocale(File file) => jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
String? _valueAt(Map<String, dynamic> locale, List<String> path) {
Object? value = locale;
for (final segment in path) {
if (value is! Map<String, dynamic> || !value.containsKey(segment)) return null;
value = value[segment];
}
return value as String?;
}
+79 -12
View File
@@ -3,21 +3,88 @@ import 'package:plezy/models/shader_preset.dart';
void main() {
group('ShaderPreset ArtCNN presets', () {
test('exposes stable built-in preset ids in the expected order', () {
final ids = ShaderPreset.allPresets.map((preset) => preset.id).toList();
expect(ids.take(8), [
ShaderPreset.none.id,
ShaderPreset.nvscalerDefault.id,
'artcnn_c4f16_neutral',
'artcnn_c4f16_dn',
'artcnn_c4f16_ds',
'artcnn_c4f32_neutral',
'artcnn_c4f32_dn',
'artcnn_c4f32_ds',
test('exposes the complete built-in catalog in stable order', () {
expect(ShaderPreset.allPresets.map((preset) => preset.toJson()).toList(), [
{'id': 'none', 'name': 'Off', 'type': 'none'},
{
'id': 'nvscaler',
'name': 'NVScaler',
'type': 'nvscaler',
'nvscalerConfig': {'autoHdrSkip': true},
},
{
'id': 'artcnn_c4f16_neutral',
'name': 'ArtCNN C4F16',
'type': 'artcnn',
'artcnnConfig': {'model': 'c4f16', 'variant': 'neutral'},
},
{
'id': 'artcnn_c4f16_dn',
'name': 'ArtCNN C4F16 Denoise',
'type': 'artcnn',
'artcnnConfig': {'model': 'c4f16', 'variant': 'denoise'},
},
{
'id': 'artcnn_c4f16_ds',
'name': 'ArtCNN C4F16 Denoise + Sharpen',
'type': 'artcnn',
'artcnnConfig': {'model': 'c4f16', 'variant': 'denoiseSharpen'},
},
{
'id': 'artcnn_c4f32_neutral',
'name': 'ArtCNN C4F32',
'type': 'artcnn',
'artcnnConfig': {'model': 'c4f32', 'variant': 'neutral'},
},
{
'id': 'artcnn_c4f32_dn',
'name': 'ArtCNN C4F32 Denoise',
'type': 'artcnn',
'artcnnConfig': {'model': 'c4f32', 'variant': 'denoise'},
},
{
'id': 'artcnn_c4f32_ds',
'name': 'ArtCNN C4F32 Denoise + Sharpen',
'type': 'artcnn',
'artcnnConfig': {'model': 'c4f32', 'variant': 'denoiseSharpen'},
},
for (final entry in const [
('fast', 'modeA', 'Anime4K Fast A'),
('fast', 'modeB', 'Anime4K Fast B'),
('fast', 'modeC', 'Anime4K Fast C'),
('fast', 'modeAA', 'Anime4K Fast A+A'),
('fast', 'modeBB', 'Anime4K Fast B+B'),
('fast', 'modeCA', 'Anime4K Fast C+A'),
('hq', 'modeA', 'Anime4K HQ A'),
('hq', 'modeB', 'Anime4K HQ B'),
('hq', 'modeC', 'Anime4K HQ C'),
('hq', 'modeAA', 'Anime4K HQ A+A'),
('hq', 'modeBB', 'Anime4K HQ B+B'),
('hq', 'modeCA', 'Anime4K HQ C+A'),
])
{
'id': 'anime4k_${entry.$1}_${entry.$2}',
'name': entry.$3,
'type': 'anime4k',
'anime4kConfig': {'quality': entry.$1, 'mode': entry.$2},
},
]);
});
test('shares an unmodifiable catalog and canonical id lookup', () {
final first = ShaderPreset.allPresets;
final second = ShaderPreset.allPresets;
expect(identical(first, second), isTrue);
expect(() => first.add(ShaderPreset.none), throwsUnsupportedError);
expect(() => first.removeLast(), throwsUnsupportedError);
for (final preset in first) {
expect(identical(ShaderPreset.fromId(preset.id), preset), isTrue);
expect(identical(ShaderPreset.fromJson(preset.toJson()), preset), isTrue);
}
expect(ShaderPreset.fromId('unknown'), isNull);
});
test('creates ArtCNN presets with names, type, and config', () {
final neutral = ShaderPreset.artcnnPreset(ArtCNNModel.c4f16, ArtCNNVariant.neutral);
final denoise = ShaderPreset.artcnnPreset(ArtCNNModel.c4f32, ArtCNNVariant.denoise);
+119
View File
@@ -1,9 +1,17 @@
import 'dart:io';
import 'dart:convert';
import 'package:path/path.dart' as path;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/providers/shader_provider.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/settings_export_service.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
void main() {
@@ -163,6 +171,117 @@ void main() {
p.dispose();
});
test('reuses the unmodifiable built-in catalog when no custom presets exist', () async {
final p = ShaderProvider();
await Future.delayed(Duration.zero);
expect(identical(p.allPresets, ShaderPreset.allPresets), isTrue);
expect(identical(p.allPresets, p.allPresets), isTrue);
expect(() => p.allPresets.add(ShaderPreset.none), throwsUnsupportedError);
p.dispose();
});
test('refreshes a stable merged catalog when custom presets change', () async {
final originalPathProvider = PathProviderPlatform.instance;
final root = await Directory.systemTemp.createTemp('plezy_shader_provider_test_');
PathProviderPlatform.instance = FakePathProvider(root);
addTearDown(() async {
PathProviderPlatform.instance = originalPathProvider;
if (await root.exists()) await root.delete(recursive: true);
});
final source = File(path.join(root.path, 'custom.glsl'))..writeAsStringSync('shader');
final p = ShaderProvider();
addTearDown(p.dispose);
await Future.delayed(Duration.zero);
final builtIns = p.allPresets;
final custom = await p.importCustomShader(source.path, 'Custom');
final merged = p.allPresets;
expect(identical(merged, builtIns), isFalse);
expect(identical(merged, p.allPresets), isTrue);
expect(merged.last, custom);
expect(() => merged.removeLast(), throwsUnsupportedError);
await p.deleteCustomShader(custom);
expect(p.findPresetById(custom.id), isNull);
expect(identical(p.allPresets, ShaderPreset.allPresets), isTrue);
});
test('filters unsafe persisted custom shader rows and invalid saved selection', () async {
const valid = ShaderPreset(
id: 'custom_ks9p7.glsl',
name: 'Legacy',
type: ShaderPresetType.custom,
fileName: 'ks9p7.glsl',
);
const unsafeRows = [
ShaderPreset(
id: 'custom_traversal',
name: 'Traversal',
type: ShaderPresetType.custom,
fileName: '../sentinel.glsl',
),
ShaderPreset(
id: 'custom_nested',
name: 'Nested',
type: ShaderPresetType.custom,
fileName: 'nested/shader.glsl',
),
ShaderPreset(
id: 'custom_wrong_type',
name: 'Wrong type',
type: ShaderPresetType.custom,
fileName: 'shader.txt',
),
];
final svc = await SettingsService.getInstance();
await svc.write(SettingsService.customShaderPresets, [valid.toJson(), ...unsafeRows.map((row) => row.toJson())]);
await svc.write(SettingsService.globalShaderPreset, unsafeRows.first.id);
final p = ShaderProvider();
await Future.delayed(Duration.zero);
expect(p.customPresets, [valid]);
expect(p.findPresetById(unsafeRows.first.id), isNull);
expect(p.savedPreset, ShaderPreset.none);
expect(p.currentPreset, ShaderPreset.none);
p.dispose();
});
test('portable settings import cannot expose an unsafe custom shader row', () async {
const unsafe = ShaderPreset(
id: 'custom_imported_traversal',
name: 'Imported traversal',
type: ShaderPresetType.custom,
fileName: '../sentinel.glsl',
);
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'custom_shader_presets': {
'type': 'string',
'value': jsonEncode([unsafe.toJson()]),
},
},
},
prefs,
currentUserUuid: 'profile',
);
expect(result.keysImported, 0);
final svc = await SettingsService.getInstance();
await svc.write(SettingsService.globalShaderPreset, unsafe.id);
final p = ShaderProvider();
await Future.delayed(Duration.zero);
expect(p.customPresets, isEmpty);
expect(p.findPresetById(unsafe.id), isNull);
expect(p.savedPreset, ShaderPreset.none);
p.dispose();
});
test('safeNotifyListeners no-ops after dispose', () async {
final p = ShaderProvider();
await Future.delayed(Duration.zero);
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -26,6 +28,13 @@ import '../test_helpers/prefs.dart';
class _FakeCatalogSource implements CatalogSource {
final WatchlistChangeNotifier _watchlistChanges = WatchlistChangeNotifier();
_FakeCatalogSource({bool watchlistLoading = false})
: _watchlistValue = watchlistLoading ? null : false,
_watchlistLoad = watchlistLoading ? Completer<void>() : null;
bool? _watchlistValue;
final Completer<void>? _watchlistLoad;
int addToWatchlistCalls = 0;
@override
CatalogSourceId get id => CatalogSourceId.trakt;
@@ -56,7 +65,26 @@ class _FakeCatalogSource implements CatalogSource {
];
@override
bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => false;
Future<void> ensureWatchlistLoaded() async {
final load = _watchlistLoad;
if (load != null) await load.future;
}
void completeWatchlistLoad() {
_watchlistValue = false;
_watchlistChanges.notify();
_watchlistLoad!.complete();
}
@override
Future<void> addToWatchlist(MediaKind kind, CatalogItemIds ids) async {
addToWatchlistCalls++;
_watchlistValue = true;
_watchlistChanges.notify();
}
@override
bool? isOnWatchlist(MediaKind kind, CatalogItemIds ids) => _watchlistValue;
@override
void dispose() => _watchlistChanges.dispose();
@@ -216,6 +244,35 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_library_match_1');
});
testWidgets('loading watchlist action cannot receive focus or activate', (tester) async {
final source = _FakeCatalogSource(watchlistLoading: true);
await _pumpDetail(tester, source);
final actionBar = tester.widget<FocusableActionBar>(find.byType(FocusableActionBar));
expect(actionBar.actions.single.onPressed, isNull);
final actionNode = tester
.widgetList<Focus>(find.descendant(of: find.byType(FocusableActionBar), matching: find.byType(Focus)))
.map((widget) => widget.focusNode)
.whereType<FocusNode>()
.singleWhere((node) => node.debugLabel == 'ActionBar[0]');
expect(actionNode.canRequestFocus, isFalse);
actionNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.select);
expect(actionNode.hasFocus, isFalse);
expect(source.addToWatchlistCalls, 0);
source.completeWatchlistLoad();
await tester.pump();
final loadedActionBar = tester.widget<FocusableActionBar>(find.byType(FocusableActionBar));
expect(loadedActionBar.actions.single.onPressed, isNotNull);
actionNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(source.addToWatchlistCalls, 1);
});
testWidgets('TV Back closes a hosted sheet without popping the catalog route', (tester) async {
await _pumpDetail(tester, _FakeCatalogSource(), pushedRoute: true);
@@ -24,6 +24,7 @@ import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/focusable_media_card.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/media_card_sliver_layout.dart';
import 'package:provider/provider.dart';
@@ -66,6 +67,42 @@ void main() {
expect(layout.fullBleedImage, isFalse);
expect(tester.widget<FocusableMediaCard>(find.byType(FocusableMediaCard)).cardShapeOverride, CardShape.square);
});
testWidgets('collection cards announce list position without duplicate actions', (tester) async {
final semantics = tester.ensureSemantics();
final items = [
testMediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'First movie',
serverId: 'server_1',
serverName: 'Server',
),
testMediaItem(
id: 'movie_2',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Second movie',
serverId: 'server_1',
serverName: 'Server',
),
];
final harness = await _createHarness(items);
await SettingsService.instance.write(SettingsService.viewMode, ViewMode.list);
await tester.pumpWidget(
harness.wrap(SizedBox(width: 1280, height: 720, child: CollectionDetailScreen(collection: _collection))),
);
await tester.pumpAndSettle();
final cards = tester.widgetList<FocusableMediaCard>(find.byType(FocusableMediaCard)).toList();
expect(cards.map((card) => card.semanticValue), ['Row 1 of 2', 'Row 2 of 2']);
final first = tester.getSemantics(find.bySemanticsLabel(mediaCardSemanticLabel(items.first))).getSemanticsData();
expect(first.value, 'Row 1 of 2');
semantics.dispose();
});
}
final _collection = MediaItem(
@@ -1,8 +1,11 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/dpad_navigator.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/library_first_character.dart';
import 'package:plezy/screens/libraries/alpha_jump_bar.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -215,6 +218,43 @@ void main() {
expect(jumpedTo, 2);
});
testWidgets('exposes operable letter buttons with selected state', (tester) async {
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'test_alpha_jump_semantics');
addTearDown(focusNode.dispose);
int? jumpedTo;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
height: 300,
child: AlphaJumpBar(
firstCharacters: const [
LibraryFirstCharacter(key: 'A', title: 'A', size: 3),
LibraryFirstCharacter(key: 'B', title: 'B', size: 4),
LibraryFirstCharacter(key: 'C', title: 'C', size: 2),
],
currentLetter: 'B',
focusNode: focusNode,
onJump: (index) => jumpedTo = index,
),
),
),
),
);
expect(find.bySemanticsLabel(t.accessibility.alphabetNavigation), findsOneWidget);
final selected = tester.getSemantics(find.bySemanticsLabel('B')).getSemanticsData();
expect(selected.flagsCollection.isButton, isTrue);
expect(selected.flagsCollection.isSelected, Tristate.isTrue);
expect(selected.hasAction(SemanticsAction.tap), isTrue);
final cNode = tester.getSemantics(find.bySemanticsLabel('C'));
cNode.owner!.performAction(cNode.id, SemanticsAction.tap);
expect(jumpedTo, 7);
semantics.dispose();
});
}
class _BackKeyCase {
@@ -0,0 +1,78 @@
import 'dart:ui' show SemanticsAction;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/library_first_character.dart';
import 'package:plezy/screens/libraries/alpha_scroll_handle.dart';
void main() {
const characters = [
LibraryFirstCharacter(key: 'A', title: 'A', size: 2),
LibraryFirstCharacter(key: 'B', title: 'B', size: 3),
LibraryFirstCharacter(key: 'C', title: 'C', size: 4),
];
Widget buildHandle({required bool isScrolling, required ValueChanged<int> onJump}) {
return MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.centerRight,
child: SizedBox(
height: 300,
child: AlphaScrollHandle(
firstCharacters: characters,
currentLetter: 'B',
isScrolling: isScrolling,
onJump: onJump,
),
),
),
),
);
}
testWidgets('keeps adjustable semantics mounted while fully hidden', (tester) async {
final semantics = tester.ensureSemantics();
final jumps = <int>[];
await tester.pumpWidget(buildHandle(isScrolling: false, onJump: jumps.add));
final finder = find.bySemanticsLabel(t.accessibility.alphabetNavigation);
expect(finder, findsOneWidget);
final node = tester.getSemantics(finder);
final data = node.getSemanticsData();
expect(data.value, 'B');
expect(data.hasAction(SemanticsAction.increase), isTrue);
expect(data.hasAction(SemanticsAction.decrease), isTrue);
expect(tester.widget<IgnorePointer>(find.byType(IgnorePointer).last).ignoring, isTrue);
node.owner!.performAction(node.id, SemanticsAction.increase);
expect(jumps, [5]);
semantics.dispose();
});
testWidgets('announces the current letter and exposes bounded letter steps', (tester) async {
final semantics = tester.ensureSemantics();
final jumps = <int>[];
await tester.pumpWidget(buildHandle(isScrolling: false, onJump: jumps.add));
await tester.pumpWidget(buildHandle(isScrolling: true, onJump: jumps.add));
await tester.pump(const Duration(milliseconds: 250));
final finder = find.bySemanticsLabel(t.accessibility.alphabetNavigation);
expect(finder, findsOneWidget);
final node = tester.getSemantics(finder);
final data = node.getSemanticsData();
expect(data.value, 'B');
expect(data.hint, t.accessibility.alphabetScrollHint);
expect(data.hasAction(SemanticsAction.increase), isTrue);
expect(data.hasAction(SemanticsAction.decrease), isTrue);
node.owner!.performAction(node.id, SemanticsAction.increase);
expect(jumps, [5]);
node.owner!.performAction(node.id, SemanticsAction.decrease);
expect(jumps, [5, 0]);
semantics.dispose();
});
}
@@ -38,6 +38,10 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pump();
expect(tester.widget<TextField>(find.byType(TextField)).controller!.text, isNotEmpty);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
@@ -0,0 +1,103 @@
import 'dart:async';
import 'dart:collection';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection_registry.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile.dart';
import 'package:plezy/profiles/profile_connection_registry.dart';
import 'package:plezy/profiles/profile_registry.dart';
import 'package:plezy/screens/profile/borrow_connection_screen.dart';
import 'package:plezy/services/storage_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:provider/provider.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
LocaleSettings.setLocaleSync(AppLocale.en);
});
testWidgets('load failure has retry and remains distinct from successful empty state', (tester) async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final connections = ConnectionRegistry(db);
final profileConnections = ProfileConnectionRegistry(db);
final profiles = ProfileRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = _ControlledPlexHomeService(
connections: connections,
profileConnections: profileConnections,
storage: storage,
);
addTearDown(() async {
await plexHome.dispose();
await db.close();
});
final target = Profile.local(id: 'target', displayName: 'Target', createdAt: DateTime(2026));
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
Provider<ProfileConnectionRegistry>.value(value: profileConnections),
Provider<ConnectionRegistry>.value(value: connections),
Provider<ProfileRegistry>.value(value: profiles),
Provider<PlexHomeService>.value(value: plexHome),
],
child: InputModeTracker(
child: MaterialApp(
theme: monoTheme(dark: true),
home: BorrowConnectionScreen(targetProfile: target),
),
),
),
),
);
expect(plexHome.starts, hasLength(1));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
plexHome.starts.first.completeError(StateError('load failed'));
await tester.pumpAndSettle();
expect(find.text(t.profiles.borrowLoadFailed), findsOneWidget);
expect(find.text(t.common.retry), findsOneWidget);
expect(find.text(t.profiles.borrowEmpty), findsNothing);
await tester.tap(find.text(t.common.retry));
await tester.pump();
expect(plexHome.starts, hasLength(2));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text(t.profiles.borrowLoadFailed), findsNothing);
plexHome.starts.last.complete();
await tester.pumpAndSettle();
expect(find.text(t.profiles.borrowEmpty), findsOneWidget);
expect(find.text(t.profiles.borrowLoadFailed), findsNothing);
});
}
class _ControlledPlexHomeService extends PlexHomeService {
_ControlledPlexHomeService({required super.connections, required super.profileConnections, required super.storage});
final Queue<Completer<void>> starts = Queue();
@override
Future<void> start() {
final completer = Completer<void>();
starts.add(completer);
return completer.future;
}
}
@@ -0,0 +1,167 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/screens/settings/keyboard_shortcuts_screen.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/keyboard_shortcuts_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
import 'package:plezy/widgets/focusable_list_tile.dart';
import 'package:plezy/widgets/hotkey_recorder.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../../test_helpers/prefs.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
});
testWidgets('clear persists an unassigned row that can be rebound', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
await _pumpScreen(tester, service);
await _openAction(tester, service, 'play_pause');
await tester.tap(find.byTooltip(t.hotkeys.clearShortcut));
await tester.pump();
await tester.tap(_saveButton());
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(service.getHotkey('play_pause'), isNull);
expect(find.text(t.hotkeys.noShortcutSet), findsOneWidget);
final stored =
json.decode(SettingsService.instance.prefs.getString(SettingsService.keyboardHotkeys.key)!)
as Map<String, dynamic>;
expect(stored['play_pause'], {'disabled': true});
await _openAction(tester, service, 'play_pause');
expect(tester.widget<HotKeyRecorder>(find.byType(HotKeyRecorder)).initalHotKey, isNull);
await tester.tap(_recorderSurface());
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyK, physicalKey: PhysicalKeyboardKey.keyK);
await tester.pump();
await tester.pump();
await tester.tap(_saveButton());
await tester.pumpAndSettle();
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.keyK);
expect(find.text(service.formatHotkey(service.getHotkey('play_pause'))), findsOneWidget);
});
testWidgets('clear then cancel keeps the persisted assignment', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
await _pumpScreen(tester, service);
await _openAction(tester, service, 'play_pause');
await tester.tap(find.byTooltip(t.hotkeys.clearShortcut));
await tester.pump();
await tester.tap(find.widgetWithText(TextButton, t.common.cancel));
await tester.pumpAndSettle();
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.space);
expect(find.text(service.formatHotkey(service.getHotkey('play_pause'))), findsOneWidget);
});
testWidgets('conflicting recording is rejected without changing either action', (tester) async {
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
await _pumpScreen(tester, service);
await _openAction(tester, service, 'play_pause');
await tester.tap(_recorderSurface());
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp, physicalKey: PhysicalKeyboardKey.arrowUp);
await tester.pump();
await tester.pump();
await tester.tap(_saveButton());
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.space);
expect(service.getHotkey('volume_up')?.key, PhysicalKeyboardKey.arrowUp);
expect(
find.text(t.settings.shortcutAlreadyAssigned(action: service.getActionDisplayName('volume_up'))),
findsOneWidget,
);
});
testWidgets('persistence failure keeps the dialog retryable and the row unchanged', (tester) async {
final preferences = _FailingHotkeyPreferences(const {});
SharedPreferencesAsyncPlatform.instance = preferences;
SettingsService.resetForTesting();
BaseSharedPreferencesService.resetForTesting();
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
await _pumpScreen(tester, service);
await _openAction(tester, service, 'play_pause');
await tester.tap(find.byTooltip(t.hotkeys.clearShortcut));
await tester.pump();
preferences.failNextHotkeyWrite = true;
await tester.tap(_saveButton());
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.space);
expect(find.text(t.common.error), findsOneWidget);
expect(
tester.widget<DialogActionButton>(find.widgetWithText(DialogActionButton, t.common.save)).onPressed,
isNotNull,
);
await tester.tap(_saveButton());
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(service.getHotkey('play_pause'), isNull);
});
}
Future<void> _pumpScreen(WidgetTester tester, KeyboardShortcutsService service) async {
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
theme: monoTheme(dark: true),
home: KeyboardShortcutsScreen(keyboardService: service),
),
),
);
await tester.pumpAndSettle();
}
Future<void> _openAction(WidgetTester tester, KeyboardShortcutsService service, String action) async {
await tester.tap(find.widgetWithText(FocusableListTile, service.getActionDisplayName(action)));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
}
Finder _recorderSurface() =>
find.ancestor(of: find.byType(HotKeyRecorder), matching: find.byType(GestureDetector)).first;
Finder _saveButton() => find.widgetWithText(FilledButton, t.common.save);
final class _FailingHotkeyPreferences extends InMemorySharedPreferencesAsync {
_FailingHotkeyPreferences(super.data) : super.withData();
bool failNextHotkeyWrite = false;
@override
Future<bool> setString(String key, String value, SharedPreferencesOptions options) {
if (key == SettingsService.keyboardHotkeys.key && failNextHotkeyWrite) {
failNextHotkeyWrite = false;
throw PlatformException(code: 'write_failed');
}
return super.setString(key, value, options);
}
}
@@ -0,0 +1,330 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/mpv_config_models.dart';
import 'package:plezy/screens/settings/mpv_config_screen.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
import 'package:plezy/widgets/focusable_list_tile.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('typing burst coalesces to one full-document write', (tester) async {
final backend = await _pumpEditor(tester, holdConfigWrites: true);
await tester.enterText(find.byType(TextField), 'vo');
await tester.enterText(find.byType(TextField), 'vol');
await tester.enterText(find.byType(TextField), 'volume=80');
await tester.pump(const Duration(milliseconds: 399));
expect(backend.configWrites, isEmpty);
await tester.pump(const Duration(milliseconds: 1));
expect(backend.configWrites, ['volume=80']);
expect(backend.maxActiveConfigWrites, 1);
backend.completeNextConfigWrite();
await tester.pump();
expect(backend.durableConfig, 'volume=80');
});
testWidgets('new revisions wait for the active write and only the latest follows', (tester) async {
final backend = await _pumpEditor(tester, holdConfigWrites: true);
await tester.enterText(find.byType(TextField), 'first=1');
await tester.pump(const Duration(milliseconds: 400));
expect(backend.configWrites, ['first=1']);
await tester.enterText(find.byType(TextField), 'second=2');
await tester.enterText(find.byType(TextField), 'final=3');
await tester.pump(const Duration(milliseconds: 400));
expect(backend.configWrites, ['first=1']);
expect(backend.activeConfigWrites, 1);
backend.completeNextConfigWrite();
await tester.pump();
expect(backend.configWrites, ['first=1', 'final=3']);
expect(backend.maxActiveConfigWrites, 1);
expect(tester.widget<TextField>(find.byType(TextField)).controller!.text, 'final=3');
backend.completeNextConfigWrite();
await tester.pump();
expect(backend.durableConfig, 'final=3');
});
testWidgets('manual Enter insertion joins the debounced writer', (tester) async {
final backend = await _pumpEditor(tester, holdConfigWrites: true);
await tester.enterText(find.byType(TextField), 'alpha=1');
await tester.pump(const Duration(milliseconds: 400));
backend.completeNextConfigWrite();
await tester.pump();
backend.configWrites.clear();
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(tester.widget<TextField>(find.byType(TextField)).controller!.text, 'alpha=1\n');
await tester.pump(const Duration(milliseconds: 399));
expect(backend.configWrites, isEmpty);
await tester.pump(const Duration(milliseconds: 1));
expect(backend.configWrites, ['alpha=1\n']);
backend.completeNextConfigWrite();
await tester.pump();
expect(backend.durableConfig, 'alpha=1\n');
});
testWidgets('observed external replacement wins after an in-flight local write', (tester) async {
final backend = await _pumpEditor(tester, holdConfigWrites: true);
final settings = SettingsService.instance;
await tester.enterText(find.byType(TextField), 'local=old');
await tester.pump(const Duration(milliseconds: 400));
expect(backend.configWrites, ['local=old']);
unawaited(settings.write(SettingsService.mpvConfigText, 'external=new'));
await tester.pump();
expect(backend.configWrites, ['local=old', 'external=new']);
backend.completeConfigWriteAt(1);
await tester.pump();
expect(tester.widget<TextField>(find.byType(TextField)).controller!.text, 'external=new');
backend.completeNextConfigWrite();
await tester.pump();
expect(backend.configWrites, ['local=old', 'external=new', 'external=new']);
backend.completeNextConfigWrite();
await tester.pump();
expect(backend.durableConfig, 'external=new');
});
testWidgets('focus loss flushes immediately and route pop waits for persistence', (tester) async {
final backend = await _pumpEditor(tester, holdConfigWrites: true);
await tester.enterText(find.byType(TextField), 'profile=gpu-hq');
FocusManager.instance.primaryFocus?.unfocus();
await tester.pump();
expect(backend.configWrites, ['profile=gpu-hq']);
unawaited(tester.binding.handlePopRoute());
await tester.pump();
expect(find.byType(MpvConfigScreen), findsOneWidget);
backend.completeNextConfigWrite();
await tester.pumpAndSettle();
expect(find.byType(MpvConfigScreen), findsNothing);
expect(find.text('home'), findsOneWidget);
expect(backend.durableConfig, 'profile=gpu-hq');
});
testWidgets('dispose flushes its captured edit without using disposed UI', (tester) async {
final backend = await _pumpEditor(tester, holdConfigWrites: true);
await tester.enterText(find.byType(TextField), 'video-sync=display-resample');
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
expect(backend.configWrites, ['video-sync=display-resample']);
backend.completeNextConfigWrite();
await tester.pump();
expect(tester.takeException(), isNull);
expect(backend.durableConfig, 'video-sync=display-resample');
});
testWidgets('failed flush stays retryable and does not pop until retry succeeds', (tester) async {
final backend = await _pumpEditor(tester, holdConfigWrites: true);
const config = 'gpu-api=secret-test-value';
await tester.enterText(find.byType(TextField), config);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pump();
backend.completeNextConfigWrite(error: PlatformException(code: 'write_failed'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(t.settings.saveFailed), findsOneWidget);
expect(find.textContaining('write_failed'), findsNothing);
expect(backend.durableConfig, isNull);
unawaited(tester.binding.handlePopRoute());
await tester.pump();
expect(backend.configWrites, [config, config]);
expect(find.byType(MpvConfigScreen), findsOneWidget);
backend.completeNextConfigWrite();
await tester.pumpAndSettle();
expect(find.byType(MpvConfigScreen), findsNothing);
expect(backend.durableConfig, config);
});
testWidgets('saving a preset flushes the matching active text before success', (tester) async {
final backend = await _pumpEditor(tester, holdConfigWrites: true);
final settings = SettingsService.instance;
await tester.enterText(find.byType(TextField), 'preset-source=yes');
await tester.pump();
final saveTile = tester.widget<FocusableListTile>(find.widgetWithText(FocusableListTile, t.mpvConfig.saveAsPreset));
expect(saveTile.enabled, isTrue);
saveTile.onTap!();
await tester.pumpAndSettle();
final dialog = find.byType(AlertDialog);
expect(dialog, findsOneWidget);
final nameField = find.descendant(of: dialog, matching: find.byType(TextField));
expect(nameField, findsOneWidget);
await tester.enterText(nameField, 'Saved');
await tester.tap(find.widgetWithText(DialogActionButton, t.common.save));
await tester.pump();
expect(settings.read(SettingsService.mpvPresets), isEmpty);
expect(find.text(t.mpvConfig.presetSaved), findsNothing);
backend.completeNextConfigWrite();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final presets = settings.read(SettingsService.mpvPresets);
expect(presets, hasLength(1));
expect(presets.single.name, 'Saved');
expect(presets.single.text, 'preset-source=yes');
expect(find.text(t.mpvConfig.presetSaved), findsOneWidget);
});
testWidgets('preset replacement waits behind an active edit and wins last', (tester) async {
final preset = MpvPreset(name: 'Cinema', text: 'profile=gpu-hq', createdAt: DateTime(2026));
final backend = await _pumpEditor(tester, holdConfigWrites: true, presets: [preset]);
await tester.enterText(find.byType(TextField), 'old-edit=yes');
await tester.pump(const Duration(milliseconds: 400));
expect(backend.configWrites, ['old-edit=yes']);
await tester.tap(find.text('Cinema'));
await tester.pump();
expect(backend.configWrites, ['old-edit=yes']);
expect(find.text(t.mpvConfig.presetLoaded), findsNothing);
backend.completeNextConfigWrite();
await tester.pump();
expect(backend.configWrites, ['old-edit=yes', 'profile=gpu-hq']);
expect(backend.maxActiveConfigWrites, 1);
backend.completeNextConfigWrite();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(t.mpvConfig.presetLoaded), findsOneWidget);
expect(backend.durableConfig, 'profile=gpu-hq');
});
testWidgets('clean external settings update replaces the editor', (tester) async {
final backend = await _pumpEditor(tester);
final settings = SettingsService.instance;
await settings.write(SettingsService.mpvConfigText, 'external=yes');
await tester.pump();
expect(tester.widget<TextField>(find.byType(TextField)).controller!.text, 'external=yes');
expect(backend.durableConfig, 'external=yes');
});
}
Future<_ControlledPreferences> _pumpEditor(
WidgetTester tester, {
bool holdConfigWrites = false,
List<MpvPreset> presets = const [],
}) async {
resetSharedPreferencesForTest();
final initial = <String, Object>{
if (presets.isNotEmpty)
SettingsService.mpvPresets.key: jsonEncode(presets.map((preset) => preset.toJson()).toList()),
};
final backend = _ControlledPreferences(initial)..holdConfigWrites = holdConfigWrites;
SharedPreferencesAsyncPlatform.instance = backend;
BaseSharedPreferencesService.resetForTesting();
SettingsService.resetForTesting();
await SettingsService.getInstance();
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
navigatorKey: navigatorKey,
theme: monoTheme(dark: true),
home: const Scaffold(body: Text('home')),
),
),
);
unawaited(navigatorKey.currentState!.push<void>(MaterialPageRoute<void>(builder: (_) => const MpvConfigScreen())));
await tester.pumpAndSettle();
return backend;
}
base class _ControlledPreferences extends InMemorySharedPreferencesAsync {
_ControlledPreferences(super.data) : super.withData();
bool holdConfigWrites = false;
final List<String> configWrites = [];
final List<Completer<void>> _configWriteGates = [];
int activeConfigWrites = 0;
int maxActiveConfigWrites = 0;
String? durableConfig;
@override
Future<bool> setString(String key, String value, SharedPreferencesOptions options) async {
if (key != SettingsService.mpvConfigText.key) return super.setString(key, value, options);
configWrites.add(value);
activeConfigWrites++;
if (activeConfigWrites > maxActiveConfigWrites) maxActiveConfigWrites = activeConfigWrites;
try {
if (holdConfigWrites) {
final gate = Completer<void>();
_configWriteGates.add(gate);
await gate.future;
}
final result = await super.setString(key, value, options);
durableConfig = value;
return result;
} finally {
activeConfigWrites--;
}
}
void completeNextConfigWrite({Object? error}) {
completeConfigWriteAt(0, error: error);
}
void completeConfigWriteAt(int index, {Object? error}) {
final gate = _configWriteGates.removeAt(index);
if (error == null) {
gate.complete();
} else {
gate.completeError(error);
}
}
}
+162 -2
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
@@ -24,11 +25,13 @@ import 'package:plezy/screens/settings/settings_screen.dart';
import 'package:plezy/services/donation_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/settings_export_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/update_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
import 'package:plezy/widgets/focusable_list_tile.dart';
import 'package:plezy/widgets/loading_indicator_box.dart';
import 'package:plezy/widgets/setting_tile.dart';
@@ -310,6 +313,148 @@ void main() {
expect(SettingsService.instance.read(SettingsService.customDownloadPath), isNull);
expect(find.text(t.settings.resetSettingsSuccess), findsOneWidget);
});
testWidgets('cancelled directory picker remains silent and leaves the dialog retryable', (tester) async {
final harness = await _pumpSettingsScreen(tester);
addTearDown(() => harness.dispose(tester));
await tester.tap(find.text(t.settings.downloadLocationDefault));
await _pumpUi(tester);
await tester.tap(find.text(t.settings.selectFolder));
await _pumpUi(tester);
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.settings.saveFailed), findsNothing);
expect(find.text(t.settings.downloadLocationChanged), findsNothing);
expect(harness.locationEvents, isEmpty);
});
testWidgets('directory picker platform failure uses shared settings feedback', (tester) async {
directoryPicker.directoryError = PlatformException(code: 'picker_failed');
final harness = await _pumpSettingsScreen(tester);
addTearDown(() => harness.dispose(tester));
await tester.tap(find.text(t.settings.downloadLocationDefault));
await _pumpUi(tester);
await tester.tap(find.text(t.settings.selectFolder));
await _pumpUi(tester);
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.settings.saveFailed), findsOneWidget);
expect(harness.locationEvents, isEmpty);
});
testWidgets('directory filesystem failure uses shared settings feedback', (tester) async {
directoryPicker.directoryPath = '${temporaryDirectory.path}/selected-downloads';
final harness = await _pumpSettingsScreen(
tester,
writableChecker: (_) async => throw const FileSystemException('writable check failed'),
);
addTearDown(() => harness.dispose(tester));
await tester.tap(find.text(t.settings.downloadLocationDefault));
await _pumpUi(tester);
await tester.tap(find.text(t.settings.selectFolder));
await _pumpUi(tester);
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.settings.saveFailed), findsOneWidget);
expect(harness.locationEvents, isEmpty);
});
testWidgets('late directory picker completion does not use a disposed context', (tester) async {
directoryPicker.directoryGate = Completer<String?>();
final harness = await _pumpSettingsScreen(tester);
await tester.tap(find.text(t.settings.downloadLocationDefault));
await _pumpUi(tester);
await tester.tap(find.text(t.settings.selectFolder));
await tester.pump();
await harness.dispose(tester);
directoryPicker.directoryGate!.complete('${temporaryDirectory.path}/late-downloads');
await tester.pump();
expect(tester.takeException(), isNull);
expect(harness.locationEvents, isEmpty);
});
testWidgets('late directory picker failure does not use a disposed context', (tester) async {
directoryPicker.directoryGate = Completer<String?>();
final harness = await _pumpSettingsScreen(tester);
await tester.tap(find.text(t.settings.downloadLocationDefault));
await _pumpUi(tester);
await tester.tap(find.text(t.settings.selectFolder));
await tester.pump();
await harness.dispose(tester);
directoryPicker.directoryGate!.completeError(PlatformException(code: 'late_picker_failure'));
await tester.pump();
expect(tester.takeException(), isNull);
expect(harness.locationEvents, isEmpty);
});
testWidgets('late settings export failure does not use a disposed context', (tester) async {
final exportGate = Completer<String?>();
final harness = await _pumpSettingsScreen(tester, settingsExporter: () => exportGate.future);
await tester.tap(find.text(t.settings.exportSettings));
await tester.pump();
await harness.dispose(tester);
exportGate.completeError(PlatformException(code: 'late_export_failure'));
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('late settings import failure does not use a disposed context', (tester) async {
final importGate = Completer<ImportResult?>();
final harness = await _pumpSettingsScreen(tester, settingsImporter: () => importGate.future);
await tester.tap(find.text(t.settings.importSettings));
await _pumpUi(tester);
await tester.tap(find.widgetWithText(DialogActionButton, t.settings.importSettings));
await tester.pump();
await harness.dispose(tester);
importGate.completeError(PlatformException(code: 'late_import_failure'));
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('settings export platform failure uses shared settings feedback', (tester) async {
final harness = await _pumpSettingsScreen(
tester,
settingsExporter: () async => throw PlatformException(code: 'save_failed'),
);
addTearDown(() => harness.dispose(tester));
await tester.tap(find.text(t.settings.exportSettings));
await _pumpUi(tester);
expect(find.text(t.settings.saveFailed), findsOneWidget);
expect(find.text(t.settings.exportSettingsSuccess), findsNothing);
});
testWidgets('settings import platform failure uses shared settings feedback', (tester) async {
final harness = await _pumpSettingsScreen(
tester,
settingsImporter: () async => throw PlatformException(code: 'pick_failed'),
);
addTearDown(() => harness.dispose(tester));
await tester.tap(find.text(t.settings.importSettings));
await _pumpUi(tester);
await tester.tap(find.widgetWithText(DialogActionButton, t.settings.importSettings));
await _pumpUi(tester);
expect(find.text(t.settings.saveFailed), findsOneWidget);
expect(find.text(t.settings.importSettingsSuccess), findsNothing);
});
}
Finder _navigationTileFor(String title) =>
@@ -376,7 +521,12 @@ class _SettingsHarness {
}
}
Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async {
Future<_SettingsHarness> _pumpSettingsScreen(
WidgetTester tester, {
Future<bool> Function(Directory directory)? writableChecker,
Future<String?> Function()? settingsExporter,
Future<ImportResult?> Function()? settingsImporter,
}) async {
tester.view.physicalSize = const Size(1800, 3200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -451,7 +601,11 @@ Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async {
],
child: MaterialApp(
theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.android),
home: SettingsScreen(downloadDirectoryWritableChecker: (_) async => true),
home: SettingsScreen(
downloadDirectoryWritableChecker: writableChecker ?? (_) async => true,
settingsExporter: settingsExporter,
settingsImporter: settingsImporter,
),
),
),
),
@@ -463,6 +617,8 @@ Future<_SettingsHarness> _pumpSettingsScreen(WidgetTester tester) async {
class _FakeDirectoryPicker extends FilePicker {
String? directoryPath;
Object? directoryError;
Completer<String?>? directoryGate;
@override
Future<String?> getDirectoryPath({
@@ -470,6 +626,10 @@ class _FakeDirectoryPicker extends FilePicker {
String? initialDirectory,
bool lockParentWindow = false,
}) async {
final gate = directoryGate;
if (gate != null) return gate.future;
final error = directoryError;
if (error != null) throw error;
return directoryPath;
}
}
@@ -1,8 +1,13 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/screens/settings/settings_utils.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/dialog_action_button.dart';
void main() {
tearDown(() {
@@ -60,4 +65,105 @@ void main() {
expect(saved, isEmpty);
expect(tester.takeException(), isNull);
});
testWidgets('settings save helper reports platform failures and remains retryable', (tester) async {
final context = await _pumpHost(tester);
showRegexInputDialog(
context: context,
title: 'Regex',
currentValue: 'abc',
defaultValue: '.*',
onSave: (_) async => throw PlatformException(code: 'write_failed'),
);
await tester.pumpAndSettle();
await tester.tap(_saveButton());
await tester.pump();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.settings.saveFailed), findsOneWidget);
});
testWidgets('settings save helper reports filesystem failures and remains retryable', (tester) async {
final context = await _pumpHost(tester);
showRegexInputDialog(
context: context,
title: 'Regex',
currentValue: 'abc',
defaultValue: '.*',
onSave: (_) async => throw const FileSystemException('write failed'),
);
await tester.pumpAndSettle();
await tester.tap(_saveButton());
await tester.pump();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text(t.settings.saveFailed), findsOneWidget);
});
testWidgets('settings save helper closes only after a successful save', (tester) async {
final context = await _pumpHost(tester);
var saved = false;
showRegexInputDialog(
context: context,
title: 'Regex',
currentValue: 'abc',
defaultValue: '.*',
onSave: (_) async => saved = true,
);
await tester.pumpAndSettle();
await tester.tap(_saveButton());
await tester.pumpAndSettle();
expect(saved, isTrue);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('disposed settings save context does not present late feedback', (tester) async {
final context = await _pumpHost(tester);
final saveGate = Completer<void>();
showRegexInputDialog(
context: context,
title: 'Regex',
currentValue: 'abc',
defaultValue: '.*',
onSave: (_) async {
await saveGate.future;
throw PlatformException(code: 'late_write_failure');
},
);
await tester.pumpAndSettle();
await tester.tap(_saveButton());
await tester.pump();
await tester.pumpWidget(const SizedBox.shrink());
saveGate.complete();
await tester.pump();
expect(find.text(t.settings.saveFailed), findsNothing);
expect(tester.takeException(), isNull);
});
}
Future<BuildContext> _pumpHost(WidgetTester tester) async {
late BuildContext context;
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (hostContext) {
context = hostContext;
return const SizedBox.shrink();
},
),
),
),
),
);
return context;
}
Finder _saveButton() => find.widgetWithText(DialogActionButton, t.common.save);
+329
View File
@@ -0,0 +1,329 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../scripts/generate_ducet_ranks.dart';
void main() {
late Directory temporaryDirectory;
setUp(() async {
temporaryDirectory = await Directory.systemTemp.createTemp('plezy_ducet_test_');
});
tearDown(() async {
if (await temporaryDirectory.exists()) {
await temporaryDirectory.delete(recursive: true);
}
});
test('pins immutable Unicode and CLDR source descriptors', () {
expect(allKeysSource.url, 'https://www.unicode.org/Public/UCA/13.0.0/allkeys.txt');
expect(allKeysSource.sha256Digest, 'a3255d45b7af97f4dc14fb8364d7573b434425e5c58cacf00d16901ce081c78d');
expect(allKeysSource.bundledFileName, 'allkeys-13.0.0.txt.gz');
expect(allKeysSource.licenseUrl, 'https://www.unicode.org/license.txt');
expect(
fractionalUcaSource.url,
'https://raw.githubusercontent.com/unicode-org/cldr/'
'651afecf9ccf1541a49306993e8210fa2209aa0b/common/uca/FractionalUCA.txt',
);
expect(fractionalUcaSource.sha256Digest, 'a6144d0c8c19cc899a5d2f48fbc14e3e31e819049a73aa86b67111f1f3f81637');
expect(fractionalUcaSource.bundledFileName, 'FractionalUCA-651afecf9ccf1541a49306993e8210fa2209aa0b.txt.gz');
expect(fractionalUcaSource.licenseUrl, contains('/651afecf9ccf1541a49306993e8210fa2209aa0b/LICENSE'));
});
test('bundled gzip inputs have deterministic normalized headers and bytes', () {
final allKeys = File('scripts/data/${allKeysSource.bundledFileName}').readAsBytesSync();
final fractional = File('scripts/data/${fractionalUcaSource.bundledFileName}').readAsBytesSync();
expect(allKeys.take(8), [0x1F, 0x8B, 0x08, 0, 0, 0, 0, 0]);
expect(fractional.take(8), [0x1F, 0x8B, 0x08, 0, 0, 0, 0, 0]);
expect(sha256.convert(allKeys).toString(), '744040b99e266f9ec599744e267091c159369a36f6ca7558c12a9eb3e039575f');
expect(sha256.convert(fractional).toString(), 'edc486bae663abf83d18c9006d08c1e8bb19986810cf8ab663fa33bee6d22275');
});
test('dense lookup stores rank plus one with a zero sentinel', () {
final ranks = buildDenseRankLookup([0x41, 0x24, 0x5C]);
expect(ranks.length, 0x10000);
expect(ranks.codeUnitAt(0x41), 1);
expect(ranks.codeUnitAt(0x24), 2);
expect(ranks.codeUnitAt(0x5C), 3);
expect(ranks.codeUnitAt(0x42), 0);
});
test('dense lookup rejects duplicate, out-of-BMP, and overflowing orders', () {
expect(() => buildDenseRankLookup([0x41, 0x41]), throwsStateError);
expect(() => buildDenseRankLookup([-1]), throwsRangeError);
expect(() => buildDenseRankLookup([0x10000]), throwsRangeError);
expect(() => buildDenseRankLookup(List<int>.generate(0x10000, (index) => index)), throwsStateError);
});
test('parse and render seams are deterministic and escape Dart interpolation characters', () {
const allKeysText = '''
0041 ; [.0100.0020.0002]
30A2 ; [.0200.0020.000F]
3042 ; [.0200.0020.000D]
''';
const fractionalText = '[radical 1=⼀一:一丁]';
final firstAllKeys = parseAllKeys(allKeysText);
final secondAllKeys = parseAllKeys(allKeysText);
final (firstCjk, firstKangxi) = parseRadicals(fractionalText);
final (secondCjk, secondKangxi) = parseRadicals(fractionalText);
final firstOrder = buildOrder(firstAllKeys, firstCjk);
final secondOrder = buildOrder(secondAllKeys, secondCjk);
expect(firstOrder, secondOrder);
expect(renderDucetOrder(firstOrder, firstKangxi), renderDucetOrder(secondOrder, secondKangxi));
final ranksWithSpecialCodeUnits = List<int>.generate(0x5C, (index) => 0x1000 + index);
final rendered = renderDucetOrder(ranksWithSpecialCodeUnits, const {});
expect(rendered, contains(r"\'"));
expect(rendered, contains(r'\\'));
expect(rendered, contains(r'\$'));
});
test('explicit inputs are both verified before parsing and writing', () async {
const allKeysText = '0041 ; [.0100.0020.0002]\n';
const fractionalText = '[radical 1=⼀一:一]\n';
final allKeysFile = File('${temporaryDirectory.path}/allkeys.txt')..writeAsStringSync(allKeysText);
final fractionalFile = File('${temporaryDirectory.path}/FractionalUCA.txt')..writeAsStringSync(fractionalText);
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
var downloads = 0;
await generateDucetRanks(
useBundledSources: false,
explicitAllKeys: allKeysFile,
explicitFractionalUca: fractionalFile,
output: output,
allKeysDescriptor: descriptorFor('allkeys', allKeysText),
fractionalUcaDescriptor: descriptorFor('fractional', fractionalText),
downloader: (_) async {
downloads++;
throw StateError('network must not be used');
},
);
expect(downloads, 0);
expect(output.readAsStringSync(), contains('const String _ducetRanks ='));
expect(output.readAsStringSync(), isNot(contains('_buildRanks')));
});
test('rejects an explicit digest mismatch without replacing output', () async {
final allKeysFile = File('${temporaryDirectory.path}/allkeys.txt')..writeAsStringSync('tampered');
final fractionalFile = File('${temporaryDirectory.path}/FractionalUCA.txt')..writeAsStringSync('fractional');
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
var writerCalled = false;
await expectLater(
generateDucetRanks(
useBundledSources: false,
explicitAllKeys: allKeysFile,
explicitFractionalUca: fractionalFile,
output: output,
allKeysDescriptor: descriptorFor('allkeys', 'expected'),
fractionalUcaDescriptor: descriptorFor('fractional', 'fractional'),
writer: (_, _) => writerCalled = true,
),
throwsA(isA<SourceIntegrityException>()),
);
expect(writerCalled, isFalse);
expect(output.readAsStringSync(), 'old output');
expect(allKeysFile.readAsStringSync(), 'tampered');
});
test('rejects an invalid cached source without downloading or replacing files', () async {
final cache = Directory('${temporaryDirectory.path}/cache')..createSync();
final allKeysDescriptor = descriptorFor('allkeys', 'expected allkeys');
final fractionalDescriptor = descriptorFor('fractional', 'fractional');
final cachedAllKeys = File('${cache.path}/${allKeysDescriptor.cacheFileName}')..writeAsStringSync('tampered cache');
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
var downloads = 0;
await expectLater(
generateDucetRanks(
useBundledSources: false,
cacheDirectory: cache,
output: output,
allKeysDescriptor: allKeysDescriptor,
fractionalUcaDescriptor: fractionalDescriptor,
downloader: (_) async {
downloads++;
return response(HttpStatus.ok, utf8.encode('unused'));
},
),
throwsA(isA<SourceIntegrityException>()),
);
expect(downloads, 0);
expect(cachedAllKeys.readAsStringSync(), 'tampered cache');
expect(output.readAsStringSync(), 'old output');
});
test('rejects HTTP status before parsing, caching, or replacing output', () async {
final cache = Directory('${temporaryDirectory.path}/cache')..createSync();
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
await expectLater(
generateDucetRanks(
useBundledSources: false,
cacheDirectory: cache,
output: output,
allKeysDescriptor: descriptorFor('allkeys', 'allkeys'),
fractionalUcaDescriptor: descriptorFor('fractional', 'fractional'),
downloader: (_) async => response(HttpStatus.notFound, utf8.encode('not found')),
),
throwsA(isA<SourceIntegrityException>().having((error) => error.message, 'message', contains('HTTP 404'))),
);
expect(cache.listSync(), isEmpty);
expect(output.readAsStringSync(), 'old output');
});
test('a rejected second download preserves the first cache and output', () async {
const allKeysText = '0041 ; [.0100.0020.0002]\n';
const fractionalText = '[radical 1=⼀一:一]\n';
final cache = Directory('${temporaryDirectory.path}/cache')..createSync();
final marker = File('${cache.path}/marker')..writeAsStringSync('keep');
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
var request = 0;
await expectLater(
generateDucetRanks(
useBundledSources: false,
cacheDirectory: cache,
output: output,
allKeysDescriptor: descriptorFor('allkeys', allKeysText),
fractionalUcaDescriptor: descriptorFor('fractional', fractionalText),
downloader: (_) async {
request++;
return response(HttpStatus.ok, utf8.encode(request == 1 ? allKeysText : 'tampered fractional'));
},
),
throwsA(isA<SourceIntegrityException>()),
);
expect(request, 2);
expect(cache.listSync().map((entity) => entity.path).toList(), [marker.path]);
expect(marker.readAsStringSync(), 'keep');
expect(output.readAsStringSync(), 'old output');
});
test('parse rejection leaves verified inputs, caches, and output untouched', () async {
const duplicateAllKeys = '''
0041 ; [.0100.0020.0002]
0041 ; [.0101.0020.0002]
''';
const fractionalText = '[radical 1=⼀一:一]\n';
final allKeysFile = File('${temporaryDirectory.path}/allkeys.txt')..writeAsStringSync(duplicateAllKeys);
final fractionalFile = File('${temporaryDirectory.path}/FractionalUCA.txt')..writeAsStringSync(fractionalText);
final cache = Directory('${temporaryDirectory.path}/cache')..createSync();
final marker = File('${cache.path}/marker')..writeAsStringSync('keep');
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
await expectLater(
generateDucetRanks(
useBundledSources: false,
explicitAllKeys: allKeysFile,
explicitFractionalUca: fractionalFile,
cacheDirectory: cache,
output: output,
allKeysDescriptor: descriptorFor('allkeys', duplicateAllKeys),
fractionalUcaDescriptor: descriptorFor('fractional', fractionalText),
),
throwsFormatException,
);
expect(allKeysFile.readAsStringSync(), duplicateAllKeys);
expect(fractionalFile.readAsStringSync(), fractionalText);
expect(cache.listSync().map((entity) => entity.path).toList(), [marker.path]);
expect(output.readAsStringSync(), 'old output');
});
test('default bundled generation is deterministic and never downloads', () async {
final output = File('${temporaryDirectory.path}/ducet_order.dart');
var downloads = 0;
String? first;
String? second;
Future<SourceDownloadResponse> failDownloader(Uri _) async {
downloads++;
throw StateError('bundled generation must not use the network');
}
await generateDucetRanks(output: output, downloader: failDownloader, writer: (_, contents) => first = contents);
await generateDucetRanks(output: output, downloader: failDownloader, writer: (_, contents) => second = contents);
expect(downloads, 0);
expect(first, isNotNull);
expect(second, first);
expect(first, File('lib/data/ducet_order.dart').readAsStringSync());
});
test('tampered bundled gzip fails before replacing output', () async {
const allKeysText = '0041 ; [.0100.0020.0002]\n';
const fractionalText = '[radical 1=⼀一:一]\n';
final bundled = Directory('${temporaryDirectory.path}/bundled')..createSync();
final allKeysDescriptor = descriptorFor('allkeys', allKeysText);
final fractionalDescriptor = descriptorFor('fractional', fractionalText);
File('${bundled.path}/${allKeysDescriptor.bundledFileName}').writeAsBytesSync(const [0x1F, 0x8B, 0x08, 0x00, 0x00]);
writeBundledSource(bundled, fractionalDescriptor, fractionalText);
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
await expectLater(
generateDucetRanks(
bundledSourceDirectory: bundled,
output: output,
allKeysDescriptor: allKeysDescriptor,
fractionalUcaDescriptor: fractionalDescriptor,
),
throwsA(isA<SourceIntegrityException>()),
);
expect(output.readAsStringSync(), 'old output');
});
test('tampered decompressed bundled bytes fail digest before replacing output', () async {
const allKeysText = '0041 ; [.0100.0020.0002]\n';
const fractionalText = '[radical 1=⼀一:一]\n';
final bundled = Directory('${temporaryDirectory.path}/bundled')..createSync();
final allKeysDescriptor = descriptorFor('allkeys', allKeysText);
final fractionalDescriptor = descriptorFor('fractional', fractionalText);
writeBundledSource(bundled, allKeysDescriptor, 'tampered raw bytes');
writeBundledSource(bundled, fractionalDescriptor, fractionalText);
final output = File('${temporaryDirectory.path}/ducet_order.dart')..writeAsStringSync('old output');
await expectLater(
generateDucetRanks(
bundledSourceDirectory: bundled,
output: output,
allKeysDescriptor: allKeysDescriptor,
fractionalUcaDescriptor: fractionalDescriptor,
),
throwsA(
isA<SourceIntegrityException>().having((error) => error.message, 'message', contains('SHA-256 mismatch')),
),
);
expect(output.readAsStringSync(), 'old output');
});
}
SourceDescriptor descriptorFor(String name, String contents) => SourceDescriptor(
name: name,
url: 'https://example.test/$name',
bundledFileName: '$name.txt.gz',
cacheFileName: '$name.txt',
sha256Digest: sha256.convert(utf8.encode(contents)).toString(),
licenseUrl: 'https://example.test/license',
);
void writeBundledSource(Directory directory, SourceDescriptor descriptor, String contents) {
File('${directory.path}/${descriptor.bundledFileName}').writeAsBytesSync(gzip.encode(utf8.encode(contents)));
}
SourceDownloadResponse response(int statusCode, List<int> bytes) =>
SourceDownloadResponse(statusCode: statusCode, bytes: Stream<List<int>>.value(bytes));
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../../scripts/generate_hid_key_labels.dart';
void main() {
late String validSource;
setUpAll(() async {
validSource = await File(defaultHidKeyLabelsInput).readAsString();
});
test('parse and render are deterministic and reproduce the checked-in artifact', () async {
final first = renderHidKeyLabels(parseHidKeyLabelsCatalog(validSource));
final second = renderHidKeyLabels(parseHidKeyLabelsCatalog(validSource));
expect(second, first);
expect(first, await File(defaultHidKeyLabelsOutput).readAsString());
});
test('rejects invalid schema, empty groups, group names, keys, labels, and IDs', () {
_expectInvalid(validSource, (catalog) => catalog['schemaVersion'] = 2);
_expectInvalid(validSource, (catalog) => catalog['groups'] = <Object?>[]);
_expectInvalid(validSource, (catalog) => _group(catalog, 0)['name'] = ' ');
_expectInvalid(validSource, (catalog) => _group(catalog, 0)['keys'] = <Object?>[]);
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 0)['label'] = '');
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 0)['id'] = '0000001');
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 0)['id'] = '0000001A');
});
test('rejects globally duplicate or noncanonical numeric IDs', () {
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 1)['id'] = _key(catalog, 0, 0)['id']);
_expectInvalid(validSource, (catalog) => _key(catalog, 0, 1)['id'] = '0000000f');
_expectInvalid(validSource, (catalog) => _key(catalog, 1, 0)['id'] = '0000000f');
});
test('escapes controls in labels that require double-quoted Dart literals', () {
const catalog = HidKeyCatalog(
groups: [
HidKeyGroup(
name: 'Test',
keys: [HidKeyLabel(id: '00000001', label: "Player's\nKey")],
),
],
);
expect(renderHidKeyLabels(catalog), contains("\"Player's\\nKey\""));
});
test('invalid input fails before the existing output is replaced', () async {
final directory = await Directory.systemTemp.createTemp('plezy_hid_generator_test.');
addTearDown(() => directory.delete(recursive: true));
final input = File('${directory.path}/invalid.json');
final output = File('${directory.path}/output.dart');
await input.writeAsString('{"schemaVersion":1,"groups":[]}');
await output.writeAsString('sentinel');
await expectLater(generateHidKeyLabels(input.path, output.path), throwsA(isA<FormatException>()));
expect(await output.readAsString(), 'sentinel');
});
test('atomic writer replaces the complete destination', () async {
final directory = await Directory.systemTemp.createTemp('plezy_hid_atomic_test.');
addTearDown(() => directory.delete(recursive: true));
final output = File('${directory.path}/output.dart');
await output.writeAsString('old');
await writeFileAtomically(output.path, 'new bytes\n');
expect(await output.readAsString(), 'new bytes\n');
expect(directory.listSync().where((entry) => entry.path.contains('.tmp.')), isEmpty);
});
}
void _expectInvalid(String source, void Function(Map<String, Object?> catalog) mutate) {
final catalog = (jsonDecode(source) as Map).cast<String, Object?>();
mutate(catalog);
expect(() => parseHidKeyLabelsCatalog(jsonEncode(catalog)), throwsA(isA<FormatException>()));
}
Map<String, Object?> _group(Map<String, Object?> catalog, int index) {
final groups = catalog['groups']! as List<Object?>;
return (groups[index]! as Map).cast<String, Object?>();
}
Map<String, Object?> _key(Map<String, Object?> catalog, int groupIndex, int keyIndex) {
final keys = _group(catalog, groupIndex)['keys']! as List<Object?>;
return (keys[keyIndex]! as Map).cast<String, Object?>();
}
@@ -0,0 +1,98 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../../scripts/generate_iso_639_data.dart';
void main() {
late String validSource;
setUpAll(() async {
validSource = await File(defaultIso639Input).readAsString();
});
test('parse and render are deterministic and reproduce the checked-in artifact', () async {
final first = renderIso639Data(parseIso639Catalog(validSource));
final second = renderIso639Data(parseIso639Catalog(validSource));
expect(second, first);
expect(first, await File(defaultIso639Output).readAsString());
});
test('rejects invalid schema, keys, primary consistency, names, and field shapes', () {
_expectInvalid(validSource, (catalog) => catalog['schemaVersion'] = 2);
_expectInvalid(validSource, (catalog) => catalog['languages'] = <String, Object?>{});
_expectInvalid(validSource, (catalog) => _entry(catalog, 'aa')['primary'] = 'ab');
_expectInvalid(validSource, (catalog) => _entry(catalog, 'aa')['terminology'] = 'AAR');
_expectInvalid(validSource, (catalog) => _entry(catalog, 'bo')['bibliographic'] = 'TIB');
_expectInvalid(validSource, (catalog) => _entry(catalog, 'aa')['name'] = ' ');
_expectInvalid(validSource, (catalog) {
final languages = _languages(catalog);
languages['a'] = languages.remove('aa');
});
});
test('rejects duplicate aliases and noncanonical primary ordering', () {
_expectInvalid(
validSource,
(catalog) => _entry(catalog, 'ab')['terminology'] = _entry(catalog, 'aa')['terminology'],
);
_expectInvalid(
validSource,
(catalog) => _entry(catalog, 'bo')['bibliographic'] = _entry(catalog, 'aa')['terminology'],
);
_expectInvalid(validSource, (catalog) {
final languages = _languages(catalog);
final first = languages.remove('aa');
languages['aa'] = first;
});
});
test('escapes controls in names that require double-quoted Dart literals', () {
const catalog = Iso639Catalog(
entries: [Iso639CatalogEntry(primary: 'aa', terminology: 'aaa', bibliographic: null, name: "People's\nLanguage")],
);
expect(renderIso639Data(catalog), contains("\"People's\\nLanguage\""));
});
test('invalid input fails before the existing output is replaced', () async {
final directory = await Directory.systemTemp.createTemp('plezy_iso_generator_test.');
addTearDown(() => directory.delete(recursive: true));
final input = File('${directory.path}/invalid.json');
final output = File('${directory.path}/output.dart');
await input.writeAsString('{"schemaVersion":1,"languages":{}}');
await output.writeAsString('sentinel');
await expectLater(generateIso639Data(input.path, output.path), throwsA(isA<FormatException>()));
expect(await output.readAsString(), 'sentinel');
});
test('atomic writer replaces the complete destination', () async {
final directory = await Directory.systemTemp.createTemp('plezy_iso_atomic_test.');
addTearDown(() => directory.delete(recursive: true));
final output = File('${directory.path}/output.dart');
await output.writeAsString('old');
await writeFileAtomically(output.path, 'new bytes\n');
expect(await output.readAsString(), 'new bytes\n');
expect(directory.listSync().where((entry) => entry.path.contains('.tmp.')), isEmpty);
});
}
void _expectInvalid(String source, void Function(Map<String, Object?> catalog) mutate) {
final catalog = (jsonDecode(source) as Map).cast<String, Object?>();
mutate(catalog);
expect(() => parseIso639Catalog(jsonEncode(catalog)), throwsA(isA<FormatException>()));
}
Map<String, Object?> _languages(Map<String, Object?> catalog) {
return (catalog['languages']! as Map).cast<String, Object?>();
}
Map<String, Object?> _entry(Map<String, Object?> catalog, String code) {
return (_languages(catalog)[code]! as Map).cast<String, Object?>();
}
@@ -150,7 +150,7 @@ void main() {
'SearchResult': [
{
'score': 100,
'Metadata': {'ratingKey': 'plex-movie', 'type': 'movie', 'title': 'The Boys in the Boat'},
'Metadata': {'ratingKey': 'plex-movie', 'type': 'movie', 'title': 'Spider Man Returns'},
},
],
},
@@ -169,7 +169,7 @@ void main() {
if (req.url.path == '/Items') {
return _json({
'Items': [
{'Id': 'jf-show', 'Type': 'Series', 'Name': 'The Boys'},
{'Id': 'jf-show', 'Type': 'Series', 'Name': 'SpiderMan'},
],
});
}
@@ -179,17 +179,19 @@ void main() {
addTearDown(jellyfinClient.close);
manager.debugRegisterJellyfinClientForTesting(jellyfinClient);
final results = await service.searchAcrossServers('The Boys', limit: 1);
final results = await service.searchAcrossServers('Spider Man', limit: 1);
expect(results.map((item) => item.id), ['jf-show']);
expect(plexRequests.single.queryParameters['query'], 'Spider Man');
expect(plexRequests.single.queryParameters['limit'], '100');
expect(plexRequests.single.queryParameters['searchTypes'], 'movies,tv,music');
// Jellyfin search fans out to /Items plus a best-effort /Artists call
// (500 above → treated as empty).
final jfItemsRequest = jellyfinRequests.singleWhere((url) => url.path == '/Items');
expect(jfItemsRequest.queryParameters['Limit'], '100');
expect(jfItemsRequest.queryParameters['SearchTerm'], 'Spider Man');
final jfArtistsRequest = jellyfinRequests.singleWhere((url) => url.path == '/Artists');
expect(jfArtistsRequest.queryParameters['searchTerm'], 'The Boys');
expect(jfArtistsRequest.queryParameters['searchTerm'], 'Spider Man');
});
test('getOnDeckFromAllServers forwards preview limit to clients', () async {
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
@@ -6,8 +7,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/hotkey_model.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/keyboard_shortcuts_service.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_filter_manager.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:shared_preferences_platform_interface/types.dart';
import '../test_helpers/prefs.dart';
@@ -87,6 +92,97 @@ void main() {
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.space);
expect(SettingsService.instance.prefs.containsKey(SettingsService.keyboardHotkeys.key), isFalse);
});
test('explicit unassignment survives restart and reset restores dispatch', () async {
final service = await KeyboardShortcutsService.getInstance();
await service.setHotkey('play_pause', null);
await service.setHotkey('volume_up', const HotKey(key: PhysicalKeyboardKey.keyQ));
final stored =
json.decode(SettingsService.instance.prefs.getString(SettingsService.keyboardHotkeys.key)!)
as Map<String, dynamic>;
expect(stored['play_pause'], {'disabled': true});
expect(service.getHotkey('play_pause'), isNull);
expect(service.getActionForHotkey(const HotKey(key: PhysicalKeyboardKey.space)), isNull);
service.dispose();
SettingsService.resetForTesting();
BaseSharedPreferencesService.resetForTesting();
final reloaded = await KeyboardShortcutsService.getInstance();
addTearDown(reloaded.dispose);
expect(reloaded.getHotkey('play_pause'), isNull);
expect(reloaded.getHotkey('volume_up')?.key, PhysicalKeyboardKey.keyQ);
expect(reloaded.getHotkey('volume_down')?.key, PhysicalKeyboardKey.arrowDown);
var playPauseCalls = 0;
final player = _FakePlayer();
KeyEventResult dispatch() {
return reloaded.handleVideoPlayerKeyEvent(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.space,
logicalKey: LogicalKeyboardKey.space,
timeStamp: Duration.zero,
),
player,
null,
null,
null,
null,
null,
null,
canControlPlayback: true,
canNavigateMediaItems: true,
onPlayPause: () => playPauseCalls++,
);
}
expect(dispatch(), KeyEventResult.ignored);
expect(playPauseCalls, 0);
await reloaded.resetToDefaults();
expect(dispatch(), KeyEventResult.handled);
expect(playPauseCalls, 1);
final resetStored =
json.decode(SettingsService.instance.prefs.getString(SettingsService.keyboardHotkeys.key)!)
as Map<String, dynamic>;
expect(resetStored['play_pause'], {'key': '0007002c', 'modifiers': <dynamic>[]});
});
test('serialized writes preserve rapid edits and recover after a failure', () async {
final preferences = _HotkeyPreferences(const {});
SharedPreferencesAsyncPlatform.instance = preferences;
SettingsService.resetForTesting();
BaseSharedPreferencesService.resetForTesting();
final service = await KeyboardShortcutsService.getInstance();
addTearDown(service.dispose);
preferences.blockNextHotkeyWrite();
final first = service.setHotkey('play_pause', const HotKey(key: PhysicalKeyboardKey.keyQ));
await preferences.blocked;
final second = service.setHotkey('volume_up', const HotKey(key: PhysicalKeyboardKey.keyW));
await Future<void>.delayed(Duration.zero);
expect(preferences.hotkeyWriteCount, 1);
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.space);
preferences.release();
await Future.wait([first, second]);
expect(preferences.hotkeyWriteCount, 2);
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.keyQ);
expect(service.getHotkey('volume_up')?.key, PhysicalKeyboardKey.keyW);
preferences.failNextHotkeyWrite = true;
await expectLater(
service.setHotkey('play_pause', const HotKey(key: PhysicalKeyboardKey.keyE)),
throwsA(isA<PlatformException>()),
);
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.keyQ);
await service.setHotkey('volume_down', const HotKey(key: PhysicalKeyboardKey.keyR));
expect(service.getHotkey('play_pause')?.key, PhysicalKeyboardKey.keyQ);
expect(service.getHotkey('volume_down')?.key, PhysicalKeyboardKey.keyR);
});
});
testWidgets('Ctrl+S takes a screenshot once while held', (tester) async {
@@ -597,3 +693,41 @@ class _FakePlayer implements Player {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
final class _HotkeyPreferences extends InMemorySharedPreferencesAsync {
_HotkeyPreferences(super.data) : super.withData();
Completer<void>? _entered;
Completer<void>? _release;
int hotkeyWriteCount = 0;
bool failNextHotkeyWrite = false;
Future<void> get blocked => _entered!.future;
void blockNextHotkeyWrite() {
_entered = Completer<void>();
_release = Completer<void>();
}
void release() {
final release = _release;
if (release != null && !release.isCompleted) release.complete();
}
@override
Future<bool> setString(String key, String value, SharedPreferencesOptions options) async {
if (key == SettingsService.keyboardHotkeys.key) {
hotkeyWriteCount++;
if (failNextHotkeyWrite) {
failNextHotkeyWrite = false;
throw PlatformException(code: 'write_failed');
}
final entered = _entered;
if (entered != null && !entered.isCompleted) {
entered.complete();
await _release!.future;
}
}
return super.setString(key, value, options);
}
}
@@ -13,6 +13,7 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/utils/active_client_scope.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/media_items.dart';
@@ -268,6 +269,29 @@ void main() {
expect(activities.last.cancellable, isTrue);
});
test('activity fetch abort reaches transport and preserves cancellation identity', () async {
final transport = _AbortAwareActivitiesClient();
final client = testPlexClient(
serverId: publicServerId,
profileScopeId: defaultProfileScopeId,
httpClient: transport,
prioritizedEndpoints: const ['https://plex.example.com', 'https://plex-fallback.example.com'],
);
addTearDown(client.close);
final abort = AbortController();
final activities = client.getActivities(abort: abort);
await transport.requestStarted.future;
abort.abort();
await expectLater(
activities,
throwsA(isA<MediaServerHttpException>().having((error) => error.isCancellation, 'isCancellation', isTrue)),
);
await expectLater(transport.abortObserved.future, completes);
expect(transport.requestCount, 1);
});
test('metadata edit preserves locked fields and removed tag wire format', () async {
http.Request? captured;
final client = makeClient((request) async {
+204 -18
View File
@@ -5,8 +5,11 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'package:plezy/services/settings_export_service.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/trackers/tracker_constants.dart';
import '../test_helpers/prefs.dart';
@@ -34,14 +37,16 @@ void main() {
});
group('portable settings registry', () {
test('exports every supported storage type and strips only active-user library scope', () async {
test('exports scalar and JSON-backed values and strips only the active-user library scope', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('enable_hardware_decoding', true);
await prefs.setInt('seek_time_small', 42);
await prefs.setDouble('volume', 75.5);
await prefs.setString('preferred_video_codec', 'h264');
await prefs.setStringList('user_alice_library_order', const ['movies', 'shows']);
await prefs.setStringList('user_bob_library_order', const ['private']);
await prefs.setString('user_alice_hidden_libraries', jsonEncode(['server-a:hidden']));
await prefs.setString('user_alice_library_order', jsonEncode(['movies', 'shows']));
await prefs.setString('user_bob_hidden_libraries', jsonEncode(['private-hidden']));
await prefs.setString('user_bob_library_order', jsonEncode(['private-order']));
final out = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice', appVersion: '1.2.3');
final exported = out['prefs'] as Map<String, dynamic>;
@@ -53,11 +58,16 @@ void main() {
expect(exported['seek_time_small'], {'type': 'int', 'value': 42});
expect(exported['volume'], {'type': 'double', 'value': 75.5});
expect(exported['preferred_video_codec'], {'type': 'string', 'value': 'h264'});
expect(exported['library_order'], {
'type': 'stringList',
'value': ['movies', 'shows'],
expect(exported['hidden_libraries'], {
'type': 'string',
'value': jsonEncode(['server-a:hidden']),
});
expect(jsonEncode(out), isNot(contains('private')));
expect(exported['library_order'], {
'type': 'string',
'value': jsonEncode(['movies', 'shows']),
});
expect(jsonEncode(out), isNot(contains('private-hidden')));
expect(jsonEncode(out), isNot(contains('private-order')));
});
test('excludes device-local download roots while preserving portable download controls', () async {
@@ -78,6 +88,32 @@ void main() {
expect(encoded, isNot(contains(sourcePath)));
});
test('round-trips a custom shader selection as a portable disabled selection', () async {
const custom = ShaderPreset(
id: 'custom_local-only.glsl',
name: 'Local only',
type: ShaderPresetType.custom,
fileName: 'local-only.glsl',
);
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('custom_shader_presets', jsonEncode([custom.toJson()]));
await prefs.setString('global_shader_preset', custom.id);
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
final exported = export['prefs'] as Map<String, dynamic>;
expect(exported['global_shader_preset'], {'type': 'string', 'value': ShaderPreset.none.id});
expect(exported, isNot(contains('custom_shader_presets')));
expect(jsonEncode(export), isNot(contains(custom.fileName!)));
await prefs.clear();
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'bob');
expect(result.keysImported, 1);
expect(result.keysSkipped, 0);
expect(prefs.getString('global_shader_preset'), ShaderPreset.none.id);
expect(prefs.getString('custom_shader_presets'), isNull);
});
test('fails closed for unknown, credential, account, path, history, and runtime keys', () async {
const canaries = ['SEERR-BEARER-CANARY', 'ACCOUNT-ID-CANARY', 'DEVICE-PATH-CANARY', 'RUNTIME-TIME-CANARY'];
final prefs = await BaseSharedPreferencesService.sharedCache();
@@ -115,7 +151,7 @@ void main() {
test('does not export any user-scoped value without an active user', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setStringList('user_alice_library_order', const ['movies']);
await prefs.setString('user_alice_library_order', jsonEncode(['movies']));
await prefs.setBool('enable_hdr', true);
final exported = SettingsExportService.buildExportMap(prefs)['prefs'] as Map<String, dynamic>;
@@ -169,6 +205,130 @@ void main() {
expect(prefs.getBool('enable_hdr'), isNull);
});
test('round-trips both filter modes and IDs for every tracker service', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
for (final mode in TrackerLibraryFilterMode.values) {
await prefs.clear();
for (final service in TrackerService.values) {
await prefs.setString(SettingsService.trackerFilterModePref(service).key, mode.name);
await prefs.setStringList(SettingsService.trackerFilterIdsPref(service).key, [
'${service.name}:library-a',
'${service.name}:library-b',
]);
}
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'source-user');
final exported = export['prefs'] as Map<String, dynamic>;
for (final service in TrackerService.values) {
final modeKey = SettingsService.trackerFilterModePref(service).key;
final idsKey = SettingsService.trackerFilterIdsPref(service).key;
expect(exported[modeKey], {'type': 'string', 'value': mode.name});
expect(exported[idsKey], {
'type': 'stringList',
'value': ['${service.name}:library-a', '${service.name}:library-b'],
});
}
await prefs.clear();
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'target-user');
expect(result.keysImported, TrackerService.values.length * 2);
expect(result.keysSkipped, 0);
for (final service in TrackerService.values) {
expect(prefs.getString(SettingsService.trackerFilterModePref(service).key), mode.name);
expect(prefs.getStringList(SettingsService.trackerFilterIdsPref(service).key), [
'${service.name}:library-a',
'${service.name}:library-b',
]);
}
}
});
test('preserves an empty whitelist so import cannot broaden tracker access', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
for (final service in TrackerService.values) {
await prefs.setString(
SettingsService.trackerFilterModePref(service).key,
TrackerLibraryFilterMode.whitelist.name,
);
await prefs.setStringList(SettingsService.trackerFilterIdsPref(service).key, const []);
}
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'source-user');
await prefs.clear();
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'target-user');
final settings = await SettingsService.getInstance();
expect(result.keysImported, TrackerService.values.length * 2);
expect(result.keysSkipped, 0);
for (final service in TrackerService.values) {
expect(settings.read(SettingsService.trackerFilterModePref(service)), TrackerLibraryFilterMode.whitelist);
expect(settings.read(SettingsService.trackerFilterIdsPref(service)), isEmpty);
expect(settings.isLibraryAllowedForTracker(service, '${service.name}:unlisted'), isFalse);
expect(settings.isLibraryAllowedForTracker(service, null), isFalse);
}
});
test('rejects tracker preference keys with unknown service suffixes', () async {
const modeKey = 'tracker_library_filter_mode_future';
const idsKey = 'tracker_library_filter_ids_future';
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString(modeKey, 'local-mode');
await prefs.setStringList(idsKey, const ['local-id']);
final exported = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice')['prefs'] as Map;
expect(exported, isNot(contains(modeKey)));
expect(exported, isNot(contains(idsKey)));
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
modeKey: {'type': 'string', 'value': TrackerLibraryFilterMode.whitelist.name},
idsKey: {
'type': 'stringList',
'value': ['crafted-id'],
},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 0);
expect(result.keysSkipped, 2);
expect(prefs.getString(modeKey), 'local-mode');
expect(prefs.getStringList(idsKey), ['local-id']);
});
test('normalizes format-v1 hidden and order string lists into JSON string storage', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': 1,
'prefs': {
'hidden_libraries': {
'type': 'stringList',
'value': ['server-a:hidden'],
},
'library_order': {
'type': 'stringList',
'value': ['server-b:movies', 'server-a:shows'],
},
},
},
prefs,
currentUserUuid: 'target-user',
);
expect(result.keysImported, 2);
expect(result.keysSkipped, 0);
expect(prefs.getString('user_target-user_hidden_libraries'), jsonEncode(['server-a:hidden']));
expect(prefs.getString('user_target-user_library_order'), jsonEncode(['server-b:movies', 'server-a:shows']));
});
test('imports allowlisted values, re-scopes library settings, and skips unsafe entries', () async {
const seerrCanary = 'SEERR-IMPORT-CANARY';
final prefs = await BaseSharedPreferencesService.sharedCache();
@@ -184,8 +344,8 @@ void main() {
'enable_hardware_decoding': {'type': 'bool', 'value': true},
'default_playback_speed': {'type': 'double', 'value': 1},
'library_order': {
'type': 'stringList',
'value': ['movies'],
'type': 'string',
'value': jsonEncode(['movies']),
},
'library_sort_movies': {'type': 'string', 'value': '{"key":"titleSort"}'},
'seerr_session': {'type': 'string', 'value': seerrCanary},
@@ -206,7 +366,7 @@ void main() {
expect(result.keysSkipped, 8);
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getDouble('default_playback_speed'), 1.0);
expect(prefs.getStringList('user_alice_library_order'), ['movies']);
expect(prefs.getString('user_alice_library_order'), jsonEncode(['movies']));
expect(prefs.getString('user_alice_library_sort_movies'), '{"key":"titleSort"}');
expect(prefs.getString('seerr_session'), isNull);
expect(prefs.getString('user_alice_seerr_session'), isNull);
@@ -248,6 +408,26 @@ void main() {
expect(prefs.getString('custom_download_path'), isNot(contains(sourcePath)));
});
test('reports an unresolved custom shader selection as skipped', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setString('global_shader_preset', ShaderPreset.nvscalerDefault.id);
final result = await SettingsExportService.applyImportMap(
{
'formatVersion': SettingsExportService.formatVersion,
'prefs': {
'global_shader_preset': {'type': 'string', 'value': 'custom_missing.glsl'},
},
},
prefs,
currentUserUuid: 'alice',
);
expect(result.keysImported, 0);
expect(result.keysSkipped, 1);
expect(prefs.getString('global_shader_preset'), ShaderPreset.nvscalerDefault.id);
});
test('skips malformed or mismatched entries before applying valid mutations', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
@@ -294,7 +474,7 @@ void main() {
prefs,
currentUserUuid: 'alice',
),
throwsA(isA<SettingsExportException>()),
throwsA(isA<StateError>()),
);
expect(prefs.getBool('enable_hdr'), isFalse);
@@ -304,7 +484,8 @@ void main() {
test('round-trips portable values across user scopes without account identifiers', () async {
final prefs = await BaseSharedPreferencesService.sharedCache();
await prefs.setBool('enable_hardware_decoding', true);
await prefs.setStringList('user_alice_library_order', const ['movies']);
await prefs.setString('user_alice_hidden_libraries', jsonEncode(['server-a:hidden']));
await prefs.setString('user_alice_library_order', jsonEncode(['server-b:movies']));
final export = SettingsExportService.buildExportMap(prefs, currentUserUuid: 'alice');
expect(jsonEncode(export), isNot(contains('alice')));
@@ -312,11 +493,13 @@ void main() {
final result = await SettingsExportService.applyImportMap(export, prefs, currentUserUuid: 'bob');
expect(result.keysImported, 2);
expect(result.keysImported, 3);
expect(result.keysSkipped, 0);
expect(prefs.getBool('enable_hardware_decoding'), isTrue);
expect(prefs.getStringList('user_bob_library_order'), ['movies']);
expect(prefs.getStringList('user_alice_library_order'), isNull);
expect(prefs.getString('user_bob_hidden_libraries'), jsonEncode(['server-a:hidden']));
expect(prefs.getString('user_bob_library_order'), jsonEncode(['server-b:movies']));
expect(prefs.getString('user_alice_hidden_libraries'), isNull);
expect(prefs.getString('user_alice_library_order'), isNull);
});
test('malicious import cannot replace any tvOS database recovery key', () async {
@@ -389,7 +572,7 @@ void main() {
expect(await SettingsExportService.exportToFile(), isNull);
picker.saveError = PlatformException(code: 'save_failed');
await expectLater(SettingsExportService.exportToFile(), throwsA(isA<SettingsExportException>()));
await expectLater(SettingsExportService.exportToFile(), throwsA(isA<PlatformException>()));
picker.saveError = null;
picker.saveResult = '/tmp/recovered.json';
@@ -432,11 +615,14 @@ void main() {
picker.pickResult = FilePickerResult([
PlatformFile(name: 'missing.json', size: 1, path: '/path/that/does/not/exist.json'),
]);
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<InvalidExportFileException>()));
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<FileSystemException>()));
picker.pickResult = FilePickerResult([PlatformFile(name: 'settings.json', size: 1, bytes: importBytes())]);
expect((await SettingsExportService.importFromFile())?.keysImported, 1);
expect(picker.pickCalls, 4);
picker.pickError = PlatformException(code: 'pick_failed');
await expectLater(SettingsExportService.importFromFile(), throwsA(isA<PlatformException>()));
});
test('missing active profile rejects before opening the picker', () async {
+193
View File
@@ -0,0 +1,193 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/services/shader_asset_loader.dart';
import '../test_helpers/io_fakes.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late PathProviderPlatform originalPathProvider;
late Directory root;
late Directory supportDirectory;
late File sentinel;
setUp(() async {
originalPathProvider = PathProviderPlatform.instance;
root = await Directory.systemTemp.createTemp('plezy_shader_asset_loader_test_');
PathProviderPlatform.instance = FakePathProvider(root);
supportDirectory = Directory(path.join(root.path, 'support'))..createSync(recursive: true);
sentinel = File(path.join(supportDirectory.path, 'sentinel.glsl'))..writeAsStringSync('sentinel');
ShaderAssetLoader.clearCache();
});
tearDown(() async {
PathProviderPlatform.instance = originalPathProvider;
ShaderAssetLoader.clearCache();
if (await root.exists()) await root.delete(recursive: true);
});
ShaderPreset customPreset(String fileName) {
return ShaderPreset(id: 'custom_$fileName', name: fileName, type: ShaderPresetType.custom, fileName: fileName);
}
Future<List<int>> bundledBytes(String assetPath) async {
final data = await rootBundle.load('assets/shaders/$assetPath');
return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
}
Future<void> expectBundledFile(String filePath, String assetPath) async {
expect(await File(filePath).readAsBytes(), await bundledBytes(assetPath));
}
test('traversal and absolute names cannot load or delete outside managed directory', () async {
for (final fileName in ['../sentinel.glsl', sentinel.path]) {
expect(await ShaderAssetLoader.getShadersForPreset(customPreset(fileName)), isEmpty);
await ShaderAssetLoader.deleteCustomShader(fileName);
expect(await sentinel.readAsString(), 'sentinel');
}
});
test('repairs stale built-in bytes in the application cache without touching temporary storage', () async {
final cacheFile = File(path.join(root.path, 'cache', 'shaders', 'nvscaler', 'NVScaler.glsl'))
..createSync(recursive: true)
..writeAsStringSync('stale');
final oldTemporaryFile = File(path.join(root.path, 'temp', 'shaders', 'nvscaler', 'NVScaler.glsl'))
..createSync(recursive: true)
..writeAsStringSync('old temporary sentinel');
final shaders = await ShaderAssetLoader.getShadersForPreset(ShaderPreset.nvscalerDefault);
expect(shaders, [cacheFile.path]);
await expectBundledFile(shaders.single, 'nvscaler/NVScaler.glsl');
expect(await oldTemporaryFile.readAsString(), 'old temporary sentinel');
});
test('recovers a truncated built-in final through a complete staged replacement', () async {
final targetDirectory = Directory(path.join(root.path, 'cache', 'shaders', 'nvscaler'))
..createSync(recursive: true);
final target = File(path.join(targetDirectory.path, 'NVScaler.glsl'))..writeAsBytesSync([1, 2, 3]);
final unrelatedPending = File('${target.path}.pending.interrupted')..writeAsBytesSync([1]);
final shaders = await ShaderAssetLoader.getNVScalerShaders();
expect(shaders, [target.path]);
await expectBundledFile(target.path, 'nvscaler/NVScaler.glsl');
expect(unrelatedPending.existsSync(), isTrue);
final ownedPending = await targetDirectory
.list()
.where((entry) => entry.path.contains('.pending.') && entry.path != unrelatedPending.path)
.toList();
expect(ownedPending, isEmpty);
});
test('does not return a partial path when final-file promotion fails', () async {
final target = Directory(path.join(root.path, 'cache', 'shaders', 'nvscaler', 'NVScaler.glsl'))
..createSync(recursive: true);
expect(await ShaderAssetLoader.getNVScalerShaders(), isEmpty);
final siblings = await target.parent.list().toList();
expect(siblings.where((entry) => entry.path.contains('.pending.')), isEmpty);
expect(target.existsSync(), isTrue);
});
test('coalesces concurrent built-in loads onto one complete final path', () async {
final results = await Future.wait([ShaderAssetLoader.getNVScalerShaders(), ShaderAssetLoader.getNVScalerShaders()]);
expect(results[0], results[1]);
expect(results[0], hasLength(1));
await expectBundledFile(results[0].single, 'nvscaler/NVScaler.glsl');
final directory = File(results[0].single).parent;
expect((await directory.list().toList()).where((entry) => entry.path.contains('.pending.')), isEmpty);
});
test('materializes representative built-in chains in MPV order with bundled bytes', () async {
final nvscaler = await ShaderAssetLoader.getNVScalerShaders();
final artcnn = await ShaderAssetLoader.getArtCNNShaders(
const ArtCNNConfig(model: ArtCNNModel.c4f16, variant: ArtCNNVariant.denoise),
);
final anime4k = await ShaderAssetLoader.getAnime4KShaders(
const Anime4KConfig(quality: Anime4KQuality.fast, mode: Anime4KMode.modeB),
);
final expected = {
nvscaler.single: 'nvscaler/NVScaler.glsl',
artcnn.single: 'artcnn/ArtCNN_C4F16_DN.glsl',
anime4k[0]: 'anime4k/Anime4K_Clamp_Highlights.glsl',
anime4k[1]: 'anime4k/Anime4K_Restore_CNN_M.glsl',
anime4k[2]: 'anime4k/Anime4K_Upscale_CNN_x2_M.glsl',
anime4k[3]: 'anime4k/Anime4K_AutoDownscalePre_x2.glsl',
};
expect(anime4k.map(path.basename).toList(), [
'Anime4K_Clamp_Highlights.glsl',
'Anime4K_Restore_CNN_M.glsl',
'Anime4K_Upscale_CNN_x2_M.glsl',
'Anime4K_AutoDownscalePre_x2.glsl',
]);
for (final entry in expected.entries) {
expect(path.isWithin(path.join(root.path, 'cache'), entry.key), isTrue);
await expectBundledFile(entry.key, entry.value);
}
});
test('nested and non-GLSL names are rejected without touching matching files', () async {
final customDirectory = Directory(path.join(supportDirectory.path, 'custom_shaders'))..createSync(recursive: true);
final nested = File(path.join(customDirectory.path, 'subdir', 'name.glsl'))
..createSync(recursive: true)
..writeAsStringSync('nested');
final extraExtension = File(path.join(customDirectory.path, 'name.glsl.txt'))..writeAsStringSync('extra');
for (final fileName in ['subdir/name.glsl', r'subdir\name.glsl', '.', '..', 'name.glsl.txt', 'name.txt']) {
expect(ShaderAssetLoader.isValidCustomShaderFileName(fileName), isFalse);
expect(await ShaderAssetLoader.getShadersForPreset(customPreset(fileName)), isEmpty);
await ShaderAssetLoader.deleteCustomShader(fileName);
}
expect(await nested.readAsString(), 'nested');
expect(await extraExtension.readAsString(), 'extra');
});
test('non-GLSL import fails before creating a managed file', () async {
final source = File(path.join(root.path, 'shader.txt'))..writeAsStringSync('not glsl');
await expectLater(ShaderAssetLoader.importCustomShader(source.path), throwsArgumentError);
final customDirectory = Directory(path.join(supportDirectory.path, 'custom_shaders'));
expect(customDirectory.existsSync(), isFalse);
});
test('imports a direct UUID GLSL child and deletes only that file', () async {
final source = File(path.join(root.path, 'shader.GLSL'))..writeAsStringSync('shader');
final storedName = await ShaderAssetLoader.importCustomShader(source.path);
expect(storedName, matches(RegExp(r'^[0-9a-f-]+\.glsl$')));
expect(ShaderAssetLoader.isValidCustomShaderFileName(storedName), isTrue);
final shaders = await ShaderAssetLoader.getShadersForPreset(customPreset(storedName));
expect(shaders, hasLength(1));
expect(path.dirname(shaders.single), path.join(supportDirectory.path, 'custom_shaders'));
expect(await File(shaders.single).readAsString(), 'shader');
await ShaderAssetLoader.deleteCustomShader(storedName);
expect(File(shaders.single).existsSync(), isFalse);
expect(await source.readAsString(), 'shader');
});
test('legacy base-36 managed names remain loadable and deletable', () async {
const storedName = 'ks9p7.glsl';
final customDirectory = Directory(path.join(supportDirectory.path, 'custom_shaders'))..createSync(recursive: true);
final managedFile = File(path.join(customDirectory.path, storedName))..writeAsStringSync('legacy');
expect(ShaderAssetLoader.isValidCustomShaderFileName(storedName), isTrue);
expect(await ShaderAssetLoader.getShadersForPreset(customPreset(storedName)), [managedFile.path]);
await ShaderAssetLoader.deleteCustomShader(storedName);
expect(managedFile.existsSync(), isFalse);
});
}
+62
View File
@@ -0,0 +1,62 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/mpv/player/player.dart';
import 'package:plezy/services/shader_service.dart';
import '../test_helpers/io_fakes.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late PathProviderPlatform originalPathProvider;
late Directory root;
setUp(() async {
originalPathProvider = PathProviderPlatform.instance;
root = await Directory.systemTemp.createTemp('plezy_shader_service_test_');
PathProviderPlatform.instance = FakePathProvider(root);
});
tearDown(() async {
PathProviderPlatform.instance = originalPathProvider;
if (await root.exists()) await root.delete(recursive: true);
});
test('an escaped custom preset never reaches the MPV shader append command', () async {
final supportDirectory = Directory(path.join(root.path, 'support'))..createSync(recursive: true);
final sentinel = File(path.join(supportDirectory.path, 'sentinel.glsl'))..writeAsStringSync('sentinel');
final player = _RecordingPlayer();
final service = ShaderService(player);
const preset = ShaderPreset(
id: 'custom_traversal',
name: 'Traversal',
type: ShaderPresetType.custom,
fileName: '../sentinel.glsl',
);
await service.applyPreset(preset);
expect(player.commands.where((command) => command.length > 2 && command[2] == 'append'), isEmpty);
expect(player.commands.single, ['change-list', 'glsl-shaders', 'clr', '']);
expect(await sentinel.readAsString(), 'sentinel');
});
}
class _RecordingPlayer implements Player {
final commands = <List<String>>[];
@override
String get playerType => 'mpv';
@override
Future<void> command(List<String> args) async {
commands.add(List.unmodifiable(args));
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/initials_palette.dart';
void main() {
group('initialOf', () {
test('keeps fallback, trimming, and ordinary casing behavior', () {
expect(initialOf(''), '?');
expect(initialOf(' \t\n'), '?');
expect(initialOf(' alice '), 'A');
});
test('returns the complete first extended grapheme', () {
expect(initialOf('🇯🇵 Japan'), '🇯🇵');
expect(initialOf('👍🏽 Approved'), '👍🏽');
expect(initialOf('👨‍👩‍👧‍👦 Family'), '👨‍👩‍👧‍👦');
expect(initialOf('e\u0301clair'), 'E\u0301');
});
});
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/utils/search_relevance.dart';
import '../test_helpers/media_items.dart';
void main() {
group('normalizeSearchText', () {
test('normalizes canonical, compatibility, separator, and typography variants', () {
expect(normalizeSearchText('Amélie'), normalizeSearchText('Ame\u0301lie'));
expect(normalizeSearchText('Amelie'), normalizeSearchText('Amelie'));
expect(normalizeSearchText('Spider\u00a0Man'), normalizeSearchText('Spider Man'));
final expected = normalizeSearchText('Spider Man');
for (final value in ['Spider-Man', 'SpiderMan', 'SpiderMan', 'Spider—Man']) {
expect(normalizeSearchText(value), expected, reason: value);
}
});
test('preserves accents and script-significant marks', () {
expect(normalizeSearchText('Cafe'), isNot(normalizeSearchText('Café')));
expect(normalizeSearchText('Café'), normalizeSearchText('Cafe\u0301'));
expect(normalizeSearchText(''), isNot(normalizeSearchText('कि')));
expect(normalizeSearchText('कि'), contains('ि'));
});
});
group('media search ranking', () {
test('does not give an English-only leading article bonus', () {
final items = [
testMediaItem(id: 'the', title: 'The Boys'),
testMediaItem(id: 'les', title: 'Les Boys'),
testMediaItem(id: 'los', title: 'Los Boys'),
];
expect(_ids(rankMediaSearchResults(items, 'Boys')), ['the', 'les', 'los']);
expect(_ids(rankMediaSearchResults(items.reversed.toList(), 'Boys')), ['los', 'les', 'the']);
});
test('keeps exact titles above longer prefixes', () {
final items = [
testMediaItem(id: 'longer', title: 'The Boys in the Boat'),
testMediaItem(id: 'exact', title: 'The Boys'),
];
expect(_ids(rankMediaSearchResults(items, 'The Boys')), ['exact', 'longer']);
});
test('preserves nullable-field weighting', () {
final items = [
testMediaItem(id: 'parent', parentTitle: 'Target'),
testMediaItem(id: 'original', originalTitle: 'Target'),
];
expect(_ids(rankMediaSearchResults(items, 'Target')), ['original', 'parent']);
});
});
group('rankMediaSearchResults', () {
test('ranks equivalent query forms identically and preserves normalized ties', () {
final items = [
testMediaItem(id: 'first', title: 'Spider-Man'),
testMediaItem(id: 'second', title: 'SpiderMan'),
testMediaItem(id: 'third', title: 'Spider Man Returns'),
testMediaItem(id: 'accented', title: 'Amélie'),
testMediaItem(id: 'decomposed', title: 'Ame\u0301lie'),
];
final asciiOrder = _ids(rankMediaSearchResults(items, 'Spider Man'));
final compatibilityOrder = _ids(rankMediaSearchResults(items, 'Spider Man'));
final typographicOrder = _ids(rankMediaSearchResults(items, 'Spider—Man'));
expect(compatibilityOrder, asciiOrder);
expect(typographicOrder, asciiOrder);
expect(asciiOrder.take(2), ['first', 'second']);
expect(_ids(rankMediaSearchResults(items, 'Spider Man', limit: 1)), ['first']);
expect(_ids(rankMediaSearchResults(items, 'Amélie')).take(2), ['accented', 'decomposed']);
});
test('bounded selection matches the unbounded full ordering for a large input', () {
final items = <MediaItem>[
for (var i = 0; i < 1000; i++)
testMediaItem(
id: 'item-$i',
title: switch (i) {
999 => 'Target',
_ when i % 137 == 0 => 'Target result $i',
_ when i % 41 == 0 => 'A Target result $i',
_ => 'Candidate $i',
},
),
];
final expected = _fullyRankedIds(items, 'Target').take(100).toList();
expect(_ids(rankMediaSearchResults(items, 'Target', limit: 100)), expected);
expect(expected.first, 'item-999');
});
test('retains the earliest items when an equal-score run crosses the cap', () {
final items = [for (var i = 0; i < 150; i++) testMediaItem(id: 'tie-$i', title: 'Same title')];
expect(_ids(rankMediaSearchResults(items, 'Same title', limit: 100)), [for (var i = 0; i < 100; i++) 'tie-$i']);
});
test('preserves limit boundaries, full ordering, and empty-query input order', () {
final items = [
testMediaItem(id: 'prefix', title: 'Target Extended'),
testMediaItem(id: 'exact', title: 'Target'),
testMediaItem(id: 'contains', title: 'A Target Story'),
];
final expected = _fullyRankedIds(items, 'Target');
expect(rankMediaSearchResults(items, 'Target', limit: 0), isEmpty);
expect(_ids(rankMediaSearchResults(items, 'Target', limit: 1)), ['exact']);
expect(_ids(rankMediaSearchResults(items, 'Target', limit: items.length)), expected);
expect(_ids(rankMediaSearchResults(items, 'Target', limit: items.length + 5)), expected);
expect(_ids(rankMediaSearchResults(items, 'Target')), expected);
expect(_ids(rankMediaSearchResults(items, ' !!!', limit: 2)), ['prefix', 'exact']);
});
test('keeps the negative-limit failure contract', () {
final items = [testMediaItem(title: 'Target')];
expect(() => rankMediaSearchResults(items, 'Target', limit: -1), throwsRangeError);
expect(() => rankMediaSearchResults(items, '!!!', limit: -1), throwsRangeError);
});
});
}
List<String> _ids(Iterable<MediaItem> items) => [for (final item in items) item.id];
List<String> _fullyRankedIds(List<MediaItem> items, String query) => _ids(rankMediaSearchResults(items, query));
@@ -0,0 +1,58 @@
import 'dart:ui' show SemanticsAction;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/widgets/app_bar_back_button.dart';
void main() {
Future<void> pumpButton(WidgetTester tester, {required FocusNode focusNode, required VoidCallback onPressed}) async {
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
home: InputModeTracker(
child: Scaffold(
body: AppBarBackButton(focusNode: focusNode, onPressed: onPressed),
),
),
),
),
);
}
testWidgets('pointer, D-pad select, and Space activate exactly once', (tester) async {
final focusNode = FocusNode(debugLabel: 'back_button_test');
addTearDown(focusNode.dispose);
var activations = 0;
await pumpButton(tester, focusNode: focusNode, onPressed: () => activations++);
await tester.tap(find.byType(AppBarBackButton));
expect(activations, 1);
focusNode.requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.select);
expect(activations, 2);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
expect(activations, 3);
});
testWidgets('exposes one localized operable button node', (tester) async {
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'back_button_semantics');
addTearDown(focusNode.dispose);
await pumpButton(tester, focusNode: focusNode, onPressed: () {});
final finder = find.bySemanticsLabel(t.common.back);
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.flagsCollection.isButton, isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
semantics.dispose();
});
}
+161 -4
View File
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/utils/media_image_helper.dart';
import 'package:plezy/widgets/cycling_media_backdrop.dart';
import 'package:plezy/widgets/tv_spotlight_background.dart';
@@ -62,7 +63,6 @@ void main() {
mediaKey: mediaKey,
imagePaths: paths,
client: null,
localArtworkPathResolver: (path) => path,
imageProviderResolver: (path) => imageProviders[path],
allowNetwork: false,
active: active,
@@ -78,6 +78,48 @@ void main() {
);
}
Widget buildAsyncBackdrop(
List<String> paths, {
required Future<bool> Function(File file) fileExists,
Object? mediaKey = 'movie-1',
List<String> fallbackPaths = const [],
}) {
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: 320,
displayHeight: 180,
imageType: ImageType.art,
);
return MaterialApp(
home: MediaQuery(
data: const MediaQueryData(size: Size(320, 180), devicePixelRatio: 1),
child: SizedBox(
width: 320,
height: 180,
child: CyclingMediaBackdrop(
mediaKey: mediaKey,
imagePaths: paths,
fallbackImagePaths: fallbackPaths,
client: null,
localArtworkPathResolver: (path) => path,
imageProviderResolver: (path) {
final provider = imageProviders[path];
return provider == null
? null
: MediaImageHelper.boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
},
localFileExists: fileExists,
allowNetwork: false,
width: 320,
height: 180,
fallbackColor: Colors.black,
rotationInterval: _rotationInterval,
fadeDuration: _fadeDuration,
),
),
),
);
}
String pathForProvider(ImageProvider provider) {
while (provider is ResizeImage) {
provider = provider.imageProvider;
@@ -101,7 +143,11 @@ void main() {
Future<void> finishImageTransition(WidgetTester tester, {Duration fadeDuration = _fadeDuration}) async {
final incoming = find.byType(Image).last;
final image = tester.widget<Image>(incoming);
if (image.image is MemoryImage) {
var source = image.image;
while (source is ResizeImage) {
source = source.imageProvider;
}
if (source is MemoryImage) {
final configuration = createLocalImageConfiguration(tester.element(incoming));
await tester.runAsync(() {
final frame = Completer<void>();
@@ -161,6 +207,113 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('pending local rotation keeps the settled backdrop until the file resolves', (tester) async {
final checks = <String, Completer<bool>>{};
final probes = <String, int>{};
Future<bool> fileExists(File file) {
probes.update(file.path, (count) => count + 1, ifAbsent: () => 1);
return (checks[file.path] ??= Completer<bool>()).future;
}
await tester.pumpWidget(buildAsyncBackdrop([first.path, second.path], fileExists: fileExists));
checks[first.path]!.complete(true);
await tester.pump();
await tester.pump();
await finishImageTransition(tester);
expectVisibleBackdrop(tester, first.path);
await tester.pump(_rotationInterval);
expect(checks[second.path], isNotNull);
expect(renderedFilePaths(tester), [first.path]);
expect(probes[second.path], 1);
checks[second.path]!.complete(true);
await tester.pump();
await tester.pump();
expect(renderedFilePaths(tester), [first.path, second.path]);
final incoming = tester.widget<Image>(find.byType(Image).last);
final resized = incoming.image as ResizeImage;
final (expectedWidth, expectedHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: 320,
displayHeight: 180,
imageType: ImageType.art,
);
expect(resized.width, expectedWidth);
expect(resized.height, expectedHeight);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('late local completion cannot replace a newer media path', (tester) async {
final checks = <String, Completer<bool>>{};
Future<bool> fileExists(File file) => (checks[file.path] ??= Completer<bool>()).future;
await tester.pumpWidget(buildAsyncBackdrop([first.path], fileExists: fileExists, mediaKey: 'movie-a'));
expect(find.byType(Image), findsNothing);
await tester.pumpWidget(buildAsyncBackdrop([second.path], fileExists: fileExists, mediaKey: 'movie-b'));
checks[second.path]!.complete(true);
await tester.pump();
await tester.pump();
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
checks[first.path]!.complete(true);
await tester.pump();
await tester.pump();
await tester.pump();
expect(renderedFilePaths(tester), [second.path]);
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('confirmed missing local path advances once and stays cached', (tester) async {
final missing = '${directory.path}/missing.png';
final checks = <String, Completer<bool>>{};
final probes = <String, int>{};
Future<bool> fileExists(File file) {
probes.update(file.path, (count) => count + 1, ifAbsent: () => 1);
return (checks[file.path] ??= Completer<bool>()).future;
}
await tester.pumpWidget(buildAsyncBackdrop([first.path], fileExists: fileExists, mediaKey: 'settled'));
checks[first.path]!.complete(true);
await tester.pump();
await tester.pump();
await finishImageTransition(tester);
expectVisibleBackdrop(tester, first.path);
await tester.pumpWidget(
buildAsyncBackdrop([missing, second.path], fileExists: fileExists, mediaKey: 'replacement'),
);
expect(renderedFilePaths(tester), [first.path]);
checks[missing]!.complete(false);
await tester.pump();
await tester.pump();
await tester.pump();
expect(checks[second.path], isNotNull);
expect(renderedFilePaths(tester), [first.path]);
checks[second.path]!.complete(true);
await tester.pump();
await tester.pump();
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(
buildAsyncBackdrop([missing, second.path], fileExists: fileExists, mediaKey: 'replacement'),
);
await tester.pump(_rotationInterval * 2);
expect(probes[missing], 1);
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('pauses while the application is not resumed', (tester) async {
addTearDown(() => tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
await tester.pumpWidget(buildBackdrop([first.path, second.path]));
@@ -244,12 +397,16 @@ void main() {
),
),
);
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
await tester.pump();
expect(renderedFilePaths(tester), [first.path]);
await tester.pump(const Duration(seconds: 10));
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
await tester.pump();
expect(renderedFilePaths(tester).last, second.path);
await finishImageTransition(tester, fadeDuration: const Duration(milliseconds: 280));
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(const SizedBox.shrink());
});
+6 -4
View File
@@ -29,7 +29,7 @@ void main() {
expect(backed, 1);
});
testWidgets('nullable callback keeps its graph position while disabling activation', (tester) async {
testWidgets('nullable callback disables activation and removes the action from traversal', (tester) async {
final focusNode = FocusNode(debugLabel: 'disabled dialog action');
addTearDown(focusNode.dispose);
@@ -48,9 +48,11 @@ void main() {
);
await tester.pump();
expect(focusNode.hasFocus, isTrue);
expect(focusNode.canRequestFocus, isFalse);
expect(focusNode.hasFocus, isFalse);
expect(tester.widget<FilledButton>(find.byType(FilledButton)).onPressed, isNull);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(focusNode.hasFocus, isTrue);
focusNode.requestFocus();
await tester.pump();
expect(focusNode.hasFocus, isFalse);
});
}
+122
View File
@@ -674,6 +674,128 @@ void main() {
expect(controller.selection, const TextSelection.collapsed(offset: 1));
});
testWidgets('TV hardware caret and deletion stay on grapheme boundaries', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'grapheme_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pump();
for (final grapheme in ['😀', 'e\u0301', '🇯🇵', '👨‍👩‍👧‍👦']) {
final text = 'A${grapheme}B';
final graphemeEnd = 1 + grapheme.length;
controller.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: graphemeEnd),
);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
expect(controller.selection, const TextSelection.collapsed(offset: 1));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
expect(controller.selection, TextSelection.collapsed(offset: graphemeEnd));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
expect(controller.selection, TextSelection.collapsed(offset: text.length));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
expect(controller.selection, TextSelection.collapsed(offset: text.length));
controller.selection = const TextSelection.collapsed(offset: 2);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
expect(controller.selection, const TextSelection.collapsed(offset: 1));
controller.selection = const TextSelection.collapsed(offset: 2);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
expect(controller.selection, TextSelection.collapsed(offset: graphemeEnd));
controller.value = TextEditingValue(text: text, selection: const TextSelection.collapsed(offset: 0));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
expect(controller.text, text);
expect(controller.selection, const TextSelection.collapsed(offset: 0));
await tester.sendKeyEvent(LogicalKeyboardKey.delete);
expect(controller.text, '${grapheme}B');
expect(controller.selection, const TextSelection.collapsed(offset: 0));
controller.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: graphemeEnd),
);
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
controller.value = TextEditingValue(text: text, selection: const TextSelection.collapsed(offset: 1));
await tester.sendKeyEvent(LogicalKeyboardKey.delete);
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
controller.value = TextEditingValue(
text: text,
selection: TextSelection(baseOffset: graphemeEnd - 1, extentOffset: 1),
);
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
}
});
testWidgets('grapheme deletion preserves formatter and callback ordering', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
const grapheme = '👨‍👩‍👧‍👦';
final text = 'A${grapheme}B';
final controller = TextEditingController(text: text);
final fieldFocusNode = FocusNode(debugLabel: 'formatted_grapheme_field');
final formatterCandidates = <TextEditingValue>[];
final changes = <String>[];
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
maxLength: 8,
inputFormatters: [
TextInputFormatter.withFunction((_, nextValue) {
formatterCandidates.add(nextValue);
return nextValue;
}),
],
onChanged: changes.add,
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pump();
controller.selection = TextSelection.collapsed(offset: 1 + grapheme.length);
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
expect(formatterCandidates.single.text, 'AB');
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
expect(changes, ['AB']);
});
testWidgets('TV keyboard done resolves callbacks against the latest field widget', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
+101 -31
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -12,7 +14,7 @@ void main() {
tearDown(SelectKeyUpSuppressor.clearSuppression);
testWidgets('initially unbound shortcut captures from a tap and saves', (tester) async {
final saved = <HotKey>[];
final saved = <HotKey?>[];
await _pumpRecorder(tester, saved: saved);
expect(_recorder(tester).enabled, isFalse);
@@ -39,48 +41,106 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
expect(saved, hasLength(1));
expect(saved.single.key, PhysicalKeyboardKey.keyK);
expect(saved.single.modifiers, isNull);
expect(saved.single!.key, PhysicalKeyboardKey.keyK);
expect(saved.single!.modifiers, isNull);
});
testWidgets('cleared shortcut can capture and save a replacement', (tester) async {
final saved = <HotKey>[];
testWidgets('cleared assigned shortcut saves an explicit unassignment', (tester) async {
final saved = <HotKey?>[];
await _pumpRecorder(
tester,
saved: saved,
currentHotKey: const HotKey(key: PhysicalKeyboardKey.keyJ, modifiers: [HotKeyModifier.shift]),
);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyJ)), findsOneWidget);
expect(_saveAction(tester).onPressed, isNotNull);
await tester.tap(find.byTooltip(t.hotkeys.clearShortcut));
await tester.pump();
expect(_recorder(tester).enabled, isFalse);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyJ)), findsNothing);
expect(find.text(t.hotkeys.pressToRecord), findsNWidgets(2));
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.record');
expect(_saveAction(tester).onPressed, isNull);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.keyL, physicalKey: PhysicalKeyboardKey.keyL);
await _pumpFocusChange(tester);
expect(_recorder(tester).enabled, isFalse);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyL)), findsOneWidget);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save');
expect(_saveAction(tester).onPressed, isNotNull);
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
await tester.pump();
expect(saved, hasLength(1));
expect(saved.single.key, PhysicalKeyboardKey.keyL);
expect(saved.single.modifiers, isNull);
expect(saved, <HotKey?>[null]);
});
testWidgets('D-pad can save a cleared shortcut', (tester) async {
final saved = <HotKey?>[];
await _pumpRecorder(
tester,
saved: saved,
currentHotKey: const HotKey(key: PhysicalKeyboardKey.keyJ),
);
await tester.tap(find.byTooltip(t.hotkeys.clearShortcut));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await _pumpFocusChange(tester);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'HotKeyRecorder.save');
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(saved, <HotKey?>[null]);
});
testWidgets('cancel and Escape discard a local clear', (tester) async {
final saved = <HotKey?>[];
var cancelCount = 0;
const original = HotKey(key: PhysicalKeyboardKey.keyJ);
await _pumpRecorder(tester, saved: saved, currentHotKey: original, onCancel: () => cancelCount++);
await tester.tap(find.byTooltip(t.hotkeys.clearShortcut));
await tester.pump();
await tester.tap(find.widgetWithText(TextButton, t.common.cancel));
expect(saved, isEmpty);
expect(cancelCount, 1);
await _pumpRecorder(tester, saved: saved, currentHotKey: original, onCancel: () => cancelCount++);
expect(find.text(physicalKeyLabel(PhysicalKeyboardKey.keyJ)), findsOneWidget);
await tester.tap(find.ancestor(of: find.byType(HotKeyRecorder), matching: find.byType(GestureDetector)).first);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(saved, isEmpty);
expect(cancelCount, 2);
});
testWidgets('save is single-flight and blocks cancellation', (tester) async {
final gate = Completer<void>();
var saveCount = 0;
var cancelCount = 0;
await _pumpRecorder(
tester,
saved: <HotKey?>[],
currentHotKey: const HotKey(key: PhysicalKeyboardKey.keyJ),
onCancel: () => cancelCount++,
onSave: (_) {
saveCount++;
return gate.future;
},
);
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
await tester.tap(find.widgetWithText(FilledButton, t.common.save), warnIfMissed: false);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(saveCount, 1);
expect(cancelCount, 0);
expect(_saveAction(tester).onPressed, isNull);
expect(_cancelAction(tester).onPressed, isNull);
gate.complete();
await tester.pump();
expect(_cancelAction(tester).onPressed, isNotNull);
});
testWidgets('modifier-first Control+P completes with the held modifier', (tester) async {
final saved = <HotKey>[];
final saved = <HotKey?>[];
await _pumpRecorder(tester, saved: saved);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
@@ -105,8 +165,8 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
expect(saved, hasLength(1));
expect(saved.single.key, PhysicalKeyboardKey.keyP);
expect(saved.single.modifiers, [HotKeyModifier.control]);
expect(saved.single!.key, PhysicalKeyboardKey.keyP);
expect(saved.single!.modifiers, [HotKeyModifier.control]);
});
for (final entry in <(String, LogicalKeyboardKey, PhysicalKeyboardKey)>[
@@ -114,7 +174,7 @@ void main() {
('select', LogicalKeyboardKey.select, PhysicalKeyboardKey.select),
]) {
testWidgets('${entry.$1} completion does not rearm capture or activate Save on key-up', (tester) async {
final saved = <HotKey>[];
final saved = <HotKey?>[];
await _pumpRecorder(tester, saved: saved);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
@@ -138,21 +198,28 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, t.common.save));
expect(saved, hasLength(1));
expect(saved.single.key, entry.$3);
expect(saved.single.modifiers, isNull);
expect(saved.single!.key, entry.$3);
expect(saved.single!.modifiers, isNull);
});
}
}
Future<void> _pumpRecorder(WidgetTester tester, {required List<HotKey> saved, HotKey? currentHotKey}) async {
Future<void> _pumpRecorder(
WidgetTester tester, {
required List<HotKey?> saved,
HotKey? currentHotKey,
VoidCallback? onCancel,
FutureOr<void> Function(HotKey?)? onSave,
}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: HotKeyRecorderWidget(
key: UniqueKey(),
actionName: 'Play/Pause',
currentHotKey: currentHotKey,
onHotKeyRecorded: saved.add,
onCancel: () {},
onHotKeyRecorded: onSave ?? saved.add,
onCancel: onCancel ?? () {},
),
),
),
@@ -169,3 +236,6 @@ HotKeyRecorder _recorder(WidgetTester tester) => tester.widget(find.byType(HotKe
DialogActionButton _saveAction(WidgetTester tester) =>
tester.widget(find.widgetWithText(DialogActionButton, t.common.save));
DialogActionButton _cancelAction(WidgetTester tester) =>
tester.widget(find.widgetWithText(DialogActionButton, t.common.cancel));
+67
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/focus/locked_hub_controller.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.dart';
import 'package:plezy/media/media_item.dart';
@@ -43,6 +44,7 @@ void main() {
_TestApp(
child: HubSection(
hub: _hubWith(item),
focusMemory: HubFocusMemory(),
icon: Symbols.live_tv_rounded,
onItemTap: (value) => tappedItem = value,
onItemLongPress: (value) => longPressedItem = value,
@@ -74,6 +76,7 @@ void main() {
child: HubSection(
key: hubKey,
hub: _hubWith(item),
focusMemory: HubFocusMemory(),
icon: Symbols.live_tv_rounded,
onItemTap: (value) => tappedItem = value,
onItemLongPress: (value) => longPressedItem = value,
@@ -109,6 +112,7 @@ void main() {
_TestApp(
child: HubSection(
hub: _hubWith(item),
focusMemory: HubFocusMemory(),
icon: Symbols.live_tv_rounded,
cardSizing: HubCardSizing.grid,
episodePosterModeOverride: EpisodePosterMode.seriesPoster,
@@ -128,6 +132,69 @@ void main() {
);
expect(outerPadding.padding.resolve(TextDirection.ltr).bottom, 0);
});
testWidgets('restores within one owner but resets for a fresh owner', (tester) async {
final items = [
for (var index = 0; index < 3; index++)
testMediaItem(id: 'item_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Item $index'),
];
MediaHub hub(String id) => MediaHub(id: id, title: id, type: 'movie', items: items, size: items.length);
final ownerA = HubFocusMemory();
final ownerB = HubFocusMemory();
String? focusedItemId;
Future<void> mount({
required HubFocusMemory owner,
required String hubId,
required GlobalKey<HubSectionState> key,
}) async {
await tester.pumpWidget(
_TestApp(
child: HubSection(
key: key,
hub: hub(hubId),
focusMemory: owner,
icon: Symbols.movie_rounded,
onFocusedItemChanged: (item) => focusedItemId = item.id,
),
),
);
}
final firstMountKey = GlobalKey<HubSectionState>();
await mount(owner: ownerA, hubId: 'detail_episodes', key: firstMountKey);
firstMountKey.currentState!.requestFocusAt(2);
await tester.pumpAndSettle();
expect(focusedItemId, 'item_2');
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
final remountKey = GlobalKey<HubSectionState>();
await mount(owner: ownerA, hubId: 'detail_episodes', key: remountKey);
remountKey.currentState!.requestFocusFromMemory();
await tester.pumpAndSettle();
expect(focusedItemId, 'item_2');
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
final secondHubKey = GlobalKey<HubSectionState>();
await mount(owner: ownerA, hubId: 'detail_extras', key: secondHubKey);
secondHubKey.currentState!.requestFocusFromMemory();
await tester.pumpAndSettle();
expect(focusedItemId, 'item_2');
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
final freshOwnerKey = GlobalKey<HubSectionState>();
await mount(owner: ownerB, hubId: 'detail_episodes', key: freshOwnerKey);
freshOwnerKey.currentState!.requestFocusFromMemory();
await tester.pumpAndSettle();
expect(focusedItemId, 'item_0');
});
}
MediaHub _hubWith(MediaItem item) {
+148
View File
@@ -1,8 +1,11 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focus_glow_overlay.dart';
import 'package:plezy/focus/focus_theme.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
@@ -208,6 +211,151 @@ void main() {
expect(find.byType(CompositedTransformFollower), findsNothing);
});
testWidgets('grid and list cards expose card and detail actions without decorative semantics', (tester) async {
final semantics = tester.ensureSemantics();
final item = testMediaItem(
id: 'semantic_episode',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Decorative Episode Title',
summary: 'Decorative episode summary',
parentId: 'season_2',
parentIndex: 2,
index: 3,
grandparentId: 'show_1',
grandparentTitle: 'Semantic Series',
);
for (final forceGridMode in [true, false]) {
await tester.pumpWidget(
_TestApp(
child: SizedBox(
width: forceGridMode ? 200 : 420,
height: forceGridMode ? 330 : 180,
child: MediaCard(item: item, forceGridMode: forceGridMode, forceListMode: !forceGridMode, isOffline: true),
),
),
);
final cardData = tester.getSemantics(find.bySemanticsLabel(mediaCardSemanticLabel(item))).getSemanticsData();
expect(cardData.flagsCollection.isButton, isTrue);
expect(cardData.hasAction(ui.SemanticsAction.tap), isTrue);
for (final detailLabel in ['Semantic Series', 'S2']) {
final detailData = tester.getSemantics(find.bySemanticsLabel(detailLabel)).getSemanticsData();
expect(detailData.flagsCollection.isButton, isTrue);
expect(detailData.hasAction(ui.SemanticsAction.tap), isTrue);
expect(detailData.hint, t.mediaMenu.viewDetails);
}
expect(find.bySemanticsLabel(RegExp('Decorative Episode Title|Decorative episode summary')), findsNothing);
}
semantics.dispose();
});
testWidgets('movie and season title links remain distinct semantic actions', (tester) async {
final semantics = tester.ensureSemantics();
final scenarios = [
(
item: testMediaItem(
id: 'linked_movie',
kind: MediaKind.movie,
title: 'Linked Movie',
summary: 'Movie decorative summary',
),
forceGridMode: true,
detailLabel: 'Linked Movie',
decorativeLabel: 'Movie decorative summary',
),
(
item: testMediaItem(
id: 'linked_season',
kind: MediaKind.season,
title: 'Season Two',
parentId: 'linked_show',
parentTitle: 'Linked Series',
summary: 'Season decorative summary',
),
forceGridMode: false,
detailLabel: 'Linked Series',
decorativeLabel: 'Season Two',
),
];
for (final scenario in scenarios) {
await tester.pumpWidget(
_TestApp(
child: SizedBox(
width: scenario.forceGridMode ? 200 : 420,
height: scenario.forceGridMode ? 330 : 180,
child: MediaCard(
item: scenario.item,
forceGridMode: scenario.forceGridMode,
forceListMode: !scenario.forceGridMode,
isOffline: true,
),
),
),
);
expect(
tester
.getSemantics(find.bySemanticsLabel(mediaCardSemanticLabel(scenario.item)))
.getSemanticsData()
.hasAction(ui.SemanticsAction.tap),
isTrue,
);
final detail = tester.getSemantics(find.bySemanticsLabel(scenario.detailLabel)).getSemanticsData();
expect(detail.flagsCollection.isButton, isTrue);
expect(detail.hasAction(ui.SemanticsAction.tap), isTrue);
expect(detail.hint, t.mediaMenu.viewDetails);
expect(find.bySemanticsLabel(scenario.decorativeLabel), findsNothing);
}
semantics.dispose();
});
testWidgets('custom card actions keep detail-link semantics disabled in grid and list modes', (tester) async {
final semantics = tester.ensureSemantics();
final item = testMediaItem(
id: 'semantic_movie',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Custom Semantic Movie',
summary: 'Decorative movie summary',
);
var tapCount = 0;
for (final forceGridMode in [true, false]) {
await tester.pumpWidget(
_TestApp(
child: SizedBox(
width: forceGridMode ? 200 : 420,
height: forceGridMode ? 330 : 180,
child: MediaCard(
item: item,
forceGridMode: forceGridMode,
forceListMode: !forceGridMode,
isOffline: true,
onTap: () => tapCount++,
),
),
),
);
final card = tester.getSemantics(find.bySemanticsLabel(mediaCardSemanticLabel(item)));
expect(card.getSemanticsData().hasAction(ui.SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel('Custom Semantic Movie'), findsNothing);
expect(find.bySemanticsLabel(RegExp('Decorative movie summary')), findsNothing);
card.owner!.performAction(card.id, ui.SemanticsAction.tap);
expect(tapCount, forceGridMode ? 1 : 2);
}
semantics.dispose();
});
testWidgets('custom tap owns pointer and programmatic activation', (tester) async {
final item = testMediaItem(
id: 'custom_tap',
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -89,6 +90,102 @@ void main() {
expect(tester.getSize(placeholder), const Size(96, 96));
});
testWidgets('local resolver rejects stale path completions and disposal completions', (tester) async {
final checks = <String, Completer<bool>>{};
final probes = <String, int>{};
var path = '/artwork/a.png';
late StateSetter rebuild;
Future<bool> fileExists(File file) {
probes.update(file.path, (count) => count + 1, ifAbsent: () => 1);
return (checks[file.path] ??= Completer<bool>()).future;
}
Widget resolutionBuilder(BuildContext context, LocalFileResolution resolution, File? file) {
return Text('${resolution.name}:${file?.path ?? path}');
}
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) {
rebuild = setState;
return ResolvedLocalFile(path: path, fileExists: fileExists, builder: resolutionBuilder);
},
),
),
);
expect(find.text('pending:/artwork/a.png'), findsOneWidget);
rebuild(() => path = '/artwork/b.png');
await tester.pump();
rebuild(() {});
await tester.pump();
expect(find.text('pending:/artwork/b.png'), findsOneWidget);
expect(probes['/artwork/b.png'], 1);
checks['/artwork/a.png']!.complete(true);
await tester.pump();
expect(find.text('pending:/artwork/b.png'), findsOneWidget);
checks['/artwork/b.png']!.complete(true);
await tester.pump();
await tester.pump();
expect(find.text('present:/artwork/b.png'), findsOneWidget);
rebuild(() => path = '/artwork/c.png');
await tester.pump();
await tester.pumpWidget(const SizedBox.shrink());
checks['/artwork/c.png']!.complete(true);
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
});
testWidgets('local resolver caches confirmed missing paths only when requested', (tester) async {
final probes = <String, int>{};
var path = '/artwork/missing-a.png';
late StateSetter rebuild;
Future<bool> fileExists(File file) {
probes.update(file.path, (count) => count + 1, ifAbsent: () => 1);
return Future<bool>.value(false);
}
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) {
rebuild = setState;
return ResolvedLocalFile(
path: path,
cacheMissing: true,
fileExists: fileExists,
builder: (context, resolution, file) => Text('${resolution.name}:$path'),
);
},
),
),
);
await tester.pump();
expect(find.text('missing:/artwork/missing-a.png'), findsOneWidget);
expect(probes['/artwork/missing-a.png'], 1);
rebuild(() {});
await tester.pump();
rebuild(() {});
await tester.pump();
expect(probes['/artwork/missing-a.png'], 1);
rebuild(() => path = '/artwork/missing-b.png');
await tester.pump();
await tester.pump();
expect(probes['/artwork/missing-b.png'], 1);
rebuild(() => path = '/artwork/missing-a.png');
await tester.pump();
expect(find.text('missing:/artwork/missing-a.png'), findsOneWidget);
expect(probes['/artwork/missing-a.png'], 1);
});
testWidgets('same local artwork path re-resolves after the file appears', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
+46
View File
@@ -65,6 +65,34 @@ void main() {
expect(find.byType(PinEntryDialog), findsNothing);
});
testWidgets('mobile PIN normalizes oversized input before submitting', (tester) async {
TvDetectionService.debugSetAppleTVOverride(false);
String? result;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.iOS),
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () async {
result = await showPinEntryDialog(context, 'Protected Profile');
},
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
tester.widget<TextField>(find.byType(TextField)).onChanged!('12345');
await tester.pumpAndSettle();
expect(result, '1234');
expect(find.byType(PinEntryDialog), findsNothing);
});
testWidgets('mobile duplicate submit does not pop route below PIN dialog', (tester) async {
TvDetectionService.debugSetAppleTVOverride(false);
String? pinResult;
@@ -304,6 +332,24 @@ void main() {
expect(find.byType(PinEntryDialog), findsNothing);
});
testWidgets('unowned keyboard keys remain available to ancestor handlers', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
await _pumpPinDialogLauncher(tester, onResult: (_) {});
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final result = _dispatchKey(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.f12,
logicalKey: LogicalKeyboardKey.f12,
timeStamp: Duration.zero,
),
);
expect(result, KeyEventResult.ignored);
expect(find.byType(PinEntryDialog), findsOneWidget);
});
testWidgets('non-mobile PIN entry accepts physical keyboard digits', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
String? result;
+140 -3
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_wrapper.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -105,6 +107,139 @@ void main() {
expect(thumbnails.map((thumbnail) => thumbnail.blurThumbnail), [true, false, false]);
});
testWidgets('content strip falls back from chapters to a focusable queue', (tester) async {
final playback = _playbackWithQueue();
addTearDown(playback.dispose);
final player = _FakePlayer();
final stripKey = GlobalKey<ContentStripState>();
final chapter = MediaChapter(id: 1, startTimeOffset: 10000, title: 'Old Chapter');
MediaItem? selectedItem;
await tester.pumpWidget(
_queueHarness(
playback: playback,
child: ContentStrip(
key: stripKey,
player: player,
chapters: [chapter],
chaptersLoaded: true,
canControl: true,
showQueueTab: true,
onQueueItemSelected: (item) => selectedItem = item,
useFocusNavigation: true,
),
),
);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(find.text(t.videoControls.chapters), findsOneWidget);
expect(find.text('Old Chapter'), findsOneWidget);
expect(find.text('Spoiler Episode'), findsNothing);
await tester.pumpWidget(
_queueHarness(
playback: playback,
child: ContentStrip(
key: stripKey,
player: player,
chapters: const [],
chaptersLoaded: true,
canControl: true,
showQueueTab: true,
onQueueItemSelected: (item) => selectedItem = item,
useFocusNavigation: true,
),
),
);
await tester.pump();
expect(find.text(t.videoControls.queue), findsOneWidget);
expect(find.text('Spoiler Episode'), findsOneWidget);
expect(find.text('Old Chapter'), findsNothing);
stripKey.currentState!.requestInitialFocus();
await tester.pump();
final firstQueueItem = tester.widget<FocusableWrapper>(
find.ancestor(of: find.text('Spoiler Episode'), matching: find.byType(FocusableWrapper)),
);
expect(firstQueueItem.focusNode!.hasPrimaryFocus, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(selectedItem?.id, 'spoiler-episode');
});
testWidgets('content strip preserves queue selection until only chapters remain', (tester) async {
final playback = _playbackWithQueue();
addTearDown(playback.dispose);
final player = _FakePlayer();
final stripKey = GlobalKey<ContentStripState>();
final initialChapter = MediaChapter(id: 1, startTimeOffset: 10000, title: 'Initial Chapter');
final replacementChapter = MediaChapter(id: 2, startTimeOffset: 20000, title: 'Replacement Chapter');
await tester.pumpWidget(
_queueHarness(
playback: playback,
child: ContentStrip(
key: stripKey,
player: player,
chapters: [initialChapter],
chaptersLoaded: true,
canControl: true,
showQueueTab: true,
onQueueItemSelected: (_) {},
),
),
);
await tester.pump();
await tester.tap(find.text(t.videoControls.queue));
await tester.pump();
expect(find.text('Spoiler Episode'), findsOneWidget);
expect(find.text('Initial Chapter'), findsNothing);
await tester.pumpWidget(
_queueHarness(
playback: playback,
child: ContentStrip(
key: stripKey,
player: player,
chapters: [replacementChapter],
chaptersLoaded: true,
canControl: true,
showQueueTab: true,
onQueueItemSelected: (_) {},
),
),
);
await tester.pump();
expect(find.text('Spoiler Episode'), findsOneWidget);
expect(find.text('Replacement Chapter'), findsNothing);
await tester.pumpWidget(
_queueHarness(
playback: playback,
child: ContentStrip(
key: stripKey,
player: player,
chapters: [replacementChapter],
chaptersLoaded: true,
canControl: true,
showQueueTab: false,
onQueueItemSelected: null,
),
),
);
await tester.pump();
expect(find.text('Replacement Chapter'), findsOneWidget);
expect(find.text('Spoiler Episode'), findsNothing);
await tester.tap(find.text('Replacement Chapter'));
await tester.pump();
expect(player.seeks, [const Duration(seconds: 20)]);
});
testWidgets('denied chapter remains visible but touch and select do not seek', (tester) async {
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
@@ -197,9 +332,11 @@ void main() {
Widget _queueHarness({required PlaybackStateProvider playback, required Widget child}) {
return ChangeNotifierProvider<PlaybackStateProvider>.value(
value: playback,
child: MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(body: SizedBox(width: 600, height: 400, child: child)),
child: InputModeTracker(
child: MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(body: SizedBox(width: 600, height: 400, child: child)),
),
),
);
}
@@ -1,19 +1,35 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/widgets/server_activities_button.dart';
import 'package:provider/provider.dart';
import '../test_helpers/backend_client_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase database;
setUp(() {
LocaleSettings.setLocaleSync(AppLocale.en);
database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
});
tearDown(() => database.close());
testWidgets('togglePanel opens and closes the server activities overlay', (tester) async {
final manager = MultiServerManager();
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
@@ -47,4 +63,224 @@ void main() {
expect(find.text(t.serverTasks.title), findsNothing);
});
testWidgets('polls three seconds after completion without overlapping activity aggregates', (tester) async {
final transport = _ControlledActivitiesClient();
final harness = await _pumpActivitiesHarness(tester, transport);
expect(transport.activityRequests, hasLength(1));
transport.completeActivities(0, const ['Initial activity']);
await tester.pump();
await tester.pump();
expect(find.text('Initial activity'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
expect(transport.activityRequests, hasLength(2));
expect(transport.activeActivityGets, 1);
await tester.pump(const Duration(seconds: 4));
expect(transport.activityRequests, hasLength(2));
expect(transport.maximumActiveActivityGets, 1);
transport.completeActivities(1, const ['Completed poll']);
await tester.pump();
await tester.pump();
expect(find.text('Completed poll'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 2999));
expect(transport.activityRequests, hasLength(2));
await tester.pump(const Duration(milliseconds: 1));
expect(transport.activityRequests, hasLength(3));
expect(transport.maximumActiveActivityGets, 1);
harness.buttonKey.currentState!.togglePanel();
await tester.pump();
});
testWidgets('close and reopen rejects a stale completion and preserves the new poll cadence', (tester) async {
final transport = _ControlledActivitiesClient(honorAbort: false);
final harness = await _pumpActivitiesHarness(tester, transport);
final oldRequest = transport.activityRequests.single;
harness.buttonKey.currentState!.togglePanel();
await tester.pump();
await expectLater(oldRequest.abortObserved.future, completes);
harness.buttonKey.currentState!.togglePanel();
await tester.pump();
expect(transport.activityRequests, hasLength(2));
transport.completeActivities(1, const ['Fresh activity']);
await tester.pump();
await tester.pump();
expect(find.text('Fresh activity'), findsOneWidget);
transport.completeActivities(0, const ['Old activity']);
await tester.pump();
await tester.pump();
expect(find.text('Fresh activity'), findsOneWidget);
expect(find.text('Old activity'), findsNothing);
await tester.pump(const Duration(milliseconds: 2999));
expect(transport.activityRequests, hasLength(2));
await tester.pump(const Duration(milliseconds: 1));
expect(transport.activityRequests, hasLength(3));
transport.completeActivities(2, const ['Next activity']);
await tester.pump();
await tester.pump();
harness.buttonKey.currentState!.togglePanel();
await tester.pump();
});
testWidgets('post-cancel refresh cannot be undone by a pre-cancel poll', (tester) async {
final transport = _ControlledActivitiesClient(honorAbort: false);
final harness = await _pumpActivitiesHarness(tester, transport);
transport.completeActivities(0, const ['Cancelable activity'], cancellable: true);
await tester.pump();
await tester.pump();
expect(find.text('Cancelable activity'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
expect(transport.activityRequests, hasLength(2));
await tester.tap(find.byTooltip(t.common.cancel));
await tester.pump();
await tester.pump();
expect(transport.deleteCount, 1);
expect(transport.activityRequests, hasLength(3));
await expectLater(transport.activityRequests[1].abortObserved.future, completes);
transport.completeActivities(2, const []);
await tester.pump();
await tester.pump();
expect(find.text(t.serverTasks.noTasks), findsOneWidget);
transport.completeActivities(1, const ['Cancelable activity'], cancellable: true);
await tester.pump();
await tester.pump();
expect(find.text(t.serverTasks.noTasks), findsOneWidget);
expect(find.text('Cancelable activity'), findsNothing);
expect(transport.activityRequests, hasLength(3));
harness.buttonKey.currentState!.togglePanel();
await tester.pump();
});
}
class _ActivitiesHarness {
const _ActivitiesHarness(this.buttonKey);
final GlobalKey<ServerActivitiesButtonState> buttonKey;
}
Future<_ActivitiesHarness> _pumpActivitiesHarness(WidgetTester tester, _ControlledActivitiesClient transport) async {
final serverId = ServerId('plex-server');
final client = testPlexClient(serverId: serverId, serverName: 'Test server', httpClient: transport);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final buttonKey = GlobalKey<ServerActivitiesButtonState>();
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: multiServerProvider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(body: ServerActivitiesButton(key: buttonKey)),
),
),
),
);
buttonKey.currentState!.togglePanel();
await tester.pump();
return _ActivitiesHarness(buttonKey);
}
class _ControlledActivityRequest {
_ControlledActivityRequest(this.request);
final http.BaseRequest request;
final response = Completer<http.StreamedResponse>();
final abortObserved = Completer<void>();
}
class _ControlledActivitiesClient extends http.BaseClient {
_ControlledActivitiesClient({this.honorAbort = true});
final bool honorAbort;
final activityRequests = <_ControlledActivityRequest>[];
var activeActivityGets = 0;
var maximumActiveActivityGets = 0;
var deleteCount = 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
if (request.method == 'DELETE' && request.url.path.startsWith('/activities/')) {
deleteCount++;
return Future.value(_response(request, const {}));
}
if (request.method != 'GET' || request.url.path != '/activities') {
return Future.value(_response(request, const {}));
}
final pending = _ControlledActivityRequest(request);
activityRequests.add(pending);
activeActivityGets++;
if (activeActivityGets > maximumActiveActivityGets) {
maximumActiveActivityGets = activeActivityGets;
}
final abortTrigger = (request as http.Abortable).abortTrigger!;
unawaited(
abortTrigger.then((_) {
if (!pending.abortObserved.isCompleted) {
pending.abortObserved.complete();
}
if (honorAbort && !pending.response.isCompleted) {
pending.response.completeError(http.RequestAbortedException(request.url));
}
}),
);
return pending.response.future.whenComplete(() {
activeActivityGets--;
});
}
void completeActivities(int requestIndex, List<String> titles, {bool cancellable = false}) {
final pending = activityRequests[requestIndex];
if (pending.response.isCompleted) return;
pending.response.complete(
_response(pending.request, {
'MediaContainer': {
'Activity': [
for (var index = 0; index < titles.length; index++)
{
'uuid': 'activity-$requestIndex-$index',
'type': 'library.update',
'title': titles[index],
'progress': 50,
'cancellable': cancellable,
},
],
},
}),
);
}
http.StreamedResponse _response(http.BaseRequest request, Map<String, dynamic> body) {
return http.StreamedResponse(
Stream.value(utf8.encode(jsonEncode(body))),
200,
headers: const {'content-type': 'application/json'},
request: request,
);
}
}
+80 -7
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:ui' show Tristate;
import 'package:flutter/material.dart';
@@ -106,7 +108,9 @@ void main() {
isTranscoding: true,
sourceSubtitleTracks: [MediaSubtitleTrack(id: 0, title: 'Stream zero', selected: false, forced: false)],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.off(),
onSwitchSubtitle: (choice) => switchedChoice = choice,
onSwitchSubtitle: (choice) async {
switchedChoice = choice;
},
subtitleSearchSupported: false,
),
);
@@ -143,7 +147,9 @@ void main() {
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecarIds: const {2},
onSwitchSubtitle: (choice) => switchedSourceChoice = choice,
onSwitchSubtitle: (choice) async {
switchedSourceChoice = choice;
},
subtitleSearchSupported: false,
),
);
@@ -155,6 +161,71 @@ void main() {
expect(switchedSourceChoice, const PlaybackSourceSubtitleChoice.source(2));
});
testWidgets('keeps a source sheet open until the async selection commits', (tester) async {
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
),
);
final selectionGate = Completer<void>();
late BuildContext hostContext;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: OverlaySheetHost(
child: Builder(
builder: (context) {
hostContext = context;
return const Scaffold(body: SizedBox.expand());
},
),
),
),
);
final sheetResult = OverlaySheetController.of(hostContext).show<void>(
builder: (_) => SizedBox(
height: 400,
child: TrackSheet(
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(id: 1, languageCode: 'eng', codec: 'srt', selected: true, forced: false),
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
codec: 'ass',
external: true,
selected: false,
forced: false,
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecarIds: const {2},
onSwitchSubtitle: (_) => selectionGate.future,
subtitleSearchSupported: false,
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Remote sidecar'));
await tester.pump();
expect(find.byType(LinearProgressIndicator), findsOneWidget);
expect(find.text('Remote sidecar'), findsOneWidget);
selectionGate.complete();
await tester.pumpAndSettle();
await sheetResult;
expect(find.text('Remote sidecar'), findsNothing);
});
testWidgets('direct-play embedded selection stays on the native player path', (tester) async {
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
@@ -178,7 +249,9 @@ void main() {
MediaSubtitleTrack(id: 2, languageCode: 'swe', selected: false, forced: false),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
onSwitchSubtitle: (choice) => switchedSourceChoice = choice,
onSwitchSubtitle: (choice) async {
switchedSourceChoice = choice;
},
subtitleSearchSupported: false,
),
);
@@ -221,7 +294,7 @@ void main() {
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
selectedSecondarySubtitleStreamId: 2,
sourceSubtitleSidecarIds: const {2},
onSwitchSubtitle: (_) {},
onSwitchSubtitle: (_) async {},
subtitleSearchSupported: false,
),
);
@@ -284,7 +357,7 @@ void main() {
TrackControlsState(
isTranscoding: true,
sourceSubtitleTracks: [sourceSubtitle],
onSwitchSubtitle: (_) {},
onSwitchSubtitle: (_) async {},
).hasSubtitleControls(const Tracks()),
isTrue,
);
@@ -295,7 +368,7 @@ void main() {
final state = TrackControlsState(
sourceSubtitleTracks: [sourceSidecar],
sourceSubtitleSidecarIds: {sourceSidecar.id},
onSwitchSubtitle: (_) {},
onSwitchSubtitle: (_) async {},
);
expect(state.canUseSourceSubtitles, isFalse);
@@ -305,7 +378,7 @@ void main() {
test('does not misclassify a Jellyfin external-delivery embedded row as a sidecar', () {
final sourceTrack = MediaSubtitleTrack(id: 1, usesExternalDelivery: true, selected: false, forced: false);
final state = TrackControlsState(sourceSubtitleTracks: [sourceTrack], onSwitchSubtitle: (_) {});
final state = TrackControlsState(sourceSubtitleTracks: [sourceTrack], onSwitchSubtitle: (_) async {});
expect(state.directPlaySourceSidecars, isEmpty);
});
+154 -11
View File
@@ -319,10 +319,12 @@ void main() {
});
});
late HubFocusMemory focusMemory;
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
HubFocusMemory.clear();
focusMemory = HubFocusMemory();
await SettingsService.getInstance();
});
@@ -352,6 +354,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
@@ -413,7 +416,7 @@ void main() {
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.tv_rounded),
child: TvBrowseRail(focusMemory: focusMemory, hubs: [hub], iconForHub: (_, _) => Icons.tv_rounded),
),
),
),
@@ -454,6 +457,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hubFor('serverA'), hubFor('serverB')],
iconForHub: (_, _) => Icons.movie_rounded,
),
@@ -496,7 +500,7 @@ void main() {
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
child: TvBrowseRail(focusMemory: focusMemory, hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
),
),
),
@@ -539,7 +543,12 @@ void main() {
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded),
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
@@ -625,6 +634,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [firstHub, secondHub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
@@ -696,7 +706,12 @@ void main() {
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded),
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
@@ -751,7 +766,12 @@ void main() {
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], autofocus: true, iconForHub: (_, _) => Icons.movie_rounded),
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
@@ -808,6 +828,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
@@ -865,6 +886,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
@@ -922,7 +944,11 @@ void main() {
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [firstHub, secondHub], iconForHub: (_, _) => Icons.movie_rounded),
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [firstHub, secondHub],
iconForHub: (_, _) => Icons.movie_rounded,
),
),
),
),
@@ -957,6 +983,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
initialHubId: initialHubId,
@@ -998,6 +1025,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
initialHubId: initialHubId,
@@ -1040,6 +1068,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
initialItemId: initialItemId,
@@ -1107,7 +1136,7 @@ void main() {
// Seed remembered focus under the rail's server-qualified hub key (mirrors
// _TvBrowseRailState._hubKey: '<serverId>:<id>'), so the multi-server keying
// resolves it the same way the rail does.
HubFocusMemory.setForHub('${episodeHub.serverId ?? ''}:${episodeHub.id}', 5);
focusMemory.setForHub('${episodeHub.serverId ?? ''}:${episodeHub.id}', 5);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
@@ -1121,6 +1150,7 @@ void main() {
width: 700,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [movieHub, episodeHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
@@ -1232,6 +1262,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: hubs,
autofocus: true,
@@ -1314,6 +1345,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: [firstHub, activeHub, backgroundLoaded ? backgroundUpdatedHub : backgroundInitialHub],
autofocus: true,
@@ -1391,6 +1423,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [firstHub, middleLoaded ? middleUpdatedHub : middleInitialHub, lastHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
@@ -1452,7 +1485,7 @@ void main() {
expect(_verticalRailPosition(tester).pixels, closeTo(middleTargetOffset, 0.1));
});
testWidgets('uses per-hub item focus instead of global column hint', (tester) async {
testWidgets('uses per-hub item focus instead of the owner last-column hint', (tester) async {
List<MediaItem> movieItems() => List.generate(
8,
(index) =>
@@ -1492,6 +1525,7 @@ void main() {
width: 700,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [movieHub, episodeHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
@@ -1522,6 +1556,97 @@ void main() {
expect(focused.last, 'movies:movie_5');
});
testWidgets('repeated detail hub ids restore only within their browse owner', (tester) async {
final episodeItems = [
for (var index = 0; index < 12; index++)
testMediaItem(
id: 'episode_$index',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode $index',
thumbPath: '/episode_$index',
),
];
final extraItems = [
for (var index = 0; index < 3; index++)
testMediaItem(id: 'extra_$index', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Extra $index'),
];
final episodeHub = MediaHub(
id: 'detail_episodes',
title: 'Episodes',
type: 'episode',
items: episodeItems,
size: episodeItems.length,
);
final extrasHub = MediaHub(
id: 'detail_extras',
title: 'Extras',
type: 'movie',
items: extraItems,
size: extraItems.length,
);
final focused = <String>[];
Future<void> mount(HubFocusMemory owner, List<MediaHub> hubs) async {
final serverManager = MultiServerManager();
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 700,
height: 720,
child: TvBrowseRail(
focusMemory: owner,
hubs: hubs,
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
onFocusedHubItemChanged: (hub, item) => focused.add('${hub.id}:${item.id}'),
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
}
Future<void> press(LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump();
await tester.sendKeyUpEvent(key);
await tester.pumpAndSettle();
}
await mount(focusMemory, [episodeHub, extrasHub]);
for (var index = 0; index < 5; index++) {
await press(LogicalKeyboardKey.arrowRight);
}
expect(focused.last, 'detail_episodes:episode_5');
expect(_activeRailPosition(tester).pixels, greaterThan(0));
await press(LogicalKeyboardKey.arrowDown);
expect(focused.last, 'detail_extras:extra_0');
await press(LogicalKeyboardKey.arrowUp);
expect(focused.last, 'detail_episodes:episode_5');
expect(_activeRailPosition(tester).pixels, greaterThan(0));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
final freshOwner = HubFocusMemory();
await mount(freshOwner, [extrasHub, episodeHub]);
await press(LogicalKeyboardKey.arrowDown);
expect(focused.last, 'detail_episodes:episode_0');
expect(_activeRailPosition(tester).pixels, 0);
});
testWidgets('keeps late episode thumbnails visible in long TV rows', (tester) async {
await SettingsService.instanceOrNull!.write(SettingsService.tvFullCardLayout, false);
tester.view.devicePixelRatio = 1.0;
@@ -1563,6 +1688,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
@@ -1653,6 +1779,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
@@ -1726,6 +1853,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
onActivateItem: (_, _) {
@@ -1788,6 +1916,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
onActivateItem: (_, _) {
@@ -1838,6 +1967,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
onActivateItem: (_, _) {
@@ -1885,6 +2015,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
selectSuppressionGestureSignal: gesture,
@@ -1940,6 +2071,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.person_rounded,
selectSuppressionGestureSignal: gesture,
@@ -1989,7 +2121,12 @@ void main() {
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(hubs: [hub], autofocus: autofocus, iconForHub: (_, _) => Icons.tv_rounded),
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
autofocus: autofocus,
iconForHub: (_, _) => Icons.tv_rounded,
),
),
),
),
@@ -2025,7 +2162,11 @@ void main() {
left: 0,
right: 0,
bottom: 0,
child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.movie_rounded),
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.movie_rounded,
),
),
],
),
@@ -2061,6 +2202,7 @@ void main() {
width: 1060,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
hubs: [hub],
iconForHub: (_, _) => Icons.movie_rounded,
backgroundBleedLeft: SideNavigationRailState.expandedWidth,
@@ -2101,6 +2243,7 @@ void main() {
width: 1280,
height: 720,
child: TvBrowseRail(
focusMemory: focusMemory,
key: const ValueKey('rail'),
hubs: [hub],
iconForHub: (_, _) => Icons.movie_rounded,
@@ -58,6 +58,24 @@ void main() {
expect(find.byType(Dialog), findsOneWidget);
});
testWidgets('unowned keyboard keys remain available to ancestor handlers', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await _pumpKeyboard(tester, controller: controller);
final result = _dispatchKey(
const KeyDownEvent(
physicalKey: PhysicalKeyboardKey.f12,
logicalKey: LogicalKeyboardKey.f12,
timeStamp: Duration.zero,
),
);
expect(result, KeyEventResult.ignored);
expect(controller.text, isEmpty);
expect(find.byType(Dialog), findsOneWidget);
});
testWidgets('directional pad enter activates highlighted key', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
@@ -230,6 +248,79 @@ void main() {
expect(find.byType(Dialog), findsNothing);
expect(underlyingBackEvents, 0);
});
testWidgets('rendered backspace deletes whole graphemes and keeps the keyboard open', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
for (final grapheme in ['😀', 'e\u0301', '🇯🇵', '👨‍👩‍👧‍👦']) {
controller.value = TextEditingValue(
text: 'A${grapheme}B',
selection: TextSelection.collapsed(offset: 1 + grapheme.length),
);
await _pumpKeyboard(tester, controller: controller);
await tester.tap(find.byIcon(Symbols.backspace_rounded));
await tester.pump();
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
expect(find.byType(Dialog), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
}
});
testWidgets('physical delete removes a whole grapheme and dismisses the keyboard', (tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
for (final grapheme in ['😀', 'e\u0301', '🇯🇵', '👨‍👩‍👧‍👦']) {
controller.value = TextEditingValue(text: 'A${grapheme}B', selection: const TextSelection.collapsed(offset: 1));
await _pumpKeyboard(tester, controller: controller);
await tester.sendKeyEvent(LogicalKeyboardKey.delete);
await tester.pumpAndSettle();
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
expect(find.byType(Dialog), findsNothing);
}
});
testWidgets('grapheme selection deletion preserves formatter and callback ordering', (tester) async {
const grapheme = '🇯🇵';
final controller = TextEditingController()
..value = TextEditingValue(
text: 'A${grapheme}B',
selection: TextSelection(baseOffset: grapheme.length, extentOffset: 1),
);
final formatterCandidates = <TextEditingValue>[];
final changes = <String>[];
addTearDown(controller.dispose);
await _pumpKeyboard(
tester,
controller: controller,
maxLength: 8,
inputFormatters: [
TextInputFormatter.withFunction((_, nextValue) {
formatterCandidates.add(nextValue);
return nextValue;
}),
],
onChanged: changes.add,
);
await tester.tap(find.byIcon(Symbols.backspace_rounded));
await tester.pump();
expect(formatterCandidates.single.text, 'AB');
expect(controller.text, 'AB');
expect(controller.selection, const TextSelection.collapsed(offset: 1));
expect(changes, ['AB']);
});
}
Future<void> _pumpKeyboard(
@@ -237,6 +328,9 @@ Future<void> _pumpKeyboard(
required TextEditingController controller,
TextInputType? keyboardType,
int? maxLines,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
}) async {
TvDetectionService.debugSetAppleTVOverride(true);
@@ -260,6 +354,9 @@ Future<void> _pumpKeyboard(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
onSubmitted: onSubmitted,
);
await tester.pumpAndSettle();
+287 -1
View File
@@ -4,6 +4,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.dart';
@@ -13,9 +14,11 @@ import 'package:plezy/media/media_version.dart';
import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/playback_subtitle_resolver.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/video_volume_controller.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart';
import 'package:plezy/widgets/video_controls/desktop_video_controls.dart';
import 'package:plezy/widgets/video_controls/mobile_video_controls.dart';
import 'package:plezy/watch_together/providers/watch_together_provider.dart';
@@ -27,6 +30,7 @@ import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart';
import 'package:plezy/widgets/video_controls/widgets/skip_marker_button.dart';
import 'package:plezy/widgets/video_controls/widgets/sync_offset_control.dart';
import 'package:plezy/widgets/video_controls/widgets/timeline_slider.dart';
import 'package:plezy/widgets/video_controls/video_control_button.dart';
import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart';
import '../test_helpers/watch_together_fakes.dart';
@@ -1479,6 +1483,85 @@ void main() {
});
});
group('subtitle visibility', () {
testWidgets('rolls back a failed latest toggle to the preceding successful mutation', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await initializeDateFormatting('en');
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
final settings = await SettingsService.getInstance();
final firstWrite = Completer<void>();
final secondWrite = Completer<void>();
final player = _FakeSubtitleVisibilityPlayer(writes: [firstWrite, secondWrite]);
final volume = VideoVolumeController(player: player, settings: settings, initialVolume: 100);
final playbackState = PlaybackStateProvider();
final watchTogether = WatchTogetherProvider();
final chrome = PlayerChromeController();
final toast = PlayerToastController();
addTearDown(volume.dispose);
addTearDown(playbackState.dispose);
addTearDown(watchTogether.dispose);
addTearDown(chrome.dispose);
addTearDown(toast.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playbackState),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogether),
],
child: MaterialApp(
theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 1200,
height: 800,
child: PlexVideoControls(
player: player,
volumeController: volume,
metadata: testMediaItem(id: 'subtitle-visibility'),
toastController: toast,
canNavigateMediaItems: false,
chromeController: chrome,
isLive: true,
),
),
),
),
),
);
await tester.pump();
expect(find.byIcon(Symbols.subtitles_rounded), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.pump();
expect(player.propertyValues, ['no']);
expect(find.byIcon(Symbols.subtitles_off_rounded), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.pump();
expect(player.propertyValues, ['no'], reason: 'the latest toggle must wait for the in-flight native write');
expect(find.byIcon(Symbols.subtitles_rounded), findsOneWidget);
firstWrite.complete();
await tester.pump();
await tester.pump();
expect(player.propertyValues, ['no', 'yes']);
secondWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.byIcon(Symbols.subtitles_off_rounded), findsOneWidget);
chrome.cancelAutoHide();
await tester.pumpWidget(const SizedBox.shrink());
});
});
group('SyncOffsetControl', () {
testWidgets('uses 100ms slider steps without rendering tick marks', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
@@ -1594,16 +1677,165 @@ void main() {
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(200);
await tester.pump();
await tester.pump();
expect(persistedOffsets, [200]);
expect(persistedOffsets, isEmpty);
staleWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(tester.widget<Slider>(find.byType(Slider)).value, 200);
expect(persistedOffsets, [200]);
});
testWidgets('rolls back a failed latest write to the preceding successful offset', (tester) async {
final firstWrite = Completer<void>();
final secondWrite = Completer<void>();
final propertyValues = <String>[];
final persistedOffsets = <int>[];
final player = _FakeSyncPlayer(
onSetProperty: (_, value) {
propertyValues.add(value);
return propertyValues.length == 1 ? firstWrite.future : secondWrite.future;
},
);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: player,
propertyName: 'sub-delay',
initialOffset: 0,
labelText: 'Subtitles',
onOffsetChanged: (offset) async {
persistedOffsets.add(offset);
},
compact: true,
),
),
),
),
);
tester.widget<Slider>(find.byType(Slider)).onChanged!(100);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(100);
await tester.pump();
expect(propertyValues, ['0.1']);
tester.widget<Slider>(find.byType(Slider)).onChanged!(200);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(200);
await tester.pump();
expect(propertyValues, ['0.1']);
expect(tester.widget<Slider>(find.byType(Slider)).value, 200);
firstWrite.complete();
await tester.pump();
await tester.pump();
expect(propertyValues, ['0.1', '0.2']);
expect(persistedOffsets, [100]);
secondWrite.completeError(PlatformException(code: 'SET_PROPERTY_FAILED'));
await tester.pump();
await tester.pump();
expect(tester.takeException(), isNull);
expect(tester.widget<Slider>(find.byType(Slider)).value, 100);
expect(persistedOffsets, [100]);
});
testWidgets('persists an accepted offset after the control is disposed', (tester) async {
final propertyWrite = Completer<void>();
final persistedOffsets = <int>[];
final player = _FakeSyncPlayer(onSetProperty: (_, _) => propertyWrite.future);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 700,
child: SyncOffsetControl(
player: player,
propertyName: 'sub-delay',
initialOffset: 0,
labelText: 'Subtitles',
onOffsetChanged: (offset) async => persistedOffsets.add(offset),
compact: true,
),
),
),
),
);
tester.widget<Slider>(find.byType(Slider)).onChanged!(100);
tester.widget<Slider>(find.byType(Slider)).onChangeEnd!(100);
await tester.pumpWidget(const SizedBox.shrink());
propertyWrite.complete();
await tester.pump();
expect(persistedOffsets, [100]);
});
});
group('VideoControlButton semantics', () {
testWidgets('exposes one operable node with value and checked state', (tester) async {
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'semantic_video_control');
addTearDown(focusNode.dispose);
var activations = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: VideoControlButton(
icon: Icons.settings,
tooltip: 'Player settings',
semanticValue: '720p',
checked: true,
focusNode: focusNode,
onPressed: () => activations++,
),
),
),
);
final finder = find.bySemanticsLabel('Player settings');
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, '720p');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isChecked, ui.CheckedState.isTrue);
expect(data.hasAction(ui.SemanticsAction.tap), isTrue);
final node = tester.getSemantics(finder);
node.owner!.performAction(node.id, ui.SemanticsAction.tap);
await tester.pump();
expect(activations, 1);
semantics.dispose();
});
testWidgets('keeps disabled controls discoverable without a tap action', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: VideoControlButton(icon: Icons.skip_next, semanticLabel: 'Next item', onPressed: null),
),
),
);
final data = tester.getSemantics(find.bySemanticsLabel('Next item')).getSemanticsData();
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, ui.Tristate.isFalse);
expect(data.hasAction(ui.SemanticsAction.tap), isFalse);
semantics.dispose();
});
});
}
@@ -1670,3 +1902,57 @@ class _FakeSyncPlayer implements Player {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeSubtitleVisibilityPlayer implements Player {
_FakeSubtitleVisibilityPlayer({required this.writes});
final List<Completer<void>> writes;
final List<String> propertyValues = [];
@override
String get playerType => 'mpv';
@override
PlayerState get state => PlayerState(
duration: const Duration(minutes: 45),
seekable: true,
tracks: const Tracks(
subtitle: [SubtitleTrack(id: 'subtitle-1', language: 'eng')],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 'subtitle-1', language: 'eng'),
),
);
@override
PlayerStreams get streams => PlayerStreams(
playing: const Stream<bool>.empty(),
completed: const Stream<bool>.empty(),
buffering: const Stream<bool>.empty(),
position: const Stream<Duration>.empty(),
duration: const Stream<Duration>.empty(),
seekable: const Stream<bool>.empty(),
buffer: const Stream<Duration>.empty(),
volume: const Stream<double>.empty(),
rate: const Stream<double>.empty(),
tracks: const Stream<Tracks>.empty(),
track: const Stream<TrackSelection>.empty(),
log: const Stream<PlayerLog>.empty(),
error: const Stream<PlayerError>.empty(),
audioDevice: const Stream<AudioDevice>.empty(),
audioDevices: const Stream<List<AudioDevice>>.empty(),
bufferRanges: const Stream<List<BufferRange>>.empty(),
playbackRestart: const Stream<void>.empty(),
backendSwitched: const Stream<void>.empty(),
);
@override
Future<void> setProperty(String name, String value) {
expect(name, 'sub-visibility');
propertyValues.add(value);
return writes[propertyValues.length - 1].future;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}