331 lines
12 KiB
Dart
331 lines
12 KiB
Dart
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);
|
|
}
|
|
}
|
|
}
|