[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

+252
View File
@@ -0,0 +1,252 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_service_test.dart' as fixtures;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
test('Iran migration is restored by a fresh instance without contacting discovery', () async {
final adapter = fixtures.StubAdapter(
(request) => request.uri.host == 'ninnies-server.info'
? ResponseBody.fromString(ApiService.defaultBaseUrl, 200)
: fixtures.jsonBody([
{'id': 1, 'title': 'Drama'},
]),
);
await ApiService(dio: Dio()..httpClientAdapter = adapter).initialize();
var reads = 0;
final offline = fixtures.StubAdapter(
(_) => throw StateError('No network permitted'),
);
final restored = ApiService(
dio: Dio()..httpClientAdapter = offline,
preferences: () async {
reads++;
return SharedPreferences.getInstance();
},
);
await restored.initialize(discover: false);
expect(reads, 1);
expect(restored.baseUrl, ApiService.defaultBaseUrl);
expect(offline.requests, isEmpty);
});
for (final value in [
'broken',
'https://untrusted.example/api/',
'http://ninwinios.info/api/',
'https://fixture:fixture@ninwinios.info/api/',
'https://ninwinios.info/api/?x=1',
'https://ninwinios.info/api/#fragment',
'https://127.0.0.1/api/',
'https://ninwinios.info:444/api/',
]) {
test(
'invalid cached configuration cannot change the trusted destination: ${Uri.tryParse(value)?.host ?? 'malformed'}',
() async {
SharedPreferences.setMockInitialValues({
'reelbox.trusted-api.v1': value,
});
final adapter = fixtures.StubAdapter(
(_) => throw StateError('No network permitted'),
);
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.initialize(discover: false);
expect(api.baseUrl, ApiService.defaultBaseUrl);
expect(adapter.requests, isEmpty);
},
);
}
test(
'removed discovery cannot contact an offline or invalid-schema server',
() async {
SharedPreferences.setMockInitialValues({
'reelbox.trusted-api.v1': ApiService.defaultBaseUrl,
});
final offline = fixtures.StubAdapter(
(r) => throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
),
);
final api = ApiService(dio: Dio()..httpClientAdapter = offline);
await api.initialize();
expect(api.baseUrl, ApiService.defaultBaseUrl);
expect(offline.requests, isEmpty);
expect(
(await SharedPreferences.getInstance()).getString(
'reelbox.trusted-api.v1',
),
isNull,
);
SharedPreferences.setMockInitialValues({});
final invalid = fixtures.StubAdapter(
(r) => r.uri.host == 'ninnies-server.info'
? ResponseBody.fromString(ApiService.defaultBaseUrl, 200)
: fixtures.jsonBody([]),
);
await ApiService(dio: Dio()..httpClientAdapter = invalid).initialize();
expect(invalid.requests, isEmpty);
expect(
(await SharedPreferences.getInstance()).getString(
'reelbox.trusted-api.v1',
),
isNull,
);
},
);
test(
'read-only transport failure recovers once without a discovery storm',
() async {
var attempts = 0;
final adapter = fixtures.StubAdapter((r) {
if (++attempts == 1) {
throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
);
}
return fixtures.jsonBody([
{'id': 1, 'title': 'Drama'},
]);
});
final result = await ApiService(dio: Dio()..httpClientAdapter = adapter)
.genres();
expect(result.single.title, 'Drama');
expect(attempts, 2);
expect(
adapter.requests.every(
(r) => r.uri.origin == 'https://reelbox.dartty.top',
),
true,
);
},
);
test('persistent transport failure is bounded to two GET attempts', () async {
final adapter = fixtures.StubAdapter(
(r) => throw DioException(
requestOptions: r,
type: DioExceptionType.receiveTimeout,
),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter).genres(),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, hasLength(2));
});
for (final status in [401, 403, 404, 429, 500]) {
test('HTTP $status is preserved without automatic retry', () async {
final adapter = fixtures.StubAdapter(
(_) => fixtures.jsonBody({}, status: status),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter).genres(),
throwsA(
isA<ApiException>().having((e) => e.statusCode, 'status', status),
),
);
expect(adapter.requests, hasLength(1));
});
}
for (final type in [
DioExceptionType.cancel,
DioExceptionType.badCertificate,
]) {
test('$type is never retried', () async {
final adapter = fixtures.StubAdapter(
(r) => throw DioException(requestOptions: r, type: type),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter).genres(),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, hasLength(1));
});
}
test('POST transport failure is not replayed', () async {
final adapter = fixtures.StubAdapter(
(r) => throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter)
.post('genre/read', {'fixture': true}),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, hasLength(1));
});
test(
'concurrent failed GETs share cache loading and each retry only once',
() async {
var reads = 0;
final attempts = <String, int>{};
final adapter = fixtures.StubAdapter((r) {
final n = attempts.update(r.uri.path, (n) => n + 1, ifAbsent: () => 1);
if (n == 1) {
throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
);
}
return fixtures.jsonBody([]);
});
final cache = Completer<SharedPreferences>();
final api = ApiService(
dio: Dio()..httpClientAdapter = adapter,
preferences: () {
reads++;
return cache.future;
},
);
final requests = Future.wait([
for (final n in [1, 2, 3]) api.get('fixture-$n'),
]);
await Future<void>.delayed(Duration.zero);
cache.complete(await SharedPreferences.getInstance());
await requests;
expect(reads, 1);
expect(attempts.values, everyElement(2));
expect(adapter.requests, hasLength(6));
},
);
test(
'production default persists Iran without fetching remote configuration',
() async {
SharedPreferences.setMockInitialValues({});
final adapter = fixtures.StubAdapter((request) {
if (request.uri.host == 'ninnies-server.info') {
return ResponseBody.fromString(ApiService.defaultBaseUrl, 200);
}
return fixtures.jsonBody([
{'id': 1, 'title': 'Drama'},
]);
});
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.initialize();
final preferences = await SharedPreferences.getInstance();
expect(
preferences.getString('reelbox.api-selection.v1'),
'primary',
);
expect(preferences.getString('reelbox.trusted-api.v1'), isNull);
expect(adapter.requests, isEmpty);
},
);
}
+132
View File
@@ -0,0 +1,132 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/models.dart';
import 'api_service_test.dart' as fixtures;
void main() {
test(
'all title decoders reject channels and promotional catalog entries',
() {
final api = ApiService();
expect(
api
.decodeTitles([
{'id': 1, 'title': 'A real movie', 'type': 'movie'},
{'id': 2, 'title': 'نورتون', 'type': 'movie'},
{'id': 3, 'title': 'Station', 'type': 'channel'},
{'id': 4, 'title': 'ملودی باکس'},
])
.map((e) => e.id),
[1],
);
},
);
test(
'home exposes only film discovery, never updater/support/public feeds',
() async {
final adapter = fixtures.StubAdapter(
(_) => fixtures.jsonBody({
'posters': [
{'id': 1, 'title': 'Film'},
{'id': 2, 'title': 'ملودی باکس'},
],
'genres': [],
'slides': [],
'channels': [
{'id': 3, 'title': 'Station'},
],
'public_playlists': [],
'news': {'title': 'Original news'},
'support': 'https://t.me/support',
'latest_version': '100',
'settings': {'installer': 'https://example.com/latest.apk'},
'unknown_promotion': 'https://t.me/original',
}),
);
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
final home = await api.home();
expect(home.keys.toSet(), {'posters', 'genres', 'slides'});
expect((home['posters'] as List).length, 1);
},
);
test(
'removed API wrappers cannot issue public-playlist/live-channel requests',
() async {
final adapter = fixtures.StubAdapter(
(_) => fixtures.jsonBody([
{'id': 1, 'title': 'Old entry'},
]),
);
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
expect(await api.channels(), isEmpty);
expect(await api.categories(), isEmpty);
expect(await api.playlists(), isEmpty);
expect(await api.playlistItems(1), isEmpty);
await expectLater(api.channel(1), throwsA(isA<ApiException>()));
expect(adapter.requests, isEmpty);
},
);
test(
'generic paths and query maps cannot bypass removed endpoint policy',
() async {
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
for (final path in [
'channel/by/1/${ApiService.sharedKey}/',
'news/all/',
'updates/latest/',
]) {
await expectLater(api.get(path), throwsA(isA<ApiException>()));
}
await expectLater(
api.get(
'${api.hostRoot}/api-user/newapi/like.php',
query: {'action': 'showlist'},
),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, isEmpty);
},
);
test(
'a raw page containing only hidden items still has a continuation',
() async {
final adapter = fixtures.StubAdapter(
(_) => fixtures.jsonBody([
{'id': 2, 'title': 'ملودی باکس', 'type': 'movie'},
]),
);
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
final page = await api.catalog(page: 5);
expect(page, isEmpty);
expect((page as CatalogPage).nextPage, 6);
final search = await api.search('film', page: 3);
expect(search, isEmpty);
expect((search as CatalogPage).nextPage, 4);
},
);
test(
'stored model round trips cannot retain hidden promotional metadata',
() {
final title = TitleRecord.fromJson({
'id': 1,
'title': 'Film',
'support': 'https://t.me/original',
'news': ['Original app news'],
'sources': [
{'id': 1, 'url': 'https://example.com/film.mp4'},
{'id': 2, 'url': 'https://example.com/new.apk'},
],
});
final encoded = jsonEncode(title.toJson());
expect(encoded.contains('t.me'), isFalse);
expect(encoded.contains('new.apk'), isFalse);
expect(title.sources, hasLength(1));
expect(TitleRecord.fromJson({'id': 8, 'title': 'ملودی باکس'}).id, 0);
},
);
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_service_test.dart' as fixtures;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
for (final key in ['reelbox.api-selection.v1', 'reelbox.trusted-api.v1']) {
for (final initializeFirst in [true, false]) {
test(
'migrates $key before ${initializeFirst ? 'initialization' : 'first request'} without touching local data',
() async {
const library =
'{"saved":[{"id":7}],"locale":"fa","unknown":"keep exactly"}';
SharedPreferences.setMockInitialValues({
key: 'https://ninwinios.info/api/',
'reelbox.local.v1': library,
'unrelated-setting': 'keep',
});
String? valueAtRequest;
final adapter = fixtures.StubAdapter((request) async {
final prefs = await SharedPreferences.getInstance();
valueAtRequest = prefs.getString(key);
return fixtures.jsonBody([]);
});
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
if (initializeFirst) {
await api.initialize();
expect(adapter.requests, isEmpty);
}
await api.genres();
expect(
valueAtRequest,
key == 'reelbox.api-selection.v1' ? 'primary' : isNull,
);
expect(
adapter.requests.single.uri.origin,
'https://reelbox.dartty.top',
);
expect(api.baseUrl, ApiService.iranBaseUrl);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('reelbox.api-selection.v1'), 'primary');
expect(prefs.getString('reelbox.local.v1'), library);
expect(prefs.getString('unrelated-setting'), 'keep');
},
);
}
}
test(
'Original API cannot be selected or injected as a production backend',
() async {
const original = 'https://ninwinios.info/api/';
expect(() => ApiService(baseUrl: original), throwsA(isA<ApiException>()));
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await expectLater(api.setBaseUrl(original), throwsA(isA<ApiException>()));
expect(api.baseUrl, ApiService.iranBaseUrl);
for (final url in [
'${original}genre/all/public/',
'https://ninnies-server.info/api-user/newapi/config-server186.txt',
]) {
await expectLater(api.get(url), throwsA(isA<ApiException>()));
}
expect(adapter.requests, isEmpty);
},
);
test(
'production starts on Iran without configuration discovery traffic',
() async {
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.initialize();
expect(api.baseUrl, ApiService.iranBaseUrl);
expect(ApiService.defaultBaseUrl, ApiService.iranBaseUrl);
expect(adapter.requests, isEmpty);
await api.genres();
expect(adapter.requests.single.uri.origin, 'https://reelbox.dartty.top');
},
);
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_service_test.dart' as fixtures;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('fresh catalogue requests use HTTPS port 443', () async {
SharedPreferences.setMockInitialValues({});
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.genres();
expect(adapter.requests.single.uri.port, 443);
expect(api.baseUrl, 'https://reelbox.dartty.top/api/');
});
test(
'legacy 8443 selections migrate without retaining endpoint URLs',
() async {
SharedPreferences.setMockInitialValues({
'reelbox.api-selection.v1': 'https://reelbox.dartty.top:8443/api/',
'reelbox.trusted-api.v1': 'https://reelbox.dartty.top:8443/api/',
});
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.genres();
expect(adapter.requests.single.uri.port, 443);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('reelbox.api-selection.v1'), 'primary');
expect(prefs.containsKey('reelbox.trusted-api.v1'), isFalse);
},
);
}
+46
View File
@@ -0,0 +1,46 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'api_service_test.dart' show StubAdapter, jsonBody;
void main() {
test('reopening genre and country menus reuses only those successful lists', () async {
final adapter = StubAdapter((_) => jsonBody([{'id': 1, 'title': 'Tag'}]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter,
baseUrl: 'http://localhost/api/');
final first = await api.genres();
first.clear();
for (var i = 0; i < 8; i++) {
expect((await api.genres()).single.id, 1);
expect((await api.countries()).single.id, 1);
}
expect(adapter.requests, hasLength(2));
});
test('concurrent identical metadata reads perform one transport operation', () async {
final gate = Completer<void>();
final entered = Completer<void>();
final adapter = StubAdapter((request) async {
if (!entered.isCompleted) entered.complete();
await gate.future;
return jsonBody({'id': 1, 'title': 'Film', 'nested': {'items': [1]}});
});
final api = ApiService(
dio: Dio()..httpClientAdapter = adapter,
baseUrl: 'http://localhost/api/',
);
final reads = List.generate(12, (_) => api.get('movie/by/1/public/'));
await entered.future;
// Drain dispatch microtasks while all response bodies remain gated.
await Future<void>.delayed(Duration.zero);
gate.complete();
final results = await Future.wait(reads);
expect(adapter.requests, hasLength(1));
results.first['nested']['items'].add(2);
expect(results.last['nested']['items'], [1]);
await api.get('movie/by/1/public/');
expect(adapter.requests, hasLength(2), reason: 'completed detail reads stay fresh');
});
}
+218
View File
@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_service_test.dart' as fixtures;
const iranBase = 'https://reelbox.dartty.top/api/';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
test(
'malicious persisted selections cannot redirect the first network request',
() async {
for (final invalid in [
'https://reelbox.dartty.top:8443/api/',
'http://reelbox.dartty.top:8443/api/',
'https://reelbox.dartty.top.evil.example:8443/api/',
'http://127.0.0.1:8123/api/',
'https://user@reelbox.dartty.top:8443/api/',
]) {
SharedPreferences.setMockInitialValues({
'reelbox.api-selection.v1': invalid,
});
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.genres();
expect(api.baseUrl, ApiService.defaultBaseUrl, reason: invalid);
expect(
adapter.requests.single.uri.origin,
'https://reelbox.dartty.top',
);
}
},
);
test('explicit choice wins over delayed cache restoration', () async {
final cacheStarted = Completer<void>();
final cache = Completer<SharedPreferences>();
var reads = 0;
final api = ApiService(
preferences: () {
if (reads++ == 0) {
cacheStarted.complete();
return cache.future;
}
return SharedPreferences.getInstance();
},
);
final restoration = api.initialize(discover: false);
await cacheStarted.future;
await api.setBaseUrl(iranBase);
cache.complete(await SharedPreferences.getInstance());
await restoration;
expect(api.baseUrl, iranBase);
});
test('actor search restores the selected origin before building its absolute route', () async {
await ApiService().setBaseUrl(iranBase);
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final restarted = ApiService(dio: Dio()..httpClientAdapter = adapter);
await restarted.actors(search: 'Example');
expect(adapter.requests.single.uri.origin, 'https://reelbox.dartty.top');
});
test('Iran catalogue preserves media URLs and never falls back on failure', () async {
const media =
'https://original-media.example:9443/video.mp4?token=Exact%2FValue&x=1';
final adapter = fixtures.StubAdapter(
(_) => fixtures.jsonBody({
'id': 7,
'title': 'Movie',
'sources': [
{'id': 3, 'url': media, 'type': 'mp4'},
],
}),
);
final api = ApiService(
baseUrl: iranBase,
dio: Dio()..httpClientAdapter = adapter,
);
expect((await api.poster(7)).sources.single.url, media);
final offline = fixtures.StubAdapter(
(r) => throw DioException(
requestOptions: r,
type: DioExceptionType.connectionError,
),
);
final failing = ApiService(
baseUrl: iranBase,
dio: Dio()..httpClientAdapter = offline,
);
await expectLater(failing.genres(), throwsA(isA<ApiException>()));
expect(failing.baseUrl, iranBase);
expect(offline.requests, hasLength(2));
expect(
offline.requests.every(
(r) => r.uri.origin == 'https://reelbox.dartty.top',
),
isTrue,
);
});
test(
'concurrent initialization shares migration without discovery or probes',
() async {
SharedPreferences.setMockInitialValues({
'reelbox.api-selection.v1': 'https://ninwinios.info/api/',
});
final cache = Completer<SharedPreferences>();
var reads = 0;
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(
dio: Dio()..httpClientAdapter = adapter,
preferences: () {
reads++;
return cache.future;
},
);
final initialization = Future.wait([api.initialize(), api.initialize()]);
cache.complete(await SharedPreferences.getInstance());
await initialization;
expect(reads, 1);
expect(adapter.requests, isEmpty);
expect(api.baseUrl, iranBase);
final restarted = ApiService();
await restarted.initialize(discover: false);
expect(restarted.baseUrl, iranBase);
},
);
test(
'unsigned advertisements are never fetched or used during initialization',
() async {
for (final advertised in [iranBase, 'http://ninwinios.info/api/']) {
final adapter = fixtures.StubAdapter(
(r) => r.uri.host == 'ninnies-server.info'
? ResponseBody.fromString(advertised, 200)
: fixtures.jsonBody([
{'id': 1, 'title': 'Drama'},
]),
);
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.initialize();
expect(api.baseUrl, ApiService.defaultBaseUrl);
expect(adapter.requests, isEmpty);
}
},
);
test('only exact HTTPS service origins and constructor loopback fixtures are accepted', () async {
for (final invalid in [
'http://reelbox.dartty.top:8443/api/',
'https://reelbox.dartty.top:8443/api/',
'https://reelbox.dartty.top:8444/api/',
'https://reelbox.dartty.top.evil.example:8443/api/',
'https://evil.reelbox.dartty.top:8443/api/',
'https://ninwinios.info:8443/api/',
'https://ninwinios.info/api/',
'https://user@reelbox.dartty.top:8443/api/',
'https://reelbox.dartty.top/api/?q=1',
'https://reelbox.dartty.top/api/#fragment',
'https://reelbox.dartty.top/api/../api/',
' $iranBase',
]) {
expect(
() => ApiService(baseUrl: invalid),
throwsA(isA<ApiException>()),
reason: invalid,
);
await expectLater(
ApiService().setBaseUrl(invalid),
throwsA(isA<ApiException>()),
reason: invalid,
);
}
for (final base in [
'http://127.0.0.1:4589/api/',
'http://localhost:4589/api/',
'http://[::1]:4589/api/',
]) {
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(
baseUrl: base,
dio: Dio()..httpClientAdapter = adapter,
);
await api.genres();
expect(adapter.requests.single.uri.origin, Uri.parse(base).origin);
}
});
test(
'explicit Iran choice persists and restores before first catalogue request',
() async {
final first = ApiService();
await first.setBaseUrl(iranBase);
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final restarted = ApiService(dio: Dio()..httpClientAdapter = adapter);
await restarted.genres();
expect(restarted.baseUrl, iranBase);
expect(adapter.requests.single.uri.origin, 'https://reelbox.dartty.top');
await restarted.initialize();
expect(
adapter.requests,
hasLength(1),
reason: 'Iran must not run original discovery',
);
await restarted.setBaseUrl(ApiService.defaultBaseUrl);
final fresh = ApiService();
await fresh.initialize(discover: false);
expect(fresh.baseUrl, iranBase);
},
);
}
+201
View File
@@ -0,0 +1,201 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/app.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/catalog.dart';
import 'package:reelbox/ui/library.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_service_test.dart' as fixtures;
import 'api_selector_test.dart' show iranBase;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
fixtures.StubAdapter adapter() => fixtures.StubAdapter(
(r) => r.uri.path.contains('/first/')
? fixtures.jsonBody({'posters': []})
: fixtures.jsonBody([]),
);
for (final locale in ['en', 'fa']) {
testWidgets(
'settings hides connection details and preserves migrated library in $locale',
(tester) async {
final network = adapter();
final state = AppState(
api: ApiService(dio: Dio()..httpClientAdapter = network),
);
addTearDown(state.dispose);
await state.initialize(discover: false);
await state.updateSettings(state.prefs.copyWith(locale: locale));
const title = TitleRecord(id: 9, title: 'Saved title');
await state.toggleSaved(title);
await state.recordWatch(
title,
sourceUrl: 'https://media.example/film.mp4?token=exact',
positionSeconds: 12,
);
await state.createCollection('Keep collection');
final storedLibrary = (await SharedPreferences.getInstance()).getString(
AppState.storageKey,
);
var notifications = 0;
state.addListener(() => notifications++);
await tester.pumpWidget(
AppScope(
state: state,
child: const MaterialApp(home: SettingsPage()),
),
);
await tester.drag(find.byType(ListView), const Offset(0, -10000));
await tester.pumpAndSettle();
expect(state.api.baseUrl, iranBase);
expect(find.text(iranBase), findsNothing);
expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing);
expect(
find.text(locale == 'en' ? 'Iran Catalogue' : 'فهرست ایران'),
findsNothing,
);
expect(find.byKey(const Key('catalog-api-selector')), findsNothing);
expect(find.text('Original API'), findsNothing);
expect(find.text('API اصلی'), findsNothing);
expect(find.text('Refresh API server'), findsNothing);
expect(notifications, 0);
expect(
network.requests,
isEmpty,
reason: 'Endpoint information must not probe or offer fallback',
);
expect(
(await SharedPreferences.getInstance()).getString(
AppState.storageKey,
),
storedLibrary,
);
final preferences = await SharedPreferences.getInstance();
await preferences.setString(
'reelbox.api-selection.v1',
'https://ninwinios.info/api/',
);
await preferences.setString(
'reelbox.trusted-api.v1',
'https://ninwinios.info/api/',
);
final restarted = AppState(
api: ApiService(dio: Dio()..httpClientAdapter = adapter()),
);
addTearDown(restarted.dispose);
await restarted.initialize(discover: false);
expect(preferences.getString(AppState.storageKey), storedLibrary);
expect(restarted.api.baseUrl, iranBase);
expect(restarted.saved.single.title, 'Saved title');
expect(
restarted.history.single.sourceUrl,
'https://media.example/film.mp4?token=exact',
);
expect(restarted.collections.single.name, 'Keep collection');
expect(restarted.locale, locale);
},
);
}
testWidgets(
'Iran-only catalogue normalizes a legacy views browse on first mount',
(tester) async {
final network = adapter();
final state = AppState(
api: ApiService(dio: Dio()..httpClientAdapter = network),
);
addTearDown(state.dispose);
await state.initialize(discover: false);
await tester.pumpWidget(
AppScope(
state: state,
child: const MaterialApp(home: BrowsePage(initialOrder: 'views')),
),
);
await tester.pumpAndSettle();
expect(find.text('Most viewed'), findsNothing);
expect(find.text('Recently added'), findsOneWidget);
final iranRequests = network.requests.where(
(r) => r.uri.host == 'reelbox.dartty.top',
);
expect(iranRequests.any((r) => r.uri.path.contains('/created/')), isTrue);
expect(iranRequests.any((r) => r.uri.path.contains('/views/')), isFalse);
await tester.tap(find.byTooltip('Sort titles'));
await tester.pumpAndSettle();
expect(find.text('Most viewed'), findsNothing);
await tester.tap(find.text('Release year'));
await tester.pumpAndSettle();
await tester.pumpWidget(
AppScope(
state: state,
child: const MaterialApp(home: DiscoveryDirectoryPage()),
),
);
await tester.pumpAndSettle();
final list = tester.widget<ListView>(find.byType(ListView));
final children =
(list.childrenDelegate as SliverChildListDelegate).children;
final titles = children
.whereType<ListTile>()
.map((tile) => (tile.title as Text).data)
.toList();
expect(titles, isNot(contains('Most-viewed movies')));
expect(titles, isNot(contains('Most-viewed series')));
},
);
testWidgets(
'catalogue tabs survive simplified settings without changing local library',
(tester) async {
final network = adapter();
final state = AppState(
api: ApiService(dio: Dio()..httpClientAdapter = network),
);
addTearDown(state.dispose);
await state.initialize(discover: false);
await state.updateSettings(state.prefs.copyWith(introCompleted: true));
await state.toggleSaved(const TitleRecord(id: 9, title: 'Saved title'));
await tester.pumpWidget(MyApp(state: state));
await tester.pumpAndSettle();
for (final tab in ['Browse', 'Search', 'Library', 'More']) {
await tester.tap(find.widgetWithText(NavigationDestination, tab));
await tester.pumpAndSettle();
}
await tester.tap(find.text('Settings'));
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -10000));
await tester.pumpAndSettle();
expect(find.text(iranBase), findsNothing);
expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing);
expect(find.byKey(const Key('catalog-api-selector')), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
final iranRequests = network.requests
.where((r) => r.uri.origin == 'https://reelbox.dartty.top')
.toList();
expect(iranRequests.any((r) => r.uri.path.contains('/first/')), isTrue);
expect(
iranRequests.any((r) => r.uri.path.contains('/movie/by/filtres/')),
isTrue,
);
expect(
iranRequests.any(
(r) => r.uri.queryParameters['action'] == 'search-trand',
),
isTrue,
);
expect(state.saved.single.title, 'Saved title');
await tester.tap(find.widgetWithText(NavigationDestination, 'Library'));
await tester.pumpAndSettle();
expect(find.text('Watchlist (1)'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
}
+454
View File
@@ -0,0 +1,454 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
class StubAdapter implements HttpClientAdapter {
final FutureOr<ResponseBody> Function(RequestOptions) handler;
final requests = <RequestOptions>[];
final bodies = <String>[];
StubAdapter(this.handler);
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? body,
Future<void>? cancelFuture,
) async {
requests.add(options);
if (body != null) {
bodies.add(utf8.decode(await body.expand((chunk) => chunk).toList()));
}
return handler(options);
}
@override
void close({bool force = false}) {}
}
ResponseBody jsonBody(dynamic data, {int status = 200}) =>
ResponseBody.fromString(
jsonEncode(data),
status,
headers: {
Headers.contentTypeHeader: ['application/json'],
},
);
void main() {
test(
'observed search IDs take precedence over unrelated posted_id values',
() {
final api = ApiService();
final raw = jsonDecode(
File('test/fixtures/search.json').readAsStringSync(),
);
final titles = api.decodeTitles(raw);
expect(titles.map((title) => title.id).toList(), [32789, 18602]);
expect(titles.first.year, 2026);
expect(titles.first.imdb, 10);
expect(titles.first.raw['posted_id'], '2324949');
expect(titles.first.image, raw[0]['image']);
for (final key in ['posters', 'data', 'result', 'items']) {
expect(api.decodeTitles({key: raw}).map((title) => title.id), [
32789,
18602,
]);
}
expect(api.decodeTitles(null), isEmpty);
expect(
api.decodeTitles([
null,
12,
{},
{'id': '1', 'title': ''},
]),
isEmpty,
);
expect(
() => api.decodeTitles({'unexpected': 'shape'}),
throwsA(isA<ApiException>()),
);
},
);
test('search, recommendation, trending and actors use observed queries; removed public lists never request', () async {
final raw = jsonDecode(
File('test/fixtures/search.json').readAsStringSync(),
);
final adapter = StubAdapter((request) {
if (request.uri.queryParameters['action'] == 'search-trand') {
return jsonBody([
'Avatar',
{'title': 'Dune'},
{'q': 'Lost'},
null,
{},
]);
}
return jsonBody(raw);
});
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
expect((await api.search('A & ب', page: 2)).first.id, 32789);
await api.search('Avatar', english: true);
await api.recommended(3, page: 2);
await api.bestRandom();
await api.bestRandom(byYear: true, page: 3);
expect(await api.trending(), ['Avatar', 'Dune', 'Lost']);
await api.actors(page: 2);
await api.actors(search: ' Tom & ب ', page: 3);
expect(await api.playlists(page: 2), isEmpty);
expect(await api.playlistItems(3328, page: 3), isEmpty);
expect(adapter.requests.map((r) => r.uri.path).toList(), [
'/api-user/newapi/like.php',
'/api-user/newapi/like.php',
'/api-user/newapi/filter.php',
'/api-user/newapi/like.php',
'/api-user/newapi/like.php',
'/api-user/newapi/like.php',
'/api-user/newapi/like.php',
'/api-user/filmahang/star.php',
]);
expect(adapter.requests.map((r) => r.uri.queryParameters).toList(), [
{'action': 'search-movie', 'q': 'A & ب', 'pageno': '2'},
{'action': 'search-movie-en', 'q': 'Avatar', 'pageno': '1'},
{'action': 'pishnahad', 'garid': '3', 'pageno': '2'},
{'action': 'best-rand-top', 'pageno': '1'},
{'action': 'best-rand-year', 'pageno': '3'},
{'action': 'search-trand', 'pageno': '1'},
{'action': 'all-actor', 'pageno': '2'},
{'action': 'starsearch', 'q': 'Tom & ب', 'pageno': '3'},
]);
expect(
adapter.requests.every((request) => request.method == 'GET'),
isTrue,
);
await expectLater(
api.playlists(mine: true, userId: '1'),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, hasLength(8));
});
test('real curated parallel arrays flatten and rewrite allowlisted legacy links safely', () async {
final raw = jsonDecode(
File('test/fixtures/curated.json').readAsStringSync(),
);
final adapter = StubAdapter(
(request) => request.uri.path.endsWith('filter.php')
? jsonBody(
jsonDecode(File('test/fixtures/search.json').readAsStringSync()),
)
: jsonBody(raw),
);
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
final links = await api.curatedLinks();
expect(links.single['title'], 'ترسناک');
expect(
links.single['link'],
'https://reelbox.dartty.top/api-user/newapi/filter.php?action=pishnahad&garid=1&pageno=',
);
await api.curatedLinks(best: true);
final titles = await api.curatedContent(
links.single['link'] as String,
page: 3,
);
expect(titles.first.id, 32789);
expect(adapter.requests[0].uri.path, '/api-user/newapi/pishnahad.php');
expect(adapter.requests[1].uri.path, '/api-user/newapi/bartar.php');
expect(adapter.requests[2].uri.queryParameters['pageno'], '3');
expect(adapter.requests[2].uri.queryParametersAll['pageno'], ['3']);
await expectLater(
api.curatedContent('https://evil.example/api-user/newapi/filter.php'),
throwsA(isA<ApiException>()),
);
});
test(
'public catalog endpoints use public tokens and preserve zero-based pages',
() async {
final movie = jsonDecode(
File('test/fixtures/movie.json').readAsStringSync(),
);
final season = jsonDecode(
File('test/fixtures/season.json').readAsStringSync(),
);
final adapter = StubAdapter((request) {
final path = request.uri.path;
if (path.contains('/first/')) {
return jsonBody({
'posters': [movie],
'genres': [],
});
}
if (path.contains('/all/')) {
return jsonBody([
{'id': 3, 'title': 'انیمیشن + انیمه'},
]);
}
if (path.contains('/season/')) return jsonBody([season]);
if (path.contains('/movie/by/35721/')) return jsonBody(movie);
if (path.contains('/channel/by/17/')) {
return jsonBody({...movie, 'id': 17, 'type': 'channel'});
}
return jsonBody([movie]);
});
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
expect((await api.home())['posters'][0]['id'], 35721);
expect((await api.genres()).single.id, 3);
expect((await api.countries()).single.id, 3);
expect(await api.categories(), isEmpty);
expect((await api.catalog()).single.id, 35721);
await api.catalog(kind: 'serie', genre: 3, order: 'imdb', page: 2);
await api.catalog(
kind: 'mixed',
genre: 2,
country: 9,
order: 'year',
page: 1,
);
await api.catalog(country: 9);
expect(await api.channels(category: 17, country: 1, page: 2), isEmpty);
expect((await api.poster(35721)).id, 35721);
await expectLater(api.channel(17), throwsA(isA<ApiException>()));
expect((await api.seasons(1003)).single.episodes.first.id, 72819);
await api.actorTitles(582);
await api.roles(35721);
await api.subtitles('movie', 35721);
await api.subtitles('episode', 72819);
await api.comments(35721);
const k = 'public';
expect(adapter.requests.map((r) => r.uri.path).toList(), [
'/api/first/$k/',
'/api/genre/all/$k/',
'/api/country/all/$k/',
'/api/movie/by/filtres/0/created/0/$k/',
'/api/serie/by/filtres/3/imdb/2/$k/',
'/api/poster/by/filtres/2/9/year/1/$k/',
'/api/poster/by/filtres/0/9/created/0/$k/',
'/api/movie/by/35721/$k/',
'/api/season/by/serie/1003/$k/',
'/api/movie/by/actor/582/$k/',
'/api/role/by/poster/35721/$k/',
'/api/subtitles/by/movie/35721/$k/',
'/api/subtitles/by/episode/72819/$k/',
]);
},
);
test(
'invalid catalog kinds and subtitle kinds cannot inject routes',
() async {
final adapter = StubAdapter((_) => jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await expectLater(
api.catalog(kind: '../user'),
throwsA(isA<ApiException>()),
);
await expectLater(
api.subtitles('../user', 1),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, isEmpty);
},
);
test(
'rejects untrusted, insecure and ambiguous routes before transport',
() async {
final adapter = StubAdapter((_) => jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
for (final path in [
'https://attacker.example/api/',
'http://ninwinios.info/api/first/',
'//attacker.example/api/',
'https://ninwinios.info@attacker.example/api/',
'https://user:secret@ninwinios.info/api/',
'https://ninwinios.info:444/api/',
'../private',
'/api/../private',
'/api/%2e%2e/private',
r'\evil\api',
'genre/all/#secret',
'file:///api/first',
]) {
await expectLater(
api.get(path),
throwsA(isA<ApiException>()),
reason: path,
);
}
expect(adapter.requests, isEmpty);
},
);
test(
'rewrites only allowlisted legacy public links to HTTPS current host',
() async {
final adapter = StubAdapter((_) => jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await api.get(
'http://simbaapp.info/api-user/newapi/filter.php?action=best&x=a%2Fb',
);
expect(
adapter.requests.single.uri.toString(),
'https://reelbox.dartty.top/api-user/newapi/filter.php?action=best&x=a%2Fb',
);
},
);
test(
'base updates are atomic, allow HTTPS service and local HTTP tests only',
() async {
final api = ApiService();
for (final url in [
'http://ninwinios.info/api/',
'https://attacker.example/api/',
'https://user:secret@ninwinios.info/api/',
'https://ninwinios.info/other/',
]) {
await expectLater(api.setBaseUrl(url), throwsA(isA<ApiException>()));
expect(api.baseUrl, ApiService.defaultBaseUrl);
}
await api.setBaseUrl('http://127.0.0.1:8080/api');
expect(api.baseUrl, 'http://127.0.0.1:8080/api/');
expect(
() => ApiService(baseUrl: 'http://evil.example/api/'),
throwsA(isA<ApiException>()),
);
},
);
test(
'generic POST is form encoded and never follows credential redirects',
() async {
final adapter = StubAdapter((request) {
expect(request.followRedirects, isFalse);
expect(request.maxRedirects, 0);
return ResponseBody.fromString(
'',
307,
headers: {
'location': ['https://evil.example/collect'],
},
);
});
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
try {
await api.post('test-only', {
'password': 'never-log-me',
'comment': 'a & ب',
});
fail('Redirect must not succeed');
} on ApiException catch (error) {
expect(error.statusCode, 307);
expect(error.toString(), isNot(contains('never-log-me')));
expect(error.toString(), isNot(contains('test-only')));
}
expect(adapter.requests, hasLength(1));
expect(
adapter.requests.single.contentType,
Headers.formUrlEncodedContentType,
);
expect(Uri.splitQueryString(adapter.bodies.single), {
'password': 'never-log-me',
'comment': 'a & ب',
});
},
);
test(
'removed discovery ignores advertisements and inherits no credentials',
() async {
for (final candidate in [
'http://ninwinios.info/api/',
'https://attacker.example/api/',
]) {
final adapter = StubAdapter((request) {
if (request.uri.host == 'ninnies-server.info') {
return ResponseBody.fromString(candidate, 200);
}
return jsonBody([
{'id': 1, 'title': 'ترسناک'},
]);
});
final dio = Dio(BaseOptions(headers: {'Authorization': 'secret'}))
..httpClientAdapter = adapter;
final api = ApiService(dio: dio);
await api.initialize();
expect(api.baseUrl, ApiService.defaultBaseUrl);
expect(adapter.requests, isEmpty);
await api.genres();
expect(
adapter.requests.first.uri.toString(),
'${ApiService.iranBaseUrl}genre/all/public/',
);
expect(
adapter.requests.every(
(request) => !request.headers.containsKey('Authorization'),
),
isTrue,
);
expect(
adapter.requests.any(
(request) => request.uri.host == 'attacker.example',
),
isFalse,
);
}
},
);
test(
'HTML, malformed JSON, timeout and HTTP denials produce safe errors',
() async {
for (final status in [200, 401, 403, 500]) {
final adapter = StubAdapter(
(_) =>
ResponseBody.fromString('<html>private details</html>', status),
);
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
await expectLater(
api.get('private/password-secret'),
throwsA(
isA<ApiException>().having(
(error) => error.toString(),
'sanitized message',
isNot(contains('password-secret')),
),
),
);
}
final adapter = StubAdapter(
(request) => throw DioException.connectionTimeout(
timeout: const Duration(seconds: 1),
requestOptions: request,
),
);
await expectLater(
ApiService(dio: Dio()..httpClientAdapter = adapter).get('secret'),
throwsA(isA<ApiException>()),
);
},
);
test(
'HTTPS relative route uses timeouts, public marker and JSON decoding',
() async {
final adapter = StubAdapter(
(_) => jsonBody([
{'id': 1, 'title': 'ترسناک'},
]),
);
final dio = Dio()..httpClientAdapter = adapter;
final api = ApiService(dio: dio);
await api.initialize(discover: false);
final result = await api.get('genre/all/${ApiService.sharedKey}/');
expect(result[0]['id'], 1);
expect(ApiService.sharedKey, 'public');
expect(api.baseUrl, ApiService.iranBaseUrl);
expect(api.hostRoot, 'https://reelbox.dartty.top');
final request = adapter.requests.single;
expect(
request.uri.toString(),
'${api.baseUrl}genre/all/${ApiService.sharedKey}/',
);
expect(request.connectTimeout, isNotNull);
expect(request.receiveTimeout, isNotNull);
expect(request.sendTimeout, isNotNull);
expect(request.followRedirects, isFalse);
},
);
}
+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(),
);
});
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:reelbox/ui/common.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
testWidgets(
'data saver never builds network images and retains artwork placeholder',
(tester) async {
SharedPreferences.setMockInitialValues({});
final state = AppState();
await state.updateSettings(state.prefs.copyWith(dataSaver: true));
await tester.pumpWidget(
AppScope(
state: state,
child: const MaterialApp(
home: Scaffold(
body: MediaArtwork(
url: 'https://example.test/poster.jpg',
width: 100,
height: 140,
),
),
),
),
);
expect(find.byType(Image), findsNothing);
expect(find.byIcon(Icons.local_movies_outlined), findsOneWidget);
final size = tester.getSize(find.byType(MediaArtwork));
expect(size, const Size(100, 140));
await tester.pumpWidget(const SizedBox());
state.dispose();
},
);
}
+66
View File
@@ -0,0 +1,66 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/bootstrap.dart';
void main() {
testWidgets('shows initialization progress before opening the real app', (
tester,
) async {
final ready = Completer<void>();
var calls = 0;
await tester.pumpWidget(
ReelboxBootstrap(
initialize: () {
calls++;
return ready.future;
},
builder: (_) => const MaterialApp(home: Text('Application ready')),
),
);
expect(calls, 1);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Application ready'), findsNothing);
ready.complete();
await tester.pumpAndSettle();
expect(find.text('Application ready'), findsOneWidget);
});
testWidgets(
'initialization failure is recoverable and never displays raw vendor text',
(tester) async {
var calls = 0;
await tester.pumpWidget(
ReelboxBootstrap(
initialize: () async {
if (calls++ == 0) throw StateError('نورتون https://t.me/support');
},
builder: (_) => const MaterialApp(home: Text('Application ready')),
),
);
await tester.pumpAndSettle();
expect(find.text('Try again'), findsOneWidget);
expect(find.textContaining('نورتون'), findsNothing);
expect(find.textContaining('t.me'), findsNothing);
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(calls, 2);
expect(find.text('Application ready'), findsOneWidget);
},
);
testWidgets('late initialization completion does not update disposed UI', (
tester,
) async {
final ready = Completer<void>();
await tester.pumpWidget(
ReelboxBootstrap(
initialize: () => ready.future,
builder: (_) => const MaterialApp(home: Text('Application ready')),
),
);
await tester.pumpWidget(const SizedBox.shrink());
ready.complete();
await tester.pump();
expect(tester.takeException(), isNull);
});
}
+294
View File
@@ -0,0 +1,294 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/cast_media_share.dart';
import 'package:reelbox/media/segmented_download.dart';
void main() {
final original = <String, dynamic>{
'url': 'https://authorized.invalid/synthetic.mp4',
'title': 'Synthetic fixture (not a decoder test)',
'contentType': 'video/mp4',
'positionMs': 1250,
'subtitleUrl': 'https://authorized.invalid/not-webvtt.srt',
};
late Directory root;
setUp(() async {
root = await Directory.systemTemp.createTemp('cast-share-test-');
});
tearDown(() async {
await root.delete(recursive: true);
});
test('explicit local-file lease serves only the exact file and revokes without deleting media', () async {
// Synthetic bytes exercise HTTP ownership, not audiovisual decoding.
final video = await File('${root.path}/synthetic.mp4')
.writeAsBytes([0, 1, 2, 3, 4, 5]);
await File('${root.path}/private.txt').writeAsString('do not share');
final lease = await CastMediaShare.prepare(
payload: original,
localFile: video.path,
bindAddress: InternetAddress.loopbackIPv4,
);
addTearDown(lease.close);
final url = Uri.parse(lease.payload['url'] as String);
expect(url.scheme, 'http');
expect(url.pathSegments.first.length, greaterThanOrEqualTo(32));
expect((await fetch(url)).bytes, [0, 1, 2, 3, 4, 5]);
expect((await fetch(url, range: 'bytes=2-4')).bytes, [2, 3, 4]);
for (final forbidden in [
url.resolve('./'),
url.resolve('private.txt'),
url.resolve('package.json'),
url.replace(path: '/media.mp4'),
]) {
expect((await fetch(forbidden)).status, 404);
}
expect(lease.payload['title'], original['title']);
expect(lease.payload['positionMs'], 1250);
expect(original['url'], startsWith('https://'));
await Future.wait([lease.close(), lease.close()]);
await expectLater(fetch(url), throwsA(isA<SocketException>()));
expect(await video.readAsBytes(), [0, 1, 2, 3, 4, 5]);
expect(
await File('${root.path}/private.txt').readAsString(),
'do not share',
);
});
test('remote video plus selected text serves actual UTF8 WebVTT only and deletes disposable files', () async {
final before = await sessionTemps();
final lease = await CastMediaShare.prepare(
payload: original,
subtitleText: '1\n00:00:01,000 --> 00:00:02,000\nسلام',
bindAddress: InternetAddress.loopbackIPv4,
);
addTearDown(lease.close);
expect(lease.payload['url'], original['url']);
final url = Uri.parse(lease.payload['subtitleUrl'] as String);
final response = await fetch(url);
expect(response.type, startsWith('text/vtt'));
expect(
utf8.decode(response.bytes),
'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nسلام\n\n',
);
final owned = (await sessionTemps()).difference(before);
expect(owned, hasLength(1));
expect(owned.single.startsWith(root.path), isFalse);
expect(
(await Directory(owned.single).list().toList()).single.path,
endsWith('.vtt'),
);
await lease.close();
expect(await sessionTemps(), before);
await expectLater(fetch(url), throwsA(isA<SocketException>()));
});
test('local file and selected text share separate actual VTT and media resources', () async {
final video = await File('${root.path}/synthetic.webm')
.writeAsString('synthetic media fixture');
final lease = await CastMediaShare.prepare(
payload: original,
localFile: video.path,
subtitleText: 'WEBVTT\n\n00:00.000 --> 00:01.000\n<b>caption</b>',
bindAddress: InternetAddress.loopbackIPv4,
);
addTearDown(lease.close);
final videoUri = Uri.parse(lease.payload['url'] as String);
final subUri = Uri.parse(lease.payload['subtitleUrl'] as String);
expect(subUri.pathSegments.first, videoUri.pathSegments.first);
expect(subUri, isNot(videoUri));
expect(utf8.decode((await fetch(subUri)).bytes), contains('\ncaption\n'));
await lease.close();
expect(await video.exists(), isTrue);
});
test('invalid local selections fail rather than quietly loading the remote fallback', () async {
final empty = await File('${root.path}/empty.mp4').create();
final unsupported = await File('${root.path}/notes.txt')
.writeAsString('not video');
for (final path in [
'relative.mp4',
'${root.path}/missing.mp4',
root.path,
empty.path,
unsupported.path,
]) {
await expectLater(
CastMediaShare.prepare(
payload: original,
localFile: path,
bindAddress: InternetAddress.loopbackIPv4,
),
throwsA(
anyOf(
isA<ArgumentError>(),
isA<FileSystemException>(),
isA<UnsupportedError>(),
),
),
);
}
});
test('unsupported selected subtitles and failed server preparation leave no temp data', () async {
final before = await sessionTemps();
await expectLater(
CastMediaShare.prepare(
payload: original,
subtitleText: '<tt>unsupported</tt>',
bindAddress: InternetAddress.loopbackIPv4,
),
throwsFormatException,
);
expect(await sessionTemps(), before);
await expectLater(
CastMediaShare.prepare(
payload: original,
subtitleText: '1\n00:00:00,000 --> 00:00:01,000\nSynthetic',
bindAddress: InternetAddress.loopbackIPv4,
advertisedHost: '8.8.8.8',
),
throwsArgumentError,
);
expect(await sessionTemps(), before);
});
test('complete indexed package shares only rewritten manifests and validated resources', () async {
final package = await syntheticPackage(root);
await File('${package.directory}/private.txt').writeAsString('private');
final lease = await CastMediaShare.prepare(
payload: original,
localFile: package.path,
bindAddress: InternetAddress.loopbackIPv4,
);
addTearDown(lease.close);
final uri = Uri.parse(lease.payload['url'] as String);
expect(uri.scheme, 'http');
expect(
utf8.decode((await fetch(uri)).bytes),
package.manifests[package.manifest],
);
expect(
utf8.decode((await fetch(uri.resolve('playlist_1.m3u8'))).bytes),
contains('resource_0.ts'),
);
expect(
utf8.decode((await fetch(uri.resolve('resource_0.ts'))).bytes),
'synthetic resource',
);
expect((await fetch(uri.resolve('package.json'))).status, 404);
expect((await fetch(uri.resolve('private.txt'))).status, 404);
await lease.close();
expect(await File(package.path).exists(), true);
expect(await File('${package.directory}/resource_0.ts').exists(), true);
});
test('damaged package is not advertised as Cast-ready', () async {
final package = await syntheticPackage(root);
await File('${package.directory}/resource_0.ts').writeAsString('corrupt');
await expectLater(
CastMediaShare.prepare(
payload: original,
localFile: package.path,
bindAddress: InternetAddress.loopbackIPv4,
),
throwsStateError,
);
});
test(
'remote-only lease copies payload and never passes through an SRT URL',
() async {
final lease = await CastMediaShare.prepare(payload: original);
expect(lease.payload, {...original}..remove('subtitleUrl'));
expect(original['subtitleUrl'], endsWith('.srt'));
await lease.close();
await lease.close();
},
);
test(
'public injectable lease constructor closes its resource only once',
() async {
var closed = 0;
final lease = CastMediaLease(
payload: original,
onClose: () async {
closed++;
},
);
await Future.wait([lease.close(), lease.close()]);
expect(closed, 1);
expect(lease.payload, original);
expect(identical(lease.payload, original), isFalse);
},
);
}
Future<OfflinePackage> syntheticPackage(Directory root) async {
final dir = await Directory('${root.path}/pkg').create();
final bytes = utf8.encode('synthetic resource');
final package = OfflinePackage(
id: 'pkg',
url: 'https://authorized.invalid/root.m3u8',
directory: dir.path,
manifest: 'playlist_0.m3u8',
resources: [
OfflineResource(
url: 'https://authorized.invalid/segment.ts',
filename: 'resource_0.ts',
bytes: bytes.length,
digest: sha256.convert(bytes).toString(),
),
],
manifests: {
'playlist_0.m3u8':
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100\nplaylist_1.m3u8\n',
'playlist_1.m3u8': '#EXTM3U\n#EXTINF:1,\nresource_0.ts\n#EXT-X-ENDLIST\n',
},
status: 'complete',
);
await File('${dir.path}/resource_0.ts').writeAsBytes(bytes);
for (final entry in package.manifests.entries) {
await File('${dir.path}/${entry.key}').writeAsString(entry.value);
}
await File('${dir.path}/package.json')
.writeAsString(jsonEncode(package.toJson()));
return package;
}
Future<({int status, List<int> bytes, String type})> fetch(
Uri uri, {
String? range,
}) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 2);
try {
final request = await client.getUrl(uri);
if (range != null) request.headers.set('range', range);
final response = await request.close();
final bytes = await response.fold<List<int>>([], (a, b) => a..addAll(b));
return (
status: response.statusCode,
bytes: bytes,
type: response.headers.value('content-type') ?? '',
);
} finally {
client.close(force: true);
}
}
Future<Set<String>> sessionTemps() async =>
(await Directory.systemTemp
.list(followLinks: false)
.where(
(f) =>
f is Directory &&
f.uri.pathSegments
.where((s) => s.isNotEmpty)
.last
.startsWith('reelbox-cast-'),
)
.toList())
.map((f) => f.path)
.toSet();
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/ui/catalog.dart';
import 'catalog_widget_test.dart' show CatalogApi, mountCatalog;
class DiscoveryApi extends CatalogApi {
@override
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async => [];
@override
Future<List<Map<String, dynamic>>> playlists({
bool mine = false,
String userId = '',
int page = 1,
}) async => [];
@override
Future<List<Map<String, dynamic>>> curatedLinks({bool best = false}) async =>
[];
@override
Future<List<TitleRecord>> bestRandom({
bool byYear = false,
int page = 1,
}) async => [];
}
Future<void> openDirectory(WidgetTester tester) async {
await tester.tap(find.byTooltip('Explore more'));
await tester.pumpAndSettle();
}
void main() {
for (final preset in [
('Best titles', 'mixed', 0, 'rating'),
('Newest titles', 'mixed', 0, 'created'),
('Popular titles', 'mixed', 0, 'rating'),
('Top 250 movies', 'movie', 0, 'imdb'),
('Top 250 series', 'serie', 0, 'imdb'),
('Korean series', 'serie', 36, 'created'),
('Chinese / Japanese series', 'serie', 37, 'created'),
('Turkish series', 'serie', 35, 'created'),
('Indian series', 'serie', 38, 'created'),
('Indian movies', 'movie', 38, 'created'),
('Most-viewed movies', 'movie', 0, 'views'),
('Most-viewed series', 'serie', 0, 'views'),
]) {
testWidgets(
'${preset.$1} ${preset.$4 == 'views' ? 'is unavailable on Iran' : 'uses the Iran catalog contract'}',
(tester) async {
final api = DiscoveryApi();
await mountCatalog(tester, api, const DiscoverPage());
await openDirectory(tester);
if (preset.$4 == 'views') {
final list = tester.widget<ListView>(find.byType(ListView));
final children =
(list.childrenDelegate as SliverChildListDelegate).children;
expect(
children.whereType<ListTile>().map(
(tile) => (tile.title as Text).data,
),
isNot(contains(preset.$1)),
);
expect(api.catalogCalls, isEmpty);
} else {
await tester.scrollUntilVisible(find.text(preset.$1), 300);
await tester.ensureVisible(find.text(preset.$1));
await tester.pumpAndSettle();
await tester.tap(find.text(preset.$1));
await tester.pumpAndSettle();
expect(api.catalogCalls, [
for (final page in [0, 1, 2])
{
'kind': preset.$2,
'genre': preset.$3,
'country': 0,
'order': preset.$4,
'page': page,
},
]);
}
},
);
}
for (final entry in [
('Actors', ActorsPage),
('Editor picks', CuratedPage),
('Best collections', CuratedPage),
('Best by year', RecommendationsPage),
]) {
testWidgets('Discover reaches ${entry.$1}', (tester) async {
await mountCatalog(tester, DiscoveryApi(), const DiscoverPage());
await openDirectory(tester);
await tester.scrollUntilVisible(find.text(entry.$1), 300);
await tester.ensureVisible(find.text(entry.$1));
await tester.pumpAndSettle();
await tester.tap(find.text(entry.$1));
await tester.pumpAndSettle();
expect(find.byType(entry.$2), findsOneWidget);
if (entry.$1 == 'Best by year') {
expect(
tester
.widget<RecommendationsPage>(find.byType(RecommendationsPage))
.byYear,
isTrue,
);
}
});
}
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/media_platform.dart';
import 'package:reelbox/ui/catalog.dart';
import 'catalog_widget_test.dart' show CatalogApi, mountCatalog;
void main() {
testWidgets(
'source has a visible external-player button independent of preference',
(tester) async {
final calls = <MethodCall>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
calls.add(call);
return call.method == 'openExternal';
},
);
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
);
});
final state = await mountCatalog(
tester,
CatalogApi(),
const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')),
);
addTearDown(state.dispose);
expect(state.prefs.externalPlayer, false);
final button = find.widgetWithText(TextButton, 'External player').first;
await tester.ensureVisible(button);
await tester.tap(button);
await tester.pumpAndSettle();
final call = calls.singleWhere((call) => call.method == 'openExternal');
expect(call.arguments['url'], 'https://media.example/movie.mp4');
expect(call.arguments['packageName'], 'ask');
expect(state.prefs.externalPlayer, false);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets(
'external-player failure is recoverable without opening an internal decoder',
(tester) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method == 'openExternal') {
throw PlatformException(code: 'NO_PLAYER');
}
return null;
},
);
addTearDown(() {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
);
});
final state = await mountCatalog(
tester,
CatalogApi(),
const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')),
);
addTearDown(state.dispose);
final button = find.widgetWithText(TextButton, 'External player').first;
await tester.ensureVisible(button);
await tester.tap(button);
await tester.pump();
expect(
find.text('No external player could open this source.'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/ui/catalog.dart';
import 'catalog_widget_test.dart' show CatalogApi, mountCatalog;
class CollisionApi extends CatalogApi {
int channelCalls = 0;
int posterCalls = 0;
@override
Future<TitleRecord> channel(int id) async {
channelCalls++;
return TitleRecord(id: id, title: 'Channel fixture', type: 'channel');
}
@override
Future<TitleRecord> poster(int id) {
posterCalls++;
return super.poster(id);
}
}
void main() {
testWidgets(
'legacy channel detail is blocked without changing a same-ID saved movie',
(tester) async {
final api = CollisionApi();
final state = await mountCatalog(
tester,
api,
const TitlePage(
title: TitleRecord(id: 7, title: 'Channel fixture', type: 'channel'),
),
);
addTearDown(state.dispose);
const movie = TitleRecord(id: 7, title: 'Movie fixture');
await state.toggleSaved(movie);
await tester.pumpAndSettle();
expect(find.text('This title is unavailable.'), findsOneWidget);
expect(find.text('Add to watchlist'), findsNothing);
expect(find.text('In watchlist'), findsNothing);
expect(api.channelCalls, 0);
expect(api.posterCalls, 0);
expect(state.saved.single.toJson(), movie.toJson());
expect(state.hasSaved(7), isTrue);
expect(state.hasSaved(7, kind: 'channel'), isFalse);
expect(find.text('Try again'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
},
);
for (final series in [false, true]) {
testWidgets(
'${series ? 'series' : 'movie'} detail keeps allowed watchlist identity after a blocked channel mutation',
(tester) async {
final api = CollisionApi()..series = series;
final state = await mountCatalog(
tester,
api,
TitlePage(
title: TitleRecord(
id: 7,
title: 'Allowed fixture',
type: series ? 'serie' : 'movie',
),
),
);
addTearDown(state.dispose);
await state.toggleSaved(
const TitleRecord(id: 7, title: 'Channel fixture', type: 'channel'),
);
await tester.pumpAndSettle();
expect(state.saved, isEmpty);
expect(find.text('Add to watchlist'), findsOneWidget);
await tester.ensureVisible(find.text('Add to watchlist'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add to watchlist'));
await tester.pumpAndSettle();
expect(state.saved.single.id, 7);
expect(state.saved.single.isSeries, series);
expect(state.hasSaved(7), isTrue);
expect(state.hasSaved(7, kind: 'channel'), isFalse);
expect(find.text('In watchlist'), findsOneWidget);
await tester.ensureVisible(find.text('In watchlist'));
await tester.pumpAndSettle();
await tester.tap(find.text('In watchlist'));
await tester.pumpAndSettle();
expect(state.saved, isEmpty);
expect(find.text('Add to watchlist'), findsOneWidget);
expect(api.posterCalls, 1);
expect(api.channelCalls, 0);
await tester.pumpWidget(const SizedBox());
},
);
}
}
+202
View File
@@ -0,0 +1,202 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:reelbox/ui/catalog.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_service_test.dart' show StubAdapter, jsonBody;
Future<AppState> mountPaging(
WidgetTester tester,
ApiService api,
Widget page, {
bool settle = true,
}) async {
SharedPreferences.setMockInitialValues({});
final state = AppState(
api: api,
storage: await SharedPreferences.getInstance(),
);
addTearDown(state.dispose);
await state.initialize(discover: false);
await tester.pumpWidget(
AppScope(
state: state,
child: MaterialApp(home: page),
),
);
if (settle) await tester.pumpAndSettle();
return state;
}
Map<String, dynamic> titleRow(int id, String kind) => {
'id': id,
'type': kind,
'title': '$kind $id',
};
void main() {
testWidgets(
'failed raw page retries the same cursor without losing prior titles',
(tester) async {
final pages = <int>[];
var failed = false;
final adapter = StubAdapter((request) {
if (request.uri.path.contains('/all/')) return jsonBody([]);
final page = int.parse(request.uri.pathSegments[7]);
pages.add(page);
if (page == 1 && !failed) {
failed = true;
return jsonBody({}, status: 503);
}
return jsonBody(page < 2 ? [titleRow(30 + page, 'movie')] : []);
});
await mountPaging(
tester,
ApiService(dio: Dio()..httpClientAdapter = adapter),
const BrowsePage(initialCountry: 9),
);
// Short pages now prefetch automatically; a failure still retains its cursor.
expect(pages, [0, 1]);
expect(find.text('movie 30'), findsOneWidget);
await tester.ensureVisible(find.text('Try again'));
await tester.pumpAndSettle();
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(find.text('movie 30'), findsOneWidget);
expect(find.text('movie 31'), findsOneWidget);
expect(pages, [0, 1, 1, 2]);
},
);
testWidgets('filter changes reject stale raw pages', (tester) async {
final delayed = Completer<ResponseBody>();
addTearDown(() {
if (!delayed.isCompleted) delayed.complete(jsonBody([]));
});
var pendingRequested = false;
final adapter = StubAdapter((request) {
final path = request.uri.path;
if (path.contains('/all/')) return jsonBody([]);
if (path.contains('/movie/')) {
if (path.contains('/created/1/')) {
pendingRequested = true;
return delayed.future;
}
return jsonBody([titleRow(40, 'movie')]);
}
return jsonBody(
path.contains('/created/0/') ? [titleRow(50, 'serie')] : [],
);
});
await mountPaging(
tester,
ApiService(dio: Dio()..httpClientAdapter = adapter),
const BrowsePage(),
settle: false,
);
for (var frame = 0; frame < 24 && !pendingRequested; frame++) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(pendingRequested, true);
await tester.ensureVisible(find.text('Series'));
await tester.tap(find.text('Series'));
await tester.pumpAndSettle();
delayed.complete(jsonBody([titleRow(41, 'movie')]));
await tester.pumpAndSettle();
expect(find.text('serie 50'), findsOneWidget);
expect(find.text('movie 41'), findsNothing);
expect(find.text('movie 40'), findsNothing);
});
testWidgets('duplicate-only title pages do not truncate later results', (
tester,
) async {
final pages = <int>[];
final adapter = StubAdapter((request) {
final query = request.uri.queryParameters;
if (['search-trand', 'best-rand-top'].contains(query['action'])) {
return jsonBody([]);
}
final page = int.parse(query['pageno']!);
pages.add(page);
return jsonBody(switch (page) {
1 || 2 => [titleRow(20, 'movie')],
3 => [titleRow(21, 'movie')],
_ => [],
});
});
await mountPaging(
tester,
ApiService(dio: Dio()..httpClientAdapter = adapter),
const SearchPage(),
);
await tester.enterText(find.byType(TextField).first, 'movie');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.text('movie 20'), findsOneWidget);
expect(find.text('movie 21'), findsOneWidget);
expect(pages, [1, 2, 3, 4]);
expect(find.text('Load more'), findsNothing);
});
testWidgets(
'duplicate-only actor directory pages retain server continuation',
(tester) async {
final pages = <int>[];
final adapter = StubAdapter((request) {
final page = int.parse(request.uri.queryParameters['pageno']!);
pages.add(page);
return jsonBody(switch (page) {
1 || 2 => [
{'id': 1, 'name': 'Actor one'},
],
3 => [
{'id': 2, 'name': 'Actor two'},
],
_ => [],
});
});
await mountPaging(
tester,
ApiService(dio: Dio()..httpClientAdapter = adapter),
const ActorsPage(),
);
expect(find.text('Actor one'), findsOneWidget);
expect(find.text('Actor two'), findsOneWidget);
expect(pages, [1, 2, 3, 4]);
},
);
testWidgets('country movies retain continuation across opposite-kind pages', (
tester,
) async {
final pages = <int>[];
final adapter = StubAdapter((request) {
if (request.uri.path.contains('/all/')) return jsonBody([]);
final page = int.parse(request.uri.pathSegments[7]);
pages.add(page);
return jsonBody(switch (page) {
0 => [titleRow(10, 'serie')],
1 => [titleRow(11, 'serie')],
2 => [titleRow(12, 'movie')],
_ => [],
});
});
await mountPaging(
tester,
ApiService(dio: Dio()..httpClientAdapter = adapter),
const BrowsePage(initialCountry: 9),
);
expect(find.text('No titles found'), findsNothing);
expect(find.text('movie 12'), findsOneWidget);
expect(find.text('serie 10'), findsNothing);
expect(pages, [0, 1, 2, 3]);
expect(find.text('Load more'), findsNothing);
expect(find.text('You’re all caught up'), findsOneWidget);
});
}
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_platform.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/ui/catalog.dart';
import 'catalog_widget_test.dart' show CatalogApi, mountCatalog;
class QualityApi extends CatalogApi {
@override
Future<TitleRecord> poster(int id) async => TitleRecord(
id: id,
title: 'Quality fixture',
sources: const [
MediaSource(
id: 1,
quality: '720p',
type: 'mp4',
url: 'https://media.example/720.mp4',
),
MediaSource(
id: 2,
quality: '1080p',
type: 'mp4',
url: 'https://media.example/1080.mp4',
),
],
);
}
void main() {
testWidgets(
'ask quality waits for an explicit source choice and supports cancel',
(tester) async {
final state = await mountCatalog(
tester,
QualityApi(),
const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')),
);
expect(state.prefs.defaultQuality, 'ask');
final play = find.byKey(const ValueKey('play-preferred'));
await tester.ensureVisible(play);
await tester.tap(play);
await tester.pumpAndSettle();
expect(find.text('Choose a source'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Choose a source'), findsNothing);
expect(find.byType(TitlePage), findsOneWidget);
},
);
testWidgets('preferred quality is used by the primary play action', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final calls = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
calls.add((call.arguments as Map)['url'] as String);
return true;
},
);
final state = await mountCatalog(
tester,
QualityApi(),
const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')),
);
await state.updateSettings(
state.prefs.copyWith(externalPlayer: true, defaultQuality: '1080'),
);
await tester.pumpAndSettle();
final play = find.byKey(const ValueKey('play-preferred'));
expect(play, findsOneWidget);
await tester.ensureVisible(play);
await tester.tap(play);
await tester.pumpAndSettle();
expect(calls, ['https://media.example/1080.mp4']);
debugDefaultTargetPlatformOverride = null;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
);
});
testWidgets(
'external-player preference dispatches selected media to native player',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final calls = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method == 'openExternal') {
calls.add((call.arguments as Map)['url'] as String);
return true;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
),
);
final state = await mountCatalog(
tester,
CatalogApi(),
const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')),
);
await state.updateSettings(state.prefs.copyWith(externalPlayer: true));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byTooltip('Play').first);
await tester.tap(find.byTooltip('Play').first);
await tester.pumpAndSettle();
expect(calls, ['https://media.example/movie.mp4']);
debugDefaultTargetPlatformOverride = null;
},
);
}
+269
View File
@@ -0,0 +1,269 @@
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/catalog.dart';
import 'package:shared_preferences/shared_preferences.dart';
class CatalogApi extends ApiService {
int homeCalls = 0;
bool failHome = false;
@override
Future<Map<String, dynamic>> home() async {
homeCalls++;
if (failHome) throw Exception('Offline');
return {
'genres': [
{
'id': 2,
'title': 'From the server',
'posters': [const TitleRecord(id: 7, title: 'Real fixture').toJson()],
},
],
};
}
@override
Future<void> initialize({bool discover = true}) async {}
bool series = false;
@override
Future<TitleRecord> poster(int id) async => TitleRecord(
id: id,
title: 'Fixture detail',
type: series ? 'serie' : 'movie',
description: 'Actual fixture synopsis',
genres: [const Tag(id: 2, title: 'Action')],
sources: [
const MediaSource(
id: 1,
quality: '1080p',
type: 'mp4',
url: 'https://media.example/movie.mp4',
),
const MediaSource(
id: 2,
quality: 'Trailer',
type: 'mp4',
url: 'https://media.example/trailer.mp4',
),
],
);
@override
Future<List<SeasonRecord>> seasons(int id) async => [
const SeasonRecord(
id: 1,
title: 'Season 1',
episodes: [
EpisodeRecord(
id: 51,
title: 'Episode 1',
sources: [
MediaSource(
id: 1,
quality: '720p',
url: 'https://media.example/1.mp4',
),
],
),
EpisodeRecord(
id: 52,
title: 'Episode 2',
sources: [
MediaSource(
id: 2,
quality: '720p',
url: 'https://media.example/2.mp4',
),
],
),
],
),
];
@override
Future<List<Map<String, dynamic>>> roles(int posterId) async => [];
int commentCalls = 0;
@override
Future<List<Map<String, dynamic>>> comments(int posterId) async {
commentCalls++;
return [{'id': 1, 'user': 'Viewer', 'content': 'SGVsbG8='}];
}
@override
Future<List<Map<String, dynamic>>> subtitles(String kind, int id) async => [];
final catalogCalls = <Map<String, dynamic>>[];
final searchCalls = <Map<String, dynamic>>[];
@override
Future<List<Tag>> genres() async => [const Tag(id: 3, title: 'Animation')];
@override
Future<List<Tag>> countries() async => [const Tag(id: 1, title: 'Country')];
@override
Future<List<Tag>> categories() async => [];
@override
Future<List<TitleRecord>> catalog({
String kind = 'movie',
int genre = 0,
int country = 0,
String order = 'created',
int page = 0,
}) async {
catalogCalls.add({
'kind': kind,
'genre': genre,
'country': country,
'order': order,
'page': page,
});
return page < 2 ? [TitleRecord(id: page + 20, title: 'Page $page')] : [];
}
@override
Future<List<String>> trending() async => ['سرچ'];
@override
Future<List<TitleRecord>> search(
String query, {
int page = 1,
bool english = false,
}) async {
searchCalls.add({'q': query, 'page': page, 'english': english});
return CatalogPage(
page == 1 ? [TitleRecord(id: 41, title: 'Result $query')] : [],
nextPage: null,
);
}
@override
Future<List<TitleRecord>> recommended(int genre, {int page = 1}) async => [];
}
Future<AppState> mountCatalog(
WidgetTester tester,
CatalogApi api,
Widget page,
) async {
SharedPreferences.setMockInitialValues({});
final state = AppState(
api: api,
storage: await SharedPreferences.getInstance(),
);
await state.initialize(discover: false);
await tester.pumpWidget(
AppScope(
state: state,
child: MaterialApp(theme: ThemeData.dark(useMaterial3: true), home: page),
),
);
await tester.pumpAndSettle();
return state;
}
void main() {
testWidgets(
'movie detail exposes sources and local watchlist without public comments',
(tester) async {
final api = CatalogApi();
final state = await mountCatalog(
tester,
api,
const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')),
);
expect(find.text('Fixture detail'), findsWidgets);
await tester.ensureVisible(find.text('Add to watchlist'));
await tester.tap(find.text('Add to watchlist'));
await tester.pumpAndSettle();
expect(state.hasSaved(7), isTrue);
await tester.ensureVisible(find.text('1080p'));
expect(find.text('1080p'), findsOneWidget);
await tester.drag(find.byType(Scrollable).first, const Offset(0, -1500));
await tester.pumpAndSettle();
expect(find.text('Comments'), findsNothing);
expect(find.text('نظرات'), findsNothing);
expect(find.text('Replies'), findsNothing);
expect(api.commentCalls, 0);
expect(find.textContaining('Sign in'), findsNothing);
expect(find.byType(TextField), findsNothing);
},
);
testWidgets('series detail uses nested real seasons and episode sources', (
tester,
) async {
final api = CatalogApi()..series = true;
await mountCatalog(
tester,
api,
const TitlePage(
title: TitleRecord(id: 7, title: 'Fixture', type: 'serie'),
),
);
await tester.ensureVisible(find.text('Season 1'));
await tester.tap(find.text('Season 1'));
await tester.pumpAndSettle();
expect(find.text('Episode 1'), findsOneWidget);
expect(find.text('Episode 2'), findsOneWidget);
await tester.ensureVisible(find.text('Episode 1'));
await tester.tap(find.text('Episode 1'));
await tester.pumpAndSettle();
expect(find.text('720p'), findsOneWidget);
expect(find.byTooltip('Play'), findsWidgets);
});
testWidgets(
'browse anime preserves mixed genre 3 across automatically loaded pages',
(tester) async {
final api = CatalogApi();
await mountCatalog(
tester,
api,
const BrowsePage(initialKind: 'mixed', initialGenre: 3),
);
// Small real-result fixture pages are prefetched until the viewport is
// filled or the upstream empty page is reached, without changing filters.
expect(api.catalogCalls.map((call) => call['page']), [0, 1, 2]);
for (final call in api.catalogCalls) {
expect(call['kind'], 'mixed');
expect(call['genre'], 3);
}
expect(find.text('Page 0'), findsOneWidget);
expect(find.text('Page 1'), findsOneWidget);
expect(find.text('You’re all caught up'), findsOneWidget);
},
);
testWidgets(
'search submits Persian query and switches to English endpoint explicitly',
(tester) async {
final api = CatalogApi();
await mountCatalog(tester, api, const SearchPage());
await tester.scrollUntilVisible(
find.text('سرچ'),
200,
scrollable: find.byType(Scrollable).last,
);
expect(find.text('سرچ'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, 'فیلم');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(api.searchCalls.single, {
'q': 'فیلم',
'page': 1,
'english': false,
});
await tester.tap(find.text('Advanced filters'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('English search'));
await tester.tap(find.text('English search'));
await tester.pumpAndSettle();
expect(api.searchCalls.last['english'], true);
expect(find.text('Result فیلم'), findsOneWidget);
},
);
testWidgets('Discover displays server rail without refetching on rebuild', (
tester,
) async {
final api = CatalogApi();
final state = await mountCatalog(tester, api, const DiscoverPage());
expect(find.text('From the server'), findsOneWidget);
expect(find.text('Real fixture'), findsWidgets);
expect(api.homeCalls, 1);
await state.toggleSaved(const TitleRecord(id: 12, title: 'Saved'));
await tester.pumpAndSettle();
expect(api.homeCalls, 1);
});
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/content_policy.dart';
import 'api_service_test.dart' as fixtures;
void main() {
test('public comments compatibility never sends a request', () async {
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
expect(await api.comments(7), isEmpty);
expect(adapter.requests, isEmpty);
});
test(
'raw comment paths and reply actions are blocked before transport',
() async {
final adapter = fixtures.StubAdapter((_) => fixtures.jsonBody([]));
final api = ApiService(dio: Dio()..httpClientAdapter = adapter);
for (final path in [
'comments/by/poster/7/key/',
'comment/add/',
'/api/%63omments/by/poster/7/key/',
'/api-user/newapi/comments.php',
'/api-user/filmahang/star.php?action=pasokh&cid=7',
'/api-user/newapi/comments_list.php',
'/api-user/newapi/star.php?action=comments&action=search',
]) {
expect(
ContentPolicy.allowsApiPath(Uri.parse(path)),
isFalse,
reason: path,
);
await expectLater(api.get(path), throwsA(isA<ApiException>()));
}
for (final action in ['comments', 'add-comment', 'pasokh', 'reply']) {
await expectLater(
api.get('/api-user/filmahang/star.php', query: {'action': action}),
throwsA(isA<ApiException>()),
);
await expectLater(
api.post('/api-user/filmahang/star.php', {'action': action}),
throwsA(isA<ApiException>()),
);
}
await expectLater(
api.get(
'/api-user/filmahang/star.php?action=comments',
query: {'action': 'search'},
),
throwsA(isA<ApiException>()),
);
expect(adapter.requests, isEmpty);
},
);
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.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';
Future<AppState> stateForTest() async {
SharedPreferences.setMockInitialValues({});
final state = AppState(api: ApiService());
await state.initialize(discover: false);
return state;
}
Widget host(AppState state, Widget child) => AppScope(
state: state,
child: MaterialApp(home: Scaffold(body: child)),
);
void main() {
const movie = TitleRecord(id: 1, title: 'A real film');
const promo = TitleRecord(id: 2, title: 'ملودی باکس');
const station = TitleRecord(
id: 3,
title: 'Old live station',
type: 'channel',
);
testWidgets(
'shared grids reject removed records even when API parsing is bypassed',
(tester) async {
final state = await stateForTest();
await tester.pumpWidget(
host(state, const PosterGrid(items: [movie, promo, station])),
);
expect(find.text('A real film'), findsOneWidget);
expect(find.text('ملودی باکس'), findsNothing);
expect(find.text('Old live station'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
state.dispose();
},
);
testWidgets('a directly constructed removed poster never paints its label', (
tester,
) async {
final state = await stateForTest();
await tester.pumpWidget(
host(state, const SizedBox(height: 280, child: PosterCard(title: promo))),
);
expect(find.text('ملودی باکس'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
state.dispose();
});
testWidgets('a promotional rail heading cannot reintroduce vendor content', (
tester,
) async {
final state = await stateForTest();
await tester.pumpWidget(
host(state, const PosterRail(title: 'نورتون', items: [movie])),
);
expect(find.text('نورتون'), findsNothing);
expect(find.byType(PosterCard), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
state.dispose();
});
testWidgets(
'compact preference renders usable rows rather than poster grids',
(tester) async {
final state = await stateForTest();
await state.updateSettings(state.prefs.copyWith(compactCatalog: true));
await tester.pumpWidget(host(state, const PosterGrid(items: [movie])));
expect(find.byType(GridView), findsNothing);
expect(
find.byKey(const ValueKey('compact-title-row:poster:1')),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
state.dispose();
},
);
testWidgets('status messages have an explicit accessible dismiss action', (
tester,
) async {
final state = await stateForTest();
await tester.pumpWidget(
host(
state,
Builder(
builder: (context) => TextButton(
onPressed: () => toast(context, 'Saved locally'),
child: const Text('Show status'),
),
),
),
);
await tester.tap(find.text('Show status'));
await tester.pumpAndSettle();
final closeTooltip = MaterialLocalizations.of(
tester.element(find.byType(SnackBar)),
).closeButtonTooltip;
expect(find.byTooltip(closeTooltip), findsOneWidget);
await tester.tap(find.byTooltip(closeTooltip));
await tester.pumpAndSettle();
expect(find.text('Saved locally'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
state.dispose();
});
}
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/content_policy.dart';
void main() {
group('removed service promotions cannot reach the interface', () {
for (final value in <String>[
'ملودی باکس',
'ملودي باكس',
'مِلودی‌ باکس',
'MELODY BOX',
'MelodyBox music',
'نورتون',
'نُورتون',
'Norton VPN',
'دانلود نسخه جدید برنامه',
'آپدیت برنامه سیمبا',
'Original app support: https://t.me/original_support',
'خبرهای سرویس در تلگرام',
'@OriginalSupportAccount',
]) {
test('blocks $value', () {
expect(ContentPolicy.allowsText(value), isFalse);
expect(ContentPolicy.sanitizeText(value), isEmpty);
});
}
test('retains normal film metadata and does not mutate the input', () {
final original = <String, dynamic>{
'id': 5,
'title': 'The Newsroom',
'description': 'A story about a newspaper',
'updated_at': '2026-10-01',
'sources': [
{'url': 'https://media.example/film.mp4', 'quality': '1080p'},
],
};
expect(ContentPolicy.allowsTitleJson(original), isTrue);
expect(ContentPolicy.sanitizeJson(original), original);
expect(original['updated_at'], '2026-10-01');
});
test('removes complete promotion records, not merely their labels', () {
final clean = ContentPolicy.sanitizeJson({
'posters': [
{'id': 1, 'title': 'Ordinary film', 'type': 'movie'},
{'id': 2, 'title': 'ملودی باکس', 'type': 'movie'},
{
'id': 3,
'title': 'Install the latest app',
'url': 'https://cdn.example/app.apk',
},
{'id': 4, 'title': 'Live station', 'type': 'channel'},
],
}) as Map<String, dynamic>;
expect((clean['posters'] as List).map((e) => e['id']), [1]);
});
test(
'discards original support/news/update/list/channel home payloads',
() {
final clean = ContentPolicy.sanitizeJson({
'posters': [
{'id': 1, 'title': 'Film'},
],
'channels': [
{'id': 4, 'title': 'Station'},
],
'public_playlists': [
{'id': 2, 'title': 'Remote list'},
],
'news': {'title': 'Latest news'},
'support': 'https://example.com/support',
'update': {'version': '99', 'url': 'https://example.com/new.apk'},
'telegram_channel': '@OriginalAccount',
}) as Map<String, dynamic>;
expect(clean.keys, ['posters']);
},
);
test('preserves local collections and episode playback playlists', () {
final value = {
'collections': [
{'name': 'Weekend films', 'titles': []},
],
'episodes': [
{'id': 3, 'title': 'Episode 3'},
],
};
expect(ContentPolicy.sanitizeJson(value), value);
});
test('rejects channel and promotional records', () {
expect(
ContentPolicy.allowsTitleJson({
'id': 4,
'title': 'Station',
'type': 'channel',
}),
isFalse,
);
expect(
ContentPolicy.allowsTitleJson({
'id': 5,
'title': 'Movie',
'description': 'نورتون',
}),
isFalse,
);
expect(
ContentPolicy.allowsTitleJson({
'id': 6,
'title': 'Movie',
'type': 'news',
}),
isFalse,
);
});
});
group('outgoing destination policy', () {
for (final url in [
'https://t.me/original',
'https://www.telegram.me/original',
'tg://resolve?domain=original',
'https://telegram.org/original',
'https://cdn.example/release.apk?version=2',
'https://cdn.example/release%2Eapk',
'https://example.com/original-app/support',
'https://www.norton.com/',
'https://melodybox.example/',
'javascript:alert(1)',
'intent://example',
]) {
test('rejects $url', () => expect(ContentPolicy.allowsUrl(url), isFalse));
}
test('allows normal artwork, film and subtitle media URLs', () {
for (final url in [
'https://media.example/film.mp4',
'http://media.example/video.m3u8',
'https://images.example/p.jpg',
'https://subtitles.example/fa.srt',
]) {
expect(ContentPolicy.allowsUrl(url), isTrue, reason: url);
}
});
test(
'blocks removed API families without blocking catalog and episodes',
() {
for (final path in [
'/api/channel/by/2/key/',
'/api-user/newapi/like.php?action=showlist',
'/api-user/newapi/like.php?action=show-list_fav&listid=5',
]) {
expect(ContentPolicy.allowsApiPath(Uri.parse(path)), isFalse);
}
expect(
ContentPolicy.allowsApiPath(Uri.parse('/api/season/by/serie/2/key/')),
isTrue,
);
expect(
ContentPolicy.allowsApiPath(Uri.parse('/api/movie/by/2/key/')),
isTrue,
);
},
);
});
}
@@ -0,0 +1,124 @@
import 'dart:async';
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
class _Storage implements PersistentStorage {
@override
Future<void> initialize() async {}
@override
Future<List<TaskRecord>> retrieveAllTaskRecords() async => [];
@override
Future<void> removeResumeData(String? id) async {}
@override
Future<void> removePausedTask(String? id) async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const native = MethodChannel('com.bbflight.background_downloader');
const connectivity = MethodChannel('dev.fluttercommunity.plus/connectivity');
const events = MethodChannel('dev.fluttercommunity.plus/connectivity_status');
const codec = StandardMethodCodec();
late FileDownloader downloader;
final oldPlatform = debugDefaultTargetPlatformOverride;
setUpAll(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
messenger.setMockMethodCallHandler(connectivity, (_) async => ['wifi']);
messenger.setMockMethodCallHandler(events, (_) async => null);
downloader = FileDownloader(persistentStorage: _Storage());
await downloader.ready;
});
tearDownAll(() {
downloader.destroy();
debugDefaultTargetPlatformOverride = oldPlatform;
messenger.setMockMethodCallHandler(native, null);
messenger.setMockMethodCallHandler(connectivity, null);
messenger.setMockMethodCallHandler(events, null);
});
Future<Object?> enqueueChild(String id) async {
final reply = Completer<ByteData?>();
final parent = ParallelDownloadTask(
taskId: 'parent-$id',
url: 'https://example.test/video.mp4',
);
final child = DownloadTask(
taskId: id,
url: parent.url,
group: FileDownloader.chunkGroup,
);
// Exercise the real plugin background handler and its JSON worker.
// ignore: deprecated_member_use
messenger.handlePlatformMessage(
'com.bbflight.background_downloader.background',
codec.encodeMethodCall(
MethodCall('enqueueChild', [
jsonEncode(parent.toJson()),
jsonEncode(child.toJson()),
]),
),
reply.complete,
);
return codec.decodeEnvelope(
(await reply.future.timeout(const Duration(seconds: 3)))!,
);
}
test('child acknowledgement waits for native scheduling', () async {
final dispatched = Completer<void>();
final scheduled = Completer<bool>();
messenger.setMockMethodCallHandler(native, (call) async {
if (call.method == 'enqueue') {
dispatched.complete();
return scheduled.future;
}
return true;
});
var replied = false;
final reply = enqueueChild('pending-child').then((value) {
replied = true;
return value;
});
await dispatched.future.timeout(const Duration(seconds: 3));
await Future<void>.delayed(Duration.zero);
expect(replied, isFalse);
scheduled.complete(true);
expect(await reply, isTrue);
});
test(
'native child dispatch error returns failure without a detached error',
() async {
messenger.setMockMethodCallHandler(native, (call) async {
throw PlatformException(
code: 'scheduler_rejected',
message: 'Fixture rejection',
);
});
expect(await enqueueChild('throwing-child'), isFalse);
await Future<void>.delayed(const Duration(milliseconds: 150));
},
);
test('child enqueue rejection is not acknowledged as success', () async {
var attempts = 0;
messenger.setMockMethodCallHandler(native, (call) async {
if (call.method == 'enqueue') attempts++;
return false;
});
final accepted = await enqueueChild('rejected-child');
// Drain the old detached 100ms enqueue before asserting/teardown too.
await Future<void>.delayed(const Duration(milliseconds: 150));
expect(attempts, 1);
expect(accepted, isFalse);
});
}
@@ -0,0 +1,65 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
class _Storage implements PersistentStorage {
@override
Future<void> initialize() async {}
@override
Future<List<TaskRecord>> retrieveAllTaskRecords() async => [];
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('Android connectivity initialization error fails ready without detached error', () async {
final previous = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = previous);
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const connectivity = MethodChannel(
'dev.fluttercommunity.plus/connectivity',
);
const events = MethodChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
messenger.setMockMethodCallHandler(
connectivity,
(_) async => throw PlatformException(code: 'network_unavailable'),
);
messenger.setMockMethodCallHandler(events, (_) async => null);
addTearDown(() {
messenger.setMockMethodCallHandler(connectivity, null);
messenger.setMockMethodCallHandler(events, null);
});
final unhandled = <Object>[];
final done = Completer<void>();
Object? observed;
runZonedGuarded(() async {
final downloader = FileDownloader(persistentStorage: _Storage());
try {
await downloader.ready;
} catch (error) {
observed = error;
} finally {
await Future<void>.delayed(Duration.zero);
done.complete();
}
}, (error, stack) => unhandled.add(error));
await done.future;
expect(
observed,
isA<PlatformException>().having(
(error) => error.code,
'code',
'network_unavailable',
),
);
expect(unhandled, isEmpty);
});
}
@@ -0,0 +1,43 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
class _FailingStorage implements PersistentStorage {
final failure = StateError('download storage unavailable');
@override
Future<void> initialize() async => throw failure;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'Android initialization failure reaches ready instead of unhandled future',
() async {
final previous = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = previous);
final storage = _FailingStorage();
final unhandled = <Object>[];
final done = Completer<void>();
Object? observed;
runZonedGuarded(() async {
final downloader = FileDownloader(persistentStorage: storage);
try {
await downloader.ready.timeout(const Duration(milliseconds: 200));
} catch (error) {
observed = error;
} finally {
done.complete();
}
}, (error, stack) => unhandled.add(error));
await done.future;
expect(observed, same(storage.failure));
expect(unhandled, isEmpty);
},
);
}
@@ -0,0 +1,60 @@
// Exercise the pinned Android protocol without constructing connectivity/jobs.
// ignore: implementation_imports
import 'package:background_downloader/src/permissions.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.bbflight.background_downloader');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() => messenger.setMockMethodCallHandler(channel, null));
test(
'failed permission dispatch releases slot for the next request',
() async {
final service = AndroidPermissionsService();
var calls = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (++calls == 1) {
throw PlatformException(
code: 'activity_detached',
message: 'No activity',
);
}
service.onPermissionRequestResult(PermissionStatus.granted);
return true;
});
await expectLater(
service.request(PermissionType.notifications),
throwsA(isA<PlatformException>()),
);
expect(
await service.request(PermissionType.notifications),
PermissionStatus.granted,
);
expect(calls, 2);
},
);
test(
'notification result preceding native request reply completes request',
() async {
final service = AndroidPermissionsService();
messenger.setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'requestPermission');
expect(call.arguments, PermissionType.notifications.index);
// Android may deliver its activity callback before the method reply.
service.onPermissionRequestResult(PermissionStatus.denied);
return true;
});
expect(
await service.request(PermissionType.notifications),
PermissionStatus.denied,
);
expect(service.permissionStatusCompleter, isNull);
},
);
}
+118
View File
@@ -0,0 +1,118 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
class _Storage implements PersistentStorage {
final failure = FileSystemException('fixture storage is read-only');
bool failWrites = true;
int writes = 0;
int? failWriteNumber;
final saved = <String, TaskRecord>{};
@override
Future<void> initialize() async {}
@override
Future<TaskRecord?> retrieveTaskRecord(String id) async => saved[id];
@override
Future<List<TaskRecord>> retrieveAllTaskRecords() async =>
saved.values.toList();
@override
Future<void> storeTaskRecord(TaskRecord record) async {
writes++;
if (failWrites || writes == failWriteNumber) throw failure;
saved[record.taskId] = record;
}
@override
Future<void> removePausedTask(String? id) async {}
@override
Future<void> removeResumeData(String? id) async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'failed native status persistence fails flush and later writes still run',
() async {
final previous = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = previous);
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const connectivity = MethodChannel(
'dev.fluttercommunity.plus/connectivity',
);
const connectivityEvents = MethodChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
messenger.setMockMethodCallHandler(connectivity, (_) async => ['wifi']);
messenger.setMockMethodCallHandler(connectivityEvents, (_) async => null);
addTearDown(() {
messenger.setMockMethodCallHandler(connectivity, null);
messenger.setMockMethodCallHandler(connectivityEvents, null);
});
final storage = _Storage();
final unhandled = <Object>[];
final done = Completer<void>();
Object? observed;
Object? batchFailure;
var retried = false;
runZonedGuarded(() async {
final downloader = FileDownloader(persistentStorage: storage);
await downloader.ready;
final engine = downloader.downloaderForTesting;
engine.trackedGroups.add('fixture');
engine.groupStatusCallbacks['fixture'] = (_) {};
final task = DownloadTask(
taskId: 'read-only-status',
url: 'https://example.test/fixture.mp4',
group: 'fixture',
);
try {
engine.processStatusUpdate(
TaskStatusUpdate(task, TaskStatus.running),
);
try {
await engine.flushDatabaseUpdates();
} catch (error) {
observed = error;
}
storage.failWrites = false;
engine.processStatusUpdate(
TaskStatusUpdate(task, TaskStatus.running),
);
await engine.flushDatabaseUpdates();
retried = storage.saved[task.taskId]?.status == TaskStatus.running;
// One background message can queue status plus progress. A later
// successful write must not turn the whole flush into false success.
storage.failWriteNumber = storage.writes + 1;
engine.processStatusUpdate(
TaskStatusUpdate(task, TaskStatus.enqueued),
);
engine.processStatusUpdate(
TaskStatusUpdate(task, TaskStatus.running),
);
try {
await engine.flushDatabaseUpdates();
} catch (error) {
batchFailure = error;
}
await Future<void>.delayed(Duration.zero);
} finally {
downloader.destroy();
done.complete();
}
}, (error, stack) => unhandled.add(error));
await done.future;
expect(observed, same(storage.failure));
expect(batchFailure, same(storage.failure));
expect(unhandled, isEmpty);
expect(retried, isTrue);
},
);
}
+58
View File
@@ -0,0 +1,58 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_files_io.dart' as files;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'parallel optimization requires a successful HEAD with size and ranges',
() async {
final previous = HttpOverrides.current;
HttpOverrides.global = null;
addTearDown(() => HttpOverrides.global = previous);
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
final methods = <String>[];
server.listen((request) async {
methods.add(request.method);
switch (request.uri.path) {
case '/capable':
request.response.contentLength = 524288;
request.response.headers.set('accept-ranges', 'bytes');
case '/no-head':
request.response.statusCode = HttpStatus.methodNotAllowed;
case '/no-ranges':
request.response.contentLength = 524288;
case '/unknown-size':
request.response.headers.set('accept-ranges', 'bytes');
case '/forbidden':
request.response.statusCode = HttpStatus.forbidden;
default:
request.response.statusCode = HttpStatus.notFound;
}
await request.response.close();
});
String url(String path) => 'http://127.0.0.1:${server.port}$path';
expect(await files.supportsParallelDownload(url('/capable')), isTrue);
for (final path in [
'/no-head',
'/no-ranges',
'/unknown-size',
'/forbidden',
]) {
expect(
await files.supportsParallelDownload(url(path)),
isFalse,
reason: path,
);
}
expect(methods, List.filled(5, 'HEAD'));
expect(
await files.supportsParallelDownload('file:///private.mp4'),
isFalse,
);
},
);
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/download_service.dart';
import 'package:reelbox/state/app_state.dart';
void main() {
test(
'retry can replace an incompatible multipart task with a normal GET',
() {
final old = createDirectDownloadTask(
id: 'old-multipart',
title: const TitleRecord(
id: 81,
title: 'A local test film',
type: 'serie',
),
source: const MediaSource(
url: 'https://example.test/video.mp4',
quality: '720p',
),
episode: const EpisodeRecord(id: 9, title: 'Episode 1'),
preferences: AppSettings(
wifiOnlyDownloads: true,
downloadNotifications: false,
),
supportsParallel: true,
);
final entry = DownloadEntry.fromTask(old, taskStatus: TaskStatus.failed);
final retry = createRetryDownloadTask(entry, id: 'retry-single');
expect(retry, isNot(isA<ParallelDownloadTask>()));
expect(retry.taskId, 'retry-single');
expect(retry.url, old.url);
expect(retry.metaData, old.metaData);
expect(retry.requiresWiFi, isTrue);
expect(retry.notificationConfig, isNull);
expect(retry.retriesRemaining, old.retries);
expect(retry.filename, isNot(old.filename));
expect(DownloadEntry.fromTask(retry).episodeId, 9);
expect(DownloadEntry.fromTask(retry).titleKind, 'serie');
},
);
test('default download works without server HEAD or byte-range support', () {
final task = createDirectDownloadTask(
id: 'ordinary-get',
title: const TitleRecord(id: 81, title: 'A local test film'),
source: const MediaSource(
url: 'https://example.test/video.mp4',
type: 'mp4',
),
preferences: AppSettings(),
);
// Multipart workers require both HEAD and range headers. A normal GET
// download must be the safe default, not a server-dependent optimization.
expect(task, isNot(isA<ParallelDownloadTask>()));
expect(task.httpRequestMethod, 'GET');
expect(task.requiresWiFi, isFalse);
expect(task.updates, Updates.statusAndProgress);
});
}
+46
View File
@@ -0,0 +1,46 @@
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/download_service.dart';
import 'package:reelbox/state/app_state.dart';
void main() {
for (final kind in ['channel', 'movie', 'serie', 'series']) {
test(
'offline and retry reconstruction preserve $kind after disk round trip',
() {
final title = TitleRecord(id: 42, title: 'Fixture', type: kind);
final original = DownloadTask(
url: 'https://example.test/a.mp4',
metaData: downloadMetadata(
title: title,
source: const MediaSource(url: 'https://example.test/a.mp4'),
notifications: false,
),
);
final recovered = DownloadTask.fromJson(
jsonDecode(jsonEncode(original.toJson())),
);
final restored = DownloadEntry.fromTask(recovered).restoredTitle;
expect(restored.type, kind);
expect(restored.libraryKey, title.libraryKey);
},
);
}
test(
'legacy records retain the movie/episode fallback without guessing channel',
() {
for (final episodeId in [null, 9]) {
final task = DownloadTask(
url: 'https://example.test/a.mp4',
metaData: jsonEncode({'titleId': 42, 'episodeId': episodeId}),
);
final title = DownloadEntry.fromTask(task).restoredTitle;
expect(title.type, episodeId == null ? 'movie' : 'serie');
expect(title.libraryKey, 'poster:42');
}
},
);
}
+525
View File
@@ -0,0 +1,525 @@
// Console output is intentional evidence from this real-worker test fixture.
// ignore_for_file: avoid_print
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:logging/logging.dart';
// flutter_tester does not attach the generated Dart plugin registrant in
// Flutter 3.47.9. Include the engine lookup constant and host-only registrar.
// Run under the shared compiler lock, with test concurrency=1:
// flock .tooling.lock flutter test --concurrency=1 \
// --dart-define=flutter.dart_plugin_registrant=file://$PWD/test/download_multipart_http_test.dart \
// test/download_multipart_http_test.dart
// ignore: implementation_imports, unused_import
import 'package:flutter/src/dart_plugin_registrant.dart';
// The installed Linux path provider bootstraps real worker isolates only.
// ignore: depend_on_referenced_packages
import 'package:path_provider_linux/path_provider_linux.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/download_service.dart';
import 'package:reelbox/state/app_state.dart';
// The engine invokes this in every real downloader isolate. Do not register
// connectivity_plus's DBus backend: the headless test host has no NetworkManager.
@pragma('vm:entry-point')
class _PluginRegistrant {
@pragma('vm:entry-point')
static void register() => PathProviderLinux.registerWith();
}
// Only host persistence/connectivity are fixtures. HTTP, range splitting,
// worker isolates, pause/resume, callbacks and stitching are real plugin code.
class _Storage implements PersistentStorage {
final records = <String, TaskRecord>{},
paused = <String, Task>{},
resumes = <String, ResumeData>{};
@override
(String, int) get currentDatabaseVersion => ('fixture', 1);
@override
Future<(String, int)> get storedDatabaseVersion async =>
currentDatabaseVersion;
@override
Future<void> initialize() async {}
@override
Future<void> storeTaskRecord(TaskRecord r) async {
records[r.taskId] = r;
}
@override
Future<TaskRecord?> retrieveTaskRecord(String id) async => records[id];
@override
Future<List<TaskRecord>> retrieveAllTaskRecords() async =>
records.values.toList();
@override
Future<void> removeTaskRecord(String? id) async {
id == null ? records.clear() : records.remove(id);
}
@override
Future<void> storePausedTask(Task t) async {
paused[t.taskId] = t;
}
@override
Future<Task?> retrievePausedTask(String id) async => paused[id];
@override
Future<List<Task>> retrieveAllPausedTasks() async => paused.values.toList();
@override
Future<void> removePausedTask(String? id) async {
id == null ? paused.clear() : paused.remove(id);
}
@override
Future<void> storeResumeData(ResumeData r) async {
resumes[r.taskId] = r;
}
@override
Future<ResumeData?> retrieveResumeData(String id) async => resumes[id];
@override
Future<List<ResumeData>> retrieveAllResumeData() async =>
resumes.values.toList();
@override
Future<void> removeResumeData(String? id) async {
id == null ? resumes.clear() : resumes.remove(id);
}
// Reconstitute fresh task/resume objects from actual disk JSON, not merely
// another reference to the same objects. This is not a process restart test.
Future<void> reloadResumeSnapshot(File file) async {
await file.writeAsString(
jsonEncode({
'paused': paused.values.map((t) => t.toJson()).toList(),
'resumes': resumes.values.map((r) => r.toJson()).toList(),
}),
flush: true,
);
paused.clear();
resumes.clear();
final json = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
for (final value in json['paused'] as List) {
final task = Task.createFromJson(value as Map<String, dynamic>);
paused[task.taskId] = task;
}
for (final value in json['resumes'] as List) {
final resume = ResumeData.fromJson(value as Map<String, dynamic>);
resumes[resume.taskId] = resume;
}
}
}
Future<void> _until(FutureOr<bool> Function() predicate, String reason) async {
final watch = Stopwatch()..start();
while (!await predicate()) {
if (watch.elapsed > const Duration(seconds: 10)) {
fail('Timed out waiting for $reason');
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}
class _RangeFixture {
static const chunkLength = 262144;
final bytes = Uint8List.fromList(
List.generate(chunkLength * 3, (i) => i % 251),
);
final ranges = <(int, int)>[];
final errors = <Object>[];
final handlers = <Future<void>>[];
final bool throttle;
late final HttpServer server;
late final Directory root;
var active = 0, peak = 0;
var closing = false;
final shutdown = Completer<void>();
_RangeFixture({this.throttle = false});
Future<void> start() async {
root = await Directory.systemTemp.createTemp('reelbox-multipart-');
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) => handlers.add(_serve(request)));
}
Future<void> _serve(HttpRequest request) async {
final response = request.response;
response.headers.set('accept-ranges', 'bytes');
response.headers.set('content-type', 'video/mp4');
response.headers.set('etag', '"reelbox-fixture-v1"');
if (request.method == 'HEAD') {
response.contentLength = bytes.length;
await response.close();
return;
}
final match = RegExp(r'^bytes=(\d+)-(\d*)$')
.firstMatch(request.headers.value('range') ?? '');
if (match == null) {
errors.add(StateError('Expected an actual range request'));
response.statusCode = 400;
await response.close();
return;
}
final start = int.parse(match[1]!);
final end = match[2]!.isEmpty ? bytes.length - 1 : int.parse(match[2]!);
ranges.add((start, end));
response.statusCode = 206;
response.headers.set('content-range', 'bytes $start-$end/${bytes.length}');
response.contentLength = end - start + 1;
active++;
if (active > peak) peak = active;
try {
// Range zero finishes first; two other workers genuinely transfer bytes
// slowly so the parent can pause only after the fast worker completed.
await Future<void>.delayed(const Duration(milliseconds: 50));
final step = throttle && start >= chunkLength ? 4096 : end - start + 1;
for (var offset = start; offset <= end && !closing; offset += step) {
final next = offset + step > end + 1 ? end + 1 : offset + step;
response.add(bytes.sublist(offset, next));
// A paused worker stops consuming the old response. On teardown,
// cancel that server-side wait too; force-close alone can strand flush.
await Future.any([response.flush(), shutdown.future]);
if (step == 4096) {
await Future<void>.delayed(const Duration(milliseconds: 40));
}
}
if (!closing) await Future.any([response.close(), shutdown.future]);
} on SocketException catch (error) {
// Pause closes the real worker's connection partway through a response.
if (!throttle && !closing) errors.add(error);
} on HttpException catch (error) {
if (!throttle && !closing) errors.add(error);
} finally {
active--;
}
}
DownloadTask task(String id) => createDirectDownloadTask(
id: id,
title: const TitleRecord(id: 8, title: 'Fixture'),
source: MediaSource(url: 'http://127.0.0.1:${server.port}/video.mp4'),
supportsParallel: true,
preferences: AppSettings(
parallelDownloads: 3,
downloadNotifications: false,
),
).copyWith(baseDirectory: BaseDirectory.root, directory: root.path);
Future<void> verify(DownloadTask task) async {
final actual = await File(await task.filePath()).readAsBytes();
expect(actual.length, bytes.length);
expect(actual, orderedEquals(bytes));
expect(sha256.convert(actual), sha256.convert(bytes));
expect(errors, isEmpty);
expect(peak, greaterThan(1));
print(
'[multipart-result] ${task.taskId}: bytes=${actual.length} '
'sha256=${sha256.convert(actual)} ranges=$ranges peak=$peak',
);
}
Future<void> close() async {
closing = true;
shutdown.complete();
await server.close(force: true);
await Future.wait(handlers);
await root.delete(recursive: true);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final storage = _Storage();
late FileDownloader downloader;
final previousPlatform = debugDefaultTargetPlatformOverride;
final previousHttp = HttpOverrides.current;
setUpAll(() async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
HttpOverrides.global = null;
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
(call) async => ['wifi'],
);
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/connectivity_status'),
(call) async => null,
);
downloader = FileDownloader(persistentStorage: storage);
await downloader.ready;
});
tearDownAll(() {
downloader.destroy();
debugDefaultTargetPlatformOverride = previousPlatform;
HttpOverrides.global = previousHttp;
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
null,
);
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/connectivity_status'),
null,
);
});
setUp(() {
Logger.root.level = Level.ALL;
final logs = Logger.root.onRecord.listen((record) {
print(
'[multipart] ${record.loggerName} ${record.level}: ${record.message}',
);
if (record.error != null) print(record.error);
if (record.stackTrace != null) print(record.stackTrace);
});
addTearDown(logs.cancel);
});
test(
'uninterrupted real three-range transfer stitches exact bytes and SHA-256',
() async {
final fixture = _RangeFixture();
await fixture.start();
addTearDown(fixture.close);
await downloader.configure(
desktopConfig: (Config.tempFilePath, fixture.root.path),
);
final task = fixture.task('multipart-uninterrupted');
expect(task, isA<ParallelDownloadTask>());
final result = await downloader
.download(task, onProgress: (_) {})
.timeout(const Duration(seconds: 20));
expect(
result.status,
TaskStatus.complete,
reason: result.exception?.description,
);
await fixture.verify(task);
expect(fixture.ranges.toSet(), {
(0, _RangeFixture.chunkLength - 1),
(_RangeFixture.chunkLength, _RangeFixture.chunkLength * 2 - 1),
(_RangeFixture.chunkLength * 2, fixture.bytes.length - 1),
});
expect(fixture.ranges, hasLength(3));
},
);
test('default download transfers exact bytes from a GET-only server', () async {
final root = await Directory.systemTemp.createTemp('reelbox-get-only-');
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final payload = Uint8List.fromList(List.generate(262144, (i) => i % 239));
final methods = <String>[];
final ranges = <String?>[];
server.listen((request) async {
methods.add(request.method);
ranges.add(request.headers.value('range'));
if (request.method != 'GET' || request.headers.value('range') != null) {
request.response.statusCode = HttpStatus.methodNotAllowed;
} else {
request.response.headers.contentType = ContentType('video', 'mp4');
request.response.contentLength = payload.length;
request.response.add(payload);
}
await request.response.close();
});
addTearDown(() async {
await server.close(force: true);
await root.delete(recursive: true);
});
await downloader.configure(desktopConfig: (Config.tempFilePath, root.path));
final task =
createDirectDownloadTask(
id: 'get-only-download',
title: const TitleRecord(id: 81, title: 'A local test film'),
source: MediaSource(url: 'http://127.0.0.1:${server.port}/video.mp4'),
preferences: AppSettings(downloadNotifications: false),
).copyWith(
baseDirectory: BaseDirectory.root,
directory: root.path,
retries: 0,
);
final result = await downloader
.download(task, onProgress: (_) {})
.timeout(const Duration(seconds: 20));
expect(
result.status,
TaskStatus.complete,
reason: result.exception?.description,
);
final actual = await File(await task.filePath()).readAsBytes();
expect(actual, orderedEquals(payload));
expect(sha256.convert(actual), sha256.convert(payload));
expect(methods, ['GET']);
expect(ranges, [null]);
print(
'[get-only-result] bytes=${actual.length} sha256=${sha256.convert(actual)}',
);
});
for (final reload in [false, true]) {
test(
'resume after fast chunk completed preserves bytes/hash${reload ? ' with disk JSON rehydration' : ''}',
() async {
final fixture = _RangeFixture(throttle: true);
await fixture.start();
addTearDown(fixture.close);
await downloader.configure(
desktopConfig: (Config.tempFilePath, fixture.root.path),
);
final task = fixture.task(
reload ? 'multipart-rehydrated' : 'multipart-resume',
);
final statuses = <TaskStatus>[];
final complete = Completer<TaskStatusUpdate>();
downloader.registerCallbacks(
group: task.group,
taskProgressCallback: (_) {},
taskStatusCallback: (update) {
if (update.task.taskId != task.taskId) return;
statuses.add(update.status);
if (update.status.isFinalState && !complete.isCompleted) {
complete.complete(update);
}
},
);
final chunks = <Task>[];
addTearDown(() async {
await downloader.cancelTasksWithIds([
task.taskId,
...chunks.map((t) => t.taskId),
]);
await _until(
() async =>
(await downloader.allTasks(allGroups: true))
.where((t) => t.taskId == task.taskId || chunks.contains(t))
.isEmpty,
'worker cleanup',
);
downloader.unregisterCallbacks(group: task.group);
for (final chunk in chunks) {
final file = File(await chunk.filePath());
if (await file.exists()) await file.delete();
}
storage.paused.clear();
storage.resumes.clear();
});
expect(await downloader.enqueue(task), isTrue);
await _until(() async {
chunks.clear();
chunks.addAll(
await downloader.allTasks(group: FileDownloader.chunkGroup),
);
return chunks.length == 3;
}, 'three real chunk workers');
final fast = chunks.singleWhere(
(t) =>
t.headers['Range'] == 'bytes=0-${_RangeFixture.chunkLength - 1}',
);
await _until(
() async =>
await File(await fast.filePath()).exists() &&
!(await downloader.allTasks(group: FileDownloader.chunkGroup))
.contains(fast),
'fast chunk file and worker completion',
);
await _until(() async {
var partials = 0;
await for (final entry in fixture.root.list()) {
if (entry is File &&
entry.path.split('/').last.startsWith('com.bbflight.')) {
final length = await entry.length();
if (length > 0 && length < _RangeFixture.chunkLength) partials++;
}
}
return partials == 2;
}, 'both slow chunks to have actually written partial bytes');
expect(await downloader.pause(task), isTrue);
await _until(
() =>
statuses.contains(TaskStatus.paused) &&
storage.resumes.containsKey(task.taskId) &&
chunks
.where((t) => t != fast)
.every((t) => storage.resumes.containsKey(t.taskId)),
'parent and unfinished chunk resume records',
);
// The completed status is produced by the actual parent worker, not injected.
final savedChunks =
jsonDecode(storage.resumes[task.taskId]!.data) as List;
final savedFast = savedChunks.cast<Map<String, dynamic>>().singleWhere(
(c) => c['fromByte'] == 0,
);
expect(savedFast['status'], TaskStatus.complete.index);
expect(storage.resumes.containsKey(fast.taskId), isFalse);
expect(
await File(await fast.filePath()).readAsBytes(),
orderedEquals(fixture.bytes.sublist(0, _RangeFixture.chunkLength)),
);
final unfinished = chunks.where((t) => t != fast).toList();
final expectedResumedRanges = unfinished.map((t) {
final resume = storage.resumes[t.taskId]!;
expect(resume.requiredStartByte, greaterThan(0));
expect(resume.eTag, '"reelbox-fixture-v1"');
final original = RegExp(r'bytes=(\d+)-(\d+)')
.firstMatch(t.headers['Range']!)!;
return (
int.parse(original[1]!) + resume.requiredStartByte,
int.parse(original[2]!),
);
}).toSet();
expect(fixture.ranges, hasLength(3));
print(
'[multipart-paused] ${task.taskId}: completed=1 unfinished=2 '
'completedResumeRecord=false resumeRanges=$expectedResumedRanges',
);
var resumeTask = task;
if (reload) {
await storage.reloadResumeSnapshot(
File('${fixture.root.path}/resume.json'),
);
resumeTask = storage.paused[task.taskId]! as DownloadTask;
expect(identical(resumeTask, task), isFalse);
}
expect(
await downloader.resume(resumeTask),
isTrue,
reason:
'Completed chunks have no resume data and must not be resumed',
);
final result = await complete.future.timeout(
const Duration(seconds: 20),
);
expect(
result.status,
TaskStatus.complete,
reason: result.exception?.description,
);
expect(
statuses,
containsAllInOrder([
TaskStatus.running,
TaskStatus.paused,
TaskStatus.running,
TaskStatus.complete,
]),
);
await fixture.verify(task);
expect(fixture.ranges, hasLength(5));
expect(fixture.ranges.skip(3).toSet(), expectedResumedRanges);
expect(
fixture.ranges.where((r) => r.$1 == 0),
hasLength(1),
reason: 'The completed chunk must be reused, not downloaded again',
);
},
);
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/download_service.dart';
void main() {
test('new downloads have no stale notification templates after opt out', () {
final service = DownloadService.instance;
service.configureNotifications(true);
final enabledTask = DownloadTask(
url: 'https://example.test/a.mp4',
group: DownloadService.group,
notificationConfig: service.notificationConfigForNewTask,
);
expect(enabledTask.notificationConfig?.complete, isNotNull);
service.configureNotifications(false);
final disabledTask = DownloadTask(
url: 'https://example.test/b.mp4',
group: DownloadService.group,
notificationConfig: service.notificationConfigForNewTask,
);
expect(disabledTask.notificationConfig, isNull);
// Changing the preference does not lie about altering an active OS job.
expect(enabledTask.notificationConfig?.running, isNotNull);
});
}
+109
View File
@@ -0,0 +1,109 @@
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/download_service.dart';
import 'package:reelbox/state/app_state.dart';
void main() {
test('direct task uses pinned real multipart API and persists current source identity', () {
final task = createDirectDownloadTask(
id: 'parallel',
title: const TitleRecord(id: 6, title: 'Fixture', type: 'serie'),
source: const MediaSource(
url: 'https://example.test/video.mp4',
quality: '720p',
type: 'mp4',
),
episode: const EpisodeRecord(id: 9, title: 'Episode'),
supportsParallel: true,
preferences: AppSettings(
parallelDownloads: 4,
downloadNotifications: false,
),
);
expect(task, isA<ParallelDownloadTask>());
expect((task as ParallelDownloadTask).chunks, 4);
expect(task.allowPause, isTrue);
final metadata = jsonDecode(task.metaData) as Map;
expect(metadata['sourceType'], 'mp4');
expect(metadata['episodeId'], 9);
expect(metadata['titleKind'], 'serie');
expect(metadata['downloadNotifications'], false);
expect(Task.createFromJson(task.toJson()), isA<ParallelDownloadTask>());
});
test('excluded legacy tasks are canceled without deleting completed files or records', () async {
final task = DownloadTask(
taskId: 'legacy',
url: 'https://example.test/a.mp4',
metaData: jsonEncode({
'titleId': 7,
'title': 'Melody Box',
'titleKind': 'movie',
'downloadNotifications': true,
}),
);
final canceled = <String>[];
await retireExcludedDownload(
TaskRecord(task, TaskStatus.running, 0, 0),
cancel: (id) async {
canceled.add(id);
return true;
},
);
expect(canceled, ['legacy']);
canceled.clear();
await retireExcludedDownload(
TaskRecord(task, TaskStatus.complete, 1, 100),
cancel: (id) async {
canceled.add(id);
return true;
},
);
expect(canceled, isEmpty);
expect(
DownloadEntry.fromTask(task, taskStatus: TaskStatus.complete).visible,
false,
);
});
test(
'retry retains old failed record until replacement enqueue succeeds',
() async {
final old = DownloadTask(
taskId: 'old',
url: 'https://example.test/video.mp4',
);
final next = old.copyWith(taskId: 'new', filename: 'new.mp4');
final events = <String>[];
await expectLater(
replaceFailedDownload(
old,
next,
enqueue: (task) async {
events.add('enqueue');
return false;
},
deleteRecord: (id) async {
events.add('delete:$id');
},
),
throwsStateError,
);
expect(events, ['enqueue']);
events.clear();
await replaceFailedDownload(
old,
next,
enqueue: (task) async {
events.add('enqueue');
return true;
},
deleteRecord: (id) async {
events.add('delete:$id');
},
);
expect(events, ['enqueue', 'delete:old']);
},
);
}
+74
View File
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
// Exercise the pinned plugin's resolver without native/network initialization.
// ignore: implementation_imports
import 'package:background_downloader/src/base_downloader.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/download_service.dart';
void main() {
for (final enabled in [true, false]) {
test(
'restart resume preserves original notifications=$enabled before dispatch',
() async {
final service = DownloadService.instance;
service.configureNotifications(enabled);
final original = DownloadTask(
url: 'https://example.test/a.mp4',
group: DownloadService.group,
notificationConfig: service.notificationConfigForNewTask,
metaData: downloadMetadata(
title: const TitleRecord(id: 7, title: 'Fixture'),
source: const MediaSource(url: 'https://example.test/a.mp4'),
notifications: enabled,
),
);
final recovered = DownloadTask.fromJson(
jsonDecode(jsonEncode(original.toJson())),
);
expect(recovered.notificationConfig, isNull);
service.configureNotifications(!enabled);
final events = <String>[];
final registry = <TaskNotificationConfig>{};
final ok = await resumeDownloadTask(
DownloadEntry.fromTask(recovered),
configure: (task, config) {
events.add('configure');
registry.add(
TaskNotificationConfig(
taskOrGroup: task,
running: config.running,
complete: config.complete,
error: config.error,
paused: config.paused,
progressBar: config.progressBar,
tapOpensFile: config.tapOpensFile,
),
);
},
resume: (task) async {
events.add('resume');
final registered =
BaseDownloader.notificationConfigForTaskUsingConfigSet(
task,
registry,
);
expect(registered?.running != null, enabled);
expect(registered?.complete != null, enabled);
expect(registered?.error != null, enabled);
expect(registered?.paused != null, enabled);
return true;
},
);
expect(ok, isTrue);
expect(events, enabled ? ['configure', 'resume'] : ['resume']);
expect(
jsonDecode(recovered.metaData)['downloadNotifications'],
enabled,
);
},
);
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/download_service.dart';
void main() {
test(
'download refresh awaits durable records rather than repainting old state',
() async {
final pending = Completer<List<TaskRecord>>();
var calls = 0;
var finished = false;
final result = readDownloadEntries(() {
calls++;
return pending.future;
});
result.then((_) => finished = true);
await Future<void>.delayed(Duration.zero);
expect(calls, 1);
expect(finished, isFalse);
final task = DownloadTask(
taskId: 'reloaded',
url: 'https://example.test/a.mp4',
baseDirectory: BaseDirectory.root,
directory: '/reelbox-offline',
filename: 'a.mp4',
);
pending.complete([TaskRecord(task, TaskStatus.paused, .4, 1000)]);
final entries = await result;
expect(entries.single.id, 'reloaded');
expect(entries.single.receivedBytes, 400);
expect(entries.single.canResume, isTrue);
expect(entries.single.path, '/reelbox-offline/a.mp4');
},
);
}
+241
View File
@@ -0,0 +1,241 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/download_service.dart';
import 'package:reelbox/media/downloads_page.dart';
import 'package:reelbox/state/app_state.dart';
import 'catalog_widget_test.dart' show CatalogApi, mountCatalog;
Future<AppState> mountRow(WidgetTester tester, Widget row) async {
final state = await mountCatalog(tester, CatalogApi(), const Scaffold());
await tester.pumpWidget(
AppScope(
state: state,
child: MaterialApp(home: Scaffold(body: row)),
),
);
await tester.pump();
return state;
}
void main() {
testWidgets('secondary external downloaders live behind the quiet overflow', (
tester,
) async {
final selected = <String>[];
final state = await mountRow(
tester,
ExternalDownloadMenu(onSelected: selected.add),
);
addTearDown(state.dispose);
expect(find.byIcon(Icons.more_vert), findsOneWidget);
expect(find.byIcon(Icons.download_for_offline_outlined), findsNothing);
expect(find.text('ADM'), findsNothing);
expect(selected, isEmpty);
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
expect(find.text('External downloader: ADM'), findsOneWidget);
expect(find.text('External downloader: IDM'), findsOneWidget);
await tester.tap(find.text('External downloader: ADM'));
await tester.pumpAndSettle();
expect(selected, ['ADM']);
});
for (final status in [TaskStatus.paused, TaskStatus.failed]) {
testWidgets(
'${status.name} exposes its labeled recovery action and readable failures',
(tester) async {
var attempts = 0;
final entry = DownloadEntry.fromTask(
DownloadTask(url: 'https://media.example/movie.mp4'),
taskStatus: status,
);
Future<void> recover() async {
attempts++;
throw StateError(
'Cannot resume: this server does not support ranges.',
);
}
final state = await mountRow(
tester,
DownloadControls(
entry: entry,
onPause: () async {},
onResume: recover,
onRetry: recover,
onCancel: () async {},
),
);
addTearDown(state.dispose);
final label = status == TaskStatus.paused ? 'Resume' : 'Retry';
expect(find.widgetWithText(FilledButton, label), findsOneWidget);
await tester.tap(find.text(label));
await tester.pumpAndSettle();
expect(attempts, 1);
expect(
find.text('Cannot resume: this server does not support ranges.'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
);
}
testWidgets(
'failed unknown-length transfer stops animating and shows its actual cause',
(tester) async {
final entry = DownloadEntry.fromTask(
DownloadTask(url: 'https://media.example/movie.mp4'),
taskStatus: TaskStatus.failed,
error: 'Connection refused by media server',
);
final state = await mountRow(
tester,
DownloadTransferDetails(entry: entry),
);
addTearDown(state.dispose);
expect(
tester
.widget<LinearProgressIndicator>(
find.byType(LinearProgressIndicator),
)
.value,
0,
);
expect(find.textContaining('%'), findsNothing);
expect(find.text('Connection refused by media server'), findsOneWidget);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
},
);
testWidgets(
'download controls are labeled and dispatch once while an operation is pending',
(tester) async {
final pending = Completer<void>();
var pauses = 0;
var cancels = 0;
final entry = DownloadEntry.fromTask(
DownloadTask(url: 'https://media.example/movie.mp4'),
taskStatus: TaskStatus.running,
);
final state = await mountRow(
tester,
DownloadControls(
entry: entry,
onPause: () {
pauses++;
return pending.future;
},
onResume: () async {},
onRetry: () async {},
onCancel: () async {
cancels++;
},
),
);
addTearDown(state.dispose);
expect(find.widgetWithText(FilledButton, 'Pause'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Cancel'), findsOneWidget);
await tester.tap(find.text('Pause'));
await tester.tap(find.text('Pause'));
await tester.pump();
expect(pauses, 1);
expect(
tester
.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, 'Cancel'),
)
.onPressed,
isNull,
);
pending.complete();
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(cancels, 1);
expect(tester.takeException(), isNull);
},
);
for (final status in <TaskStatus, String>{
TaskStatus.enqueued: 'Queued',
TaskStatus.running: 'Downloading',
TaskStatus.waitingToRetry: 'Waiting to retry',
TaskStatus.paused: 'Paused',
TaskStatus.complete: 'Downloaded',
TaskStatus.failed: 'Failed',
TaskStatus.canceled: 'Canceled',
TaskStatus.notFound: 'Source not found',
}.entries) {
testWidgets(
'${status.key.name} has a readable status and preserves measured progress',
(tester) async {
final entry = DownloadEntry.fromTask(
DownloadTask(url: 'https://media.example/movie.mp4'),
taskStatus: status.key,
progress: .25,
totalBytes: 4096,
speed: 2048,
error: status.key == TaskStatus.failed ? 'HTTP 403: expired URL' : '',
);
final state = await mountRow(
tester,
DownloadTransferDetails(entry: entry),
);
addTearDown(state.dispose);
expect(find.text(status.value), findsOneWidget);
expect(
tester
.widget<LinearProgressIndicator>(
find.byType(LinearProgressIndicator),
)
.value,
status.key == TaskStatus.complete ? 1 : .25,
);
expect(
find.textContaining('/s'),
status.key == TaskStatus.running ? findsOneWidget : findsNothing,
);
if (status.key == TaskStatus.failed) {
expect(find.text('HTTP 403: expired URL'), findsOneWidget);
}
expect(tester.takeException(), isNull);
},
);
}
testWidgets(
'queued Wi-Fi download shows its constraint without invented progress',
(tester) async {
final entry = DownloadEntry.fromTask(
DownloadTask(
url: 'https://media.example/movie.mp4',
requiresWiFi: true,
),
);
final state = await mountRow(
tester,
DownloadTransferDetails(entry: entry),
);
addTearDown(state.dispose);
expect(find.text('Queued · Wi-Fi required'), findsOneWidget);
expect(find.textContaining('0%'), findsNothing);
expect(find.textContaining('/s'), findsNothing);
expect(
tester
.widget<LinearProgressIndicator>(
find.byType(LinearProgressIndicator),
)
.value,
isNull,
);
expect(tester.takeException(), isNull);
},
);
}
+145
View File
@@ -0,0 +1,145 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/ui/catalog.dart';
import 'package:reelbox/media/download_button.dart';
import 'package:reelbox/media/downloads_page.dart';
import 'catalog_widget_test.dart' show CatalogApi, mountCatalog;
void main() {
testWidgets(
'embedded sources are rejected without enqueue or external handoff',
(tester) async {
var dispatched = 0;
var navigations = 0;
final state = await mountCatalog(
tester,
CatalogApi(),
Scaffold(
body: DownloadButton(
title: const TitleRecord(id: 7, title: 'Movie'),
source: const MediaSource(
type: 'youtube',
url: 'https://youtu.be/fixture',
),
enqueue: ({required title, required source, episode}) async {
dispatched++;
},
openDownloads: (_) => navigations++,
),
),
);
addTearDown(state.dispose);
await tester.tap(find.text('Download in Reelbox'));
await tester.pumpAndSettle();
expect(dispatched, 0);
expect(navigations, 0);
expect(
find.textContaining('Embedded sources cannot be downloaded'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
},
);
testWidgets('source cards label the internal Reelbox download action', (
tester,
) async {
final state = await mountCatalog(
tester,
CatalogApi(),
const TitlePage(title: TitleRecord(id: 7, title: 'Fixture')),
);
addTearDown(state.dispose);
expect(
find.widgetWithText(FilledButton, 'Download in Reelbox'),
findsWidgets,
);
});
testWidgets(
'enqueue failure shows its cause and allows retry without navigating',
(tester) async {
var attempts = 0;
var navigations = 0;
final state = await mountCatalog(
tester,
CatalogApi(),
Scaffold(
body: DownloadButton(
title: const TitleRecord(id: 7, title: 'Movie'),
source: const MediaSource(url: 'https://media.example/movie.mp4'),
enqueue: ({required title, required source, episode}) async {
attempts++;
throw StateError('HTTP 403: this media URL has expired.');
},
openDownloads: (_) => navigations++,
),
),
);
addTearDown(state.dispose);
await tester.tap(find.text('Download in Reelbox'));
await tester.pumpAndSettle();
expect(
find.textContaining('HTTP 403: this media URL has expired.'),
findsOneWidget,
);
expect(navigations, 0);
expect(tester.takeException(), isNull);
await tester.tap(find.text('Download in Reelbox'));
await tester.pump();
expect(attempts, 2);
},
);
testWidgets(
'selected episode queues once while starting then opens Downloads',
(tester) async {
const title = TitleRecord(id: 41, title: 'Series', type: 'serie');
const source = MediaSource(
id: 9,
quality: '720p',
url: 'https://media.example/episode.mp4',
);
const episode = EpisodeRecord(id: 52, title: 'Episode 2');
final pending = Completer<void>();
final dispatched = <(TitleRecord, MediaSource, EpisodeRecord?)>[];
final state = await mountCatalog(
tester,
CatalogApi(),
Scaffold(
body: DownloadButton(
title: title,
source: source,
episode: episode,
enqueue: ({required title, required source, episode}) {
dispatched.add((title, source, episode));
return pending.future;
},
),
),
);
addTearDown(state.dispose);
final button = find.widgetWithText(FilledButton, 'Download in Reelbox');
await tester.tap(button);
await tester.tap(button);
await tester.pump();
expect(dispatched, [(title, source, episode)]);
expect(
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
isNull,
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byType(DownloadsPage), findsNothing);
pending.complete();
await tester.pumpAndSettle();
expect(find.byType(DownloadsPage), findsOneWidget);
expect(dispatched, hasLength(1));
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
}
+10
View File
@@ -0,0 +1,10 @@
[
{
"title": [
"ترسناک"
],
"link": [
"http://boxlimon.com/api-user/newapi/filter.php?action=pishnahad&garid=1&pageno="
]
}
]
+49
View File
@@ -0,0 +1,49 @@
{
"id": 35721,
"type": "movie",
"title": "The Canvas",
"description": "نام اصلی: The Canvas\r\n\r\nامتیاز فیلم: 5.2/ 10\r\n\r\nنام پارسی: تابلوی نفرین‌ شده \r\n\r\nژانر : ترسناک\r\n\r\nسال تولید : 2026\r\n \r\nمحصول : آمریکا , ایتالیا\r\n\r\nزبان : انگلیسی\r\n\r\nرده سنی : 16\r\n\r\nکارگردان: John Real\r\n\r\nبازیگران: Angus Macfadyen, Francesco Marzagalli, John Real ,Nicolette McKeown ,Sabrina Culver\r\n\r\nفرمت: mkv\r\n\r\nکیفیت : 720-----1080\r\n\r\nحجم: 734 مگابایت + 1.48 گیگابایت\r\n\r\n\r\nخلاصه داستان : \r\n\r\nدر خانه‌ای دورافتاده، یک پدر و پسرش نقاشی‌ای را کشف می‌کنند که به شکلی نگران‌کننده زنده به نظر می‌رسد. وقتی تاریک‌ترین کابوس‌های آن‌ها وارد واقعیت می‌شوند، حضوری خبیث و شوم از دل بوم نقاشی بیرون می‌آید و از ترومای مدفون‌ شده و خاطرات فراموش‌شده تغذیه می‌کند. \r\n\r\n\r\nزیرنویس چسبیده پارسی",
"year": 2026,
"imdb": 5.2,
"comment": true,
"rating": 5,
"duration": null,
"downloadas": "2",
"playas": "1",
"classification": null,
"image": "https://ninwinios.info/uploads/cache/poster_thumb/uploads/jpg/6d8522b17182b73b9d6925ce15e63e72.jpg",
"cover": "https://ninwinios.info/uploads/cache/cover_thumb/uploads/jpg/14a7f16e0381c30b03bf8a6bd29e12a0.jpg",
"genres": [
{
"id": 1,
"title": "ترسناک"
}
],
"sources": [
{
"id": 520531,
"quality": "تیزر",
"type": "mp4",
"url": "http://peer.tci.cdn.146.dl150m.info/tizer/%D8%AF%D8%A7%D9%86%D9%84%D9%88%D8%AF%20%D9%81%DB%8C%D9%84%D9%85%20The%20Canvas%202026%20%D8%AA%D8%A7%D8%A8%D9%84%D9%88%DB%8C%20%D9%86%D9%81%D8%B1%DB%8C%D9%86%E2%80%8C%D8%B4%D8%AF%D9%87%20%D8%A8%D8%AF%D9%88%D9%86%20%D8%B3%D8%A7%D9%86%D8%B3%D9%88%D8%B1.mp4"
},
{
"id": 520539,
"quality": "720 زیرنویس",
"type": "mkv",
"url": "http://peer.tci.cdn.146.dl150m.info/film1/The.Canvas.2026.720p.WEBRip.x264.AAC-YTS.SoftSub.mkv"
},
{
"id": 520540,
"quality": "1080 زیرنویس",
"type": "mkv",
"url": "http://peer.tci.cdn.146.dl150m.info/film1/The.Canvas.2026.1080p.WEBRip.x264.AAC5.1-YTS.SoftSub.mkv"
}
],
"country": [
{
"id": 1,
"title": "امریکا",
"image": "https://ninwinios.info/uploads/cache/country_thumb/uploads/jpg/b8b281c0e76be9a34a53edefa904ca99.jpg"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
1
00:00:01,000 --> 00:00:03,000
׃בַד ֿהםַ÷ �־װ �� ��
+20
View File
@@ -0,0 +1,20 @@
[
{
"id": "32789",
"title": "Avatar Fire and Ash",
"year": "2026",
"type": "movie",
"imdb": "10",
"posted_id": "2324949",
"image": "http://simbaapp.info/uploads/cache/poster_thumb/uploads/jpg/f30c48e3f60d5addd2be49782d82d9f0.jpg"
},
{
"id": "18602",
"title": "Avatar The Way of Water",
"year": "2022",
"type": "movie",
"imdb": "10",
"posted_id": "558704",
"image": "http://simbaapp.info/uploads/cache/poster_thumb/uploads/jpg/8b2c739617695430fffe9a297fd779cb.jpg"
}
]
+40
View File
@@ -0,0 +1,40 @@
{
"id": 4263,
"title": "فصل اول 480 زیرنویس",
"episodes": [
{
"id": 72819,
"title": "1",
"description": "",
"duration": "",
"downloadas": "2",
"playas": "1",
"image": "https://ninwinios.info/uploads/cache/episode_thumb/uploads/jpg/1003.jpg",
"sources": [
{
"id": 83260,
"quality": "",
"type": "",
"url": "http://uplink.tci2.giftmond.ir/tv/The%20I-Land/FASL1480/The.I-Land.S01E01.480p.WEB-DL.EasyTV.mkv"
}
]
},
{
"id": 72820,
"title": "2",
"description": "",
"duration": "",
"downloadas": "2",
"playas": "1",
"image": "https://ninwinios.info/uploads/cache/episode_thumb/uploads/jpg/1003.jpg",
"sources": [
{
"id": 83261,
"quality": "",
"type": "",
"url": "http://uplink.tci2.giftmond.ir/tv/The%20I-Land/FASL1480/The.I-Land.S01E02.480p.WEB-DL.EasyTV.mkv"
}
]
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"id": 35716,
"title": "A Friend A Murderer",
"type": "serie",
"description": "نام اصلی سریال : A Friend A Murderer\r\n\r\nامتیاز سریال : 6.5/10\r\n\r\nنام پارسی سریال : یک دوست یک قاتل\r\n\r\nژانر : جنایی , مستند\r\n\r\nتاریخ انتشار: 2026\r\n\r\nمحصول کشور: آمریکا\r\n\r\nزبان: انگلیسی\r\n\r\nرده سنی: 16\r\n\r\nتعداد قسمت ها: 3 قسمت\r\n\r\nبازیگران: Magnus Fjeldsted Bach , Lasse Reimer\r\n\r\nفرمت: mkv\r\n\r\nکیفیت 720\r\n\r\nحجم: کیفیت 720 (502------602 مگابایت )\r\n\r\n\r\nخلاصه سریال : \r\n\r\nیک شهر در دانمارک به مدت هشت سال از دست یک مجرم سریالی در هراس بود. پس از دستگیری این فرد، آماندا، نیکلاس و کیری دریافتند که او همان ....\r\n\r\n\r\nزیرنویس چسبیده پارسی\r\n\r\n\r\nوضعیت مینی سریال: به پایان رسیده است",
"year": 2026,
"rating": 3,
"imdb": 6.5,
"comment": true,
"duration": "1 فصل",
"downloadas": null,
"classification": "N/A",
"image": "https://ninwinios.info/uploads/cache/poster_thumb/uploads/jpg/0ef8153ccfed80cb115f42183fb97e75.jpg",
"cover": "https://ninwinios.info/uploads/cache/cover_thumb/uploads/jpg/3fcbb1c3bddab31227b9ee0b82a1f536.jpg",
"genres": [
{
"id": 7,
"title": "جنایی"
},
{
"id": 15,
"title": "مستند"
}
],
"country": [
{
"id": 1,
"title": "امریکا"
}
]
}
+174
View File
@@ -0,0 +1,174 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/home_banners.dart';
class BannerApi extends ApiService {
String? requested;
dynamic response;
bool fail = false;
@override
Future<dynamic> get(String path, {Map<String, dynamic>? query}) async {
requested = path;
if (fail) throw const ApiException('Offline');
return response;
}
}
void main() {
test('real trusted transport preserves configured ads without restoring vendor feeds', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
final paths = <String>[];
server.listen((request) async {
paths.add(request.uri.path);
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode({
'banners': [
{
'title_en': 'Fixture sponsor',
'target_url': 'https://sponsor.example/offer',
},
{
'title_en': 'Melodybox fixture',
'target_url': 'https://vendor.example/',
},
{
'title_en': 'Blocked link',
'target_url': 'https://t.me/OtherChannel',
},
],
}),
);
await request.response.close();
});
final api = ApiService(baseUrl: 'http://127.0.0.1:${server.port}/api/');
final banners = await HomeBannerService(api).load();
expect(paths, ['/api/banners/']);
expect(banners, hasLength(2));
expect(banners.first, HomeBanner.telegram);
expect(banners.last.title(false), 'Fixture sponsor');
expect(banners.last.isAdvertisement, isTrue);
});
test(
'unsafe ads are skipped independently and config work is bounded',
() async {
final api = BannerApi();
final valid = {
'title_en': 'Fixture sponsor',
'target_url': 'https://sponsor.example/',
};
for (final target in [
'http://sponsor.example/',
'javascript:alert(1)',
'//sponsor.example/',
'https://user:pass@sponsor.example/',
'https://sponsor.example:444/',
'https://sponsor.example/ bad',
'https://sponsor.example/\\\\evil',
'https://',
'https://t.me/OtherChannel',
'https://sponsor.example/app.apk',
'https://sponsor.example/%0aevil',
'https://-bad.example/',
'https://bad..example/',
'https://bad.example./',
'https://sponsor.example/%zz',
'https://sponsor.example/${'x' * 2048}',
]) {
api.response = {
'banners': [
{...valid, 'target_url': target},
valid,
],
};
final banners = await HomeBannerService(api).load();
expect(banners, hasLength(2), reason: target);
expect(banners.last.target.toString(), 'https://sponsor.example/');
}
for (final invalid in [
null,
true,
4,
[],
{},
{'banners': {}},
{
'banners': [null, 7, true, {}],
},
]) {
api.response = invalid;
expect(await HomeBannerService(api).load(), [HomeBanner.telegram]);
}
api.response = {
'banners': [
{...valid, 'title_en': 'x' * 81},
{...valid, 'title_en': 'bad\u202etext'},
{...valid, 'subtitle_en': 'x' * 181},
{...valid, 'title_en': 5},
{...valid, 'image_url': 'http://sponsor.example/image.jpg'},
valid,
],
};
expect(await HomeBannerService(api).load(), hasLength(2));
api.response = {'banners': List.filled(1000, valid)};
expect(await HomeBannerService(api).load(), hasLength(6));
api.response = {
'banners': [...List.filled(20, {}), valid],
};
expect(await HomeBannerService(api).load(), [HomeBanner.telegram]);
},
);
test(
'configured bilingual ads append after Telegram with optional image',
() async {
final api = BannerApi()
..response = {
'banners': [
{
'title_en': 'Test sponsor',
'title_fa': 'حامی آزمایشی',
'subtitle_en': 'Fixture only',
'subtitle_fa': 'فقط دادهٔ آزمایشی',
'target_url': 'https://sponsor.example/offer?ref=reelbox',
'image_url': 'https://sponsor.example/banner.jpg',
},
{
'title_en': 'Text sponsor',
'target_url': 'https://sponsor.example/text',
},
],
};
final banners = await HomeBannerService(api).load();
expect(banners, hasLength(3));
expect(banners.first, same(HomeBanner.telegram));
expect(banners[1].isAdvertisement, isTrue);
expect(banners[1].title(true), 'حامی آزمایشی');
expect(banners[1].subtitle(true), 'فقط دادهٔ آزمایشی');
expect(banners[1].image.toString(), 'https://sponsor.example/banner.jpg');
expect(banners.last.title(true), 'Text sponsor');
expect(banners.last.image, isNull);
},
);
test(
'default Telegram banner survives missing config and API failures',
() async {
final api = BannerApi();
final service = HomeBannerService(api);
for (final fail in [false, true]) {
api.fail = fail;
final banners = await service.load();
expect(banners, hasLength(1));
expect(banners.single.target.toString(), 'https://t.me/ReelBoxApp');
expect(banners.single.title(false), 'ReelBox updates');
expect(banners.single.subtitle(false), 'Updates and announcements');
expect(banners.single.title(true), 'تازه‌های ریل‌باکس');
expect(banners.single.isAdvertisement, isFalse);
}
expect(api.requested, 'banners/');
},
);
}
+190
View File
@@ -0,0 +1,190 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/app.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/catalog.dart';
import 'package:shared_preferences/shared_preferences.dart';
class HomeBannerFixtureApi extends ApiService {
bool fail = false;
Completer<dynamic>? pending;
dynamic bannerResponse = {'banners': []};
int bannerCalls = 0;
@override
Future<dynamic> get(String path, {Map<String, dynamic>? query}) async {
if (path != 'banners/') return [];
bannerCalls++;
if (fail) throw const ApiException('Offline');
return pending == null ? bannerResponse : pending!.future;
}
@override
Future<void> initialize({bool discover = true}) async {}
@override
Future<Map<String, dynamic>> home() async {
if (fail) throw const ApiException('Offline');
return {'genres': [], 'actors': [], 'countries': []};
}
@override
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async => [];
@override
Future<List<Tag>> countries() async => [];
@override
Future<List<Tag>> genres() async => [];
}
Future<AppState> mountBannerHome(
WidgetTester tester,
HomeBannerFixtureApi api, {
String locale = 'en',
}) async {
SharedPreferences.setMockInitialValues({});
final state = AppState(
api: api,
storage: await SharedPreferences.getInstance(),
);
await state.initialize(discover: false);
await state.updateSettings(
state.prefs.copyWith(locale: locale, introCompleted: true),
);
await tester.pumpWidget(MyApp(state: state));
await tester.pumpAndSettle();
return state;
}
void main() {
testWidgets(
'pending config keeps Telegram then labels bilingual ads and honors data saver',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final api = HomeBannerFixtureApi()..pending = Completer<dynamic>();
final state = await mountBannerHome(tester, api);
expect(find.text('ReelBox updates'), findsOneWidget);
expect(find.text('Advertisement'), findsNothing);
api.pending!.complete({
'banners': [
{
'title_en': 'Fixture sponsor',
'title_fa': 'حامی آزمایشی',
'target_url': 'https://sponsor.example/offer',
'image_url': 'https://sponsor.example/banner.jpg',
},
],
});
await tester.pumpAndSettle();
expect(find.text('Advertisement'), findsOneWidget);
expect(find.text('Fixture sponsor'), findsOneWidget);
expect(find.byType(Image), findsOneWidget);
expect(find.text('ReelBox updates'), findsOneWidget);
final launches = <String>[];
const channel = MethodChannel('plugins.flutter.io/url_launcher');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
launches.add(call.arguments['url'] as String);
return true;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
await tester.tap(find.byKey(const Key('home-banner-ad-1')));
await tester.pumpAndSettle();
expect(launches, ['https://sponsor.example/offer']);
await state.updateSettings(
state.prefs.copyWith(locale: 'fa', dataSaver: true),
);
await tester.pumpAndSettle();
expect(find.text('تبلیغات'), findsOneWidget);
expect(find.text('حامی آزمایشی'), findsOneWidget);
expect(find.byType(Image), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
state.dispose();
},
);
testWidgets(
'failed external launch shows safe error and keeps official banner',
(tester) async {
const channel = MethodChannel('plugins.flutter.io/url_launcher');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(_) async => false,
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
final state = await mountBannerHome(tester, HomeBannerFixtureApi());
await tester.tap(find.byKey(const Key('home-banner-telegram')));
await tester.pump();
expect(find.text('Could not open this link'), findsOneWidget);
expect(find.text('ReelBox updates'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
state.dispose();
},
);
testWidgets(
'home Telegram is immediately clickable through API failure in both languages',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final launches = <MethodCall>[];
const channel = MethodChannel('plugins.flutter.io/url_launcher');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
call,
) async {
launches.add(call);
return true;
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
final api = HomeBannerFixtureApi()..fail = true;
final state = await mountBannerHome(tester, api);
expect(find.byType(DiscoverPage), findsOneWidget);
expect(find.text('ReelBox updates'), findsOneWidget);
expect(find.text('Updates and announcements'), findsOneWidget);
expect(find.text('Offline'), findsWidgets);
await tester.tap(find.byKey(const Key('home-banner-telegram')));
await tester.pumpAndSettle();
expect(launches.single.method, 'launch');
expect(launches.single.arguments['url'], 'https://t.me/ReelBoxApp');
expect(launches.single.arguments['useWebView'], false);
await state.updateSettings(state.prefs.copyWith(locale: 'fa'));
await tester.pumpAndSettle();
expect(find.text('تازه‌های ریل‌باکس'), findsOneWidget);
expect(find.text('به‌روزرسانی‌ها و اطلاعیه‌ها'), findsOneWidget);
expect(
Directionality.of(tester.element(find.text('تازه‌های ریل‌باکس'))),
TextDirection.rtl,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
state.dispose();
},
);
}
+153
View File
@@ -0,0 +1,153 @@
// Mobile layout checks; optional captures are host-rendered, not device evidence.
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/app.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/library.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _LocalApi extends ApiService {
@override
Future<void> initialize({bool discover = true}) async {}
@override
Future<Map<String, dynamic>> home() async => {
'genres': [],
'actors': [],
'countries': [],
};
@override
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async => [];
@override
Future<List<Tag>> countries() async => [];
}
void main() {
for (final locale in ['en', 'fa']) {
testWidgets(
'mobile intro, privacy and useful settings remain reachable in $locale',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final output = Platform.environment['REELBOX_CLEANUP_SCREENSHOTS'];
if (output != null) {
await tester.runAsync(() async {
final bytes = await File(
Platform.environment['REELBOX_TEST_FONT'] ??
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
).readAsBytes();
for (final name in ['Roboto', 'Ahem']) {
await (FontLoader(
name,
)..addFont(Future.value(bytes.buffer.asByteData()))).load();
}
final icons = await File(
Platform.environment['REELBOX_ICON_FONT'] ?? '/root/developer-tools/flutter/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf',
).readAsBytes();
await (FontLoader(
'MaterialIcons',
)..addFont(Future.value(icons.buffer.asByteData()))).load();
await Directory(output).create(recursive: true);
});
}
SharedPreferences.setMockInitialValues({});
final state = AppState(api: _LocalApi());
addTearDown(state.dispose);
await state.initialize(discover: false);
await state.updateSettings(state.prefs.copyWith(locale: locale));
final boundaryKey = GlobalKey();
await tester.pumpWidget(
RepaintBoundary(
key: boundaryKey,
child: MyApp(state: state),
),
);
await tester.pumpAndSettle();
Future<void> capture(String name) async {
expect(tester.takeException(), isNull);
if (output == null) return;
final boundary =
boundaryKey.currentContext!.findRenderObject()!
as RenderRepaintBoundary;
await tester.runAsync(() async {
final image = await boundary.toImage(pixelRatio: 2);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
await File('$output/$name-$locale.png')
.writeAsBytes(data!.buffer.asUint8List());
image.dispose();
});
}
String label(String en, String fa) => locale == 'en' ? en : fa;
Future<void> reveal(String text) async {
final scrollable = find.byType(Scrollable).hitTestable().first;
await tester.scrollUntilVisible(
find.text(text),
350,
scrollable: scrollable,
);
await tester.ensureVisible(find.text(text));
await tester.pumpAndSettle();
}
expect(find.byType(FirstRunIntroPage), findsOneWidget);
await capture('intro');
await tester.tap(find.byKey(const Key('intro-skip')));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(NavigationBar),
matching: find.text(label('More', 'بیشتر')),
),
);
await tester.pumpAndSettle();
expect(find.byType(MorePage), findsOneWidget);
await capture('more');
await reveal(label('Privacy & your data', 'حریم خصوصی و اطلاعات شما'));
await tester.tap(
find.text(label('Privacy & your data', 'حریم خصوصی و اطلاعات شما')),
);
await tester.pumpAndSettle();
await capture('privacy-top');
await reveal(label('Unencrypted media', 'رسانه بدون رمزنگاری'));
expect(find.textContaining('HTTP'), findsWidgets);
await capture('privacy-bottom');
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
await reveal(label('Settings', 'تنظیمات'));
await tester.tap(find.text(label('Settings', 'تنظیمات')));
await tester.pumpAndSettle();
expect(find.byType(SettingsPage), findsOneWidget);
expect(find.byKey(const Key('catalog-api-endpoint')), findsNothing);
await capture('settings-top');
final dataSaver = label('Data saver artwork', 'کاهش مصرف داده تصاویر');
await reveal(dataSaver);
await tester.tap(find.text(dataSaver));
await tester.pumpAndSettle();
expect(state.prefs.dataSaver, true);
await reveal(label('Clear disposable cache', 'پاک کردن حافظه موقت'));
expect(
find.text(label('Export local library', 'خروجی کتابخانه محلی')),
findsOneWidget,
);
expect(
find.text(label('Import local library', 'ورود کتابخانه محلی')),
findsOneWidget,
);
await capture('settings-data');
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
}
+233
View File
@@ -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);
},
);
}
}
+325
View File
@@ -0,0 +1,325 @@
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);
},
);
}
+143
View File
@@ -0,0 +1,143 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/local_media_server.dart';
void main() {
late Directory root;
late File video;
late HttpClient client;
final sessions = <LocalMediaServer>[];
setUp(() async {
root = await Directory(Platform.environment['TMPDIR'] ?? Directory.systemTemp.path)
.createTemp('reelbox-lan-test-');
video = await File('${root.path}/movie.mp4').writeAsString('0123456789');
client = HttpClient()..connectionTimeout = const Duration(seconds: 2);
});
tearDown(() async {
client.close(force: true);
for (final session in sessions) {
await session.close();
}
sessions.clear();
await root.delete(recursive: true);
});
Future<LocalMediaServer> start({File? file, File? subtitle, Map<String, File> extras = const {}}) async {
final session = await LocalMediaServer.start(
file: file ?? video,
subtitle: subtitle,
extraFiles: extras,
bindAddress: InternetAddress.loopbackIPv4,
advertisedHost: '127.0.0.1',
);
sessions.add(session);
return session;
}
Future<({HttpClientResponse response, List<int> bytes})> request(
Uri uri, {String method = 'GET', String? range}
) async {
final request = await client.openUrl(method, uri);
if (range != null) request.headers.set(HttpHeaders.rangeHeader, range);
final response = await request.close();
final bytes = await response.fold<List<int>>([], (all, chunk) => all..addAll(chunk));
return (response: response, bytes: bytes);
}
test('explicit session streams video and HEAD with exact type and length', () async {
final session = await start();
final get = await request(session.mediaUri);
expect(get.response.statusCode, 200);
expect(get.response.contentLength, 10);
expect(get.response.headers.contentType?.mimeType, 'video/mp4');
expect(utf8.decode(get.bytes), '0123456789');
final head = await request(session.mediaUri, method: 'HEAD');
expect(head.response.statusCode, 200);
expect(head.response.contentLength, 10);
expect(head.bytes, isEmpty);
expect(session.subtitleUri, isNull);
});
test('subtitle shares an unguessable independent session token', () async {
final sub = await File('${root.path}/captions.vtt').writeAsString('WEBVTT\n');
final first = await start(subtitle: sub);
final second = await start();
expect(first.mediaUri.pathSegments.first, matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
expect(first.subtitleUri!.pathSegments.first, first.mediaUri.pathSegments.first);
expect(second.mediaUri.pathSegments.first, isNot(first.mediaUri.pathSegments.first));
final response = await request(first.subtitleUri!);
expect(response.response.headers.contentType?.mimeType, 'text/vtt');
expect(utf8.decode(response.bytes), 'WEBVTT\n');
});
test('range GET and HEAD expose exact selected bytes and headers', () async {
final session = await start();
for (final value in <(String,String,String)>[
('bytes=2-5','2345','bytes 2-5/10'),
('bytes=7-','789','bytes 7-9/10'),
('bytes=-3','789','bytes 7-9/10'),
('bytes=8-99','89','bytes 8-9/10'),
]) {
final response = await request(session.mediaUri, range:value.$1);
expect(response.response.statusCode,206);
expect(utf8.decode(response.bytes),value.$2);
expect(response.response.headers.value('content-range'),value.$3);
expect(response.response.headers.value('accept-ranges'),'bytes');
}
final head = await request(session.mediaUri,method:'HEAD',range:'bytes=2-5');
expect(head.response.statusCode,206);
expect(head.response.contentLength,4);
expect(head.bytes,isEmpty);
});
test('invalid and unsupported multiple ranges return 416, not full video', () async {
final session = await start();
for(final range in ['bytes=100-','bytes=6-3','bytes=-0','items=0-1','bytes=0-1,4-5']) {
final response=await request(session.mediaUri,range:range);
expect(response.response.statusCode,416,reason:range);
expect(response.response.headers.value('content-range'),'bytes */10');
expect(response.bytes,isEmpty);
}
});
test('manifest extra resources are explicitly registered, with no directory access', () async {
final segment=await File('${root.path}/segment.ts').writeAsString('segment');
final session=await start(extras:{'nested/part.ts':segment});
expect(utf8.decode((await request(session.mediaUri.resolve('nested/part.ts'))).bytes),'segment');
expect((await request(session.mediaUri.resolve('segment.ts'))).response.statusCode,404);
expect((await request(session.mediaUri.replace(path:'/wrong/media.mp4'))).response.statusCode,404);
expect((await request(session.mediaUri.resolve('../private'))).response.statusCode,404);
await expectLater(start(extras:{'../private':segment}),throwsArgumentError);
await expectLater(start(extras:{'media.mp4':segment}),throwsArgumentError);
});
test('registered paths cannot follow a symlink replaced after session creation', () async {
final secret=await File('${root.path}/private.mp4').writeAsString('secret');
final link=await Link('${root.path}/selected.mp4').create(video.path);
final session=await start(file:File(link.path));
expect((await request(session.mediaUri)).response.statusCode,200);
await link.delete(); await link.create(secret.path);
expect((await request(session.mediaUri)).response.statusCode,404);
});
test('methods are restricted and Cast preflight exposes only registered resources', () async {
final session=await start();
final post=await request(session.mediaUri,method:'POST');
expect(post.response.statusCode,405);
final options=await request(session.mediaUri,method:'OPTIONS');
expect(options.response.statusCode,204);
expect(options.response.headers.value('access-control-allow-origin'),'*');
expect(options.bytes,isEmpty);
});
test('close is idempotent and listening socket no longer accepts requests', () async {
final session = await start();
await session.close();
await session.close();
expect(() => request(session.mediaUri), throwsA(isA<SocketException>()));
});
}
+147
View File
@@ -0,0 +1,147 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:reelbox/media/download_service.dart';
import 'dart:convert';
import 'package:reelbox/media/media_helpers.dart';
void main() {
test('sources retain public variants, remove trailers and unusable URLs', () {
const a = MediaSource(
id: 1,
quality: '720p',
url: 'http://media.example/a.mp4?token=x',
);
expect(
playableSources([
a,
a,
const MediaSource(url: 'javascript:alert(1)'),
const MediaSource(quality: 'trailer', url: 'https://example.com/t.mp4'),
]),
[a],
);
});
test(
'source preference selects exact quality without inventing an access gate',
() {
const sd = MediaSource(quality: '480p', url: 'https://example.com/s.mp4');
const hd = MediaSource(
quality: '1080p',
url: 'https://example.com/h.mp4',
);
expect(selectSource([sd, hd], preferredQuality: '1080p'), hd);
expect(selectSource([sd, hd], preferredQuality: 'unavailable'), sd);
expect(selectSource([]), isNull);
},
);
test(
'resume ignores completed and invalid positions and keeps exact identity',
() {
expect(
resumePosition(
position: const Duration(seconds: 30),
duration: const Duration(minutes: 20),
),
const Duration(seconds: 30),
);
expect(
resumePosition(
position: const Duration(seconds: -5),
duration: const Duration(minutes: 20),
),
Duration.zero,
);
expect(
resumePosition(
position: const Duration(seconds: 1190),
duration: const Duration(minutes: 20),
),
Duration.zero,
);
expect(watchKey(7, null), '7:movie');
expect(watchKey(7, 12), '7:12');
},
);
test('next episode stays ordered and does not silently skip an unavailable episode', () {
const episodes = [
EpisodeRecord(id: 1),
EpisodeRecord(id: 2),
EpisodeRecord(id: 3),
];
expect(nextEpisode(episodes, 1)?.id, 2);
expect(nextEpisode(episodes, 3), isNull);
expect(nextEpisode(episodes, 99), isNull);
});
test(
'download names are private-path safe and stream manifests rejected',
() {
expect(
downloadFilename(
title: '../A/B: Film',
identity: '7_8',
url: 'https://example.com/movie.mkv?secret=a',
),
'A_B_ Film_7_8.mkv',
);
expect(
downloadUnsupportedReason(
const MediaSource(url: 'https://example.com/master.m3u8'),
),
isNotNull,
);
expect(
downloadUnsupportedReason(
const MediaSource(url: 'https://example.com/a.mp4'),
),
isNull,
);
},
);
test('download metadata survives native task serialization with bounded progress', () {
final task = DownloadTask(
taskId: 'a',
url: 'http://example.com/video.mp4',
filename: 'video.mp4',
metaData: jsonEncode({
'title': 'Movie',
'titleId': 17,
'quality': '1080p',
'episodeId': 8,
'episodeTitle': 'Ep 8',
'posterImage': 'poster',
}),
);
final restored = DownloadTask.fromJson(task.toJson());
final entry = DownloadEntry.fromTask(
restored,
taskStatus: TaskStatus.paused,
progress: -5,
totalBytes: 1000,
);
expect(entry.title, 'Movie');
expect(entry.titleId, 17);
expect(entry.episodeId, 8);
expect(entry.progress, 0);
expect(entry.canResume, isTrue);
expect(entry.isComplete, isFalse);
expect(
DownloadEntry.fromTask(task, taskStatus: TaskStatus.complete).progress,
1,
);
expect(
DownloadEntry.fromTask(
task,
taskStatus: TaskStatus.failed,
progress: 1.7,
).progress,
1,
);
expect(
DownloadEntry.fromTask(task, taskStatus: TaskStatus.running).canPause,
isTrue,
);
});
}
+69
View File
@@ -0,0 +1,69 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
Map<String, dynamic> fixture(String name) =>
jsonDecode(File('test/fixtures/$name.json').readAsStringSync())
as Map<String, dynamic>;
void main() {
test('real series has no sources and retains the serie discriminator', () {
final serie = TitleRecord.fromJson(fixture('serie'));
expect(serie.id, 35716);
expect(serie.type, 'serie');
expect(serie.isSeries, isTrue);
expect(serie.sources, isEmpty);
expect(TitleRecord.fromJson(serie.toJson()).type, 'serie');
});
test('real season retains episode/source variants and unknown metadata', () {
final raw = fixture('season');
final season = SeasonRecord.fromJson(raw);
expect(season.id, 4263);
expect(season.episodes, hasLength(2));
expect(season.episodes.first.id, 72819);
expect(season.episodes.first.sources.single.id, 83260);
expect(season.episodes.first.sources.single.quality, '');
expect(
season.episodes.first.sources.single.url,
raw['episodes'][0]['sources'][0]['url'],
);
expect(season.episodes.first.raw['playas'], '1');
expect(
SeasonRecord.fromJson(season.toJson()).episodes.first.toJson(),
season.episodes.first.toJson(),
);
});
test(
'real movie preserves source IDs, URLs, countries and entitlement metadata',
() {
final raw = fixture('movie');
final movie = TitleRecord.fromJson(raw);
expect(movie.id, 35721);
expect(movie.type, 'movie');
expect(movie.title, 'The Canvas');
expect(movie.imdb, 5.2);
expect(movie.year, 2026);
expect(movie.duration, '');
expect(movie.classification, '');
expect(movie.genres.single.id, 1);
expect(movie.countries.single.id, 1);
expect(movie.sources.first.id, 520531);
expect(movie.sources.first.url, raw['sources'][0]['url']);
expect(movie.sources.first.isTrailer, isTrue);
expect(movie.sources[1].isTrailer, isFalse);
expect(movie.raw['playas'], '1');
expect(movie.raw['downloadas'], '2');
expect(movie.isSeries, isFalse);
expect(movie.isChannel, isFalse);
final restored = TitleRecord.fromJson(movie.toJson());
expect(restored.sources[1].url, movie.sources[1].url);
expect(restored.raw['playas'], '1');
expect(
restored.countries.single.toJson(),
movie.countries.single.toJson(),
);
},
);
}
+125
View File
@@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('Cast prepares an owned resource only after an explicit request and revokes on disconnect', () async {
final prepared = Completer<Map<String, dynamic>>();
final events = <String>[];
final payloads = <Map<String, dynamic>>[];
var preparations = 0, revocations = 0;
final session = PlayerNativeSession(
invoke: (method, args) async {
events.add(method);
if (method == 'castLoad') payloads.add(args!);
return true;
},
local: (action, _) async => events.add(action),
media: () => {'url': '/selected/offline.mp4', 'title': 'Film'},
prepareMedia: () {
preparations++;
return prepared.future;
},
revokeMedia: () async {
revocations++;
},
isPlaying: () => true,
isCurrent: () => true,
)..claim();
addTearDown(session.release);
await session.handle(const MethodCall('castChanged', {'connected': true}));
expect(events, isEmpty);
expect(preparations, 0);
expect(await session.picker(), true);
final load = session.handle(
const MethodCall('castChanged', {'connected': true}),
);
await Future<void>.delayed(Duration.zero);
expect(preparations, 1);
expect(events, ['castShowPicker']); // No premature pause or raw file URL.
prepared.complete({
'url': 'http://127.0.0.1:1234/synthetic/media.mp4',
'title': 'Film',
});
await load;
expect(events, ['castShowPicker', 'pause', 'castLoad']);
expect(payloads.single['url'], startsWith('http://'));
expect(session.casting, true);
await session.handle(const MethodCall('castChanged', {'connected': false}));
expect(revocations, 1);
expect(session.casting, false);
});
test('a newer media request supersedes pending preparation without casting stale media', () async {
final first = Completer<Map<String, dynamic>>();
final loaded = <String>[];
var preparations = 0;
final session = PlayerNativeSession(
castRequested: true,
invoke: (method, args) async {
if (method == 'castLoad') loaded.add(args!['url'] as String);
return true;
},
local: (_, _) async {},
media: () => {},
prepareMedia: () async {
if (++preparations == 1) return first.future;
return {'url': 'https://media.test/new.mp4'};
},
revokeMedia: () async {},
isPlaying: () => true,
isCurrent: () => true,
)..claim();
addTearDown(session.release);
final old = session.handle(
const MethodCall('castChanged', {'connected': true}),
);
await Future<void>.delayed(Duration.zero);
final newer = session.loadCast();
first.complete({'url': 'https://media.test/old.mp4'});
await Future.wait([old, newer]);
expect(preparations, 2);
expect(loaded, ['https://media.test/new.mp4']);
});
test(
'remote stop revokes sharing and connected polls do not restart it',
() async {
var live = false;
final native = <String>[];
final session = PlayerNativeSession(
castRequested: true,
invoke: (method, _) async {
native.add(method);
return true;
},
local: (_, _) async {},
media: () => {},
prepareMedia: () async {
live = true;
return {'url': 'https://media.test/film.mp4'};
},
revokeMedia: () async {
live = false;
},
isPlaying: () => true,
isCurrent: () => true,
)..claim();
addTearDown(session.release);
await session.handle(
const MethodCall('castChanged', {'connected': true}),
);
expect(live, true);
await session.command('stop');
expect(live, false);
expect(session.casting, false);
await session.handle(
const MethodCall('castChanged', {'connected': true}),
);
expect(native.where((method) => method == 'castLoad'), hasLength(1));
},
);
}
@@ -0,0 +1,140 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/media_platform.dart';
import 'package:reelbox/media/player_page.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'cast_media_share_test.dart' show syntheticPackage, fetch;
class _LocalNetwork extends HttpOverrides {}
void main() {
testWidgets(
'disposing an offline route keeps its real loopback lease until native exit',
(tester) async {
final root = await tester.runAsync(
() => Directory.systemTemp.createTemp('native-offline-route-'),
);
final package = await tester.runAsync(() => syntheticPackage(root!));
SharedPreferences.setMockInitialValues({});
final app = AppState(storage: await SharedPreferences.getInstance());
await app.initialize(discover: false);
const title = TitleRecord(id: 98, title: 'Offline fixture');
const source = MediaSource(
url: 'https://media.test/source.m3u8',
type: 'hls',
);
await app.recordWatch(
title,
sourceUrl: source.url,
positionSeconds: 600,
durationSeconds: 3600,
);
final exit = Completer<Map<String, dynamic>>();
final opened = Completer<Uri>();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method != 'openNativePlayer') return null;
opened.complete(Uri.parse((call.arguments as Map)['url'] as String));
return exit.future;
},
);
addTearDown(() async {
if (!exit.isCompleted) {
exit.complete({
'positionMs': 0,
'durationMs': 0,
'opened': false,
'completed': false,
});
}
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
);
app.dispose();
await tester.runAsync(() => root!.delete(recursive: true));
});
await tester.runAsync(
() => tester.pumpWidget(
AppScope(
state: app,
child: MaterialApp(
home: PlayerPage(
title: title,
source: source,
localFile: package!.path,
),
),
),
),
);
for (var attempt = 0; attempt < 100 && !opened.isCompleted; attempt++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 10)),
);
await tester.pump();
}
expect(
opened.isCompleted,
true,
reason: 'Offline preparation must dispatch a native open',
);
final uri = await tester.runAsync(
() => opened.future.timeout(const Duration(seconds: 2)),
);
expect(uri!.host, '127.0.0.1');
final before = await tester.runAsync(
() => HttpOverrides.runWithHttpOverrides(
() => fetch(uri),
_LocalNetwork(),
),
);
expect(before!.status, 200);
await tester.pumpWidget(const SizedBox());
final after = await tester.runAsync(
() => HttpOverrides.runWithHttpOverrides(
() => fetch(uri),
_LocalNetwork(),
),
);
expect(
after!.status,
200,
reason: 'The native activity is still reading this offline package',
);
exit.complete({
'positionMs': 1200000,
'durationMs': 3600000,
'opened': true,
'completed': false,
});
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 20)),
);
await tester.runAsync(
() => HttpOverrides.runWithHttpOverrides(() async {
await expectLater(fetch(uri), throwsA(isA<SocketException>()));
}, _LocalNetwork()),
);
expect(
app.history.single.positionSeconds,
600,
reason: 'Disposed routes must not save stale native results',
);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
}
+315
View File
@@ -0,0 +1,315 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/media_platform.dart';
import 'package:reelbox/media/player_page.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:shared_preferences/shared_preferences.dart';
const title = TitleRecord(id: 78, title: 'Film');
const source = MediaSource(
url: 'https://media.test/film.mkv?token=unchanged',
type: 'mkv',
);
Map<String, dynamic> exitAt(
int position, {
bool opened = true,
bool completed = false,
}) => {
'positionMs': position,
'durationMs': 3600000,
'opened': opened,
'completed': completed,
};
Future<AppState> mount(
WidgetTester tester, {
EpisodeRecord? episode,
List<EpisodeRecord> playlist = const [],
List<MediaSource> alternatives = const [],
String? localFile,
}) async {
SharedPreferences.setMockInitialValues({});
final state = AppState(storage: await SharedPreferences.getInstance());
await state.initialize(discover: false);
await state.updateSettings(state.prefs.copyWith(autoplay: true));
await state.recordWatch(
title,
sourceUrl: source.url,
positionSeconds: 600,
durationSeconds: 3600,
episodeId: episode?.id,
episodeTitle: episode?.title ?? '',
);
await tester.pumpWidget(
AppScope(
state: state,
child: MaterialApp(
home: PlayerPage(
title: title,
source: source,
episode: episode,
playlist: playlist,
alternatives: alternatives,
localFile: localFile,
),
),
),
);
await tester.pump();
return state;
}
void main() {
testWidgets(
'early native EOF never advances or records a completed episode',
(tester) async {
const first = EpisodeRecord(
id: 101,
title: 'Episode 1',
sources: [source],
);
const second = EpisodeRecord(
id: 102,
title: 'Episode 2',
sources: [MediaSource(url: 'https://media.test/episode2.mkv')],
);
final calls = <MethodCall>[];
final firstExit = Completer<Map<String, dynamic>>();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method != 'openNativePlayer') return null;
calls.add(call);
return calls.length == 1
? firstExit.future
: exitAt(0, opened: false);
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
),
);
final state = await mount(
tester,
episode: first,
playlist: [first, second],
);
addTearDown(state.dispose);
firstExit.complete(exitAt(180000, completed: true));
await tester.pumpAndSettle();
expect(
calls,
hasLength(1),
reason: 'Three minutes is not the end of this episode',
);
expect(state.history, hasLength(1));
expect(state.history.single.episodeId, first.id);
expect(state.history.single.positionSeconds, 180);
expect(state.history.single.durationSeconds, 3600);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets(
'auto-next recovery keeps alternatives and history on the current episode',
(tester) async {
const oldAlternative = MediaSource(
url: 'https://media.test/episode1-720.mkv',
quality: '720p',
);
const first = EpisodeRecord(
id: 101,
title: 'Episode 1',
sources: [source, oldAlternative],
);
const secondSource = MediaSource(url: 'https://media.test/episode2.mkv');
const secondAlternative = MediaSource(
url: 'https://media.test/episode2-720.mkv?token=unchanged',
quality: '720p',
);
const trailer = MediaSource(
url: 'https://media.test/episode2-trailer.mp4',
quality: 'Trailer',
);
const denied = MediaSource(
url: 'https://t.me/episode2',
quality: 'Denied',
);
const second = EpisodeRecord(
id: 102,
title: 'Episode 2',
sources: [secondSource, secondAlternative, trailer, denied],
);
final calls = <MethodCall>[];
final firstExit = Completer<Map<String, dynamic>>();
final recoveryExit = Completer<Map<String, dynamic>>();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method != 'openNativePlayer') return null;
calls.add(call);
if (calls.length == 1) return firstExit.future;
if (calls.length == 2) return exitAt(0, opened: false);
return recoveryExit.future;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
),
);
final state = await mount(
tester,
episode: first,
playlist: [first, second],
alternatives: first.sources,
);
addTearDown(state.dispose);
firstExit.complete(exitAt(3600000, completed: true));
await tester.pumpAndSettle();
expect(calls.length, 2);
expect(calls.last.arguments['url'], secondSource.url);
expect(find.text('Episode 2'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
await tester.tap(find.widgetWithText(ListTile, '720p'));
await tester.pump();
expect(calls.length, 3);
// Both episodes offer 720p: the row must dispatch episode 2, not 1.
final recoveryUrl = calls.last.arguments['url'];
recoveryExit.complete(exitAt(10000));
await tester.pumpAndSettle();
final recovered = state.history.singleWhere((e) => e.episodeId == 102);
expect(recovered.episodeTitle, second.title);
expect(recovered.positionSeconds, 10);
expect(recoveryUrl, secondAlternative.url);
expect(recovered.sourceUrl, secondAlternative.url);
expect(state.history.map((e) => e.episodeId).toSet(), {101, 102});
expect(find.text('Trailer'), findsNothing);
expect(find.text('Denied'), findsNothing);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets(
'Android default player dispatches VLC with resume and preserves source identity',
(tester) async {
final calls = <MethodCall>[];
final result = Completer<Map<String, dynamic>>();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
calls.add(call);
if (call.method == 'openNativePlayer') return result.future;
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
),
);
final state = await mount(tester);
addTearDown(state.dispose);
await tester.pump(const Duration(milliseconds: 100));
final call = calls.singleWhere(
(call) => call.method == 'openNativePlayer',
);
expect(call.arguments['url'], source.url);
expect(call.arguments['positionMs'], 595000);
expect(call.arguments['autoplay'], true);
expect(state.history.single.positionSeconds, 600);
result.complete(exitAt(700000));
await tester.pumpAndSettle();
expect(state.history.single.positionSeconds, 700);
expect(state.history.single.sourceUrl, source.url);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets('failed native open never overwrites a saved checkpoint', (
tester,
) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method == 'openNativePlayer') return exitAt(0, opened: false);
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
),
);
final state = await mount(tester);
addTearDown(state.dispose);
await tester.pumpAndSettle();
expect(state.history.single.positionSeconds, 600);
expect(find.text('External player'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
expect(tester.takeException(), isNull);
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets(
'natural native episode completion opens next episode without losing history',
(tester) async {
const first = EpisodeRecord(
id: 101,
title: 'Episode 1',
sources: [source],
);
const second = EpisodeRecord(
id: 102,
title: 'Episode 2',
sources: [MediaSource(url: 'https://media.test/episode2.mkv')],
);
final calls = <MethodCall>[];
final firstExit = Completer<Map<String, dynamic>>();
final secondExit = Completer<Map<String, dynamic>>();
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method != 'openNativePlayer') return null;
calls.add(call);
return calls.length == 1 ? firstExit.future : secondExit.future;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
),
);
final state = await mount(
tester,
episode: first,
playlist: [first, second],
);
addTearDown(state.dispose);
await tester.pump(const Duration(milliseconds: 100));
expect(calls.length, 1);
firstExit.complete(exitAt(3600000, completed: true));
await tester.pump(const Duration(milliseconds: 200));
expect(calls.length, 2);
expect(calls.last.arguments['url'], second.sources.single.url);
expect(calls.last.arguments['positionMs'], 0);
expect(state.history.single.episodeId, 101);
secondExit.complete(exitAt(10000));
await tester.pumpAndSettle();
expect(state.history.map((e) => e.episodeId).toSet(), {101, 102});
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
}
+269
View File
@@ -0,0 +1,269 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/native_player.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('org.reelbox/media');
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test('awaits exit and sends exact URL, title and checkpoint', () async {
final exit = Completer<Map<String, Object>>();
MethodCall? received;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) {
received = call;
return exit.future;
});
var returned = false;
const url = 'https://media.example.org/a%2Fb.m3u8?sig=a%2Bz&x=1';
final pending =
NativePlayerBridge.open(
url: url,
title: 'عنوان — episode',
position: const Duration(milliseconds: 12345),
autoplay: false,
externalPackage: 'vlc',
orientation: 'landscape',
videoFit: 'cover',
speed: 1.25,
subtitleEnabled: false,
).then((result) {
returned = true;
return result;
});
await Future<void>.delayed(Duration.zero);
expect(returned, isFalse);
expect(received!.method, 'openNativePlayer');
expect(received!.arguments, containsPair('url', url));
expect(received!.arguments, containsPair('positionMs', 12345));
expect(received!.arguments, containsPair('autoplay', false));
expect(received!.arguments, containsPair('externalPackage', 'vlc'));
expect(received!.arguments, containsPair('subtitleEnabled', false));
expect(received!.arguments, containsPair('orientation', 'landscape'));
expect(received!.arguments, containsPair('videoFit', 'cover'));
expect(received!.arguments, containsPair('speed', 1.25));
exit.complete({
'positionMs': 42000,
'durationMs': 90000,
'opened': true,
'completed': false,
});
final result = await pending;
expect(result.position.inMilliseconds, 42000);
expect(result.duration.inMilliseconds, 90000);
expect(result.opened, isTrue);
expect(result.completed, isFalse);
});
test(
'local and loopback sources and optional subtitles are unchanged',
() async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return {
'positionMs': 0,
'durationMs': 0,
'opened': false,
'completed': false,
};
});
for (final url in [
'/data/user/0/org.reelbox.app/files/فیلم.mkv',
'http://127.0.0.1:49123/media/lease/index.m3u8?token=a%2Bz',
]) {
final result = await NativePlayerBridge.open(
url: url,
title: 'Film',
subtitleUrl: 'https://media.example.org/sub.vtt?token=a%2Fz',
hardwareAcceleration: false,
pipEnabled: false,
);
expect(calls.last.arguments, containsPair('url', url));
expect(
calls.last.arguments,
containsPair(
'subtitleUrl',
'https://media.example.org/sub.vtt?token=a%2Fz',
),
);
expect(
calls.last.arguments,
containsPair('hardwareAcceleration', false),
);
expect(calls.last.arguments, containsPair('pipEnabled', false));
expect(result.opened, isFalse);
}
expect(calls, hasLength(2));
},
);
test(
'rejects malformed exit maps instead of manufacturing progress',
() async {
for (final response in [
null,
{
'positionMs': -1,
'durationMs': 10,
'opened': true,
'completed': false,
},
{
'positionMs': double.nan,
'durationMs': 10,
'opened': true,
'completed': false,
},
{
'positionMs': 0x7fffffffffffffff,
'durationMs': 0,
'opened': true,
'completed': false,
},
{
'positionMs': 1.5,
'durationMs': 10,
'opened': true,
'completed': false,
},
{
'positionMs': 10,
'durationMs': -1,
'opened': true,
'completed': false,
},
{
'positionMs': 10,
'durationMs': 20,
'opened': 'true',
'completed': true,
},
]) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async => response);
await expectLater(
NativePlayerBridge.open(
url: 'https://media.example.org/video.mp4',
title: 'Film',
),
throwsFormatException,
);
}
final bounded = NativePlaybackResult.fromMap({
'positionMs': 1000,
'durationMs': 100,
'opened': false,
'completed': true,
});
expect(bounded.position.inMilliseconds, 100);
expect(bounded.completed, isFalse);
},
);
test('completion requires a credible near-end checkpoint, not just EOF', () {
for (final sample in <Map<String, Object>>[
{'positionMs': 180000, 'durationMs': 3600000},
{'positionMs': 180000, 'durationMs': 0},
{'positionMs': 0, 'durationMs': 3600000},
{'positionMs': 3600001, 'durationMs': 3600000},
{'positionMs': 3597000, 'durationMs': 3600000},
{'positionMs': 3600000, 'durationMs': 3600000, 'opened': false},
{'positionMs': 3600000, 'durationMs': 3600000, 'completed': false},
]) {
final result = NativePlaybackResult.fromMap({
'opened': true,
'completed': true,
...sample,
});
expect(result.completed, isFalse, reason: sample.toString());
}
for (final position in [3599500, 3600000]) {
final result = NativePlaybackResult.fromMap({
'positionMs': position,
'durationMs': 3600000,
'opened': true,
'completed': true,
});
expect(result.completed, isTrue);
}
});
test('platform failures and busy results propagate without retry', () async {
var calls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async {
calls++;
throw PlatformException(code: 'NATIVE_PLAYER_BUSY');
});
await expectLater(
NativePlayerBridge.open(
url: 'https://media.example.org/video.mp4',
title: 'Film',
),
throwsA(isA<PlatformException>()),
);
expect(calls, 1);
});
test('unsupported platforms do not call Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
channel,
(_) async => fail('Android was invoked'),
);
await expectLater(
NativePlayerBridge.open(
url: 'https://media.example.org/video.mp4',
title: 'Film',
),
throwsUnsupportedError,
);
});
test('invalid launch preferences fail before starting an activity', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async {
fail('Invalid preferences reached Android');
});
await expectLater(
NativePlayerBridge.open(
url: 'https://media.example.org/video.mp4',
title: 'Film',
speed: double.nan,
),
throwsArgumentError,
);
await expectLater(
NativePlayerBridge.open(
url: 'https://media.example.org/video.mp4',
title: 'Film',
orientation: 'invalid',
),
throwsArgumentError,
);
await expectLater(
NativePlayerBridge.open(
url: 'https://media.example.org/video.mp4',
title: 'Film',
videoFit: 'invalid',
),
throwsArgumentError,
);
await expectLater(
NativePlayerBridge.open(url: '', title: 'Film'),
throwsArgumentError,
);
});
}
+199
View File
@@ -0,0 +1,199 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/catalog_discovery.dart';
import 'package:reelbox/data/models.dart';
class DiscoveryFixtureApi extends ApiService {
final calls = <Map<String, dynamic>>[];
dynamic response = <dynamic>[];
@override
Future<dynamic> get(String path, {Map<String, dynamic>? query}) async {
calls.add({'path': path, 'query': query});
return response;
}
@override
Future<List<TitleRecord>> bestRandom({
bool byYear = false,
int page = 1,
}) async {
final rows = discoveryRows(
await get('fixture', query: {'action': 'best-rand-top', 'pageno': page}),
);
return CatalogPage(
decodeTitles(rows),
nextPage: rows.isEmpty ? null : page + 1,
);
}
@override
Future<List<TitleRecord>> search(
String query, {
int page = 1,
bool english = false,
}) async {
final rows = discoveryRows(
await get(
'fixture',
query: {
'action': english ? 'search-movie-en' : 'search-movie',
'q': query,
'pageno': page,
},
),
);
return CatalogPage(
decodeTitles(rows),
nextPage: rows.isEmpty ? null : page + 1,
);
}
final posterIds = <int>[];
@override
Future<TitleRecord> poster(int id) async {
posterIds.add(id);
return TitleRecord(id: id, title: 'Hydrated $id');
}
bool actorFails = false;
@override
Future<List<TitleRecord>> actorTitles(int id) async {
if (actorFails) throw const ApiException('Unavailable');
return [];
}
}
void main() {
test(
'thematic and franchise directories use distinct observed PHP routes',
() async {
final api = DiscoveryFixtureApi()
..response = [
{'id': 1, 'mazmon': 'Space'},
];
final service = CatalogDiscovery(api);
final themes = await service.directory(
DiscoveryDirectory.themes,
page: 2,
);
expect(themes.single['title'], 'Space');
// Live public mazmon ignores pageno and returns the entire directory.
expect(themes.nextPage, isNull);
expect(api.calls.last['path'], endsWith('/api-user/filmahang/star.php'));
expect((api.calls.last['query'] as Map)['action'], 'mazmon');
expect((api.calls.last['query'] as Map).containsKey('key'), isFalse);
await service.directory(DiscoveryDirectory.franchises, page: 3);
expect(api.calls.last['path'], endsWith('/api-user/newapi/like.php'));
expect(api.calls.last['query'], {'action': 'colection', 'pageno': 3});
},
);
test('most-shared hydrates IDs once in order and keeps raw cursor', () async {
final api = DiscoveryFixtureApi()
..response = [
{'id': '11'},
{'id': '11'},
{'id': 12},
];
final result = await CatalogDiscovery(api).sharedTitles(page: 7);
expect(result.map((t) => t.id), [11, 12]);
expect(api.posterIds, [11, 12]);
expect(result.nextPage, 8);
expect((api.calls.single['query'] as Map)['action'], 'share');
expect((api.calls.single['query'] as Map).containsKey('key'), isFalse);
});
test(
'search filters apply locally without truncating raw page cursor',
() async {
final api = DiscoveryFixtureApi()
..response = [
{'id': 1, 'title': 'Film', 'type': 'movie', 'year': 2000},
];
final result = await CatalogDiscovery(api).search(
'query',
page: 4,
filter: const DiscoverySearchFilter(kind: 'serie', minimumYear: 2020),
);
expect(result, isEmpty);
expect(result.nextPage, 5);
expect(api.calls.single['query'], {
'action': 'search-movie',
'q': 'query',
'pageno': 4,
});
},
);
test('actor empty filmography uses paged name search rather than repeating actor request', () async {
final api = DiscoveryFixtureApi()
..response = [
{'id': 1, 'title': 'Name result'},
];
final loader = ActorFilmography(
CatalogDiscovery(api),
id: 4,
name: 'Actor',
);
await loader.load(1);
await loader.load(2);
expect(loader.usingNameFallback, isTrue);
expect((api.calls.last['query'] as Map)['pageno'], 2);
expect((api.calls.last['query'] as Map)['q'], 'Actor');
});
test(
'actor metadata handles nested role actor without using the role ID',
() {
final person = actorMetadata({
'id': 90,
'role': 'Lead',
'actor': {
'id': 7,
'name': 'Person',
'bio': 'Biography',
'born': '1980',
'height': '180',
},
});
expect(person['id'], 7);
expect(person['name'], 'Person');
expect(person['role'], 'Lead');
expect(person['bio'], 'Biography');
},
);
test('continuation selects only current non-trailer source not expired history URL', () {
const sources = [
MediaSource(url: 'https://video.example/trailer', quality: 'Trailer'),
MediaSource(id: 3, url: 'https://video.example/current', quality: '720p'),
];
expect(
currentHistorySource(sources, 'https://expired.example/file')?.id,
3,
);
expect(currentHistorySource([], 'https://expired.example/file'), isNull);
});
test(
'legacy discovery retains raw continuation even for invisible rows',
() async {
final api = DiscoveryFixtureApi()
..response = [
{'id': 0, 'title': ''},
];
final result = await CatalogDiscovery(api).suggestions(page: 4);
expect(result, isEmpty);
expect(result.nextPage, 5);
expect(api.calls.single['query'], {
'action': 'best-rand-top',
'pageno': 4,
});
api.response = [];
expect(
(await CatalogDiscovery(api).suggestions(page: 5)).nextPage,
isNull,
);
},
);
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/media_platform.dart';
import 'package:reelbox/ui/catalog.dart';
import 'next_catalog_ui_test.dart' show NextCatalogApi, mountNextCatalog;
class PolicyCatalogApi extends NextCatalogApi {
@override
Future<TitleRecord> poster(int id) async => const TitleRecord(
id: 9,
title: 'Independent title',
sources: [
MediaSource(url: 'https://t.me/removed_channel'),
MediaSource(
url: 'https://video.example/embed',
type: 'embed',
quality: 'Embedded video',
),
],
);
}
void main() {
testWidgets(
'blocked title names never appear in the header and retry stays handled',
(tester) async {
await mountNextCatalog(
tester,
PolicyCatalogApi(),
const TitlePage(title: TitleRecord(id: 99, title: 'ملودی باکس')),
);
expect(find.text('ملودی باکس'), findsNothing);
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('ملودی باکس'), findsNothing);
},
);
testWidgets(
'title sharing contains plain title and no original share destination',
(tester) async {
const shareChannel = MethodChannel('dev.fluttercommunity.plus/share');
final payloads = <Map>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
shareChannel,
(call) async {
payloads.add(call.arguments as Map);
return 'success';
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
shareChannel,
null,
),
);
await mountNextCatalog(
tester,
NextCatalogApi(),
const TitlePage(title: TitleRecord(id: 9, title: 'Initial')),
);
await tester.ensureVisible(find.text('Share'));
await tester.tap(find.text('Share'));
await tester.pumpAndSettle();
expect(payloads.single['text'], 'Current title');
expect('${payloads.single}', isNot(contains('/share/')));
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets('blocked destinations do not render sources or website actions', (
tester,
) async {
await mountNextCatalog(
tester,
PolicyCatalogApi(),
const TitlePage(title: TitleRecord(id: 9, title: 'Initial')),
);
expect(find.text('Website'), findsNothing);
expect(find.byTooltip('Play'), findsOneWidget);
expect(find.byTooltip('Copy source URL'), findsOneWidget);
});
testWidgets('embedded media uses native generic browser with video title', (
tester,
) async {
final payloads = <Map>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method == 'openEmbedded') payloads.add(call.arguments as Map);
return true;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
),
);
await mountNextCatalog(
tester,
PolicyCatalogApi(),
const TitlePage(title: TitleRecord(id: 9, title: 'Initial')),
);
await tester.ensureVisible(find.byTooltip('Play'));
await tester.tap(find.byTooltip('Play'));
await tester.pumpAndSettle();
expect(payloads.single, {
'url': 'https://video.example/embed',
'title': 'Independent title',
});
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
testWidgets('search help and advanced filters fit a small viewport', (
tester,
) async {
tester.view.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await mountNextCatalog(tester, NextCatalogApi(), const SearchPage());
await tester.tap(find.byTooltip('Search help'));
await tester.tap(find.text('Advanced filters'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
}
+336
View File
@@ -0,0 +1,336 @@
import 'package:flutter/material.dart';
import 'package:reelbox/media/media_platform.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/catalog.dart';
import 'package:shared_preferences/shared_preferences.dart';
class NextCatalogApi extends ApiService {
final pages = <int>[];
bool series = false;
@override
Future<TitleRecord> poster(int id) async => TitleRecord(
id: id,
title: 'Current title',
type: series ? 'serie' : 'movie',
sources: const [
MediaSource(id: 5, url: 'https://video.example/current.mp4'),
],
);
@override
Future<List<SeasonRecord>> seasons(int id) async => [
const SeasonRecord(
id: 1,
title: 'Season one',
episodes: [EpisodeRecord(id: 51, title: 'Episode one')],
),
];
@override
Future<List<Map<String, dynamic>>> roles(int id) async => [];
@override
Future<List<Map<String, dynamic>>> comments(int id) async => [];
@override
Future<List<Map<String, dynamic>>> subtitles(String kind, int id) async => [];
bool failSecond = false;
@override
Future<void> initialize({bool discover = true}) async {}
@override
Future<Map<String, dynamic>> home() async => {
'genres': [],
'channels': [
{'id': 8, 'title': 'Removed live', 'type': 'channel'},
],
};
@override
Future<List<Tag>> genres() async => [const Tag(id: 2, title: 'Drama')];
@override
Future<List<Tag>> countries() async => [
const Tag(id: 8, title: 'Country eight'),
];
@override
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async => page == 1
? [
{
'id': 7,
'name': 'Real actor',
'role': 'Lead',
'bio': 'Actor biography',
'born': '1980',
},
]
: [];
@override
Future<List<TitleRecord>> actorTitles(int id) async => [
const TitleRecord(id: 72, title: 'Filmography title'),
];
@override
Future<List<TitleRecord>> catalog({
String kind = 'movie',
int genre = 0,
int country = 0,
String order = 'created',
int page = 0,
}) async {
pages.add(page);
if (page == 1 && failSecond) {
failSecond = false;
throw const ApiException('Offline');
}
return CatalogPage(
page == 0
? []
: page == 1
? [const TitleRecord(id: 2, title: 'Later raw page')]
: [],
nextPage: page < 2 ? page + 1 : null,
);
}
@override
Future<List<String>> trending() async => ['Trending term'];
@override
Future<List<TitleRecord>> bestRandom({
bool byYear = false,
int page = 1,
}) async => [const TitleRecord(id: 41, title: 'Suggested real title')];
@override
Future<List<TitleRecord>> search(
String query, {
int page = 1,
bool english = false,
}) async => CatalogPage(
page == 1 ? [const TitleRecord(id: 42, title: 'Query result')] : [],
nextPage: page == 1 ? 2 : null,
);
@override
Future<dynamic> get(String path, {Map<String, dynamic>? query}) async =>
switch (query?['action']) {
'best-rand-top' => [
{'id': 41, 'title': 'Suggested real title'},
],
'search-movie' || 'search-movie-en' =>
query?['pageno'] == 1
? [
{'id': 42, 'title': 'Query result'},
]
: [],
_ => [],
};
}
Future<AppState> mountNextCatalog(
WidgetTester tester,
NextCatalogApi api,
Widget page, {
bool compact = false,
}) async {
SharedPreferences.setMockInitialValues({});
final state = AppState(
api: api,
storage: await SharedPreferences.getInstance(),
);
await state.initialize(discover: false);
if (compact) {
await state.updateSettings(AppSettings.fromJson({'compactCatalog': true}));
}
await tester.pumpWidget(
AppScope(
state: state,
child: MaterialApp(home: page),
),
);
await tester.pumpAndSettle();
return state;
}
void main() {
testWidgets(
'explicit episode watched marker is independent of playback progress',
(tester) async {
final api = NextCatalogApi()..series = true;
final state = await mountNextCatalog(
tester,
api,
const TitlePage(
title: TitleRecord(id: 9, title: 'Series', type: 'serie'),
),
);
await tester.scrollUntilVisible(find.text('Season one'), 250);
await tester.tap(find.text('Season one'));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byTooltip('Mark watched'));
await tester.tap(find.byTooltip('Mark watched'));
await tester.pumpAndSettle();
expect(state.isEpisodeWatched(9, 51), isTrue);
expect(state.history, isEmpty);
await tester.tap(find.byTooltip('Mark unwatched'));
await tester.pumpAndSettle();
expect(state.isEpisodeWatched(9, 51), isFalse);
},
);
testWidgets(
'home continuation fetches current source instead of replaying stale history URL',
(tester) async {
final calls = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
if (call.method == 'openExternal') {
calls.add((call.arguments as Map)['url'] as String);
}
return true;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
null,
),
);
final state = await mountNextCatalog(
tester,
NextCatalogApi(),
const DiscoverPage(),
);
await state.updateSettings(state.prefs.copyWith(externalPlayer: true));
await state.recordWatch(
const TitleRecord(id: 9, title: 'Stored history title'),
sourceUrl: 'https://expired.example/stale.mp4',
positionSeconds: 30,
durationSeconds: 100,
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Stored history title'),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.ensureVisible(find.text('Stored history title'));
await tester.pumpAndSettle();
await tester.tap(find.text('Stored history title'));
await tester.pumpAndSettle();
expect(calls, ['https://video.example/current.mp4']);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets(
'home has real actor shelf and country discovery, not live features',
(tester) async {
await mountNextCatalog(tester, NextCatalogApi(), const DiscoverPage());
expect(find.text('Live channels'), findsNothing);
expect(find.text('Removed live'), findsNothing);
await tester.scrollUntilVisible(
find.text('Real actor'),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.ensureVisible(find.text('Real actor'));
await tester.pumpAndSettle();
await tester.tap(find.text('Real actor'));
await tester.pumpAndSettle();
expect(find.text('Actor biography'), findsOneWidget);
expect(find.text('Filmography title'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Country eight'),
250,
scrollable: find.byType(Scrollable).first,
);
await tester.ensureVisible(find.text('Country eight'));
await tester.pumpAndSettle();
await tester.tap(find.text('Country eight'));
await tester.pumpAndSettle();
expect(find.byType(BrowsePage), findsOneWidget);
expect(
tester.widget<BrowsePage>(find.byType(BrowsePage)).initialCountry,
8,
);
},
);
testWidgets(
'empty search suggests titles and supports individual history deletion and help',
(tester) async {
final state = await mountNextCatalog(
tester,
NextCatalogApi(),
const SearchPage(),
);
expect(find.text('Suggested real title'), findsOneWidget);
await state.rememberSearch('Keep term');
await state.rememberSearch('Delete term');
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Remove Delete term'));
await tester.pumpAndSettle();
expect(state.searchHistory, ['Keep term']);
expect(find.text('Search help'), findsNothing);
await tester.tap(find.byTooltip('Search help'));
await tester.pumpAndSettle();
expect(find.textContaining('loaded search results'), findsOneWidget);
await tester.tap(find.text('Advanced filters'));
await tester.pumpAndSettle();
expect(find.text('Minimum IMDb'), findsOneWidget);
},
);
testWidgets(
'automatic pagination follows empty raw pages and retains failed cursor',
(tester) async {
final api = NextCatalogApi()..failSecond = true;
await mountNextCatalog(tester, api, const BrowsePage(initialCountry: 8));
expect(api.pages, [0, 1]);
expect(find.text('Try again'), findsOneWidget);
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(find.text('Later raw page'), findsOneWidget);
expect(api.pages.take(3), [0, 1, 1]);
},
);
testWidgets('compact catalog preference renders compact rows and count', (
tester,
) async {
await mountNextCatalog(
tester,
NextCatalogApi(),
const BrowsePage(),
compact: true,
);
expect(
find.ancestor(
of: find.text('Later raw page'),
matching: find.byType(ListTile),
),
findsOneWidget,
);
expect(find.text('1 titles loaded'), findsOneWidget);
});
testWidgets(
'directory removes public playlists and adds observed shortcuts',
(tester) async {
await mountNextCatalog(
tester,
NextCatalogApi(),
const DiscoveryDirectoryPage(),
);
expect(find.text('Public playlists'), findsNothing);
await tester.scrollUntilVisible(find.text('Miniseries'), 250);
await tester.tap(find.text('Miniseries'));
await tester.pumpAndSettle();
expect(
tester.widget<TextField>(find.byType(TextField).first).controller?.text,
'مینی سریال',
);
},
);
}
+27
View File
@@ -0,0 +1,27 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/state/cache_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('cache size and clear are limited to disposable cache, not downloads/library/symlinks', () async {
final sandbox = await Directory('${Directory.current.path}/.dart_tool')
.createTemp('next-library-cache-');
addTearDown(() => sandbox.delete(recursive: true));
final cache = await Directory('${sandbox.path}/reelbox-cache').create();
final downloads = await Directory('${sandbox.path}/downloads').create();
final completed = await File('${downloads.path}/complete.mp4')
.writeAsBytes([1, 2, 3]);
final library = await File('${sandbox.path}/library.json')
.writeAsString('private');
await File('${cache.path}/artwork').writeAsBytes([1, 2, 3, 4]);
await Link('${cache.path}/outside').create(downloads.path);
final service = CacheService(directory: () async => cache);
expect((await service.measure()).diskBytes, 4);
await service.clear();
expect((await service.measure()).diskBytes, 0);
expect(await completed.readAsBytes(), [1, 2, 3]);
expect(await library.readAsString(), 'private');
});
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/state/app_state.dart';
void main() {
test(
'platform fonts resolve generic families and preserve safe custom fonts',
() {
expect(
AppSettings(subtitleFontFamily: 'serif').resolvedSubtitleFontFamily,
isNot('serif'),
);
expect(
AppSettings(subtitleFontFamily: 'monospace').resolvedSubtitleFontFamily,
isNot('monospace'),
);
expect(
AppSettings(subtitleFontFamily: 'My Font').resolvedSubtitleFontFamily,
'My Font',
);
expect(AppSettings().subtitleFontFamilyFallback, isNotEmpty);
},
);
test('new settings validate imports and roundtrip every field', () {
final prefs = AppSettings().copyWith(
subtitleEnabled: false,
subtitleFontFamily: 'Custom Font',
videoFit: 'cover',
audioOnly: true,
playerVolume: 0.42,
rememberVolume: true,
backgroundAudio: true,
closeOnCompletion: true,
resumeRewindSeconds: 120,
parallelDownloads: 8,
compactCatalog: true,
introCompleted: true,
externalPlayerPackage: 'org.videolan.vlc',
);
expect(AppSettings.fromJson(prefs.toJson()).toJson(), prefs.toJson());
final invalid = AppSettings.fromJson({
'playerVolume': -1,
'parallelDownloads': 999,
'resumeRewindSeconds': -4,
'videoFit': 'bad',
'subtitleEnabled': 1,
'subtitleFontFamily': '\u0000bad',
'externalPlayerPackage': 'https://bad.example',
'introCompleted': false,
});
expect(invalid.playerVolume, 1);
expect(invalid.parallelDownloads, 3);
expect(invalid.resumeRewindSeconds, 5);
expect(invalid.videoFit, 'contain');
expect(invalid.subtitleEnabled, true);
expect(invalid.subtitleFontFamily, 'sans-serif');
expect(invalid.externalPlayerPackage, 'ask');
expect(invalid.introCompleted, false);
});
test(
'new settings expose next-release defaults, legacy JSON skips intro',
() {
final json = AppSettings().toJson();
expect(json['subtitleEnabled'], true);
expect(json['subtitleFontFamily'], 'sans-serif');
expect(json['videoFit'], 'contain');
expect(json['audioOnly'], false);
expect(json['playerVolume'], 1.0);
expect(json['rememberVolume'], false);
expect(json['backgroundAudio'], false);
expect(json['closeOnCompletion'], false);
expect(json['resumeRewindSeconds'], 5);
expect(json['parallelDownloads'], 3);
expect(json['compactCatalog'], false);
expect(json['introCompleted'], false);
expect(json['externalPlayerPackage'], 'ask');
expect(AppSettings.fromJson({}).toJson()['introCompleted'], true);
},
);
}
+245
View File
@@ -0,0 +1,245 @@
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: 8, title: 'A film');
const episode = EpisodeRecord(id: 12, title: 'Part 1');
class LocalApi extends ApiService {
@override
Future<void> initialize({bool discover = true}) async {}
@override
Future<TitleRecord> poster(int id) async => const TitleRecord(
id: 8,
title: 'Current film',
image: 'https://example.org/current.jpg',
sources: [MediaSource(id: 2, url: 'https://example.org/new.mp4')],
);
@override
Future<List<SeasonRecord>> seasons(int id) async => const [
SeasonRecord(
id: 1,
episodes: [
EpisodeRecord(
id: 12,
title: 'Current episode',
sources: [
MediaSource(id: 3, url: 'https://example.org/episode-new.mp4'),
],
),
],
),
];
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
test('legacy migration filters blocked raw records before validation and persists safe state', () async {
final store = await SharedPreferences.getInstance();
final legacy = {
'format': 'reelbox.library',
'version': 1,
'settings': {'locale': 'fa'},
'saved': [
movie.toJson(),
{'id': -1, 'type': 'channel'},
{'id': 0, 'title': 'Norton'},
],
'history': <dynamic>[],
'searchHistory': <dynamic>[],
'collections': [
{
'id': 'private-id',
'name': 'Private name',
'titles': [
movie.toJson(),
{'title': 'ملودی باکس'},
],
},
],
};
await store.setString(AppState.storageKey, jsonEncode(legacy));
final state = AppState(api: LocalApi());
await state.initialize(discover: false);
expect(state.prefs.introCompleted, true);
expect(state.saved.single.id, 8);
expect(state.collections.single.id, 'private-id');
expect(state.collections.single.name, 'Private name');
expect(state.collections.single.titles.single.id, 8);
final persisted = jsonDecode(store.getString(AppState.storageKey)!);
expect(persisted['saved'].length, 1);
expect(persisted['collections'][0]['titles'].length, 1);
state.dispose();
});
test(
'free-form local labels and settings cannot surface excluded promotions',
() async {
final state = AppState(api: LocalApi());
await expectLater(state.createCollection('Norton'), throwsArgumentError);
await state.updateSettings(
state.prefs.copyWith(
subtitleFontFamily: 'ملودی باکس',
defaultQuality: 'https://t.me/example',
),
);
expect(state.prefs.subtitleFontFamily, 'sans-serif');
expect(state.prefs.defaultQuality, 'ask');
final collection = await state.createCollection('Keep me');
await state.toggleCollectionTitle(collection.id, movie);
final backup = jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
backup['collections'][0]['name'] = 'Norton';
await state.importLibrary(jsonEncode(backup));
expect(state.collections.single.name, 'Local collection');
expect(state.collections.single.titles.single.id, movie.id);
state.dispose();
},
);
test('malformed backup and failed startup never reset an existing private library', () async {
final state = AppState(api: LocalApi());
await state.toggleSaved(movie);
final before = state.exportLibrary();
final malformed = jsonDecode(before) as Map<String, dynamic>;
malformed['watchedEpisodes'] = [
{'title': movie.toJson(), 'episodeId': -1, 'episodeTitle': ''},
];
await expectLater(
state.importLibrary(jsonEncode(malformed)),
throwsFormatException,
);
expect(state.exportLibrary(), before);
final store = await SharedPreferences.getInstance();
const corrupt = '{not valid JSON';
await store.setString(AppState.storageKey, corrupt);
final restored = AppState(api: LocalApi());
await expectLater(
restored.initialize(discover: false),
throwsFormatException,
);
expect(store.getString(AppState.storageKey), corrupt);
state.dispose();
restored.dispose();
});
test(
'episode watched marker persists without fabricating playback checkpoints',
() async {
final state = AppState(api: LocalApi());
await state.setEpisodeWatched(movie, episode, true);
expect(state.isEpisodeWatched(8, 12), true);
expect(state.history, isEmpty);
await state.recordWatch(movie, episodeId: 12, positionSeconds: 24);
await state.setEpisodeWatched(movie, episode, false);
expect(state.isEpisodeWatched(8, 12), false);
expect(state.resumeSeconds(8, episodeId: 12), 24);
await state.setEpisodeWatched(movie, episode, true);
final restored = AppState(api: LocalApi());
await restored.initialize(discover: false);
expect(restored.isEpisodeWatched(8, 12), true);
final imported = AppState(api: LocalApi());
await imported.importLibrary(state.exportLibrary());
expect(imported.isEpisodeWatched(8, 12), true);
state.dispose();
restored.dispose();
imported.dispose();
},
);
test(
'subtitle reset changes only subtitle fields and preserves local library',
() async {
final state = AppState(api: LocalApi());
await state.toggleSaved(movie);
await state.createCollection('Private');
await state.updateSettings(
state.prefs.copyWith(
locale: 'fa',
parallelDownloads: 7,
subtitleFontSize: 60,
subtitleColor: 0x12123456,
subtitleDelay: 4,
subtitleFontFamily: 'serif',
subtitleEnabled: false,
audioDelay: 2,
),
);
await state.resetSubtitleStyle();
expect(state.prefs.subtitleFontSize, 24);
expect(state.prefs.subtitleColor, 0xFFFFFFFF);
expect(state.prefs.subtitleFontFamily, 'sans-serif');
expect(state.prefs.subtitleDelay, 0);
expect(state.prefs.subtitleEnabled, true);
expect(state.prefs.audioDelay, 2);
expect(state.prefs.parallelDownloads, 7);
expect(state.locale, 'fa');
expect(state.saved.single.id, 8);
expect(state.collections.single.name, 'Private');
state.dispose();
},
);
test('missing current episode rejects continuation instead of playing an expired checkpoint URL', () async {
final state = AppState(api: LocalApi());
await state.recordWatch(
movie,
episodeId: 404,
sourceUrl: 'https://example.org/expired.mp4',
positionSeconds: 99,
);
await expectLater(
state.resolveHistory(state.history.single),
throwsStateError,
);
expect(state.resumeSeconds(8, episodeId: 404), 99);
state.dispose();
});
test(
'continuation resolves fresh poster and episode sources, never stale URL',
() async {
final state = AppState(api: LocalApi());
await state.recordWatch(
movie,
episodeId: 12,
sourceUrl: 'https://example.org/expired.mp4',
positionSeconds: 24,
);
final continuation = await state.resolveHistory(state.history.single);
expect(continuation.title.image, 'https://example.org/current.jpg');
expect(continuation.source.url, 'https://example.org/episode-new.mp4');
expect(continuation.episode?.id, 12);
expect(continuation.playlist.single.id, 12);
state.dispose();
},
);
test('legacy import retains private collections while removing blocked titles/channels', () async {
final state = AppState(api: LocalApi());
await state.toggleSaved(movie);
final collection = await state.createCollection('Private');
await state.toggleCollectionTitle(collection.id, movie);
final backup = jsonDecode(state.exportLibrary()) as Map<String, dynamic>;
backup['saved'].add(
const TitleRecord(id: 9, type: 'channel', title: 'Channel').toJson(),
);
backup['saved'].add(
const TitleRecord(id: 10, title: 'ملودی باکس').toJson(),
);
backup['collections'][0]['titles'].add(
const TitleRecord(id: 11, title: 'Norton').toJson(),
);
await state.importLibrary(jsonEncode(backup));
expect(state.saved.map((t) => t.id), [8]);
expect(state.collections.single.titles.map((t) => t.id), [8]);
await state.toggleSaved(const TitleRecord(id: 10, title: 'ملودی باکس'));
expect(state.saved.length, 1);
state.dispose();
});
}
+323
View File
@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/app.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:reelbox/ui/library.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _LocalApi extends ApiService {
@override
Future<void> initialize({bool discover = true}) async {}
@override
Future<Map<String, dynamic>> home() async => {
'genres': [],
'actors': [],
'countries': [],
};
@override
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async => [];
@override
Future<List<Tag>> countries() async => [];
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => SharedPreferences.setMockInitialValues({}));
Future<AppState> state() async {
final s = AppState(api: _LocalApi());
await s.initialize(discover: false);
addTearDown(s.dispose);
return s;
}
Widget host(AppState s, Widget child) => AppScope(
state: s,
child: MaterialApp(home: child),
);
testWidgets(
'fresh app routes through introduction before exposing navigation',
(tester) async {
final s = await state();
expect(s.prefs.introCompleted, false);
await tester.pumpWidget(MyApp(state: s));
await tester.pumpAndSettle();
expect(find.byType(FirstRunIntroPage), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
await tester.tap(find.byKey(const Key('intro-skip')));
await tester.pumpAndSettle();
expect(s.prefs.introCompleted, true);
expect(find.byType(FirstRunIntroPage), findsNothing);
expect(find.byType(NavigationBar), findsOneWidget);
},
);
testWidgets(
'counted library tabs remain readable and reachable with large text',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final s = await state();
await tester.pumpWidget(
host(
s,
MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: const LibraryPage(),
),
),
);
await tester.pumpAndSettle();
final label = find.text('Collections (0)');
await tester.ensureVisible(label);
await tester.pumpAndSettle();
final render = tester.renderObject<RenderBox>(label);
expect(
render.size.width,
greaterThanOrEqualTo(
render.getMaxIntrinsicWidth(double.infinity) - 0.1,
),
);
await tester.tap(label);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
},
);
for (final locale in ['en', 'fa']) {
testWidgets(
'More shares independent app information in $locale without an invented install link',
(tester) async {
const channel = MethodChannel('dev.fluttercommunity.plus/share');
final payloads = <Map>[];
final methods = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
(call) async {
methods.add(call.method);
payloads.add(call.arguments as Map);
return 'fixture.receiver';
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
final s = await state();
await s.updateSettings(s.prefs.copyWith(locale: locale));
await tester.pumpWidget(host(s, const MorePage()));
expect(payloads, isEmpty);
final action = find.byKey(const Key('share-reelbox'));
await tester.scrollUntilVisible(action, 300);
await tester.ensureVisible(action);
await tester.pumpAndSettle();
await tester.tap(action);
await tester.pumpAndSettle();
expect(payloads, hasLength(1));
expect(methods, ['share']);
expect(
payloads.single['text'],
locale == 'en'
? 'Reelbox — an account-free film and series client with a private, device-local library'
: 'Reelbox — فیلم و سریال، با کتابخانه خصوصی روی دستگاه و بدون نیاز به حساب کاربری',
);
expect(payloads.single['title'], 'Reelbox');
expect(
payloads.single['text'],
isNot(
matches(
RegExp(
r'https?://|telegram|t\.me/|ملودی|نورتون',
caseSensitive: false,
),
),
),
);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
}
testWidgets(
'unavailable app sharing shows a safe error rather than native details',
(tester) async {
const channel = MethodChannel('dev.fluttercommunity.plus/share');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
_,
) async {
throw PlatformException(
code: 'fixture',
message: 'private native fixture detail',
);
});
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
channel,
null,
),
);
final s = await state();
await tester.pumpWidget(host(s, const MorePage()));
final action = find.byKey(const Key('share-reelbox'));
await tester.scrollUntilVisible(action, 300);
await tester.ensureVisible(action);
await tester.pumpAndSettle();
await tester.tap(action);
await tester.pumpAndSettle();
expect(
find.text('Sharing is unavailable on this device'),
findsOneWidget,
);
expect(
find.textContaining('private native fixture detail'),
findsNothing,
);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets('first-run skip persists completion without touching library', (
tester,
) async {
final s = await state();
await tester.pumpWidget(host(s, const FirstRunIntroPage()));
await tester.tap(find.byKey(const Key('intro-skip')));
await tester.pumpAndSettle();
expect(s.prefs.introCompleted, true);
});
testWidgets('first-run finish persists completion', (tester) async {
final s = await state();
await tester.pumpWidget(host(s, const FirstRunIntroPage()));
await tester.tap(find.byKey(const Key('intro-complete')));
await tester.pumpAndSettle();
expect(s.prefs.introCompleted, true);
});
testWidgets(
'introduction keeps a short description and local-data disclosure',
(tester) async {
final s = await state();
await tester.pumpWidget(host(s, const FirstRunIntroPage()));
expect(
find.text(
'Save films and series, resume watching and download for offline viewing.',
),
findsOneWidget,
);
expect(find.textContaining('No account needed'), findsOneWidget);
expect(
find.textContaining('external catalog and media providers'),
findsOneWidget,
);
expect(find.text('English'), findsOneWidget);
expect(find.text('فارسی'), findsOneWidget);
},
);
testWidgets(
'more exposes no removed service links and about uses runtime package info',
(tester) async {
final s = await state();
PackageInfo.setMockInitialValues(
appName: 'Reelbox',
packageName: 'org.reelbox.app',
version: '8.9.10',
buildNumber: '456',
buildSignature: '',
);
await tester.pumpWidget(host(s, const MorePage()));
expect(find.text('Live channels'), findsNothing);
expect(find.text('Public playlists'), findsNothing);
expect(find.text('Original service support'), findsNothing);
await tester.scrollUntilVisible(find.text('About Reelbox'), 300);
await tester.tap(find.text('About Reelbox'));
await tester.pumpAndSettle();
expect(find.textContaining('8.9.10'), findsOneWidget);
expect(find.textContaining('456'), findsOneWidget);
expect(find.textContaining('org.reelbox.app'), findsOneWidget);
for (final label in [
'Dart runtime',
'Playback engine',
'Display',
'Device platform',
'Flutter framework',
'Flutter engine revision',
]) {
expect(find.text(label), findsNothing);
}
expect(find.text('Open-source licenses'), findsOneWidget);
await tester.ensureVisible(find.text('Open-source licenses'));
await tester.tap(find.text('Open-source licenses'));
await tester.pumpAndSettle();
expect(find.byType(LicensePage), findsOneWidget);
},
);
testWidgets(
'custom ARGB color and opacity save exact RGB without changing background',
(tester) async {
final s = await state();
await tester.pumpWidget(host(s, const SettingsPage()));
await tester.scrollUntilVisible(find.text('Custom subtitle color'), 450);
await tester.tap(find.text('Custom subtitle color'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('custom-color-hex')),
'12345678',
);
await tester.pump();
await tester.tap(find.byKey(const Key('custom-color-opacity')));
await tester.pump();
await tester.tap(
find.descendant(
of: find.byType(AlertDialog),
matching: find.text('Save'),
),
);
await tester.pumpAndSettle();
expect(s.prefs.subtitleColor & 0xFFFFFF, 0x345678);
expect(s.prefs.subtitleColor >> 24, inInclusiveRange(1, 254));
expect(s.prefs.subtitleBackground, 0x80000000);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'settings safely displays imported custom values and resets subtitle style only',
(tester) async {
final s = await state();
await s.updateSettings(
s.prefs.copyWith(
subtitleFontFamily: 'My Custom Font',
subtitleColor: 0x12123456,
subtitleFontSize: 72,
playbackSpeed: 1.7,
parallelDownloads: 7,
playerVolume: 0.33,
),
);
await tester.pumpWidget(host(s, const SettingsPage()));
await tester.scrollUntilVisible(
find.byKey(const Key('reset-subtitle-style')),
450,
);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const Key('reset-subtitle-style')));
await tester.pumpAndSettle();
expect(s.prefs.subtitleColor, 0xFFFFFFFF);
expect(s.prefs.parallelDownloads, 7);
expect(s.prefs.playbackSpeed, 1.7);
},
);
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_helpers.dart';
void main() {
test('opening a player cannot overwrite an existing resume checkpoint', () {
final playback = PlaybackCheckpoint();
const savedPosition = Duration(minutes: 17);
var storedPosition = savedPosition;
void persist() {
if (playback.canPersist) storedPosition = playback.position;
}
persist();
playback.beginOpen();
persist();
expect(storedPosition, savedPosition);
expect(playback.canPersist, isFalse);
// Failed opens, duration discovery and a zero position are not watched time.
playback.duration = const Duration(hours: 2);
playback.position = Duration.zero;
persist();
expect(storedPosition, savedPosition);
playback.didOpen();
persist();
expect(storedPosition, savedPosition);
playback.position = const Duration(minutes: 18);
persist();
expect(storedPosition, const Duration(minutes: 18));
});
test(
'source switches do not attribute previous progress to the new source',
() {
final playback = PlaybackCheckpoint();
playback.beginOpen();
playback.didOpen();
playback.position = const Duration(minutes: 3);
expect(playback.canPersist, isTrue);
playback.beginOpen();
expect(playback.position, Duration.zero);
expect(playback.canPersist, isFalse);
playback.position = const Duration(seconds: 40);
expect(playback.canPersist, isFalse);
playback.didOpen();
expect(playback.canPersist, isTrue);
},
);
}
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:reelbox/media/media_helpers.dart';
void main() {
for (final autoplay in [true, false]) {
test(
'engine open dispatch uses autoplay=$autoplay for remote and offline media',
() async {
for (final uri in ['https://example.test/a.mp4', '/offline/a.mp4']) {
final calls = <({Media media, bool play})>[];
await dispatchMediaOpen(
open: (media, {play = true}) async =>
calls.add((media: media, play: play)),
uri: uri,
start: const Duration(seconds: 20),
autoplay: autoplay,
);
expect(calls.single.play, autoplay);
expect(calls.single.media.uri, uri);
expect(calls.single.media.start, const Duration(seconds: 20));
}
},
);
}
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_helpers.dart';
import 'package:reelbox/media/media_platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('player handoff retains pre-player brightness rather than an earlier override', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, null);
});
var windowBrightness = -1.0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, (call) async {
switch (call.method) {
case 'getBrightness':
return windowBrightness;
case 'getEffectiveBrightness':
return .55;
case 'getVolume':
return .5;
case 'setBrightness':
windowBrightness = (call.arguments as Map)['value'] as double;
return null;
}
return null;
});
final old = PlaybackLifecycle(), current = PlaybackLifecycle();
final oldControls = PlayerSystemControls(),
currentControls = PlayerSystemControls();
await old.setup([oldControls.capture]);
await MediaPlatform.invoke<void>('setBrightness', {'value': .8});
await current.setup([currentControls.capture]);
expect(currentControls.brightness, .8);
await old.dispose(oldControls.restore);
expect(
windowBrightness,
.8,
reason: 'Old teardown must not disturb the current owner',
);
await current.dispose(currentControls.restore);
expect(
windowBrightness,
-1.0,
reason: 'Last owner must restore the original activity baseline',
);
});
}
+120
View File
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_helpers.dart';
import 'package:reelbox/media/player_page.dart';
void main() {
test(
'resume rewinds but completed progress resets without negative positions',
() {
expect(
resumePosition(
position: const Duration(seconds: 60),
duration: const Duration(seconds: 600),
rewindSeconds: 5,
),
const Duration(seconds: 55),
);
expect(
resumePosition(
position: const Duration(seconds: 6),
duration: const Duration(seconds: 600),
rewindSeconds: 120,
),
Duration.zero,
);
expect(
resumePosition(
position: const Duration(seconds: 590),
duration: const Duration(seconds: 600),
rewindSeconds: 5,
),
Duration.zero,
);
},
);
test(
'grouped subtitle variants inherit language and reject unsafe sources',
() {
final groups = subtitleGroups([
{
'language': 'Persian',
'subtitles': [
{'title': 'WEB-DL', 'url': 'https://media.test/fa.srt'},
{'name': 'BluRay', 'link': 'https://media.test/fa2.srt'},
{'url': 'javascript:bad'},
],
},
{
'language': {'name': 'English'},
'title': 'SDH',
'url': 'https://media.test/en.vtt',
},
]);
expect(groups.keys, ['Persian', 'English']);
expect(groups['Persian']!.map((e) => e.label), ['WEB-DL', 'BluRay']);
expect(groups['English']!.single.url, 'https://media.test/en.vtt');
},
);
testWidgets(
'keyboard media shortcuts dispatch commands; locked rejects input',
(tester) async {
final actions = <String>[];
Future<void> mount(bool locked) => tester.pumpWidget(
MaterialApp(
home: PlayerInteraction(
locked: locked,
controlsVisible: true,
onAction: actions.add,
child: const SizedBox.expand(),
),
),
);
await mount(false);
await tester.pump();
for (final key in [
LogicalKeyboardKey.space,
LogicalKeyboardKey.keyM,
LogicalKeyboardKey.keyF,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.mediaTrackNext,
LogicalKeyboardKey.keyS,
]) {
await tester.sendKeyEvent(key);
}
expect(actions, [
'toggle',
'mute',
'fullscreen',
'back',
'next',
'subtitles',
]);
await mount(true);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
expect(actions.length, 6);
},
);
testWidgets('double click requests fullscreen rather than changing fit', (
tester,
) async {
final actions = <String>[];
await tester.pumpWidget(
MaterialApp(
home: PlayerInteraction(
locked: false,
controlsVisible: true,
onAction: actions.add,
child: PlayerSurfaceGestures(locked: false, onAction: actions.add),
),
),
);
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(const Offset(200, 200));
await tester.pump(const Duration(milliseconds: 350));
expect(actions, contains('fullscreen'));
expect(actions, isNot(contains('fit')));
});
}
+670
View File
@@ -0,0 +1,670 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/player_page.dart';
import 'package:reelbox/media/media_platform.dart';
import 'package:reelbox/media/cast_media_share.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _Streams implements PlayerStream {
final positions = StreamController<Duration>.broadcast();
final durations = StreamController<Duration>.broadcast();
final playingEvents = StreamController<bool>.broadcast();
final completedEvents = StreamController<bool>.broadcast();
@override
Stream<Duration> get position => positions.stream;
@override
Stream<Duration> get duration => durations.stream;
@override
Stream<bool> get playing => playingEvents.stream;
@override
Stream<bool> get completed => completedEvents.stream;
@override
Stream<bool> get buffering => const Stream.empty();
@override
Stream<Tracks> get tracks => const Stream.empty();
@override
Stream<String> get error => const Stream.empty();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
Future<void> close() async {
await positions.close();
await durations.close();
await playingEvents.close();
await completedEvents.close();
}
}
class _Engine implements Player {
@override
final _Streams stream = _Streams();
@override
PlayerState get state =>
PlayerState(tracks: Tracks(subtitle: subtitleTracks));
List<SubtitleTrack> subtitleTracks = const [
SubtitleTrack('auto', null, null),
SubtitleTrack('no', null, null),
];
final calls = <String>[];
final opens = <Media>[];
@override
PlatformPlayer? platform;
@override
Future<void> open(Playable playable, {bool play = true}) async {
final m = playable as Media;
opens.add(m);
calls.add('open:$play');
}
@override
Future<void> play() async {
calls.add('play');
stream.playingEvents.add(true);
}
@override
Future<void> pause() async {
calls.add('pause');
stream.playingEvents.add(false);
}
@override
Future<void> seek(Duration position) async {
calls.add('seek:${position.inSeconds}');
stream.positions.add(position);
}
@override
Future<void> setRate(double value) async {
calls.add('rate:$value');
}
@override
Future<void> setVolume(double value) async {
calls.add('volume:$value');
}
@override
Future<void> setVideoTrack(VideoTrack value) async {
calls.add('video:${value.id}');
}
@override
Future<void> setSubtitleTrack(SubtitleTrack value) async {
calls.add('subtitle:${value.id}');
}
@override
Future<void> dispose() async {
calls.add('dispose');
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
testWidgets(
'persisted Stretch dispatches actual fill geometry instead of contain',
(tester) async {
final app = AppState();
final engine = _Engine();
final fits = <BoxFit>[];
addTearDown(() async {
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
await engine.stream.close();
app.dispose();
});
await app.updateSettings(AppSettings(videoFit: 'fill'));
await tester.pumpWidget(
AppScope(
state: app,
child: MaterialApp(
home: PlayerPage(
title: const TitleRecord(id: 82, title: 'Film'),
source: const MediaSource(url: 'https://media.test/film.mp4'),
playerFactory: () => engine,
videoControllerFactory: (_) => null,
videoSurfaceBuilder: (fit) {
fits.add(fit);
return const SizedBox.expand();
},
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
expect(fits.last, BoxFit.fill);
await tester.tap(find.byTooltip('Fit or fill'));
await tester.pump();
expect(fits.last, BoxFit.cover);
await tester.tap(find.byTooltip('Fit or fill'));
await tester.pump();
expect(fits.last, BoxFit.contain);
},
);
testWidgets(
'actual subtitle track picker updates active Cast text and clears stale external data',
(tester) async {
final app = AppState();
final engine = _Engine();
const aText = '1\n00:00:00,000 --> 00:00:01,000\nExternal A';
const bText = '1\n00:00:00,000 --> 00:00:01,000\nExternal B';
engine.subtitleTracks = [
SubtitleTrack.data(aText, title: 'External A'),
const SubtitleTrack('2', 'Embedded B', 'en'),
SubtitleTrack.no(),
SubtitleTrack.data(bText, title: 'External B'),
];
final sharedText = <String?>[];
var closed = 0;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async => switch (call.method) {
'getBrightness' => -1.0,
'getEffectiveBrightness' => 0.6,
'getVolume' => 0.5,
'castGetState' => {
'connected': true,
'deviceName': 'Fixture receiver',
},
'castLoad' => true,
_ => null,
},
);
addTearDown(() async {
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
await engine.stream.close();
app.dispose();
});
await app.updateSettings(AppSettings());
await tester.pumpWidget(
AppScope(
state: app,
child: MaterialApp(
home: PlayerPage(
title: const TitleRecord(id: 81, title: 'Film'),
source: const MediaSource(url: 'https://media.test/film.mp4'),
playerFactory: () => engine,
videoControllerFactory: (_) => null,
castShareFactory:
({required payload, localFile, subtitleText}) async {
sharedText.add(subtitleText);
return CastMediaLease(
payload: payload,
onClose: () async {
closed++;
},
);
},
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
Future<void> choose(String name) async {
await tester.tap(find.byTooltip('Player settings'));
await tester.pumpAndSettle();
final track = engine.subtitleTracks.firstWhere(
(track) => (track.title ?? track.language ?? track.id) == name,
);
final target = find.byKey(ValueKey(track));
await tester.scrollUntilVisible(
target,
300,
scrollable: find
.descendant(
of: find.byType(BottomSheet),
matching: find.byType(Scrollable),
)
.first,
);
await tester.ensureVisible(target);
await tester.pumpAndSettle();
await tester.tap(target);
await tester.pumpAndSettle();
}
await choose('External A');
await tester.tap(find.byTooltip('Cast to device'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cast this video'));
await tester.pumpAndSettle();
expect(sharedText, [aText]);
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
await choose('Embedded B');
expect(sharedText, [aText, null]);
expect(
find.text(
'Embedded subtitles cannot be shared; Cast continues without them',
),
findsOneWidget,
);
await choose('no');
expect(app.prefs.subtitleEnabled, false);
expect(sharedText, [aText, null, null]);
await choose('External B');
expect(sharedText.last, bText);
expect(app.prefs.subtitleEnabled, true);
expect(closed, 3);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
testWidgets(
'offline player explicitly hands Cast a sharing lease and revokes it on exit',
(tester) async {
final app = AppState();
final engine = _Engine();
var prepared = 0, closed = 0;
String? preparedFile;
final loads = <Map>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
return switch (call.method) {
'getBrightness' => -1.0,
'getEffectiveBrightness' => 0.6,
'getVolume' => 0.5,
'castGetState' => {
'connected': true,
'deviceName': 'Synthetic receiver',
},
'castLoad' => (() {
loads.add(call.arguments as Map);
return true;
})(),
'castShowPicker' => true,
_ => null,
};
},
);
addTearDown(() async {
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
await engine.stream.close();
app.dispose();
});
await app.updateSettings(AppSettings());
await tester.pumpWidget(
AppScope(
state: app,
child: MaterialApp(
home: PlayerPage(
title: const TitleRecord(id: 42, title: 'Synthetic offline film'),
source: const MediaSource(url: 'https://media.test/original.mp4'),
localFile: '/selected/synthetic.mp4',
playerFactory: () => engine,
videoControllerFactory: (_) => null,
castShareFactory:
({required payload, localFile, subtitleText}) async {
prepared++;
preparedFile = localFile;
return CastMediaLease(
payload: {
...payload,
'url': 'http://127.0.0.1:1234/synthetic/media.mp4',
},
onClose: () async {
closed++;
},
);
},
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
expect(
prepared,
0,
reason: 'Opening a page must not start local sharing',
);
await tester.tap(find.byTooltip('Cast to device'));
await tester.pumpAndSettle();
expect(
prepared,
0,
reason: 'Choosing a receiver menu must not start sharing',
);
await tester.tap(find.text('Cast this video'));
await tester.pump(const Duration(milliseconds: 100));
// Native receiver connection callback, not a fabricated successful load.
final completed = Completer<void>();
tester.binding.defaultBinaryMessenger.handlePlatformMessage(
MediaPlatform.channel.name,
const StandardMethodCodec().encodeMethodCall(
const MethodCall('castChanged', {
'connected': true,
'deviceName': 'Synthetic receiver',
}),
),
(_) => completed.complete(),
);
await tester.pump();
await completed.future;
await tester.pump();
expect(prepared, 1);
expect(preparedFile, '/selected/synthetic.mp4');
expect(Uri.parse(loads.single['url'] as String).scheme, 'http');
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
expect(closed, 1);
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
for (final change in [
'quality',
'episode',
'subtitles',
'stop',
'new owner',
]) {
testWidgets(
'pending page Cast lease is invalidated by actual $change change',
(tester) async {
final app = AppState();
final engine = _Engine();
final newerEngine = _Engine();
final prepared = Completer<CastMediaLease>();
final loads = <Map>[];
final snapshots = <Map<String, dynamic>>[];
var closed = 0;
const a = MediaSource(url: 'https://media.test/a.mp4', quality: '720p');
const b = MediaSource(
url: 'https://media.test/b.mp4',
quality: '1080p',
);
const one = EpisodeRecord(id: 1, title: 'First', sources: [a, b]);
const two = EpisodeRecord(id: 2, title: 'Second', sources: [b]);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
MediaPlatform.channel,
(call) async {
return switch (call.method) {
'getBrightness' => -1.0,
'getEffectiveBrightness' => 0.6,
'getVolume' => 0.5,
'castGetState' => {'connected': true, 'deviceName': 'Receiver'},
'castLoad' => (() {
loads.add(call.arguments as Map);
return true;
})(),
_ => null,
};
},
);
addTearDown(() async {
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
await engine.stream.close();
await newerEngine.stream.close();
app.dispose();
});
await app.updateSettings(AppSettings());
await tester.pumpWidget(
AppScope(
state: app,
child: MaterialApp(
home: PlayerPage(
title: const TitleRecord(id: 43, title: 'Series'),
source: a,
episode: one,
playlist: const [one, two],
playerFactory: () => engine,
videoControllerFactory: (_) => null,
castShareFactory:
({required payload, localFile, subtitleText}) async {
snapshots.add({...payload, 'selectedText': subtitleText});
if (snapshots.length == 1) return prepared.future;
return CastMediaLease(
payload: payload,
onClose: () async {},
);
},
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
expect(snapshots, isEmpty);
await tester.tap(find.byTooltip('Cast to device'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cast this video'));
await tester.pump();
expect(snapshots.single['url'], a.url);
// Dismiss the actual sheet while its explicit load is still pending.
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
if (change == 'quality') {
await tester.tap(find.text('1080p'));
} else if (change == 'episode') {
await tester.tap(find.byTooltip('Next episode'));
} else if (change == 'subtitles') {
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
} else if (change == 'stop') {
await tester.sendKeyEvent(LogicalKeyboardKey.mediaStop);
} else {
await tester.pumpWidget(
AppScope(
state: app,
child: MaterialApp(
home: PlayerPage(
key: const ValueKey('new owner'),
title: const TitleRecord(id: 44, title: 'New owner'),
source: b,
playerFactory: () => newerEngine,
videoControllerFactory: (_) => null,
),
),
),
);
}
await tester.pump(const Duration(milliseconds: 100));
if (change == 'quality' || change == 'episode') {
expect(engine.opens.last.uri, b.url);
}
if (change == 'subtitles') expect(app.prefs.subtitleEnabled, false);
prepared.complete(
CastMediaLease(
payload: {'url': a.url, 'title': 'Obsolete'},
onClose: () async {
closed++;
},
),
);
await tester.pump(const Duration(milliseconds: 100));
expect(
loads,
isEmpty,
reason:
'A malicious late lease must not load its captured old payload',
);
expect(closed, 1);
expect(find.textContaining('Could not load this video'), findsNothing);
if (change == 'quality' ||
change == 'episode' ||
change == 'subtitles') {
// Cancellation does not silently re-share; another explicit action can.
expect(snapshots, hasLength(1));
await tester.tap(find.byTooltip('Cast to device'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cast this video'));
await tester.pumpAndSettle();
expect(loads.single['url'], change == 'subtitles' ? a.url : b.url);
expect(snapshots, hasLength(2));
}
},
variant: TargetPlatformVariant.only(TargetPlatform.android),
);
}
setUp(() {
SharedPreferences.setMockInitialValues({});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (_) async => null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, (call) async {
return switch (call.method) {
'getBrightness' => -1.0,
'getEffectiveBrightness' => 0.6,
'getVolume' => 0.5,
'castGetState' => <String, dynamic>{'connected': false},
_ => null,
};
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
testWidgets(
'real player controls dispatch to engine and persist fit/subtitle options',
(tester) async {
final app = AppState();
final engine = _Engine();
addTearDown(() async {
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
await engine.stream.close();
app.dispose();
});
await app.updateSettings(AppSettings(playbackSpeed: 1.75));
await tester.pumpWidget(
AppScope(
state: app,
child: MaterialApp(
home: PlayerPage(
title: const TitleRecord(id: 1, title: 'Film'),
source: const MediaSource(url: 'https://media.test/film.mp4'),
playerFactory: () => engine,
videoControllerFactory: (_) => null,
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
// The native decoder is replaced, not the actual player control dispatch.
expect(
find.byType(CircularProgressIndicator),
findsNothing,
reason:
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
);
expect(engine.calls, containsAllInOrder(['open:false', 'rate:1.75']));
engine.stream.positions.add(const Duration(seconds: 30));
engine.stream.durations.add(const Duration(seconds: 600));
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.keyM);
await tester.pump();
expect(engine.calls, contains('seek:40'));
expect(engine.calls, contains('volume:0.0'));
await tester.tap(find.byTooltip('Fit or fill'));
await tester.pump(const Duration(milliseconds: 100));
// The native decoder is replaced, not the actual player control dispatch.
expect(
find.byType(CircularProgressIndicator),
findsNothing,
reason:
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
);
expect(app.prefs.videoFit, 'cover');
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
await tester.pump(const Duration(milliseconds: 100));
// The native decoder is replaced, not the actual player control dispatch.
expect(
find.byType(CircularProgressIndicator),
findsNothing,
reason:
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
);
expect(app.prefs.subtitleEnabled, false);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump();
await tester.pump(const Duration(seconds: 5));
expect(find.byTooltip('Player settings'), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.space);
await tester.pump();
expect(engine.calls, containsAllInOrder(['play', 'pause']));
expect(find.byTooltip('Player settings'), findsOneWidget);
},
);
testWidgets(
'resume retains history, rewinds dispatch, and next updates episode context',
(tester) async {
final app = AppState();
final engine = _Engine();
addTearDown(() async {
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
await engine.stream.close();
app.dispose();
});
const title = TitleRecord(id: 2, title: 'Series');
const one = EpisodeRecord(
id: 11,
title: 'Episode one',
sources: [MediaSource(url: 'https://media.test/1.mp4')],
);
const two = EpisodeRecord(
id: 12,
title: 'Episode two',
sources: [MediaSource(url: 'https://media.test/2.mp4')],
);
await app.updateSettings(AppSettings(resumeRewindSeconds: 5));
await app.recordWatch(
title,
episodeId: 11,
episodeTitle: one.title,
positionSeconds: 60,
durationSeconds: 600,
sourceUrl: one.sources.single.url,
);
await tester.pumpWidget(
AppScope(
state: app,
child: MaterialApp(
home: PlayerPage(
title: title,
source: one.sources.single,
episode: one,
playlist: const [one, two],
playerFactory: () => engine,
videoControllerFactory: (_) => null,
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
// The native decoder is replaced, not the actual player control dispatch.
expect(
find.byType(CircularProgressIndicator),
findsNothing,
reason:
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
);
expect(engine.opens.single.start, const Duration(seconds: 55));
expect(app.history.single.positionSeconds, 60);
engine.stream.completedEvents.add(true);
await tester.pump(const Duration(milliseconds: 100));
// The native decoder is replaced, not the actual player control dispatch.
expect(
find.byType(CircularProgressIndicator),
findsNothing,
reason:
'engine calls: ${engine.calls}; text: ${tester.widgetList<Text>(find.byType(Text)).map((e) => e.data).toList()}',
);
expect(engine.opens.last.uri, two.sources.single.url);
expect(find.text('Episode two'), findsOneWidget);
expect(app.history.single.positionSeconds, 60);
},
);
}
+52
View File
@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_helpers.dart';
void main() {
for (final disposeFirst in [true, false]) {
test(
'old player teardown cannot overwrite new owner (dispose first=$disposeFirst)',
() async {
final old = PlaybackLifecycle();
final current = PlaybackLifecycle();
final pending = Completer<void>();
final writes = <String>[];
final oldSetup = old.setup([
() async {
writes.add('old-active');
await pending.future;
},
]);
await Future<void>.delayed(Duration.zero);
late Future<void> teardown;
late Future<bool> newSetup;
Future<void> restoreOld() async {
writes.add('old-restore');
}
Future<void> setupNew() async {
writes.add('new-active');
}
if (disposeFirst) {
teardown = old.dispose(restoreOld);
newSetup = current.setup([setupNew]);
} else {
newSetup = current.setup([setupNew]);
teardown = old.dispose(restoreOld);
}
await Future<void>.delayed(Duration.zero);
pending.complete();
expect(await oldSetup, isFalse);
expect(await newSetup, isTrue);
await teardown;
expect(writes.last, 'new-active');
await current.dispose(() async {
writes.add('new-restore');
});
expect(writes.last, 'new-restore');
},
);
}
}
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_helpers.dart';
void main() {
for (final pendingStep in [0, 1, 2]) {
test(
'dispose during setup step $pendingStep cancels later writes; restore is last',
() async {
final lifecycle = PlaybackLifecycle();
final entered = Completer<void>();
final release = Completer<void>();
final events = <String>[];
final setup = lifecycle.setup([
for (var i = 0; i < 3; i++)
() async {
if (i == pendingStep) {
entered.complete();
await release.future;
}
events.add('setup:$i');
},
]);
await entered.future;
final restore = lifecycle.dispose(() async => events.add('restore'));
release.complete();
expect(await setup, isFalse);
await restore;
expect(events, [
for (var i = 0; i <= pendingStep; i++) 'setup:$i',
'restore',
]);
},
);
}
test('dispose during save prevents any player open dispatch', () async {
final lifecycle = PlaybackLifecycle();
final save = Completer<void>();
var opens = 0;
final opening = lifecycle.open(
save: () => save.future,
dispatch: (_) async {
opens++;
},
);
await lifecycle.dispose(() async {});
save.complete();
expect(await opening, isFalse);
expect(opens, 0);
});
test('newer open wins even when an older save completes last', () async {
final lifecycle = PlaybackLifecycle();
final save = Completer<void>();
final opened = <String>[];
final older = lifecycle.open(
save: () => save.future,
dispatch: (_) async => opened.add('old'),
);
expect(
await lifecycle.open(
save: () async {},
dispatch: (_) async => opened.add('new'),
),
isTrue,
);
save.complete();
expect(await older, isFalse);
expect(opened, ['new']);
});
test('disposed lifecycle rejects new setup and open work', () async {
final lifecycle = PlaybackLifecycle();
await lifecycle.dispose(() async {});
var calls = 0;
expect(
await lifecycle.setup([
() async {
calls++;
},
]),
isFalse,
);
expect(
await lifecycle.open(
save: () async {
calls++;
},
dispatch: (_) async {
calls++;
},
),
isFalse,
);
expect(calls, 0);
});
}
+406
View File
@@ -0,0 +1,406 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late List<String> actions;
late List<MethodCall> native;
late PlayerNativeSession session;
setUp(() {
actions = [];
native = [];
session = PlayerNativeSession(
invoke: (method, args) async {
native.add(MethodCall(method, args));
return true;
},
local: (action, position) async {
actions.add('$action:${position?.inMilliseconds}');
},
media: () => {
'url': 'https://media.test/v.mp4',
'title': 'Episode 2',
'positionMs': 12000,
'subtitleUrl': 'https://media.test/fa.srt',
},
castRequested: true, // These protocol tests start with a user's request.
isPlaying: () => true,
isCurrent: () => true,
);
session.claim();
});
tearDown(() => session.release());
test(
'media actions dispatch actual local controls and reject unknown actions',
() async {
await session.handle(
const MethodCall('mediaAction', {'action': 'seek', 'positionMs': 4500}),
);
await session.handle(const MethodCall('mediaAction', {'action': 'next'}));
await session.handle(
const MethodCall('mediaAction', {'action': 'invented'}),
);
expect(actions, ['seek:4500', 'next:null']);
},
);
test('Cast transfer pauses decoder and routes controls remotely until disconnect', () async {
await session.handle(
const MethodCall('castChanged', {'connected': true, 'deviceName': 'TV'}),
);
expect(session.casting, true);
expect(actions, ['pause:null']);
expect(native.single.method, 'castLoad');
expect(native.single.arguments['positionMs'], 12000);
expect(native.single.arguments['subtitleUrl'], 'https://media.test/fa.srt');
await session.command('seek', position: const Duration(seconds: 30));
expect(native.last.method, 'castCommand');
expect(native.last.arguments, {'action': 'seek', 'positionMs': 30000});
await session.handle(
const MethodCall('castChanged', {
'connected': true,
'playing': true,
'positionMs': 33000,
'durationMs': 60000,
}),
);
await session.handle(const MethodCall('castChanged', {'connected': false}));
expect(actions, ['pause:null', 'seek:33000', 'play:null']);
expect(session.casting, false);
});
test(
'failed Cast load restores local playback and leaves no remote mode',
() async {
session.invoke = (method, args) async => false;
await session.handle(
const MethodCall('castChanged', {'connected': true}),
);
expect(session.casting, false);
expect(actions, ['pause:null', 'play:null']);
},
);
test('failed Cast load is not retried by repeated connected polls', () async {
session.invoke = (method, args) async {
native.add(MethodCall(method, args));
return false;
};
await session.handle(const MethodCall('castChanged', {'connected': true}));
await session.handle(const MethodCall('castChanged', {'connected': true}));
await session.handle(const MethodCall('castChanged', {'connected': true}));
expect(native.where((call) => call.method == 'castLoad'), hasLength(1));
expect(actions, ['pause:null', 'play:null']);
// A real reconnect may retry once; ordinary state polling may not.
await session.handle(const MethodCall('castChanged', {'connected': false}));
await session.handle(const MethodCall('castChanged', {'connected': true}));
expect(native.where((call) => call.method == 'castLoad'), hasLength(2));
});
test(
'old owner cannot clear new handler or dispatch pending action',
() async {
final old = session;
final writes = <String>[];
final newer = PlayerNativeSession(
invoke: old.invoke,
local: (a, p) async => writes.add(a),
media: () => {},
isPlaying: () => false,
isCurrent: () => true,
)..claim();
old.release();
await old.handle(const MethodCall('mediaAction', {'action': 'play'}));
await newer.handle(const MethodCall('pipChanged', true));
await newer.handle(const MethodCall('mediaAction', {'action': 'pause'}));
expect(actions, isEmpty);
expect(writes, ['pause']);
expect(newer.inPip, true);
newer.release();
},
);
test(
'disconnect during pending load cannot revive stale Cast mode',
() async {
final load = Completer<bool>();
session.invoke = (method, args) => load.future;
final pending = session.handle(
const MethodCall('castChanged', {'connected': true}),
);
await Future<void>.delayed(Duration.zero);
await session.handle(
const MethodCall('castChanged', {'connected': false}),
);
load.complete(true);
await pending;
expect(session.casting, false);
expect(actions, ['pause:null', 'play:null']);
},
);
for (final intent in [true, false]) {
test('disconnect during awaited local pause restores latest intent $intent', () async {
final pause = Completer<void>();
var firstPause = true;
final writes = <String>[];
final pendingSession = PlayerNativeSession(
castRequested: true,
invoke: (method, _) async {
writes.add(method);
return true;
},
local: (action, _) async {
writes.add(action);
if (action == 'pause' && firstPause) {
firstPause = false;
await pause.future;
}
},
media: () => {'url': 'https://media.test/current.mp4'},
isPlaying: () => true,
isCurrent: () => true,
)..claim();
addTearDown(pendingSession.release);
final load = pendingSession.handle(
const MethodCall('castChanged', {'connected': true}),
);
await Future<void>.delayed(Duration.zero);
expect(writes, ['pause']);
await pendingSession.command(intent ? 'play' : 'pause');
await pendingSession.handle(
const MethodCall('castChanged', {'connected': false}),
);
// Repeated disconnected polling must not discard the restoration reason.
await pendingSession.handle(
const MethodCall('castChanged', {'connected': false}),
);
pause.complete();
await load;
expect(writes, [
'pause',
intent ? 'play' : 'pause',
intent ? 'play' : 'pause',
]);
expect(pendingSession.casting, false);
});
}
for (final cancellation in ['stop', 'release', 'new owner', 'new load']) {
test(
'awaited pause cancellation cannot resume after $cancellation',
() async {
final pause = Completer<void>();
var firstPause = true;
final writes = <String>[];
final pendingSession = PlayerNativeSession(
castRequested: true,
invoke: (method, _) async {
writes.add(method);
return true;
},
local: (action, _) async {
writes.add(action);
if (action == 'pause' && firstPause) {
firstPause = false;
await pause.future;
}
},
media: () => {'url': 'https://media.test/current.mp4'},
isPlaying: () => true,
isCurrent: () => true,
)..claim();
addTearDown(pendingSession.release);
final load = pendingSession.handle(
const MethodCall('castChanged', {'connected': true}),
);
await Future<void>.delayed(Duration.zero);
await pendingSession.handle(
const MethodCall('castChanged', {'connected': false}),
);
Future<void>? newerLoad;
if (cancellation == 'stop') {
await pendingSession.command('stop');
} else if (cancellation == 'release') {
pendingSession.release();
} else if (cancellation == 'new load') {
newerLoad = pendingSession.loadCast();
} else {
final newer = PlayerNativeSession(
invoke: (_, _) async => true,
local: (action, _) async => writes.add('new:$action'),
media: () => {},
isPlaying: () => false,
isCurrent: () => true,
)..claim();
addTearDown(newer.release);
}
pause.complete();
await load;
await newerLoad;
expect(writes, cancellation == 'stop' ? ['pause', 'stop'] : ['pause']);
expect(pendingSession.casting, false);
},
);
}
for (final initiallyPlaying in [true, false]) {
for (final throws in [false, true]) {
test(
'Cast ${throws ? 'throw' : 'rejection'} preserves newer ${initiallyPlaying ? 'pause' : 'play'} intent',
() async {
final response = Completer<bool>();
final writes = <String>[];
final failures = <String>[];
final pendingSession = PlayerNativeSession(
castRequested: true,
invoke: (_, _) => response.future,
local: (action, _) async => writes.add(action),
media: () => {'url': 'https://media.test/current.mp4'},
isPlaying: () => initiallyPlaying,
isCurrent: () => true,
failure: failures.add,
)..claim();
addTearDown(pendingSession.release);
final load = pendingSession.handle(
const MethodCall('castChanged', {'connected': true}),
);
await Future<void>.delayed(Duration.zero);
final intent = initiallyPlaying ? 'pause' : 'play';
await pendingSession.command(intent);
if (throws) {
response.completeError(StateError('native load failed'));
} else {
response.complete(false);
}
await load;
expect(writes, ['pause', intent, intent]);
expect(pendingSession.casting, false);
expect(failures, hasLength(1));
},
);
}
}
for (final cancellation in ['stop', 'release', 'new owner', 'new load']) {
test(
'stale throwing Cast load cannot overwrite state after $cancellation',
() async {
final response = Completer<bool>();
final writes = <String>[];
final failures = <String>[];
var loads = 0;
final pendingSession = PlayerNativeSession(
castRequested: true,
invoke: (method, _) async {
if (method == 'castLoad' && ++loads == 1) return response.future;
return true;
},
local: (action, _) async => writes.add(action),
media: () => {'url': 'https://media.test/current.mp4'},
isPlaying: () => true,
isCurrent: () => true,
failure: failures.add,
)..claim();
addTearDown(pendingSession.release);
final load = pendingSession.handle(
const MethodCall('castChanged', {'connected': true}),
);
await Future<void>.delayed(Duration.zero);
Future<void>? newerLoad;
if (cancellation == 'stop') {
await pendingSession.command('stop');
} else if (cancellation == 'release') {
pendingSession.release();
} else if (cancellation == 'new load') {
newerLoad = pendingSession.loadCast();
} else {
final newer = PlayerNativeSession(
invoke: (_, _) async => true,
local: (action, _) async => writes.add('new:$action'),
media: () => {},
isPlaying: () => false,
isCurrent: () => true,
)..claim();
addTearDown(newer.release);
}
response.completeError(StateError('malicious late native failure'));
await load;
await newerLoad;
expect(failures, isEmpty);
expect(
writes,
cancellation == 'stop'
? ['pause', 'stop']
: cancellation == 'new load'
? ['pause', 'pause']
: ['pause'],
);
expect(pendingSession.casting, cancellation == 'new load');
},
);
}
test(
'notification stop ends background session without automatic restart',
() async {
await session.background(
enabled: true,
playing: true,
position: Duration.zero,
duration: Duration.zero,
);
await session.command('stop');
expect(native.last.method, 'backgroundPlaybackStop');
final count = native.length;
await session.background(
enabled: true,
playing: false,
position: Duration.zero,
duration: Duration.zero,
);
expect(native.length, count);
await session.command('play');
await session.background(
enabled: true,
playing: true,
position: Duration.zero,
duration: Duration.zero,
);
expect(native.last.method, 'backgroundPlayback');
},
);
test(
'background bridge starts only by opt-in and updates exact metadata',
() async {
await session.background(
enabled: false,
playing: true,
position: Duration.zero,
duration: Duration.zero,
);
expect(native, isEmpty);
await session.background(
enabled: true,
playing: true,
position: const Duration(seconds: 12),
duration: const Duration(seconds: 60),
);
expect(native.last.method, 'backgroundPlayback');
expect(native.last.arguments, {
'enabled': true,
'title': 'Episode 2',
'playing': true,
'positionMs': 12000,
'durationMs': 60000,
});
await session.background(
enabled: true,
playing: false,
position: const Duration(seconds: 15),
duration: const Duration(seconds: 60),
);
expect(native.last.method, 'backgroundPlaybackUpdate');
await session.background(
enabled: false,
playing: false,
position: Duration.zero,
duration: Duration.zero,
);
expect(native.last.method, 'backgroundPlaybackStop');
},
);
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_helpers.dart';
import 'package:reelbox/state/app_state.dart';
void main() {
test('startup dispatch applies rate/audio but does not overwrite system volume by default', () async {
final calls = <String>[];
await applyPlayerOptions(AppSettings(playbackSpeed:1.75,audioOnly:true,playerVolume:.9),
rate:(v) async => calls.add('rate:$v'), audioOnly:(v) async => calls.add('audio:$v'), volume:(v) async => calls.add('volume:$v'));
expect(calls,['rate:1.75','audio:true']);
await applyPlayerOptions(AppSettings(rememberVolume:true,playerVolume:.2),
rate:(v) async {}, audioOnly:(v) async {}, volume:(v) async => calls.add('volume:$v'));
expect(calls.last,'volume:0.2');
});
test('subtitle reset only resets subtitle fields; complete player reset preserves library preferences', () {
final settings = AppSettings(locale:'fa',theme:'light',skipSeconds:30,subtitleEnabled:false,subtitleFontFamily:'serif',subtitleFontSize:35,subtitleDelay:2,audioDelay:3,videoFit:'cover',audioOnly:true,playbackSpeed:1.75,backgroundAudio:true,externalPlayerPackage:'org.videolan.vlc');
final sub = resetSubtitleSettings(settings);
expect(sub.subtitleEnabled,true); expect(sub.subtitleFontFamily,'sans-serif');
expect(sub.subtitleFontSize,24); expect(sub.subtitleDelay,0);
expect(sub.audioDelay,3); expect(sub.playbackSpeed,1.75); expect(sub.locale,'fa');
final all = resetPlayerSettings(settings);
expect(all.videoFit,'contain'); expect(all.audioOnly,false); expect(all.playbackSpeed,1);
expect(all.audioDelay,0); expect(all.backgroundAudio,false); expect(all.skipSeconds,10);
expect(all.locale,'fa'); expect(all.theme,'light');
});
}
+18
View File
@@ -0,0 +1,18 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_helpers.dart';
void main() {
test('decodes real Windows-1256 Persian SRT fixture without replacement', () {
final bytes = File('test/fixtures/player_persian_cp1256.srt').readAsBytesSync();
final text = decodeSubtitle(bytes);
expect(text, contains('سلام دنيا؛ پخش گچ پژ'));
expect(text, contains('00:00:01,000 --> 00:00:03,000'));
expect(text, isNot(contains('\uFFFD')));
});
test('UTF8 BOM and UTF16LE preserve subtitle text', () {
expect(decodeSubtitle([239,187,191,...utf8.encode('سلام')]), 'سلام');
expect(decodeSubtitle([255,254,0x33,0x06,0x44,0x06,0x27,0x06,0x45,0x06]), 'سلام');
});
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/media_platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, null);
});
test('player reads native control values and restores original window brightness', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final writes = <double>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, (call) async {
switch (call.method) {
case 'getBrightness':
return -1.0;
case 'getEffectiveBrightness':
return .65;
case 'getVolume':
return .22;
case 'setBrightness':
writes.add((call.arguments as Map)['value'] as double);
return null;
}
return null;
});
final controls = PlayerSystemControls();
await controls.capture();
expect(controls.brightness, .65);
expect(controls.volume, .22);
await controls.restore();
expect(writes, [-1.0]);
});
test(
'leaving a player that never initialized does not change brightness',
() async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
var called = false;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, (call) async {
called = true;
return null;
});
await PlayerSystemControls().restore();
expect(called, isFalse);
},
);
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/player_page.dart';
import 'package:reelbox/state/app_state.dart';
void main() {
testWidgets(
'Untrusted source is rejected before initializing a native player',
(tester) async {
final state = AppState();
await tester.pumpWidget(
AppScope(
state: state,
child: const MaterialApp(
home: PlayerPage(
title: TitleRecord(id: 1, title: 'Invalid source'),
source: MediaSource(url: 'javascript:alert(1)'),
),
),
),
);
await tester.pump();
expect(find.text('Unable to play this source'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
state.dispose();
},
);
}
+67
View File
@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/models.dart';
import 'package:reelbox/media/download_service.dart';
import 'package:reelbox/media/media_helpers.dart';
import 'package:reelbox/media/media_platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final calls = <MethodCall>[];
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
calls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, (call) async {
calls.add(call);
return true;
});
});
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(MediaPlatform.channel, null);
});
for (final type in [
'mpd', ' MPD ', 'hls', ' HLS ', 'm3u8', 'M3U8', 'dash', ' DASH ',
'application/dash+xml', 'application/vnd.apple.mpegurl',
'application/x-mpegURL; charset=UTF-8',
]) {
test('extensionless $type is not a direct-file or external-downloader source', () async {
final source = MediaSource(url: 'https://example.test/manifest?id=7', type: type);
expect(isSegmentedMediaSource(source), isTrue);
final entry = DownloadEntry.fromTask(DownloadTask(
taskId: 'manifest', url: source.url, filename: 'legacy.mp4',
metaData: jsonEncode({'titleId': 7, 'title': 'Fixture', 'sourceType': type}),
), taskStatus: TaskStatus.failed);
await expectLater(DownloadService.instance.externalDownloader(entry),
throwsA(isA<UnsupportedError>().having((e) => e.message, 'message', contains('package'))));
expect(calls, isEmpty);
expect(downloadUnsupportedReason(source), isNotNull);
});
}
test('manifest URL extensions override direct type hints', () {
for (final extension in ['M3U8', 'MPD']) {
expect(downloadUnsupportedReason(MediaSource(
url: 'https://example.test/video.$extension?name=movie.mp4', type: 'mp4',
)), isNotNull);
}
});
test('direct MP4 remains eligible for direct and external download', () async {
const source = MediaSource(url: 'https://example.test/video.mp4', type: 'mp4');
expect(downloadUnsupportedReason(source), isNull);
final entry = DownloadEntry.fromTask(DownloadTask(
taskId: 'direct', url: source.url, filename: 'video.mp4',
metaData: jsonEncode({'titleId': 7, 'title': 'Fixture', 'sourceType': 'mp4'}),
));
await DownloadService.instance.externalDownloader(entry);
expect(calls.map((call) => call.method), ['openDownloader']);
});
}
+371
View File
@@ -0,0 +1,371 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/segmented_download.dart';
void main() {
late Directory root;
late HttpServer server;
late String origin;
final requests = <String>[];
final fixtures = <String,String>{};
final failures = <String>{};
final redirects = <String, String>{};
final responseCodes = <String, int>{};
final encodings = <String>[];
var incorrectRange = false;
var active = 0, peak = 0;
setUp(() async {
root = await Directory.systemTemp.createTemp('reelbox-offline-test-');
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
origin = 'http://${server.address.address}:${server.port}';
requests.clear(); fixtures.clear(); failures.clear(); redirects.clear(); active = 0; peak = 0;
responseCodes.clear(); encodings.clear(); incorrectRange = false;
server.listen((request) async {
requests.add('${request.uri.path}:${request.headers.value('range') ?? ''}');
encodings.add('${request.uri.path}:${request.headers.value('accept-encoding') ?? ''}');
active++; if (active > peak) peak = active;
try {
final path = request.uri.path;
request.response.statusCode = responseCodes[path] ?? 200;
if (failures.contains(path)) {
request.response.statusCode = 403;
} else if (redirects.containsKey(path)) {
request.response.statusCode = responseCodes[path] ?? 302;
request.response.headers.set(HttpHeaders.locationHeader, redirects[path]!);
} else if (fixtures.containsKey(path)) {
await Future<void>.delayed(const Duration(milliseconds: 15));
final bytes = utf8.encode(fixtures[path]!);
request.response.contentLength = bytes.length;
request.response.add(bytes);
} else if (path == '/master.m3u8') {
request.response.write('#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=100\nvideo/list.m3u8\n');
} else if (path == '/video/list.m3u8') {
request.response.write('#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXT-X-MAP:URI="../blob",BYTERANGE="4@0"\n#EXTINF:2,\n#EXT-X-BYTERANGE:4@4\n../blob\n#EXTINF:2,\n#EXT-X-BYTERANGE:4\n../blob\n#EXT-X-ENDLIST\n');
} else if (path == '/blob') {
await Future<void>.delayed(const Duration(milliseconds: 20));
final range = RegExp(r'bytes=(\d+)-(\d+)').firstMatch(request.headers.value('range') ?? '')!;
final start = int.parse(range[1]!), end = int.parse(range[2]!);
request.response.statusCode = 206;
request.response.headers.set('content-range', incorrectRange ? 'bytes 0-0/12' : 'bytes $start-$end/12');
request.response.contentLength = end - start + 1;
request.response.add(utf8.encode('INITAAAABBBB').sublist(start, end + 1));
} else {
request.response.statusCode = 404;
}
} finally {
await request.response.close(); active--;
}
});
});
tearDown(() async { await server.close(force: true); await root.delete(recursive: true); });
test('HLS master downloads bounded parallel ranges and publishes fully local validated package', () async {
final engine = SegmentedDownload(root: root, parallel: 2);
await engine.prepare(id: 'hls', url: '$origin/master.m3u8');
final result = await engine.run('hls');
expect(result.status, 'complete');
expect(await engine.validate('hls'), isTrue);
expect(peak, 2);
expect(requests, containsAll(['/blob:bytes=0-3', '/blob:bytes=4-7', '/blob:bytes=8-11']));
final resources = result.resources;
expect(resources.length, 3);
for (final resource in resources) {
final bytes = await File('${result.directory}/${resource.filename}').readAsBytes();
expect(bytes.length, 4);
expect(sha256.convert(bytes).toString(), resource.digest);
}
final playlists = await Directory(result.directory).list().where((f) => f.path.endsWith('.m3u8')).toList();
expect(playlists.length, 2);
for (final playlist in playlists) {
final text = await File(playlist.path).readAsString();
expect(text, isNot(contains(origin)));
expect(text, isNot(contains('BYTERANGE')));
}
final zip = ZipDecoder().decodeBytes(await File(await engine.export('hls')).readAsBytes());
expect(zip.files.map((f)=>f.name).toSet(), {...result.manifests.keys,...resources.map((r)=>r.filename)});
expect(zip.files.any((f)=>f.name=='package.json'),false);
for(final r in resources) {
expect(sha256.convert(zip.findFile(r.filename)!.content).toString(),r.digest);
}
await File('${result.directory}/${resources.last.filename}').writeAsString('BAD!');
expect(await engine.validate('hls'), isFalse);
});
test('redirected HLS master and children use effective URIs for all resources', () async {
redirects.addAll({
'/entry/master.m3u8': '/media/master.m3u8',
'/media/video.m3u8': 'video/list.m3u8',
'/media/audio.m3u8': 'audio/list.m3u8',
'/media/subs.m3u8': 'subs/list.m3u8',
});
fixtures['/media/master.m3u8'] = '#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="Audio",DEFAULT=YES,URI="audio.m3u8"\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub",NAME="Subs",DEFAULT=YES,URI="subs.m3u8"\n#EXT-X-STREAM-INF:BANDWIDTH=100,AUDIO="aud",SUBTITLES="sub"\nvideo.m3u8\n';
fixtures['/media/video/list.m3u8'] = '#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key",IV=0x00000000000000000000000000000001\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n';
fixtures['/media/audio/list.m3u8'] = '#EXTM3U\n#EXTINF:1,\na.aac\n#EXT-X-ENDLIST\n';
fixtures['/media/subs/list.m3u8'] = '#EXTM3U\n#EXTINF:1,\na.vtt\n#EXT-X-ENDLIST\n';
fixtures.addAll({
'/media/video/key': '0123456789abcdef',
'/media/video/init.mp4': 'INIT', '/media/video/a.ts': 'VIDEO',
'/media/audio/a.aac': 'AUDIO', '/media/subs/a.vtt': 'WEBVTT\n',
});
final engine = SegmentedDownload(root: root);
final planned = await engine.prepare(id: 'redirect_hls', url: '$origin/entry/master.m3u8');
expect(planned.resources.map((r) => Uri.parse(r.url).path).toSet(), {
'/media/video/key', '/media/video/init.mp4', '/media/video/a.ts',
'/media/audio/a.aac', '/media/subs/a.vtt',
});
final result = await engine.run(planned.id);
expect(result.status, 'complete', reason: result.error);
expect(await engine.validate(planned.id), isTrue);
expect(requests.any((r) => r.startsWith('/entry/') && r != '/entry/master.m3u8:'), isFalse);
});
test('resource redirects validate each hop before fetching excluded targets', () async {
fixtures['/vod.m3u8'] = '#EXTM3U\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n';
redirects.addAll({'/a.ts': '/allowed-hop', '/allowed-hop': '/moviebox/support/blocked.ts'});
fixtures['/moviebox/support/blocked.ts'] = 'MUST NOT FETCH';
final engine = SegmentedDownload(root: root);
await engine.prepare(id: 'blocked_resource', url: '$origin/vod.m3u8');
final result = await engine.run('blocked_resource');
expect(result.status, 'failed');
expect(result.error, contains('authorized'));
expect(requests, ['/vod.m3u8:', '/a.ts:', '/allowed-hop:']);
expect(await File(result.path).exists(), isFalse);
expect(await engine.validate(result.id), isFalse);
});
test('redirected extensionless DASH resolves nested BaseURLs at the effective URI', () async {
redirects['/entry/manifest'] = '/media/movie/manifest';
fixtures['/media/movie/manifest'] = '<MPD type="static"><BaseURL>parts/</BaseURL><Period><BaseURL>period/</BaseURL><AdaptationSet><BaseURL>video/</BaseURL><Representation><BaseURL>main/</BaseURL><SegmentList duration="1"><Initialization sourceURL="init.mp4"/><SegmentURL media="a.m4s"/></SegmentList></Representation></AdaptationSet></Period></MPD>';
fixtures.addAll({
'/media/movie/parts/period/video/main/init.mp4': 'INIT',
'/media/movie/parts/period/video/main/a.m4s': 'VIDEO',
});
final engine = SegmentedDownload(root: root);
final planned = await engine.prepare(id: 'redirect_dash', url: '$origin/entry/manifest', type: 'mpd');
expect(planned.manifest, 'offline.mpd');
expect(planned.resources.map((r) => Uri.parse(r.url).path).toSet(), {
'/media/movie/parts/period/video/main/init.mp4',
'/media/movie/parts/period/video/main/a.m4s',
});
final result = await engine.run(planned.id);
expect(result.status, 'complete', reason: result.error);
expect(await engine.validate(result.id), isTrue);
expect(await File(result.path).readAsString(), isNot(contains('BaseURL')));
});
for (final code in [301, 302, 303, 307, 308]) {
test('manifest HTTP $code resolves relative Location against each hop', () async {
redirects.addAll({'/entry/start': 'next', '/entry/next': '../media/vod'});
responseCodes.addAll({'/entry/start': code, '/entry/next': code});
fixtures['/media/vod'] = '#EXTM3U\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n';
fixtures['/media/a.ts'] = 'MEDIA';
final engine = SegmentedDownload(root: root);
await engine.prepare(id: 'status_$code', url: '$origin/entry/start');
final result = await engine.run('status_$code');
expect(result.status, 'complete', reason: result.error);
expect(requests, ['/entry/start:', '/entry/next:', '/media/vod:', '/media/a.ts:']);
});
}
test('manifest redirects validate intermediate targets before requesting them', () async {
redirects.addAll({'/entry': '/allowed', '/allowed': '/moviebox/news/manifest'});
fixtures['/moviebox/news/manifest'] = '#EXTM3U\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n';
final engine = SegmentedDownload(root: root);
await expectLater(engine.prepare(id: 'denied_manifest', url: '$origin/entry'), throwsUnsupportedError);
expect(requests, ['/entry:', '/allowed:']);
expect(await Directory('${root.path}/denied_manifest').exists(), isFalse);
});
for (final target in ['file:///private/media', 'http://user:secret@127.0.0.1/media', '/moviebox/%73upport/media']) {
test('manifest redirects reject unauthorized target $target', () async {
redirects['/entry'] = target;
final engine = SegmentedDownload(root: root);
await expectLater(engine.prepare(id: 'unauthorized', url: '$origin/entry'), throwsUnsupportedError);
expect(requests, ['/entry:']);
});
}
test('manifest and resource redirect loops stop after five hops', () async {
redirects['/loop'] = '/loop';
final engine = SegmentedDownload(root: root);
await expectLater(engine.prepare(id: 'loop_manifest', url: '$origin/loop'),
throwsA(isA<HttpException>().having((e) => e.message, 'message', contains('Too many'))));
expect(requests, List.filled(6, '/loop:'));
expect(await Directory('${root.path}/loop_manifest').exists(), isFalse);
requests.clear();
fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\nloop\n#EXT-X-ENDLIST\n';
await engine.prepare(id: 'loop_resource', url: '$origin/vod');
final result = await engine.run('loop_resource');
expect(result.status, 'failed');
expect(result.error, contains('Too many'));
expect(requests, ['/vod:', ...List.filled(6, '/loop:')]);
expect(await File(result.path).exists(), isFalse);
});
test('manifest and resource redirects without Location fail closed', () async {
fixtures['/missing'] = '';
responseCodes['/missing'] = 302;
final engine = SegmentedDownload(root: root);
await expectLater(engine.prepare(id: 'missing_manifest', url: '$origin/missing'),
throwsA(isA<HttpException>().having((e) => e.message, 'message', contains('Location'))));
fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\nmissing\n#EXT-X-ENDLIST\n';
await engine.prepare(id: 'missing_resource', url: '$origin/vod');
final result = await engine.run('missing_resource');
expect(result.status, 'failed');
expect(result.error, contains('Location'));
expect(requests, ['/missing:', '/vod:', '/missing:']);
});
test('resource redirects preserve Range and identity headers on every hop', () async {
fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\n#EXT-X-BYTERANGE:4@4\nrange\n#EXT-X-ENDLIST\n';
redirects.addAll({'/range': '/range-hop', '/range-hop': '/blob'});
final engine = SegmentedDownload(root: root);
await engine.prepare(id: 'redirect_range', url: '$origin/vod');
final result = await engine.run('redirect_range');
expect(result.status, 'complete', reason: result.error);
expect(requests, ['/vod:', '/range:bytes=4-7', '/range-hop:bytes=4-7', '/blob:bytes=4-7']);
expect(encodings.skip(1), ['/range:identity', '/range-hop:identity', '/blob:identity']);
expect(await File('${result.directory}/${result.resources.single.filename}').readAsString(), 'AAAA');
expect(await engine.validate(result.id), isTrue);
});
for (final mode in ['ignored', 'incorrect']) {
test('redirected resource still rejects $mode byte range responses', () async {
fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\n#EXT-X-BYTERANGE:4@4\nrange\n#EXT-X-ENDLIST\n';
redirects['/range'] = mode == 'ignored' ? '/whole' : '/blob';
fixtures['/whole'] = 'INITAAAABBBB';
incorrectRange = mode == 'incorrect';
final engine = SegmentedDownload(root: root);
await engine.prepare(id: 'bad_range', url: '$origin/vod');
final result = await engine.run('bad_range');
expect(result.status, 'failed');
expect(result.error, contains(mode == 'ignored' ? 'Resource HTTP 200' : 'Incorrect Content-Range'));
expect(await File(result.path).exists(), isFalse);
});
}
for (final code in [401, 403]) {
test('redirected manifests and resources retain HTTP $code denials', () async {
redirects['/entry'] = '/denied';
fixtures['/denied'] = 'Access denied';
responseCodes['/denied'] = code;
final engine = SegmentedDownload(root: root);
await expectLater(engine.prepare(id: 'denied_manifest', url: '$origin/entry'),
throwsA(isA<HttpException>().having((e) => e.message, 'message', 'Manifest HTTP $code')));
fixtures['/vod'] = '#EXTM3U\n#EXTINF:1,\nentry\n#EXT-X-ENDLIST\n';
await engine.prepare(id: 'denied_resource', url: '$origin/vod');
final result = await engine.run('denied_resource');
expect(result.status, 'failed');
expect(result.error, contains('Resource HTTP $code'));
expect(await File(result.path).exists(), isFalse);
expect(requests, ['/entry:', '/denied:', '/vod:', '/entry:', '/denied:']);
});
}
test('HLS refuses encrypted init maps without explicit IV before creating a package', () async {
fixtures['/encrypted.m3u8'] = '#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXT-X-KEY:METHOD=AES-128,URI="key"\n#EXT-X-MAP:URI="init.mp4"\n#EXTINF:2,\na.ts\n#EXT-X-ENDLIST\n';
final engine = SegmentedDownload(root: root);
await expectLater(engine.prepare(id:'unsafe',url:'$origin/encrypted.m3u8'),throwsUnsupportedError);
expect(await Directory('${root.path}/unsafe').exists(),isFalse);
});
test('cancel/restart reuses only hash-validated complete segments and never publishes incomplete manifests', () async {
fixtures['/vod.m3u8']='#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n#EXT-X-ENDLIST\n';
fixtures.addAll({'/a.ts':'AAAA','/b.ts':'BBBB','/c.ts':'CCCC'});
final engine=SegmentedDownload(root:root,parallel:1);
await engine.prepare(id:'resume',url:'$origin/vod.m3u8',metadata:{'notifications':false});
var stopping=false;
final run=engine.run('resume',onProgress:(p) {
if(p.receivedBytes>=4 && !stopping) { stopping=true; engine.stop('resume'); }
});
final paused=await run;
expect(paused.status,'paused');
expect(await File(paused.path).exists(),isFalse);
expect(await engine.validate('resume'),isFalse);
final stateFile=File('${paused.directory}/package.json');
final saved=jsonDecode(await stateFile.readAsString()) as Map<String,dynamic>;
saved['status']='running'; // OS termination before a status checkpoint.
await stateFile.writeAsString(jsonEncode(saved));
final restarted=SegmentedDownload(root:root,parallel:2);
final recovered=(await restarted.recover()).single;
expect(recovered.status,'paused');
expect(recovered.metadata['notifications'],false);
requests.clear();
final completed=await restarted.run('resume');
expect(completed.status,'complete');
expect(requests, isNot(contains('/a.ts:')));
expect(await restarted.validate('resume'),isTrue);
await File('${completed.directory}/${completed.resources.first.filename}').writeAsString('EVIL');
final damaged=(await SegmentedDownload(root:root).recover()).single;
expect(damaged.status,'failed');
requests.clear();
final repaired=await restarted.run('resume');
expect(repaired.status,'complete');
expect(requests,['/a.ts:']);
expect(await restarted.validate('resume'),isTrue);
});
test('denied resource is failed, retains validated progress, and retry repairs it', () async {
fixtures['/vod.m3u8']='#EXTM3U\n#EXTINF:1,\na.ts\n#EXTINF:1,\nb.ts\n#EXT-X-ENDLIST\n';
fixtures.addAll({'/a.ts':'AAAA','/b.ts':'BBBB'});
failures.add('/b.ts');
final engine=SegmentedDownload(root:root,parallel:1);
await engine.prepare(id:'denied',url:'$origin/vod.m3u8');
final failed=await engine.run('denied');
expect(failed.status,'failed');
expect(failed.receivedBytes,4);
expect(await File(failed.path).exists(),isFalse);
failures.clear(); requests.clear();
expect((await SegmentedDownload(root:root).run('denied')).status,'complete');
expect(requests,['/b.ts:']);
});
test('AES-128 identity keys and separate default audio are packaged locally', () async {
fixtures['/audio-master.m3u8']='#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",DEFAULT=YES,URI="audio.m3u8"\n#EXT-X-STREAM-INF:BANDWIDTH=500,AUDIO="aud"\nvideo.m3u8\n';
fixtures['/audio.m3u8']='#EXTM3U\n#EXTINF:1,\na.aac\n#EXT-X-ENDLIST\n';
fixtures['/video.m3u8']='#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="key",IV=0x00000000000000000000000000000001\n#EXTINF:1,\nv.ts\n#EXT-X-ENDLIST\n';
fixtures.addAll({'/a.aac':'AUDIO','/v.ts':'ENCRYPTED-MEDIA','/key':'0123456789abcdef'});
final engine=SegmentedDownload(root:root);
await engine.prepare(id:'audio',url:'$origin/audio-master.m3u8');
final p=await engine.run('audio');
expect(p.status,'complete'); expect(p.resources.length,3);
expect(p.resources.singleWhere((r)=>r.key).bytes,16);
expect(await engine.validate('audio'),isTrue);
});
test('reject DRM, live manifests, dynamic DASH and unbounded timelines', () async {
final engine=SegmentedDownload(root:root);
final cases=<String,String>{
'live':'#EXTM3U\n#EXTINF:1,\na.ts\n',
'drm':'#EXTM3U\n#EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.apple.streamingkeydelivery",URI="skd://protected"\n#EXTINF:1,\na.ts\n#EXT-X-ENDLIST\n',
'dashdrm':'<MPD><Period><AdaptationSet><ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011"/><Representation/></AdaptationSet></Period></MPD>',
'dynamic':'<MPD type="dynamic"><Period/></MPD>',
'unbounded':'<MPD><Period><AdaptationSet><Representation><SegmentTemplate media="a-\$Time\$.m4s"><SegmentTimeline><S d="2" r="-1"/></SegmentTimeline></SegmentTemplate></Representation></AdaptationSet></Period></MPD>',
};
for(final entry in cases.entries) {
fixtures['/${entry.key}']=entry.value;
await expectLater(engine.prepare(id:entry.key,url:'$origin/${entry.key}'),throwsUnsupportedError,reason:entry.key);
expect(await Directory('${root.path}/${entry.key}').exists(),false);
}
});
test('clear static DASH template packages audio and video with local SegmentLists', () async {
fixtures['/movie.mpd'] = '''<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="PT4S"><BaseURL>media/</BaseURL><Period><AdaptationSet mimeType="video/mp4"><SegmentTemplate timescale="1" duration="2" startNumber="5" initialization="\$RepresentationID\$/init.mp4" media="\$RepresentationID\$/\$Number%03d\$.m4s"/><Representation id="video" bandwidth="800"/></AdaptationSet><AdaptationSet mimeType="audio/mp4"><Representation id="audio" bandwidth="100"><SegmentList timescale="1" duration="2"><Initialization sourceURL="audio/init.mp4"/><SegmentURL media="audio/1.m4s"/><SegmentURL media="audio/2.m4s"/></SegmentList></Representation></AdaptationSet></Period></MPD>''';
for (final path in ['video/init.mp4','video/005.m4s','video/006.m4s','audio/init.mp4','audio/1.m4s','audio/2.m4s']) {
fixtures['/media/$path'] = 'BYTES:$path';
}
final engine = SegmentedDownload(root: root, parallel: 3);
await engine.prepare(id: 'dash', url: '$origin/movie.mpd');
final result = await engine.run('dash');
expect(result.status, 'complete');
expect(result.resources.length, 6);
expect(await engine.validate('dash'), isTrue);
final manifest = await File(result.path).readAsString();
expect(manifest, contains('SegmentList'));
expect(manifest, isNot(contains('SegmentTemplate')));
expect(manifest, isNot(contains('BaseURL')));
expect(manifest, isNot(contains(origin)));
for (final r in result.resources) {
expect(await File('${result.directory}/${r.filename}').readAsString(), fixtures[Uri.parse(r.url).path]);
expect(manifest, contains(r.filename));
}
});
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/media/subtitle_webvtt.dart';
void main() {
test('WebVTT drops metadata, STYLE, REGION, NOTE, cue settings and unsafe markup', () {
final output = SubtitleWebVtt.convert('''WEBVTT synthetic header
X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:0
STYLE
::cue { background-image: url(https://unsafe.invalid/pixel) }
REGION
id:remote
NOTE private note
https://unsafe.invalid/note
cue-id
00:01.250 --> 00:03.000 region:remote align:start
<b>سلام</b> <img src="https://unsafe.invalid/pixel"><script>bad()</script><style>bad-style</style> &amp; safe
''');
expect(
output,
'WEBVTT\n\n00:00:01.250 --> 00:00:03.000\nسلام &amp; safe\n\n',
);
expect(output, isNot(contains('unsafe.invalid')));
});
test('SRT cue payload is sanitized, not simply relabelled', () {
expect(
SubtitleWebVtt.convert(
'1\n00:00:00,000 --> 00:00:01,500\n<i>Hello</i> {\\\\an8}<a href="https://unsafe.invalid">friend</a>',
),
'WEBVTT\n\n00:00:00.000 --> 00:00:01.500\nHello friend\n\n',
);
});
for (final timing in [
'00:61:00,000 --> 01:02:00,000',
'00:00:61,000 --> 00:02:00,000',
'00:00:03,000 --> 00:00:01,000',
'00:00:01,000 --> 00:00:01,000',
'-00:00:01,000 --> 00:00:02,000',
]) {
test('rejects invalid cue timing $timing', () {
expect(
() => SubtitleWebVtt.convert('1\n$timing\nSynthetic'),
throwsFormatException,
);
});
}
test('rejects decreasing cue starts but permits overlapping cues', () {
expect(
() => SubtitleWebVtt.convert(
'1\n00:00:02,000 --> 00:00:04,000\na\n\n2\n00:00:01,000 --> 00:00:03,000\nb',
),
throwsFormatException,
);
expect(
SubtitleWebVtt.convert(
'1\n00:00:01,000 --> 00:00:04,000\na\n\n2\n00:00:02,000 --> 00:00:03,000\nb',
),
contains('00:00:02.000 --> 00:00:03.000'),
);
});
for (final unsupported in [
'',
'<tt><body>TTML is not supported</body></tt>',
'plain arbitrary text',
'WEBVTT\n\nSTYLE\n::cue {color:red}',
]) {
test('rejects empty/unsupported input honestly: $unsupported', () {
expect(() => SubtitleWebVtt.convert(unsupported), throwsFormatException);
});
}
test('rejects invalid ASS time and drawing-mode dialogue', () {
for (final body in [
'Dialogue: 0,0:99:01.00,0:99:02.00,text',
r'Dialogue: 0,0:00:01.00,0:00:02.00,{\p1}m 1 1 l 2 2',
]) {
expect(
() => SubtitleWebVtt.convert(
'[Script Info]\n[Events]\nFormat: Layer, Start, End, Text\n$body',
),
throwsA(anyOf(isA<FormatException>(), isA<UnsupportedError>())),
);
}
});
for (final format in ['ASS', 'SSA']) {
test('synthetic $format uses Events format and dialogue, never styles/fonts', () {
final fields = format == 'ASS'
? 'Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text'
: 'Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text';
final output = SubtitleWebVtt.convert('''[Script Info]
Title: Synthetic only
[V4+ Styles]
Format: Name, Fontname
Style: Default,https://unsafe.invalid/font
[Events]
Format: $fields
Dialogue: 0,0:00:01.25,0:00:03.00,Default,,0,0,0,,{\\b1}سلام\\Nworld, again{\\b0}
''');
expect(
output,
'WEBVTT\n\n00:00:01.250 --> 00:00:03.000\nسلام\nworld, again\n\n',
);
expect(output, isNot(contains('unsafe.invalid')));
});
}
test('synthetic SRT becomes actual WebVTT while preserving Persian text', () {
expect(
SubtitleWebVtt.convert(
'\ufeff1\r\n00:00:01,250 --> 00:00:03,000\r\nسلام دنیا\r\n',
),
'WEBVTT\n\n00:00:01.250 --> 00:00:03.000\nسلام دنیا\n\n',
);
});
}
+69
View File
@@ -0,0 +1,69 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/data/api_service.dart';
import 'package:reelbox/state/app_state.dart';
import 'package:reelbox/ui/catalog.dart';
import 'package:reelbox/ui/common.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api_service_test.dart' show StubAdapter, jsonBody;
void main() {
testWidgets('artwork decodes at constrained thumbnail width, not fallback width', (tester) async {
await tester.pumpWidget(const MaterialApp(home: MediaQuery(
data: MediaQueryData(devicePixelRatio: 2),
child: Center(child: SizedBox(width: 62, height: 88,
child: MediaArtwork(url: 'https://example.test/poster.jpg'),
)),
)));
final image = tester.widget<Image>(find.byType(Image));
final provider = image.image as ResizeImage;
debugPrint('UI_WORK artwork decodeWidth=${provider.width} target=124');
expect(provider.width, 124);
expect(tester.getSize(find.byType(MediaArtwork)), const Size(62, 88));
await tester.pumpWidget(const SizedBox());
});
for (final compact in [false, true]) {
testWidgets('paged catalogue builds only nearby ${compact ? 'rows' : 'posters'}', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
SharedPreferences.setMockInitialValues({});
final pages = <int>[];
final adapter = StubAdapter((request) {
if (request.uri.path.contains('/all/')) return jsonBody([]);
final page = int.parse(request.uri.pathSegments[7]);
pages.add(page);
return jsonBody(page == 0 ? List.generate(160, (i) => {
'id': i + 1, 'type': 'movie', 'title': 'Fixture ${i + 1}',
}) : []);
});
final state = AppState(api: ApiService(dio: Dio()..httpClientAdapter = adapter));
await state.updateSettings(state.prefs.copyWith(compactCatalog: compact));
addTearDown(state.dispose);
await tester.pumpWidget(AppScope(state: state, child: const MaterialApp(home: BrowsePage(initialCountry: 9))));
await tester.pumpAndSettle();
final visibleRows = compact
? find.byWidgetPredicate((w) => w.key is ValueKey<String> && (w.key! as ValueKey<String>).value.startsWith('compact-title-row:'))
: find.byType(PosterCard);
final initialCount = visibleRows.evaluate().length;
debugPrint('UI_WORK paged compact=$compact initial=$initialCount total=160');
expect(initialCount, greaterThan(0));
expect(initialCount, lessThan(24), reason: 'offscreen titles must not be laid out eagerly');
expect(find.text('Fixture 160'), findsNothing);
expect(pages, [0]);
final scrollable = find.byType(Scrollable).last;
await tester.scrollUntilVisible(find.text('Fixture 160'), 650, scrollable: scrollable, maxScrolls: 100);
await tester.ensureVisible(find.text('Fixture 160'));
await tester.pumpAndSettle();
expect(find.text('Fixture 160'), findsOneWidget);
expect(pages, [0, 1]);
debugPrint('UI_WORK paged compact=$compact end=${visibleRows.evaluate().length} pages=$pages');
expect(visibleRows.evaluate().length, lessThan(24));
await tester.pumpWidget(const SizedBox());
});
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:reelbox/app.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';
class _NavigationFixtureApi extends ApiService {
@override
Future<Map<String, dynamic>> home() async => {'genres': []};
@override
Future<void> initialize({bool discover = true}) async {}
@override
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async => [];
@override
Future<List<Tag>> countries() async => [];
}
void main() {
testWidgets('Account-free app exposes its five core destinations', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final state = AppState(
api: _NavigationFixtureApi(),
storage: await SharedPreferences.getInstance(),
);
await state.initialize(discover: false);
// This fixture exercises the shell, not first-run onboarding.
await state.updateSettings(state.prefs.copyWith(introCompleted: true));
await tester.pumpWidget(MyApp(state: state));
await tester.pumpAndSettle();
for (final label in ['Discover', 'Browse', 'Search', 'Library', 'More']) {
expect(
find.text(label),
findsWidgets,
reason: 'Missing destination: $label',
);
}
for (final label in [
'Sign in',
'Register',
'Subscribe',
'Buy subscription',
]) {
expect(find.text(label), findsNothing);
}
await tester.tap(find.byIcon(Icons.video_library_outlined).last);
await tester.pumpAndSettle();
expect(find.text('Watchlist (0)'), findsOneWidget);
expect(find.text('Continue (0)'), findsOneWidget);
expect(find.text('Collections (0)'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
state.dispose();
});
}