326 lines
11 KiB
Dart
326 lines
11 KiB
Dart
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);
|
||
},
|
||
);
|
||
}
|