[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,486 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _sourceUrl = 'https://media.example/video%2F17.mp4?token=a%2Bb&part=1';
|
||||
|
||||
TitleRecord _title(int id) => TitleRecord.fromJson({
|
||||
'id': id,
|
||||
'type': 'serie',
|
||||
'title': 'Title $id',
|
||||
'sources': [
|
||||
{'id': 520531, 'quality': '1080p', 'type': 'mp4', 'url': _sourceUrl},
|
||||
],
|
||||
'metadata': {'original': 'محلی'},
|
||||
});
|
||||
|
||||
class _LocalApi extends ApiService {
|
||||
int initializeCalls = 0;
|
||||
bool? lastDiscover;
|
||||
|
||||
@override
|
||||
Future<void> initialize({bool discover = true}) async {
|
||||
initializeCalls++;
|
||||
lastDiscover = discover;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> get(String path, {Map<String, dynamic>? query}) =>
|
||||
throw StateError('Local state must not request catalog data');
|
||||
|
||||
@override
|
||||
Future<dynamic> post(String path, Map<String, dynamic> form) =>
|
||||
throw StateError('Local state must not mutate network state');
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test(
|
||||
'watchlist and settings persist locally with exact source metadata',
|
||||
() async {
|
||||
final api = _LocalApi();
|
||||
final store = await SharedPreferences.getInstance();
|
||||
final state = AppState(api: api, storage: store);
|
||||
await state.initialize(discover: false);
|
||||
expect(api.initializeCalls, 1);
|
||||
expect(api.lastDiscover, isFalse);
|
||||
var notifications = 0;
|
||||
state.addListener(() => notifications++);
|
||||
final title = _title(17);
|
||||
await state.toggleSaved(title);
|
||||
expect(state.hasSaved(17), isTrue);
|
||||
expect(state.saved.single.sources.single.url, _sourceUrl);
|
||||
await state.updateSettings(
|
||||
state.prefs.copyWith(locale: 'fa', preferredGenres: [3]),
|
||||
);
|
||||
expect(state.locale, 'fa');
|
||||
expect(state.selectedGenres, [3]);
|
||||
expect(notifications, 2);
|
||||
final restored = AppState(api: _LocalApi(), storage: store);
|
||||
await restored.initialize(discover: false);
|
||||
expect(restored.saved.single.toJson(), title.toJson());
|
||||
expect(restored.prefs.toJson(), state.prefs.toJson());
|
||||
expect(() => restored.saved.clear(), throwsUnsupportedError);
|
||||
await restored.toggleSaved(title);
|
||||
expect(restored.saved, isEmpty);
|
||||
await restored.toggleSaved(title);
|
||||
await restored.removeSaved(17);
|
||||
expect(restored.hasSaved(17), isFalse);
|
||||
state.dispose();
|
||||
restored.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'episode resume is isolated, updates reorder and removals are exact',
|
||||
() async {
|
||||
final state = AppState(api: _LocalApi());
|
||||
await state.initialize(discover: false);
|
||||
final title = _title(9);
|
||||
await state.recordWatch(title, positionSeconds: 5, durationSeconds: 100);
|
||||
await state.recordWatch(
|
||||
title,
|
||||
episodeId: 101,
|
||||
episodeTitle: 'Episode 1',
|
||||
sourceUrl: _sourceUrl,
|
||||
positionSeconds: 25,
|
||||
durationSeconds: 100,
|
||||
);
|
||||
await state.recordWatch(
|
||||
title,
|
||||
episodeId: 102,
|
||||
episodeTitle: 'Episode 2',
|
||||
positionSeconds: 75,
|
||||
durationSeconds: 100,
|
||||
);
|
||||
expect(state.resumeSeconds(9), 5);
|
||||
expect(state.resumeSeconds(9, episodeId: 101), 25);
|
||||
expect(state.resumeSeconds(9, episodeId: 102), 75);
|
||||
expect(state.resumeSeconds(10, episodeId: 101), 0);
|
||||
expect(state.history.first.progress, 0.75);
|
||||
await state.recordWatch(
|
||||
title,
|
||||
episodeId: 101,
|
||||
episodeTitle: 'Episode 1',
|
||||
sourceUrl: _sourceUrl,
|
||||
positionSeconds: 40,
|
||||
durationSeconds: 100,
|
||||
);
|
||||
expect(state.history.length, 3);
|
||||
expect(state.history.first.episodeId, 101);
|
||||
expect(state.history.first.sourceUrl, _sourceUrl);
|
||||
expect(state.history.first.updatedAt.isUtc, isTrue);
|
||||
final restored = AppState(api: _LocalApi());
|
||||
await restored.initialize(discover: false);
|
||||
expect(restored.resumeSeconds(9, episodeId: 101), 40);
|
||||
expect(restored.history.first.episodeTitle, 'Episode 1');
|
||||
expect(restored.history.first.sourceUrl, _sourceUrl);
|
||||
expect(restored.history.first.toJson(), state.history.first.toJson());
|
||||
await state.updateSettings(state.prefs.copyWith(resumePlayback: false));
|
||||
expect(state.resumeSeconds(9, episodeId: 101), 0);
|
||||
expect(state.history.first.positionSeconds, 40);
|
||||
await state.removeHistory(9, episodeId: 101);
|
||||
expect(state.history.map((entry) => entry.episodeId), [102, null]);
|
||||
await state.removeHistory(9);
|
||||
expect(state.history.single.episodeId, 102);
|
||||
await state.clearHistory();
|
||||
expect(state.history, isEmpty);
|
||||
state.dispose();
|
||||
restored.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'watch progress clamps safely and invalid positions reject without changes',
|
||||
() async {
|
||||
final state = AppState(api: _LocalApi());
|
||||
await state.recordWatch(
|
||||
_title(1),
|
||||
positionSeconds: 200,
|
||||
durationSeconds: 100,
|
||||
);
|
||||
expect(state.history.single.progress, 1);
|
||||
await state.recordWatch(_title(2));
|
||||
expect(state.history.first.progress, 0);
|
||||
await expectLater(
|
||||
state.recordWatch(_title(1), positionSeconds: double.nan),
|
||||
throwsArgumentError,
|
||||
);
|
||||
await expectLater(
|
||||
state.recordWatch(_title(1), durationSeconds: -1),
|
||||
throwsArgumentError,
|
||||
);
|
||||
expect(state.history.length, 2);
|
||||
state.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'search history trims, deduplicates case-insensitively and caps at 30',
|
||||
() async {
|
||||
final state = AppState(api: _LocalApi());
|
||||
await state.rememberSearch(' Dune ');
|
||||
await state.rememberSearch('Alien');
|
||||
await state.rememberSearch('dUnE');
|
||||
await state.rememberSearch(' ');
|
||||
expect(state.searchHistory, ['dUnE', 'Alien']);
|
||||
await state.removeSearch(' DUNE ');
|
||||
expect(state.searchHistory, ['Alien']);
|
||||
await Future.wait(
|
||||
List.generate(35, (i) => state.rememberSearch('Query $i')),
|
||||
);
|
||||
expect(state.searchHistory.length, 30);
|
||||
expect(state.searchHistory.first, 'Query 34');
|
||||
expect(state.searchHistory.last, 'Query 5');
|
||||
final restored = AppState(api: _LocalApi());
|
||||
await restored.initialize(discover: false);
|
||||
expect(restored.searchHistory, state.searchHistory);
|
||||
await restored.clearSearchHistory();
|
||||
expect(restored.searchHistory, isEmpty);
|
||||
state.dispose();
|
||||
restored.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'local collections have stable IDs and independent title membership',
|
||||
() async {
|
||||
final state = AppState(api: _LocalApi());
|
||||
final first = await state.createCollection(' Weekend ');
|
||||
final second = await state.createCollection('Weekend');
|
||||
expect(first.name, 'Weekend');
|
||||
expect(first.id, isNot(second.id));
|
||||
await state.toggleCollectionTitle(first.id, _title(17));
|
||||
await state.toggleCollectionTitle(second.id, _title(17));
|
||||
await state.toggleCollectionTitle(first.id, _title(18));
|
||||
expect(state.collections.first.titles.map((title) => title.id), [17, 18]);
|
||||
expect(state.saved, isEmpty);
|
||||
await state.toggleCollectionTitle(first.id, _title(17));
|
||||
expect(state.collections.first.titles.single.id, 18);
|
||||
expect(state.collections.last.titles.single.id, 17);
|
||||
expect(
|
||||
() => state.collections.first.titles.clear(),
|
||||
throwsUnsupportedError,
|
||||
);
|
||||
final restored = AppState(api: _LocalApi());
|
||||
await restored.initialize(discover: false);
|
||||
expect(
|
||||
restored.collections.map((item) => item.toJson()),
|
||||
state.collections.map((item) => item.toJson()),
|
||||
);
|
||||
await restored.deleteCollection(first.id);
|
||||
expect(restored.collections.single.id, second.id);
|
||||
await expectLater(
|
||||
restored.toggleCollectionTitle('missing', _title(1)),
|
||||
throwsStateError,
|
||||
);
|
||||
await expectLater(restored.createCollection(' '), throwsArgumentError);
|
||||
state.dispose();
|
||||
restored.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'versioned export/import replaces the entire library and persists it',
|
||||
() async {
|
||||
final state = AppState(api: _LocalApi());
|
||||
await state.toggleSaved(_title(17));
|
||||
await state.recordWatch(
|
||||
_title(17),
|
||||
episodeId: 41,
|
||||
episodeTitle: 'Forty one',
|
||||
sourceUrl: _sourceUrl,
|
||||
positionSeconds: 12.5,
|
||||
durationSeconds: 50,
|
||||
);
|
||||
await state.rememberSearch('سینما');
|
||||
final collection = await state.createCollection('Favorites');
|
||||
await state.toggleCollectionTitle(collection.id, _title(20));
|
||||
await state.updateSettings(
|
||||
state.prefs.copyWith(locale: 'fa', playbackSpeed: 2),
|
||||
);
|
||||
final exported = state.exportLibrary();
|
||||
final document = jsonDecode(exported) as Map<String, dynamic>;
|
||||
expect(document['format'], 'reelbox.library');
|
||||
expect(document['version'], 1);
|
||||
final imported = AppState(api: _LocalApi());
|
||||
await imported.toggleSaved(_title(99));
|
||||
var notifications = 0;
|
||||
imported.addListener(() => notifications++);
|
||||
await imported.importLibrary(exported);
|
||||
expect(imported.exportLibrary(), exported);
|
||||
expect(imported.hasSaved(99), isFalse);
|
||||
expect(imported.resumeSeconds(17, episodeId: 41), 12.5);
|
||||
expect(imported.collections.single.id, collection.id);
|
||||
expect(notifications, 1);
|
||||
final restored = AppState(api: _LocalApi());
|
||||
await restored.initialize(discover: false);
|
||||
expect(restored.exportLibrary(), exported);
|
||||
state.dispose();
|
||||
imported.dispose();
|
||||
restored.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'corrupt import is atomic across memory, storage and notifications',
|
||||
() async {
|
||||
final store = await SharedPreferences.getInstance();
|
||||
final state = AppState(api: _LocalApi(), storage: store);
|
||||
await state.toggleSaved(_title(17));
|
||||
await state.recordWatch(_title(17), episodeId: 2, positionSeconds: 3);
|
||||
await state.rememberSearch('Keep');
|
||||
final original = state.exportLibrary();
|
||||
final stored = store.getString(AppState.storageKey);
|
||||
var notifications = 0;
|
||||
state.addListener(() => notifications++);
|
||||
final mutations = <void Function(Map<String, dynamic>)>[
|
||||
(doc) => doc['version'] = 2,
|
||||
(doc) => doc['version'] = '1',
|
||||
(doc) => doc['format'] = 'other',
|
||||
(doc) => doc.remove('saved'),
|
||||
(doc) => doc['settings'] = [],
|
||||
(doc) => doc['saved'] = [
|
||||
{'id': '17', 'title': 'Invalid'},
|
||||
],
|
||||
(doc) => doc['saved'] = [doc['saved'][0], doc['saved'][0]],
|
||||
(doc) => doc['history'][0]['positionSeconds'] = -1,
|
||||
(doc) => doc['history'][0]['updatedAt'] = 'not a date',
|
||||
(doc) => doc['searchHistory'] = [42],
|
||||
(doc) => doc['collections'] = [
|
||||
{'id': '', 'name': 'Invalid', 'titles': []},
|
||||
],
|
||||
];
|
||||
for (final mutate in mutations) {
|
||||
final document = jsonDecode(original) as Map<String, dynamic>;
|
||||
mutate(document);
|
||||
await expectLater(
|
||||
state.importLibrary(jsonEncode(document)),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(state.exportLibrary(), original);
|
||||
expect(store.getString(AppState.storageKey), stored);
|
||||
}
|
||||
for (final text in ['not json', '[]', 'null', '{']) {
|
||||
await expectLater(state.importLibrary(text), throwsFormatException);
|
||||
}
|
||||
expect(notifications, 0);
|
||||
await state.rememberSearch('Still usable');
|
||||
expect(state.searchHistory.first, 'Still usable');
|
||||
state.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'import limits history to latest 1000 and normalizes 30 searches',
|
||||
() async {
|
||||
final state = AppState(api: _LocalApi());
|
||||
final document =
|
||||
jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
|
||||
final start = DateTime.utc(2026);
|
||||
document['history'] = List.generate(
|
||||
1005,
|
||||
(i) => WatchEntry(
|
||||
title: _title(i + 1),
|
||||
episodeId: 1,
|
||||
positionSeconds: i.toDouble(),
|
||||
updatedAt: start.add(Duration(seconds: i)),
|
||||
).toJson(),
|
||||
);
|
||||
document['searchHistory'] = [
|
||||
' Dune ',
|
||||
'dune',
|
||||
'',
|
||||
...List.generate(35, (i) => 'Query $i'),
|
||||
];
|
||||
await state.importLibrary(jsonEncode(document));
|
||||
expect(state.history.length, 1000);
|
||||
expect(state.history.first.title.id, 1005);
|
||||
expect(state.history.last.title.id, 6);
|
||||
expect(state.searchHistory.length, 30);
|
||||
expect(state.searchHistory.first, 'Dune');
|
||||
await state.recordWatch(_title(2000), episodeId: 7, positionSeconds: 20);
|
||||
expect(state.history.length, 1000);
|
||||
expect(state.history.first.title.id, 2000);
|
||||
expect(state.history.last.title.id, 7);
|
||||
state.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('clear resets preferences and all local state without clearing unrelated keys', () async {
|
||||
final store = await SharedPreferences.getInstance();
|
||||
await store.setString('unrelated.plugin.setting', 'keep');
|
||||
final state = AppState(api: _LocalApi(), storage: store);
|
||||
await state.toggleSaved(_title(1));
|
||||
await state.recordWatch(_title(1), positionSeconds: 5);
|
||||
await state.rememberSearch('Query');
|
||||
final collection = await state.createCollection('One');
|
||||
await state.toggleCollectionTitle(collection.id, _title(1));
|
||||
await state.updateSettings(state.prefs.copyWith(locale: 'fa'));
|
||||
await state.clearLocalData();
|
||||
expect(state.prefs.toJson(), AppSettings().toJson());
|
||||
expect(state.saved, isEmpty);
|
||||
expect(state.history, isEmpty);
|
||||
expect(state.searchHistory, isEmpty);
|
||||
expect(state.collections, isEmpty);
|
||||
expect(store.getString('unrelated.plugin.setting'), 'keep');
|
||||
final restored = AppState(api: _LocalApi(), storage: store);
|
||||
await restored.initialize(discover: false);
|
||||
expect(restored.exportLibrary(), state.exportLibrary());
|
||||
state.dispose();
|
||||
restored.dispose();
|
||||
});
|
||||
|
||||
test('settings defaults, copyWith and JSON preserve every preference', () {
|
||||
final defaults = AppSettings();
|
||||
expect(defaults.locale, 'en');
|
||||
expect(defaults.theme, 'dark');
|
||||
expect(defaults.autoNext, isTrue);
|
||||
expect(defaults.resumePlayback, isTrue);
|
||||
expect(defaults.playbackSpeed, 1.0);
|
||||
expect(defaults.skipSeconds, 10);
|
||||
expect(defaults.introCompleted, isFalse);
|
||||
final changed = defaults.copyWith(
|
||||
introCompleted: true,
|
||||
locale: 'fa',
|
||||
theme: 'light',
|
||||
wifiOnlyDownloads: true,
|
||||
autoNext: false,
|
||||
resumePlayback: false,
|
||||
autoplay: true,
|
||||
externalPlayer: true,
|
||||
defaultQuality: '1080p',
|
||||
playbackSpeed: 1.5,
|
||||
skipSeconds: 30,
|
||||
subtitleFontSize: 32,
|
||||
subtitleColor: 0xFF00FF00,
|
||||
subtitleBackground: 0x40000000,
|
||||
subtitleDelay: -1.25,
|
||||
audioDelay: 0.5,
|
||||
orientation: 'landscape',
|
||||
preferredGenres: [3, 7],
|
||||
hideTrailers: true,
|
||||
downloadNotifications: false,
|
||||
dataSaver: true,
|
||||
);
|
||||
final restored = AppSettings.fromJson(
|
||||
jsonDecode(jsonEncode(changed.toJson())) as Map<String, dynamic>,
|
||||
);
|
||||
expect(restored.toJson(), changed.toJson());
|
||||
expect(defaults.locale, 'en');
|
||||
expect(restored.copyWith().toJson(), restored.toJson());
|
||||
expect(() => restored.preferredGenres.add(4), throwsUnsupportedError);
|
||||
});
|
||||
|
||||
for (final introCompleted in <bool?>[null, false, true]) {
|
||||
test(
|
||||
'stored onboarding flag $introCompleted migrates without losing the library',
|
||||
() async {
|
||||
final store = await SharedPreferences.getInstance();
|
||||
final state = AppState(api: _LocalApi(), storage: store);
|
||||
final restored = AppState(api: _LocalApi(), storage: store);
|
||||
addTearDown(state.dispose);
|
||||
addTearDown(restored.dispose);
|
||||
final document =
|
||||
jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
|
||||
final settings = document['settings'] as Map<String, dynamic>;
|
||||
settings['locale'] = 'fa';
|
||||
settings['playbackSpeed'] = 1.5;
|
||||
if (introCompleted == null) {
|
||||
settings.remove('introCompleted');
|
||||
} else {
|
||||
settings['introCompleted'] = introCompleted;
|
||||
}
|
||||
document['saved'] = [_title(17).toJson()];
|
||||
await store.setString(AppState.storageKey, jsonEncode(document));
|
||||
await state.initialize(discover: false);
|
||||
expect(state.prefs.introCompleted, introCompleted ?? true);
|
||||
expect(state.locale, 'fa');
|
||||
expect(state.prefs.playbackSpeed, 1.5);
|
||||
expect(state.saved.single.toJson(), _title(17).toJson());
|
||||
final persisted = jsonDecode(
|
||||
store.getString(AppState.storageKey)!,
|
||||
) as Map<String, dynamic>;
|
||||
expect(persisted['settings']['introCompleted'], introCompleted ?? true);
|
||||
await restored.initialize(discover: false);
|
||||
expect(restored.exportLibrary(), state.exportLibrary());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('settings parser defaults invalid fields without type errors', () {
|
||||
final prefs = AppSettings.fromJson({
|
||||
'locale': 'xx',
|
||||
'theme': 7,
|
||||
'autoplay': 'yes',
|
||||
'playbackSpeed': -1,
|
||||
'skipSeconds': 0,
|
||||
'subtitleFontSize': 'bad',
|
||||
'subtitleDelay': double.infinity,
|
||||
'audioDelay': null,
|
||||
'orientation': 'diagonal',
|
||||
'preferredGenres': [3, '7', null, -1, 3],
|
||||
});
|
||||
expect(prefs.locale, 'en');
|
||||
expect(prefs.theme, 'dark');
|
||||
expect(prefs.autoplay, isFalse);
|
||||
expect(prefs.playbackSpeed, 1);
|
||||
expect(prefs.skipSeconds, 10);
|
||||
expect(prefs.subtitleFontSize, 24);
|
||||
expect(prefs.subtitleDelay, 0);
|
||||
expect(prefs.audioDelay, 0);
|
||||
expect(prefs.orientation, 'auto');
|
||||
expect(prefs.preferredGenres, [3]);
|
||||
// Existing serialized settings predate onboarding and skip it on migration.
|
||||
expect(
|
||||
AppSettings.fromJson({}).toJson(),
|
||||
AppSettings(introCompleted: true).toJson(),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user