[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+486
View File
@@ -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(),
);
});
}