fix(ui): harden settings focus and semantics
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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?;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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': 'Spider‑Man'},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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', 'Spider‑Man', 'Spider–Man', '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: 'Spider‑Man'),
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user