Files
reelbox/test/library_settings_regression_test.dart

326 lines
11 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:reelbox/ui/common.dart';
import 'package:reelbox/ui/library.dart';
import 'package:shared_preferences/shared_preferences.dart';
const movie = TitleRecord(id: 42, title: 'Movie');
const channel = TitleRecord(id: 42, title: 'Channel', type: 'channel');
class SettingsLocalApi extends ApiService {
int discoveryChecks = 0;
@override
Future<void> initialize({bool discover = true}) async {
if (discover) discoveryChecks++;
}
}
Future<void> mount(WidgetTester tester, AppState state, Widget page) async {
await tester.pumpWidget(
AppScope(
state: state,
child: MaterialApp(home: page),
),
);
await tester.pumpAndSettle();
}
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
testWidgets('imported non-menu settings remain selected and editable', (
tester,
) async {
final state = AppState(api: SettingsLocalApi());
addTearDown(state.dispose);
final backup = jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
backup['settings'] = {
...state.prefs.toJson(),
'defaultQuality': '2160p',
'playbackSpeed': 1.75,
'skipSeconds': 12,
'subtitleColor': 0xFF12AB34,
'subtitleBackground': 0x40123456,
};
await state.importLibrary(jsonEncode(backup));
final original = state.prefs.toJson();
await mount(tester, state, const SettingsPage());
for (final setting in <String, Object>{
'Default quality': '2160p',
'Playback speed': 1.75,
'Seek step': 12,
'Subtitle color': 0xFF12AB34,
'Subtitle background': 0x40123456,
}.entries) {
await tester.scrollUntilVisible(
find.text(setting.key),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final tile = find.ancestor(
of: find.text(setting.key),
matching: find.byType(ListTile),
);
final dropdown = tester.widget<DropdownButton<dynamic>>(
find.descendant(
of: tile,
matching: find.byWidgetPredicate(
(widget) => widget is DropdownButton,
),
),
);
expect(dropdown.value, setting.value);
expect(
dropdown.items!.where((item) => item.value == setting.value).length,
1,
);
}
expect(state.prefs.toJson(), original);
await tester.scrollUntilVisible(
find.text('Playback speed'),
-250,
scrollable: find.byType(Scrollable).first,
);
final speedTile = find.ancestor(
of: find.text('Playback speed'),
matching: find.byType(ListTile),
);
await tester.ensureVisible(speedTile);
await tester.tap(
find.descendant(
of: speedTile,
matching: find.byWidgetPredicate((widget) => widget is DropdownButton),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('1×').last);
await tester.pumpAndSettle();
expect(state.prefs.playbackSpeed, 1.0);
expect(state.prefs.defaultQuality, '2160p');
});
for (final locale in ['en', 'fa']) {
testWidgets(
'settings keep useful controls without endpoint or technical clutter in $locale',
(tester) async {
final api = SettingsLocalApi();
final state = AppState(api: api);
addTearDown(state.dispose);
await state.initialize(discover: false);
await state.updateSettings(state.prefs.copyWith(locale: locale));
await mount(tester, state, const SettingsPage());
final labels = <String>{};
final scrollable = find.byType(Scrollable).first;
for (var step = 0; step < 40; step++) {
labels.addAll(
tester.widgetList<Text>(find.byType(Text)).map((w) => w.data ?? ''),
);
expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing);
final position = tester.state<ScrollableState>(scrollable).position;
if (position.pixels >= position.maxScrollExtent) break;
await tester.drag(scrollable, const Offset(0, -400));
await tester.pumpAndSettle();
}
final copy = labels.join('\n');
expect(copy, isNot(contains(ApiService.iranBaseUrl)));
expect(
copy,
isNot(
matches(
RegExp(
r'API|Iran Catalogue|فهرست ایران|Parallel download segments|بخش‌های همزمان دانلود|Player volume|بلندی صدای پخش',
),
),
),
);
for (final label
in locale == 'en'
? [
'Language',
'Theme',
'Resume from last position',
'Enable subtitles',
'Download on Wi-Fi only',
'Export local library',
'Import local library',
'Clear disposable cache',
]
: [
'زبان',
'پوسته',
'ادامه از آخرین موقعیت',
'نمایش زیرنویس',
'دانلود فقط با وای‌فای',
'خروجی کتابخانه محلی',
'ورود کتابخانه محلی',
'پاک کردن حافظه موقت',
]) {
expect(labels, contains(label));
}
for (final label
in locale == 'en'
? ['Export local library', 'Import local library']
: ['خروجی کتابخانه محلی', 'ورود کتابخانه محلی']) {
final tile = find.ancestor(
of: find.text(label),
matching: find.byType(ListTile),
);
expect(tester.widget<ListTile>(tile).onTap, isNotNull);
}
expect(api.discoveryChecks, 0);
expect(tester.takeException(), isNull);
},
);
}
testWidgets('More opens a factual privacy and local-data explanation', (
tester,
) async {
final state = AppState(api: SettingsLocalApi());
addTearDown(state.dispose);
await mount(tester, state, const MorePage());
await tester.scrollUntilVisible(
find.text('Privacy & your data'),
300,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('Privacy & your data'));
await tester.pumpAndSettle();
expect(find.text('External services'), findsOneWidget);
final disclosures = <String>{};
for (final topic in [
'On this device',
'External services',
'Sharing & backups',
'Deleting local data',
'Unencrypted media',
]) {
await tester.scrollUntilVisible(
find.text(topic),
250,
scrollable: find.byType(Scrollable).first,
);
disclosures.addAll(
tester.widgetList<Text>(find.byType(Text)).map((w) => w.data ?? ''),
);
expect(find.text(topic), findsOneWidget);
}
final copy = disclosures.join('\n');
for (final disclosure in [
'No account needed',
'recent searches',
'IP address',
'access tokens',
'outside Reelbox’s control',
'completed downloads',
'unencrypted HTTP',
'observe or modify',
]) {
expect(copy, contains(disclosure));
}
expect(copy, isNot(contains('API')));
expect(tester.takeException(), isNull);
});
testWidgets('notifications explain new-task scope and Android requirements', (
tester,
) async {
final state = AppState(api: SettingsLocalApi());
addTearDown(state.dispose);
await mount(tester, state, const SettingsPage());
await tester.scrollUntilVisible(
find.text('Notifications for new downloads'),
400,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('Notifications for new downloads'), findsOneWidget);
expect(
find.textContaining('Running downloads keep their notification settings'),
findsOneWidget,
);
expect(
find.textContaining(
'Android may still require a foreground notification',
),
findsOneWidget,
);
});
testWidgets(
'legacy channel history is pruned while same-ID movie remains removable',
(tester) async {
final state = AppState(api: SettingsLocalApi());
addTearDown(state.dispose);
final backup = jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
backup['history'] = [
WatchEntry(title: channel, positionSeconds: 30).toJson(),
WatchEntry(title: movie, positionSeconds: 15).toJson(),
];
await state.importLibrary(jsonEncode(backup));
await mount(tester, state, const LibraryPage());
expect(find.text('Watchlist (0)'), findsOneWidget);
expect(find.text('Continue (1)'), findsOneWidget);
await tester.tap(find.text('Continue (1)'));
await tester.pumpAndSettle();
expect(find.text('Channel'), findsNothing);
expect(find.text('Movie'), findsOneWidget);
expect(state.history.single.title.toJson(), movie.toJson());
expect(state.resumeSeconds(42), 15);
expect(state.resumeSeconds(42, kind: 'channel'), 0);
await tester.tap(find.byTooltip('Remove history'));
await tester.pumpAndSettle();
expect(state.history, isEmpty);
expect(find.text('Continue (0)'), findsOneWidget);
expect(find.text('Pick up where you left off'), findsOneWidget);
},
);
testWidgets(
'removed channel card has no bookmark action and preserves the same-ID movie',
(tester) async {
final state = AppState(api: SettingsLocalApi());
addTearDown(state.dispose);
await state.toggleSaved(movie);
await mount(
tester,
state,
const Scaffold(
body: SizedBox(height: 244, child: PosterCard(title: channel)),
),
);
expect(find.text('Channel'), findsNothing);
expect(find.byTooltip('Watchlist'), findsNothing);
expect(find.byIcon(Icons.bookmark_border_rounded), findsNothing);
expect(find.byIcon(Icons.bookmark_rounded), findsNothing);
expect(state.saved.single.toJson(), movie.toJson());
expect(state.hasSaved(42, kind: 'channel'), isFalse);
// Allowed cards still expose the real bookmark action for this ID.
await mount(
tester,
state,
const Scaffold(
body: SizedBox(height: 244, child: PosterCard(title: movie)),
),
);
expect(find.text('Movie'), findsWidgets);
expect(find.byIcon(Icons.bookmark_rounded), findsOneWidget);
await tester.tap(find.byTooltip('Watchlist'));
await tester.pumpAndSettle();
expect(state.saved, isEmpty);
expect(find.byIcon(Icons.bookmark_border_rounded), findsOneWidget);
await tester.tap(find.byTooltip('Watchlist'));
await tester.pumpAndSettle();
expect(state.saved.single.toJson(), movie.toJson());
expect(find.byIcon(Icons.bookmark_rounded), findsOneWidget);
},
);
}