[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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']);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"title": [
|
||||
"ترسناک"
|
||||
],
|
||||
"link": [
|
||||
"http://boxlimon.com/api-user/newapi/filter.php?action=pishnahad&garid=1&pageno="
|
||||
]
|
||||
}
|
||||
]
|
||||
Vendored
+49
@@ -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
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:01,000 --> 00:00:03,000
|
||||
׃בַד ֿהםַ÷ �־װ �� ��
|
||||
Vendored
+20
@@ -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"
|
||||
}
|
||||
]
|
||||
Vendored
+40
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+31
@@ -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": "امریکا"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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>()));
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
'مینی سریال',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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')));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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]), 'سلام');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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> & safe
|
||||
''');
|
||||
expect(
|
||||
output,
|
||||
'WEBVTT\n\n00:00:01.250 --> 00:00:03.000\nسلام & 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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user