[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,233 @@
|
||||
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 movie = TitleRecord(id: 42, title: 'Movie');
|
||||
const channel = TitleRecord(id: 42, title: 'Channel', type: 'channel');
|
||||
|
||||
class LocalLibraryApi extends ApiService {
|
||||
@override
|
||||
Future<void> initialize({bool discover = true}) async {}
|
||||
}
|
||||
|
||||
// Construct the old backup directly: current mutations must never save channels.
|
||||
String legacyLibrary(AppState state) {
|
||||
final document = jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
|
||||
document['saved'] = [channel.toJson(), movie.toJson()];
|
||||
document['history'] = [
|
||||
WatchEntry(title: channel, positionSeconds: 30).toJson(),
|
||||
WatchEntry(title: movie, positionSeconds: 15).toJson(),
|
||||
];
|
||||
document['collections'] = [
|
||||
LocalCollection(
|
||||
id: 'both',
|
||||
name: 'Both',
|
||||
titles: [channel, movie],
|
||||
).toJson(),
|
||||
];
|
||||
return jsonEncode(document);
|
||||
}
|
||||
|
||||
void expectMovieOnly(AppState state) {
|
||||
expect(state.saved.single.toJson(), movie.toJson());
|
||||
expect(state.hasSaved(42), isTrue);
|
||||
expect(state.hasSaved(42, kind: 'channel'), isFalse);
|
||||
expect(state.history.single.title.toJson(), movie.toJson());
|
||||
expect(state.resumeSeconds(42), 15);
|
||||
expect(state.resumeSeconds(42, kind: 'channel'), 0);
|
||||
expect(state.collections.single.id, 'both');
|
||||
expect(state.collections.single.name, 'Both');
|
||||
expect(state.collections.single.titles.single.toJson(), movie.toJson());
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
for (final viaStorage in [false, true]) {
|
||||
test(
|
||||
'legacy ${viaStorage ? 'storage migration' : 'import'} prunes channels without losing same-ID movies',
|
||||
() async {
|
||||
final store = await SharedPreferences.getInstance();
|
||||
final state = AppState(api: LocalLibraryApi(), storage: store);
|
||||
final restored = AppState(api: LocalLibraryApi(), storage: store);
|
||||
addTearDown(state.dispose);
|
||||
addTearDown(restored.dispose);
|
||||
final legacy = legacyLibrary(state);
|
||||
if (viaStorage) {
|
||||
await store.setString(AppState.storageKey, legacy);
|
||||
await state.initialize(discover: false);
|
||||
} else {
|
||||
await state.importLibrary(legacy);
|
||||
}
|
||||
expectMovieOnly(state);
|
||||
final sanitized = state.exportLibrary();
|
||||
expect((jsonDecode(sanitized) as Map)['version'], 1);
|
||||
expect(store.getString(AppState.storageKey), sanitized);
|
||||
await restored.initialize(discover: false);
|
||||
expectMovieOnly(restored);
|
||||
expect(restored.exportLibrary(), sanitized);
|
||||
await restored.removeSaved(42, kind: 'channel');
|
||||
await restored.removeHistory(42, kind: 'channel');
|
||||
expectMovieOnly(restored);
|
||||
await restored.removeSaved(42);
|
||||
await restored.removeHistory(42);
|
||||
expect(restored.saved, isEmpty);
|
||||
expect(restored.history, isEmpty);
|
||||
expect(
|
||||
restored.collections.single.titles.single.toJson(),
|
||||
movie.toJson(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test('blocked collection mutations cannot replace a same-ID movie', () async {
|
||||
final state = AppState(api: LocalLibraryApi());
|
||||
addTearDown(state.dispose);
|
||||
final collection = await state.createCollection('Both');
|
||||
await state.toggleCollectionTitle(collection.id, movie);
|
||||
final original = state.exportLibrary();
|
||||
await state.toggleCollectionTitle(collection.id, channel);
|
||||
await state.toggleCollectionTitle(collection.id, channel);
|
||||
expect(state.exportLibrary(), original);
|
||||
expect(state.collections.single.titles.single.toJson(), movie.toJson());
|
||||
await state.toggleCollectionTitle(collection.id, movie);
|
||||
await state.toggleCollectionTitle(collection.id, channel);
|
||||
expect(state.collections.single.titles, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'blocked history mutations cannot replace same-ID movie progress',
|
||||
() async {
|
||||
final state = AppState(api: LocalLibraryApi());
|
||||
addTearDown(state.dispose);
|
||||
await state.recordWatch(movie, positionSeconds: 15);
|
||||
final original = state.exportLibrary();
|
||||
await state.recordWatch(channel, positionSeconds: 30);
|
||||
expect(state.exportLibrary(), original);
|
||||
expect(state.history.single.title.toJson(), movie.toJson());
|
||||
expect(state.resumeSeconds(42), 15);
|
||||
expect(state.resumeSeconds(42, kind: 'channel'), 0);
|
||||
await state.removeHistory(42, kind: 'channel');
|
||||
expect(state.resumeSeconds(42), 15);
|
||||
await state.removeHistory(42);
|
||||
await state.recordWatch(channel, positionSeconds: 30);
|
||||
expect(state.history, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('blocked watchlist mutations cannot replace a same-ID movie', () async {
|
||||
final store = await SharedPreferences.getInstance();
|
||||
final state = AppState(api: LocalLibraryApi(), storage: store);
|
||||
addTearDown(state.dispose);
|
||||
await state.toggleSaved(movie);
|
||||
final original = state.exportLibrary();
|
||||
var notifications = 0;
|
||||
state.addListener(() => notifications++);
|
||||
await state.toggleSaved(channel);
|
||||
await state.toggleSaved(channel);
|
||||
expect(state.exportLibrary(), original);
|
||||
expect(store.getString(AppState.storageKey), original);
|
||||
expect(notifications, 0);
|
||||
expect(state.saved.single.toJson(), movie.toJson());
|
||||
expect(state.hasSaved(42), isTrue);
|
||||
expect(state.hasSaved(42, kind: 'channel'), isFalse);
|
||||
await state.removeSaved(42, kind: 'channel');
|
||||
expect(state.saved.single.toJson(), movie.toJson());
|
||||
await state.removeSaved(42);
|
||||
await state.toggleSaved(channel);
|
||||
expect(state.saved, isEmpty);
|
||||
expect(state.hasSaved(42, kind: 'channel'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'legacy version-one titles without type still load as posters',
|
||||
() async {
|
||||
final state = AppState(api: LocalLibraryApi());
|
||||
addTearDown(state.dispose);
|
||||
final document =
|
||||
jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
|
||||
document['saved'] = [
|
||||
channel.toJson(),
|
||||
{'id': 42, 'title': 'Legacy movie'},
|
||||
];
|
||||
await state.importLibrary(jsonEncode(document));
|
||||
expect(state.hasSaved(42), isTrue);
|
||||
expect(state.hasSaved(42, kind: 'channel'), isFalse);
|
||||
expect(state.saved.single.libraryKey, 'poster:42');
|
||||
expect(state.saved.single.title, 'Legacy movie');
|
||||
final original = state.exportLibrary();
|
||||
await state.toggleSaved(channel);
|
||||
expect(state.exportLibrary(), original);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'movie and series aliases share the poster namespace during mutation',
|
||||
() async {
|
||||
final state = AppState(api: LocalLibraryApi());
|
||||
addTearDown(state.dispose);
|
||||
const series = TitleRecord(id: 42, title: 'Series', type: 'series');
|
||||
const serie = TitleRecord(id: 42, title: 'Serie', type: 'serie');
|
||||
expect(movie.libraryKey, series.libraryKey);
|
||||
expect(series.libraryKey, serie.libraryKey);
|
||||
await state.toggleSaved(series);
|
||||
expect(state.hasSaved(42, kind: 'serie'), isTrue);
|
||||
await state.toggleSaved(serie);
|
||||
expect(state.saved, isEmpty);
|
||||
await state.recordWatch(series, episodeId: 7, positionSeconds: 10);
|
||||
await state.recordWatch(serie, episodeId: 7, positionSeconds: 20);
|
||||
expect(state.history.single.positionSeconds, 20);
|
||||
},
|
||||
);
|
||||
|
||||
for (final section in ['saved', 'history', 'collections']) {
|
||||
test(
|
||||
'$section rejects same-namespace duplicate imports atomically',
|
||||
() async {
|
||||
final store = await SharedPreferences.getInstance();
|
||||
final state = AppState(api: LocalLibraryApi(), storage: store);
|
||||
addTearDown(state.dispose);
|
||||
await state.toggleSaved(movie);
|
||||
final original = state.exportLibrary();
|
||||
var notifications = 0;
|
||||
state.addListener(() => notifications++);
|
||||
final document = jsonDecode(original) as Map<String, dynamic>;
|
||||
const series = TitleRecord(id: 42, title: 'Series', type: 'serie');
|
||||
if (section == 'history') {
|
||||
document[section] = [
|
||||
WatchEntry(title: channel).toJson(),
|
||||
WatchEntry(title: movie).toJson(),
|
||||
WatchEntry(title: series).toJson(),
|
||||
];
|
||||
} else if (section == 'collections') {
|
||||
document[section] = [
|
||||
LocalCollection(
|
||||
id: 'both',
|
||||
name: 'Both',
|
||||
titles: [channel, movie, series],
|
||||
).toJson(),
|
||||
];
|
||||
} else {
|
||||
document[section] = [
|
||||
channel.toJson(),
|
||||
movie.toJson(),
|
||||
series.toJson(),
|
||||
];
|
||||
}
|
||||
await expectLater(
|
||||
state.importLibrary(jsonEncode(document)),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(state.exportLibrary(), original);
|
||||
expect(store.getString(AppState.storageKey), original);
|
||||
expect(notifications, 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user