Initial commit
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/emby_api/emby_headers.dart';
|
||||
|
||||
void main() {
|
||||
group('EmbyRequestHeaders.media', () {
|
||||
test('媒体流 UA 使用基础 UA', () {
|
||||
final headers = EmbyRequestHeaders.media(
|
||||
deviceId: '6f43c257-2d5e-407e-9827-0a59d37ceb97',
|
||||
deviceName: 'TestDevice',
|
||||
token: 't',
|
||||
userId: 'u',
|
||||
);
|
||||
|
||||
expect(headers['User-Agent'], EmbyRequestHeaders.userAgent);
|
||||
expect(headers['Accept'], '*/*');
|
||||
expect(headers['Accept-Language'], EmbyRequestHeaders.acceptLanguage);
|
||||
});
|
||||
});
|
||||
|
||||
group('EmbyRequestHeaders.directMedia', () {
|
||||
test('播放器直连头不包含凭据或设备标识', () {
|
||||
final headers = EmbyRequestHeaders.directMedia();
|
||||
final normalizedNames = headers.keys
|
||||
.map((name) => name.toLowerCase())
|
||||
.toSet();
|
||||
|
||||
expect(headers['User-Agent'], EmbyRequestHeaders.userAgent);
|
||||
expect(headers['Accept'], '*/*');
|
||||
expect(normalizedNames, isNot(contains('authorization')));
|
||||
expect(normalizedNames, isNot(contains('cookie')));
|
||||
expect(normalizedNames, isNot(contains('x-emby-token')));
|
||||
expect(normalizedNames, isNot(contains('x-mediabrowser-token')));
|
||||
expect(normalizedNames, isNot(contains('x-emby-authorization')));
|
||||
expect(normalizedNames, isNot(contains('x-emby-device-id')));
|
||||
});
|
||||
});
|
||||
|
||||
group('SenPlayer 身份对齐', () {
|
||||
test('UA 为 SenPlayer 固定值', () {
|
||||
expect(EmbyRequestHeaders.userAgent, 'SenPlayer/6.2.0');
|
||||
expect(EmbyRequestHeaders.clientName, 'SenPlayer');
|
||||
expect(EmbyRequestHeaders.version, '6.1.0');
|
||||
});
|
||||
|
||||
test('auth 头字面格式与 SenPlayer 身份一致', () {
|
||||
final auth = EmbyAuthHeader.build(
|
||||
deviceId: 'd',
|
||||
deviceName: 'Mac',
|
||||
token: 't',
|
||||
userId: 'u',
|
||||
);
|
||||
expect(
|
||||
auth,
|
||||
'MediaBrowser Token="t", Emby UserId="u", Client="SenPlayer", '
|
||||
'Device="Mac", DeviceId="d", Version="6.1.0"',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/core/contracts/playback.dart';
|
||||
import 'package:smplayer/core/domain/ports/emby_gateway.dart';
|
||||
import 'package:smplayer/core/infra/emby_api/playback_resolver.dart';
|
||||
|
||||
|
||||
class _UnusedEmbyGateway implements EmbyGateway {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
const ctx = AuthedRequestContext(
|
||||
baseUrl: 'https://emby.example.com',
|
||||
token: 'tok123',
|
||||
userId: 'user1',
|
||||
);
|
||||
|
||||
final resolver = PlaybackResolver(
|
||||
gateway: _UnusedEmbyGateway(),
|
||||
deviceId: 'device-1',
|
||||
deviceName: 'TestBox',
|
||||
);
|
||||
|
||||
EmbyRawItem item({int? resumeTicks}) => EmbyRawItem(
|
||||
Id: 'item1',
|
||||
Name: 'Movie One',
|
||||
Type: 'Movie',
|
||||
RunTimeTicks: 600 * 10000000,
|
||||
UserData: resumeTicks == null
|
||||
? null
|
||||
: EmbyRawUserData(PlaybackPositionTicks: resumeTicks),
|
||||
);
|
||||
|
||||
Map<String, dynamic> playbackInfo(Map<String, dynamic> source) => {
|
||||
'PlaySessionId': 'ps1',
|
||||
'MediaSources': [source],
|
||||
};
|
||||
|
||||
group('PlaybackResolver.prepare 流地址优先级', () {
|
||||
test('DirectStreamUrl 优先,playMethod=DirectStream', () async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'Container': 'mkv',
|
||||
'DirectStreamUrl': '/Videos/item1/stream.mkv?x=1',
|
||||
'TranscodingUrl': '/Videos/item1/main.m3u8',
|
||||
'SupportsDirectStream': true,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.streamUrl,
|
||||
'https://emby.example.com/Videos/item1/stream.mkv?x=1&api_key=tok123',
|
||||
);
|
||||
expect(result.playMethod, 'DirectStream');
|
||||
expect(result.directStream, isTrue);
|
||||
});
|
||||
|
||||
test('无 DirectStreamUrl 时用 TranscodingUrl,playMethod=Transcode', () async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'Container': 'mkv',
|
||||
'TranscodingUrl': '/Videos/item1/main.m3u8',
|
||||
}),
|
||||
);
|
||||
expect(result.streamUrl, contains('/Videos/item1/main.m3u8'));
|
||||
expect(result.streamUrl, contains('api_key=tok123'));
|
||||
expect(result.playMethod, 'Transcode');
|
||||
});
|
||||
|
||||
test('跨域绝对 CDN 地址不追加 Emby token', () async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'Container': 'mkv',
|
||||
'DirectStreamUrl':
|
||||
'https://cdn.example.net/media/movie.mkv?signature=signed',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.streamUrl,
|
||||
'https://cdn.example.net/media/movie.mkv?signature=signed',
|
||||
);
|
||||
expect(result.streamUrl, isNot(contains('tok123')));
|
||||
expect(result.streamUrl, isNot(contains('api_key')));
|
||||
});
|
||||
|
||||
test('部署在子路径下时保留 Emby base path', () async {
|
||||
const subpathContext = AuthedRequestContext(
|
||||
baseUrl: 'https://emby.example.com/emby',
|
||||
token: 'tok123',
|
||||
userId: 'user1',
|
||||
);
|
||||
final result = await resolver.prepare(
|
||||
ctx: subpathContext,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'Container': 'mkv',
|
||||
'DirectStreamUrl': '/Videos/item1/stream.mkv',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.streamUrl,
|
||||
'https://emby.example.com/emby/Videos/item1/stream.mkv?api_key=tok123',
|
||||
);
|
||||
});
|
||||
|
||||
test('两者皆无时回退静态流(含容器扩展名与鉴权参数)', () async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(itemId: 'item1'),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: playbackInfo({'Id': 's1', 'Container': 'mp4'}),
|
||||
);
|
||||
expect(
|
||||
result.streamUrl,
|
||||
startsWith('https://emby.example.com/Videos/item1/stream.mp4?'),
|
||||
);
|
||||
expect(result.streamUrl, contains('api_key=tok123'));
|
||||
expect(result.streamUrl, contains('MediaSourceId=s1'));
|
||||
expect(result.streamUrl, contains('PlaySessionId=ps1'));
|
||||
expect(result.playMethod, 'DirectStream');
|
||||
});
|
||||
|
||||
test('按 mediaSourceId 选源,未命中回退首源', () async {
|
||||
final info = {
|
||||
'MediaSources': [
|
||||
{'Id': 's1', 'DirectStreamUrl': '/Videos/item1/a.mkv'},
|
||||
{'Id': 's2', 'DirectStreamUrl': '/Videos/item1/b.mkv'},
|
||||
],
|
||||
};
|
||||
final picked = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(
|
||||
itemId: 'item1',
|
||||
mediaSourceId: 's2',
|
||||
),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: info,
|
||||
);
|
||||
expect(picked.streamUrl, contains('/Videos/item1/b.mkv'));
|
||||
|
||||
final fallback = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: const PlaybackRequestPayload(
|
||||
itemId: 'item1',
|
||||
mediaSourceId: 'missing',
|
||||
),
|
||||
preloadedItem: item(),
|
||||
preloadedPlaybackInfo: info,
|
||||
);
|
||||
expect(fallback.streamUrl, contains('/Videos/item1/a.mkv'));
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaybackResolver.prepare 续播位置', () {
|
||||
Future<double> startSecondsFor(PlaybackRequestPayload payload) async {
|
||||
final result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: payload,
|
||||
preloadedItem: item(resumeTicks: 120 * 10000000),
|
||||
preloadedPlaybackInfo: playbackInfo({
|
||||
'Id': 's1',
|
||||
'DirectStreamUrl': '/Videos/item1/a.mkv',
|
||||
}),
|
||||
);
|
||||
return result.startPositionSeconds;
|
||||
}
|
||||
|
||||
test('playFromStart 强制从头', () async {
|
||||
expect(
|
||||
await startSecondsFor(
|
||||
const PlaybackRequestPayload(itemId: 'item1', playFromStart: true),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('显式 startPositionTicks 优先于 UserData', () async {
|
||||
expect(
|
||||
await startSecondsFor(
|
||||
const PlaybackRequestPayload(
|
||||
itemId: 'item1',
|
||||
startPositionTicks: 60 * 10000000,
|
||||
),
|
||||
),
|
||||
60,
|
||||
);
|
||||
});
|
||||
|
||||
test('默认取 UserData.PlaybackPositionTicks', () async {
|
||||
expect(
|
||||
await startSecondsFor(const PlaybackRequestPayload(itemId: 'item1')),
|
||||
120,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaybackResolver.resolveSubtitles', () {
|
||||
test('未提供 baseUrl/token 时外挂轨全部过滤,仅留内封(详尽用例见 '
|
||||
'playback_resolver_subtitles_test.dart)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(const [
|
||||
EmbyRawMediaStream(Index: 1, Type: 'Subtitle', Title: 'embedded'),
|
||||
EmbyRawMediaStream(
|
||||
Index: 2,
|
||||
Type: 'Subtitle',
|
||||
Codec: 'srt',
|
||||
IsExternal: true,
|
||||
),
|
||||
EmbyRawMediaStream(
|
||||
Index: 3,
|
||||
Type: 'Subtitle',
|
||||
Codec: 'srt',
|
||||
DeliveryMethod: 'External',
|
||||
),
|
||||
EmbyRawMediaStream(
|
||||
Index: 4,
|
||||
Type: 'Subtitle',
|
||||
Codec: 'srt',
|
||||
DeliveryUrl: '/subs/4.srt',
|
||||
),
|
||||
EmbyRawMediaStream(Index: 5, Type: 'Audio'),
|
||||
]);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.index, 1);
|
||||
expect(subtitles.single.title, 'embedded');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/library.dart';
|
||||
import 'package:smplayer/core/infra/emby_api/playback_resolver.dart';
|
||||
|
||||
void main() {
|
||||
const baseUrl = 'https://emby.example.com';
|
||||
const token = 'tok123';
|
||||
const itemId = 'item1';
|
||||
const mediaSourceId = 'src9';
|
||||
|
||||
EmbyRawMediaStream embeddedSubtitle({
|
||||
int index = 2,
|
||||
String codec = 'subrip',
|
||||
String? title,
|
||||
String? language,
|
||||
bool isDefault = false,
|
||||
bool isForced = false,
|
||||
}) => EmbyRawMediaStream(
|
||||
Index: index,
|
||||
Type: 'Subtitle',
|
||||
Codec: codec,
|
||||
Title: title,
|
||||
Language: language,
|
||||
IsDefault: isDefault,
|
||||
IsForced: isForced,
|
||||
);
|
||||
|
||||
EmbyRawMediaStream externalSubtitle({
|
||||
int index = 10,
|
||||
String codec = 'srt',
|
||||
String? deliveryUrl = '/Videos/item1/10/Subtitles/stream.srt',
|
||||
String? deliveryMethod = 'External',
|
||||
bool isExternal = true,
|
||||
String? title,
|
||||
String? language,
|
||||
}) => EmbyRawMediaStream(
|
||||
Index: index,
|
||||
Type: 'Subtitle',
|
||||
Codec: codec,
|
||||
IsExternal: isExternal,
|
||||
DeliveryMethod: deliveryMethod,
|
||||
DeliveryUrl: deliveryUrl,
|
||||
Title: title,
|
||||
Language: language,
|
||||
);
|
||||
|
||||
group('PlaybackResolver.resolveSubtitles 外挂字幕', () {
|
||||
test('内封轨保留且 isExternal=false(未传 itemId 时无抽取 URL)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 2, title: '简中', language: 'chi')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.index, 2);
|
||||
expect(subtitles.single.isExternal, isFalse);
|
||||
expect(subtitles.single.deliveryUrl, isNull);
|
||||
expect(subtitles.single.codec, 'subrip');
|
||||
});
|
||||
|
||||
test('外挂文本轨保留,deliveryUrl 补全 base 与 api_key', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[externalSubtitle(index: 10, codec: 'srt', language: 'chi')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
final track = subtitles.single;
|
||||
expect(track.isExternal, isTrue);
|
||||
expect(
|
||||
track.deliveryUrl,
|
||||
'$baseUrl/Videos/item1/10/Subtitles/stream.srt?api_key=$token',
|
||||
);
|
||||
});
|
||||
|
||||
test('deliveryUrl 已是绝对地址时不重复补 base,只补 api_key', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[
|
||||
externalSubtitle(
|
||||
deliveryUrl: 'https://cdn.example.com/subs/a.ass',
|
||||
codec: 'ass',
|
||||
),
|
||||
],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(
|
||||
subtitles.single.deliveryUrl,
|
||||
'https://cdn.example.com/subs/a.ass?api_key=$token',
|
||||
);
|
||||
});
|
||||
|
||||
test('外挂位图轨(pgs/vobsub/dvdsub)被过滤', () {
|
||||
for (final bitmapCodec in ['pgssub', 'pgs', 'dvdsub', 'sup', 'vobsub']) {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[externalSubtitle(codec: bitmapCodec)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, isEmpty, reason: 'codec=$bitmapCodec 应被过滤');
|
||||
}
|
||||
});
|
||||
|
||||
test('内封位图轨保留(由引擎渲染,行为不变)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(codec: 'pgssub')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.isExternal, isFalse);
|
||||
});
|
||||
|
||||
test('外挂轨缺 DeliveryUrl 时被过滤', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[externalSubtitle(deliveryUrl: null)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles, isEmpty);
|
||||
});
|
||||
|
||||
test('未提供 baseUrl/token 时外挂轨被过滤(内封不受影响)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles([
|
||||
embeddedSubtitle(index: 2),
|
||||
externalSubtitle(index: 10),
|
||||
]);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(subtitles.single.index, 2);
|
||||
});
|
||||
|
||||
test('仅 DeliveryMethod==External(无 IsExternal 标记)也按外挂处理', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[externalSubtitle(isExternal: false, deliveryMethod: 'External')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles.single.isExternal, isTrue);
|
||||
});
|
||||
|
||||
test('混合列表保持原顺序,各归其类', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[
|
||||
embeddedSubtitle(index: 2, codec: 'ass'),
|
||||
externalSubtitle(index: 10, codec: 'srt'),
|
||||
externalSubtitle(index: 11, codec: 'pgssub'),
|
||||
embeddedSubtitle(index: 3, codec: 'subrip'),
|
||||
],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
expect(subtitles.map((t) => t.index).toList(), [2, 10, 3]);
|
||||
expect(subtitles.map((t) => t.isExternal).toList(), [false, true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaybackResolver.resolveSubtitles 内封文本抽取地址', () {
|
||||
test('内封文本轨生成服务端抽取 deliveryUrl(isExternal 仍为 false)', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 2, codec: 'subrip', language: 'chi')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
final track = subtitles.single;
|
||||
expect(track.isExternal, isFalse);
|
||||
|
||||
|
||||
expect(
|
||||
track.deliveryUrl,
|
||||
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/2/0/Stream.subrip'
|
||||
'?api_key=$token',
|
||||
);
|
||||
});
|
||||
|
||||
test('扩展名用 codec 原名 passthrough(webvtt 归一化为 vtt)', () {
|
||||
final cases = {'subrip': 'subrip', 'ssa': 'ssa', 'webvtt': 'vtt'};
|
||||
cases.forEach((codec, ext) {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 4, codec: codec)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
expect(
|
||||
subtitles.single.deliveryUrl,
|
||||
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/4/0/Stream.$ext'
|
||||
'?api_key=$token',
|
||||
reason: 'codec=$codec 应映射为 .$ext',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('srt/ass/vtt codec 直接用作扩展名,含 /0/ 段', () {
|
||||
final cases = {'srt': 'srt', 'ass': 'ass', 'vtt': 'vtt'};
|
||||
cases.forEach((codec, ext) {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 5, codec: codec)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
expect(
|
||||
subtitles.single.deliveryUrl,
|
||||
endsWith('/Subtitles/5/0/Stream.$ext?api_key=$token'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('内封位图轨不生成抽取 URL(deliveryUrl 为 null)', () {
|
||||
for (final bitmapCodec in ['pgssub', 'pgs', 'dvdsub', 'vobsub', 'sup']) {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 6, codec: bitmapCodec)],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
expect(subtitles, hasLength(1));
|
||||
expect(
|
||||
subtitles.single.deliveryUrl,
|
||||
isNull,
|
||||
reason: 'codec=$bitmapCodec 不应生成抽取 URL',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('缺 mediaSourceId 时内封文本轨不生成抽取 URL', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[embeddedSubtitle(index: 2, codec: 'subrip')],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
);
|
||||
expect(subtitles.single.deliveryUrl, isNull);
|
||||
});
|
||||
|
||||
test('外挂轨不受内封抽取逻辑影响,仍用自身 DeliveryUrl', () {
|
||||
final subtitles = PlaybackResolver.resolveSubtitles(
|
||||
[
|
||||
embeddedSubtitle(index: 2, codec: 'subrip'),
|
||||
externalSubtitle(index: 10, codec: 'srt'),
|
||||
],
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
);
|
||||
final embedded = subtitles.firstWhere((t) => !t.isExternal);
|
||||
final external = subtitles.firstWhere((t) => t.isExternal);
|
||||
expect(
|
||||
embedded.deliveryUrl,
|
||||
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/2/0/Stream.subrip'
|
||||
'?api_key=$token',
|
||||
);
|
||||
expect(
|
||||
external.deliveryUrl,
|
||||
'$baseUrl/Videos/item1/10/Subtitles/stream.srt?api_key=$token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlaybackResolver.isTextSubtitleCodec', () {
|
||||
test('文本 codec 判定大小写不敏感', () {
|
||||
for (final textCodec in [
|
||||
'srt',
|
||||
'SUBRIP',
|
||||
'vtt',
|
||||
'WebVTT',
|
||||
'ass',
|
||||
'ssa',
|
||||
]) {
|
||||
expect(
|
||||
PlaybackResolver.isTextSubtitleCodec(textCodec),
|
||||
isTrue,
|
||||
reason: 'codec=$textCodec',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('位图/未知/null codec 返回 false', () {
|
||||
for (final nonText in ['pgssub', 'dvdsub', 'sup', 'unknown', null]) {
|
||||
expect(
|
||||
PlaybackResolver.isTextSubtitleCodec(nonText),
|
||||
isFalse,
|
||||
reason: 'codec=$nonText',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/fvp_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
group('isFvpNetworkError', () {
|
||||
test('命中 tcp:/tls: 前缀', () {
|
||||
expect(isFvpNetworkError('tcp: connection lost'), isTrue);
|
||||
expect(isFvpNetworkError('tls: certificate verify failed'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 TLS 层关键字(Windows 直连的主要场景)', () {
|
||||
expect(isFvpNetworkError('TLS handshake failed'), isTrue);
|
||||
expect(isFvpNetworkError('SSL_read: SSL_ERROR_SSL, WANT_WRITE'), isTrue);
|
||||
expect(
|
||||
isFvpNetworkError('reader: tls: handshake error (err=-1)'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('命中连接层关键字', () {
|
||||
expect(isFvpNetworkError('Connection refused'), isTrue);
|
||||
expect(isFvpNetworkError('connection reset by peer'), isTrue);
|
||||
expect(isFvpNetworkError('connection timed out'), isTrue);
|
||||
expect(isFvpNetworkError('Network is unreachable'), isTrue);
|
||||
expect(isFvpNetworkError('Host not found'), isTrue);
|
||||
expect(isFvpNetworkError('name resolution failed'), isTrue);
|
||||
expect(isFvpNetworkError('No route to host'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 HTTP 状态码文本', () {
|
||||
expect(isFvpNetworkError('HTTP error 403 Forbidden'), isTrue);
|
||||
expect(isFvpNetworkError('HTTP error 502 Bad Gateway'), isTrue);
|
||||
expect(isFvpNetworkError('Server returned 401 Unauthorized'), isTrue);
|
||||
expect(isFvpNetworkError('Server returned 503'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 ffmpeg 协议缺失(TLS 后端被剥离时的典型 detail)', () {
|
||||
expect(isFvpNetworkError('Protocol not found'), isTrue);
|
||||
});
|
||||
|
||||
test('不命中解码器与非网络错误', () {
|
||||
expect(isFvpNetworkError('Failed to allocate decoder'), isFalse);
|
||||
expect(isFvpNetworkError('Error while decoding frame'), isFalse);
|
||||
expect(isFvpNetworkError('unsupported pixel format'), isFalse);
|
||||
expect(isFvpNetworkError('decoder timeout waiting for frame'), isFalse);
|
||||
});
|
||||
|
||||
test('空串与无关文本不命中', () {
|
||||
expect(isFvpNetworkError(''), isFalse);
|
||||
expect(isFvpNetworkError('reader.buffering'), isFalse);
|
||||
expect(isFvpNetworkError('render.video 1st_frame'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('fvpNetworkErrorPayload', () {
|
||||
test('只转换 IO 类别中的已识别网络错误', () {
|
||||
expect(
|
||||
fvpNetworkErrorPayload(
|
||||
category: 'reader.error',
|
||||
errorCode: -1,
|
||||
detail: 'Connection reset by peer',
|
||||
),
|
||||
'reader: Connection reset by peer (err=-1)',
|
||||
);
|
||||
});
|
||||
|
||||
test('忽略 demuxer 解码错误和非负事件', () {
|
||||
expect(
|
||||
fvpNetworkErrorPayload(
|
||||
category: 'demuxer.error',
|
||||
errorCode: -1,
|
||||
detail: 'Invalid data found when processing input',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
fvpNetworkErrorPayload(
|
||||
category: 'reader.error',
|
||||
errorCode: 0,
|
||||
detail: 'Connection reset by peer',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('忽略非 IO 类别,即使 detail 含网络词', () {
|
||||
expect(
|
||||
fvpNetworkErrorPayload(
|
||||
category: 'decoder.video',
|
||||
errorCode: -1,
|
||||
detail: 'TLS handshake failed',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart'
|
||||
show ValueNotifier, debugDefaultTargetPlatformOverride;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/media3_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const methodChannel = MethodChannel('smplayer/media3_engine');
|
||||
const eventChannel = MethodChannel('smplayer/media3_engine/events');
|
||||
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
case 'setVolume':
|
||||
case 'setResizeMode':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(eventChannel, (call) async => null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, null);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(eventChannel, null);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
group('Media3PlayerEngine 平台守卫', () {
|
||||
test('桌面(isAndroid=false)构造抛 UnsupportedError', () {
|
||||
expect(
|
||||
() => Media3PlayerEngine(isAndroid: false),
|
||||
throwsA(isA<UnsupportedError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Android(isAndroid=true)构造不抛', () {
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
addTearDown(engine.dispose);
|
||||
expect(engine.displayName, 'media3');
|
||||
});
|
||||
|
||||
test('open 成功后请求 native 重放关键状态', () async {
|
||||
final calls = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
calls.add(call.method);
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
case 'setVolume':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
addTearDown(engine.dispose);
|
||||
|
||||
await engine.open('http://127.0.0.1/video.mkv');
|
||||
|
||||
expect(calls, containsAllInOrder(<String>['open', 'replayKeyState']));
|
||||
});
|
||||
});
|
||||
|
||||
group('Media3PlayerEngine Android 视频视图', () {
|
||||
test('源码固定使用 Hybrid Composition,避免旧设备 TLHC/HC fallback 改变 HDR 路径', () {
|
||||
final source = File(
|
||||
'lib/core/infra/player_engine/media3_player_engine.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, contains('initExpensiveAndroidView'));
|
||||
expect(source, isNot(contains('initSurfaceAndroidView')));
|
||||
});
|
||||
|
||||
test('native SurfaceView 不主动改 z-order,避免 Flutter HC 下黑屏', () {
|
||||
final source = File(
|
||||
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlatformView.kt',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, isNot(contains('setZOrderMediaOverlay(true)')));
|
||||
expect(source, isNot(contains('setZOrderOnTop(true)')));
|
||||
});
|
||||
|
||||
testWidgets('playerId 就位后使用原生 SurfaceView PlatformView', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
|
||||
addTearDown(engine.dispose);
|
||||
addTearDown(fit.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Builder(
|
||||
builder: (context) =>
|
||||
SizedBox.expand(child: engine.buildVideoView(context, fit)),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(PlatformViewLink), findsNothing);
|
||||
|
||||
await engine.open('http://127.0.0.1/video.mkv');
|
||||
await tester.pump();
|
||||
|
||||
final view = tester.widget<PlatformViewLink>(
|
||||
find.byType(PlatformViewLink),
|
||||
);
|
||||
expect(view.viewType, 'smplayer/media3_surface');
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('open 等待 native ready 时仍先挂载视频 PlatformView', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final openCompleter = Completer<Object?>();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
return openCompleter.future;
|
||||
case 'setVolume':
|
||||
case 'setResizeMode':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
try {
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
|
||||
addTearDown(engine.dispose);
|
||||
addTearDown(fit.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Builder(
|
||||
builder: (context) =>
|
||||
SizedBox.expand(child: engine.buildVideoView(context, fit)),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(PlatformViewLink), findsNothing);
|
||||
|
||||
final openFuture = engine.open('http://127.0.0.1/video.mkv');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(openCompleter.isCompleted, isFalse);
|
||||
expect(find.byType(PlatformViewLink), findsOneWidget);
|
||||
|
||||
openCompleter.complete();
|
||||
await openFuture;
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('playerId 就位与 fit 变化会同步 resize mode 到 native', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final resizeModes = <String>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
case 'setVolume':
|
||||
case 'dispose':
|
||||
case 'replayKeyState':
|
||||
return null;
|
||||
case 'setResizeMode':
|
||||
expect(call.arguments, containsPair('playerId', 7));
|
||||
resizeModes.add(
|
||||
(call.arguments as Map<Object?, Object?>)['mode']! as String,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
try {
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
final fit = ValueNotifier<BoxFit>(BoxFit.contain);
|
||||
addTearDown(engine.dispose);
|
||||
addTearDown(fit.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: Builder(
|
||||
builder: (context) =>
|
||||
SizedBox.expand(child: engine.buildVideoView(context, fit)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await engine.open('http://127.0.0.1/video.mkv');
|
||||
await tester.pump();
|
||||
expect(resizeModes, <String>['fit']);
|
||||
|
||||
fit.value = BoxFit.fill;
|
||||
await tester.pump();
|
||||
expect(resizeModes, <String>['fit', 'fill']);
|
||||
|
||||
fit.value = BoxFit.cover;
|
||||
await tester.pump();
|
||||
expect(resizeModes, <String>['fit', 'fill', 'zoom']);
|
||||
|
||||
fit.value = BoxFit.contain;
|
||||
await tester.pump();
|
||||
expect(resizeModes, <String>['fit', 'fill', 'zoom', 'fit']);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('Media3 native deferred prepare', () {
|
||||
test('源码包含 surface 到达超时和 pending open 取消完成逻辑', () {
|
||||
final source = File(
|
||||
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3PlayerInstance.kt',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, contains('SURFACE_ATTACH_TIMEOUT_MS'));
|
||||
expect(source, contains('surface not attached within timeout'));
|
||||
expect(source, contains('cancelPendingOpen'));
|
||||
expect(source, contains('open_cancelled'));
|
||||
expect(source, contains('clearPendingSurfaceTimeout'));
|
||||
});
|
||||
|
||||
test('MethodChannel 暴露 replayKeyState', () {
|
||||
final source = File(
|
||||
'plugins/media3_engine/android/src/main/kotlin/com/smplayer/media3_engine/Media3EnginePlugin.kt',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(source, contains('"replayKeyState"'));
|
||||
expect(source, contains('player.replayKeyState()'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Media3 single-flight _ensureCreated', () {
|
||||
test('并发 prewarmNative + open 只执行一次 native create', () async {
|
||||
int createCallCount = 0;
|
||||
final createCompleter = Completer<Object?>();
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
createCallCount++;
|
||||
return createCompleter.future;
|
||||
case 'open':
|
||||
expect(
|
||||
(call.arguments as Map)['playerId'],
|
||||
equals(42),
|
||||
reason: 'open must use the playerId returned by create',
|
||||
);
|
||||
return null;
|
||||
case 'setVolume':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
addTearDown(engine.dispose);
|
||||
|
||||
final prewarmFuture = engine.prewarmNative();
|
||||
final openFuture = engine.open('http://127.0.0.1/video.mkv');
|
||||
|
||||
expect(
|
||||
createCallCount,
|
||||
equals(1),
|
||||
reason: 'concurrent prewarm + open should share one create call',
|
||||
);
|
||||
|
||||
createCompleter.complete(<String, dynamic>{
|
||||
'playerId': 42,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
});
|
||||
|
||||
await prewarmFuture;
|
||||
await openFuture;
|
||||
|
||||
expect(
|
||||
createCallCount,
|
||||
equals(1),
|
||||
reason: 'create must have been called exactly once',
|
||||
);
|
||||
});
|
||||
|
||||
test('dispose 期间 create 返回时释放孤儿 native player', () async {
|
||||
final createCompleter = Completer<Object?>();
|
||||
final disposedPlayerIds = <int>[];
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
return createCompleter.future;
|
||||
case 'dispose':
|
||||
final disposePlayerId =
|
||||
(call.arguments as Map)['playerId'] as int;
|
||||
disposedPlayerIds.add(disposePlayerId);
|
||||
return null;
|
||||
case 'setVolume':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
|
||||
final prewarmFuture = engine.prewarmNative();
|
||||
|
||||
await engine.dispose();
|
||||
|
||||
createCompleter.complete(<String, dynamic>{
|
||||
'playerId': 99,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
});
|
||||
|
||||
await prewarmFuture;
|
||||
|
||||
expect(
|
||||
disposedPlayerIds,
|
||||
contains(99),
|
||||
reason: 'orphan native player created after dispose must be released',
|
||||
);
|
||||
});
|
||||
|
||||
test('create 失败后允许下次重试', () async {
|
||||
int createAttempts = 0;
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
switch (call.method) {
|
||||
case 'create':
|
||||
createAttempts++;
|
||||
if (createAttempts == 1) {
|
||||
throw PlatformException(
|
||||
code: 'INIT_FAILED',
|
||||
message: 'simulated first-create failure',
|
||||
);
|
||||
}
|
||||
return <String, dynamic>{
|
||||
'playerId': 7,
|
||||
'ffmpegAvailable': true,
|
||||
'ffmpegVersion': 'test-ffmpeg',
|
||||
};
|
||||
case 'open':
|
||||
case 'setVolume':
|
||||
case 'replayKeyState':
|
||||
case 'dispose':
|
||||
return null;
|
||||
}
|
||||
fail('Unexpected media3 method call: ${call.method}');
|
||||
});
|
||||
|
||||
final engine = Media3PlayerEngine(isAndroid: true);
|
||||
addTearDown(engine.dispose);
|
||||
|
||||
expect(engine.prewarmNative(), throwsA(isA<PlatformException>()));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await engine.open('http://127.0.0.1/video.mkv');
|
||||
expect(createAttempts, equals(2), reason: 'retry after first failure');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/media_kit_player_engine.dart';
|
||||
|
||||
void main() {
|
||||
group('isMpvNetworkError', () {
|
||||
test('命中 tcp:/tls: 前缀', () {
|
||||
expect(
|
||||
isMpvNetworkError('tcp: ffurl_read returned 0xffffff99'),
|
||||
isTrue,
|
||||
);
|
||||
expect(isMpvNetworkError('tls: error during handshake'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 HTTP open 失败', () {
|
||||
expect(
|
||||
isMpvNetworkError(
|
||||
'Failed to open https://cdn.example/v.mkv.',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('命中连接类关键词', () {
|
||||
expect(isMpvNetworkError('connection reset by peer'), isTrue);
|
||||
expect(isMpvNetworkError('Connection refused'), isTrue);
|
||||
expect(isMpvNetworkError('connection timed out'), isTrue);
|
||||
expect(isMpvNetworkError('Network is unreachable'), isTrue);
|
||||
expect(isMpvNetworkError('Host not found'), isTrue);
|
||||
expect(isMpvNetworkError('name resolution failed'), isTrue);
|
||||
});
|
||||
|
||||
test('命中 TLS 与网络超时', () {
|
||||
expect(isMpvNetworkError('TLS handshake failed'), isTrue);
|
||||
expect(isMpvNetworkError('connection timed out'), isTrue);
|
||||
});
|
||||
|
||||
test('不命中非网络 timeout 文本', () {
|
||||
expect(isMpvNetworkError('decoder timeout waiting for frame'), isFalse);
|
||||
expect(isMpvNetworkError('cplayer timeout while waiting'), isFalse);
|
||||
});
|
||||
|
||||
test('命中 HTTP 错误码', () {
|
||||
expect(isMpvNetworkError('http error 403'), isTrue);
|
||||
expect(isMpvNetworkError('http error 502'), isTrue);
|
||||
});
|
||||
|
||||
test('不命中解码器错误', () {
|
||||
expect(isMpvNetworkError('Error while decoding frame'), isFalse);
|
||||
expect(isMpvNetworkError('Failed to open decoder'), isFalse);
|
||||
});
|
||||
|
||||
test('空串不命中', () {
|
||||
expect(isMpvNetworkError(''), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/player_engine/player_engine.dart';
|
||||
|
||||
void main() {
|
||||
group('streamOpenErrorHttpStatus', () {
|
||||
test('从回填的 error 串解析状态码', () {
|
||||
expect(
|
||||
streamOpenErrorHttpStatus('Failed to open http://h/v.mp4. (http 403)'),
|
||||
403,
|
||||
);
|
||||
expect(streamOpenErrorHttpStatus('boom (http 502)'), 502);
|
||||
});
|
||||
|
||||
test('无状态码返回 null', () {
|
||||
expect(streamOpenErrorHttpStatus('Failed to open http://h/v.mp4.'), null);
|
||||
expect(streamOpenErrorHttpStatus('connection reset by peer'), null);
|
||||
expect(streamOpenErrorHttpStatus(''), null);
|
||||
});
|
||||
});
|
||||
|
||||
group('isTerminalStreamOpenError', () {
|
||||
test('客户端 4xx 视为确定性失败(跳过重连)', () {
|
||||
expect(isTerminalStreamOpenError('open failed (http 403)'), isTrue);
|
||||
expect(isTerminalStreamOpenError('open failed (http 404)'), isTrue);
|
||||
expect(isTerminalStreamOpenError('open failed (http 410)'), isTrue);
|
||||
expect(isTerminalStreamOpenError('open failed (http 401)'), isTrue);
|
||||
});
|
||||
|
||||
test('瞬时类 408/429 仍允许重连', () {
|
||||
expect(isTerminalStreamOpenError('open failed (http 408)'), isFalse);
|
||||
expect(isTerminalStreamOpenError('open failed (http 429)'), isFalse);
|
||||
});
|
||||
|
||||
test('5xx 与无状态码不视为确定性失败', () {
|
||||
expect(isTerminalStreamOpenError('open failed (http 502)'), isFalse);
|
||||
expect(isTerminalStreamOpenError('open failed (http 503)'), isFalse);
|
||||
expect(isTerminalStreamOpenError('Failed to open http://h/v.mp4.'), isFalse);
|
||||
expect(isTerminalStreamOpenError('connection reset by peer'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fjs/fjs.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'
|
||||
show ExternalLibrary;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:smplayer/core/contracts/error.dart';
|
||||
import 'package:smplayer/core/domain/errors.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/quickjs_widget_runtime.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/safe_script_http_client.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/widget_host_bridge.dart';
|
||||
|
||||
const String _widgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-test',
|
||||
title: 'Runtime Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function inspectHost(params) {
|
||||
const document = Widget.html.load(
|
||||
'<main><a class="item" href="/detail"> 示例内容 </a></main>'
|
||||
);
|
||||
const item = document('.item').first();
|
||||
return {
|
||||
value: params.value,
|
||||
storedValue: Widget.storage.get('seed'),
|
||||
text: item.text().trim(),
|
||||
href: item.attr('href')
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDetail(link) {
|
||||
return { id: 'detail', title: link };
|
||||
}
|
||||
''';
|
||||
|
||||
|
||||
Future<bool> _tryInitFjs() async {
|
||||
final environmentPath = Platform.environment['FJS_LIBRARY_PATH'];
|
||||
final candidatePaths = <String>[
|
||||
if (environmentPath != null && environmentPath.isNotEmpty) environmentPath,
|
||||
'build/fjs_native/release/libfjs.dylib',
|
||||
'build/fjs_native/release/libfjs.so',
|
||||
'build/fjs_native/release/fjs.dll',
|
||||
];
|
||||
for (final path in candidatePaths) {
|
||||
if (!File(path).existsSync()) continue;
|
||||
try {
|
||||
await LibFjs.init(externalLibrary: ExternalLibrary.open(path));
|
||||
return true;
|
||||
} on StateError {
|
||||
return true;
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
await LibFjs.init();
|
||||
return true;
|
||||
} on StateError {
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final fjsReady = await _tryInitFjs();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'smplayer.script_widget.storage.runtime-test': jsonEncode(
|
||||
<String, Object?>{'seed': 'persisted'},
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'QuickJsWidgetRuntime',
|
||||
() {
|
||||
test('加载元数据并调用宿主 storage 与 HTML API', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
|
||||
final manifest = await runtime.loadWidget(_widgetScript);
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
manifest.id,
|
||||
'inspectHost',
|
||||
<String, Object?>{'value': 7},
|
||||
);
|
||||
|
||||
expect(manifest.title, 'Runtime Test');
|
||||
expect(result, <String, Object?>{
|
||||
'value': 7,
|
||||
'storedValue': 'persisted',
|
||||
'text': '示例内容',
|
||||
'href': '/detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('ForwardWidgets 原始参数调用保持字符串参数', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_widgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-test',
|
||||
'loadDetail',
|
||||
<String, Object?>{
|
||||
'__forwardRawArgument': 'https://example.com/detail/42',
|
||||
'link': 'ignored wrapper value',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result, <String, Object?>{
|
||||
'id': 'detail',
|
||||
'title': 'https://example.com/detail/42',
|
||||
});
|
||||
});
|
||||
|
||||
test('卸载模块后拒绝继续调用', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_widgetScript);
|
||||
await runtime.unloadWidget('runtime-test');
|
||||
|
||||
await expectLater(
|
||||
runtime.invokeModuleFunction(
|
||||
'runtime-test',
|
||||
'inspectHost',
|
||||
<String, Object?>{},
|
||||
),
|
||||
throwsA(isA<DomainError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('DOM 助手覆盖 closest、顶层 text 与 console.debug', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_domHelpersWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-dom-test',
|
||||
'inspectDomHelpers',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(result, <String, Object?>{
|
||||
'honeypotAncestorClass': 'col-lg-8',
|
||||
'noAncestorForNormal': true,
|
||||
'topLevelText': '你好世界',
|
||||
'consoleDebugCallable': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('Widget.dom 句柄 API 支持 parse/select/text/attr/html', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_domHandleWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-dom-handle-test',
|
||||
'inspectDomHandles',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(result, <String, Object?>{
|
||||
'itemText': '示例',
|
||||
'itemHref': '/detail',
|
||||
'scopedCount': 1,
|
||||
'outerScopedCount': 2,
|
||||
'docHtmlContainsItem': true,
|
||||
'missingAttr': null,
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'模块使用 statusCode 校验 http 响应时可正常拿到抓取的 HTML',
|
||||
() async {
|
||||
final httpClient = _StubbedScriptHttpClient(
|
||||
responseHtmlByUrl: <String, String>{
|
||||
'https://sample.local/list': _sampleFakeListHtml,
|
||||
},
|
||||
);
|
||||
final runtime = QuickJsWidgetRuntime(
|
||||
hostBridge: WidgetHostBridge(httpClient: httpClient),
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_httpStatusCodeWidgetScript);
|
||||
|
||||
final rawItems = await runtime.invokeModuleFunction(
|
||||
'runtime-http-test',
|
||||
'fetchItems',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(rawItems, <Map<String, Object?>>[
|
||||
<String, Object?>{
|
||||
'title': '正常条目',
|
||||
'link': 'https://sample.local/normal',
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('模块超时后运行环境被卸载,宿主 Future 迟到完成不会出错', () async {
|
||||
final httpClient = _StubbedScriptHttpClient(
|
||||
responseHtmlByUrl: <String, String>{
|
||||
'https://sample.local/list': _sampleFakeListHtml,
|
||||
},
|
||||
responseDelay: const Duration(milliseconds: 150),
|
||||
);
|
||||
final runtime = QuickJsWidgetRuntime(
|
||||
hostBridge: WidgetHostBridge(httpClient: httpClient),
|
||||
invocationTimeout: const Duration(milliseconds: 50),
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_httpStatusCodeWidgetScript);
|
||||
|
||||
await expectLater(
|
||||
runtime.invokeModuleFunction(
|
||||
'runtime-http-test',
|
||||
'fetchItems',
|
||||
<String, Object?>{},
|
||||
),
|
||||
throwsA(
|
||||
isA<DomainError>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
ErrorCode.serverTimeout,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
expect(runtime.isWidgetLoaded('runtime-http-test'), isFalse);
|
||||
});
|
||||
|
||||
test('async 函数返回 List<Map> 时得到规范化后的 Dart 集合', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_asyncReturnShapesWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-async-shapes-test',
|
||||
'returnList',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(result, isA<List<Object?>>());
|
||||
expect(result, <Map<String, Object?>>[
|
||||
<String, Object?>{'title': '正常', 'link': '/a'},
|
||||
<String, Object?>{'title': '也是正常', 'link': '/b'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('async 函数显式返回 null 时上层拿到 Dart null', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_asyncReturnShapesWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-async-shapes-test',
|
||||
'returnNull',
|
||||
<String, Object?>{},
|
||||
);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('async 函数抛异常时映射为 DomainError.internalError', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_asyncReturnShapesWidgetScript);
|
||||
|
||||
await expectLater(
|
||||
runtime.invokeModuleFunction(
|
||||
'runtime-async-shapes-test',
|
||||
'throwError',
|
||||
<String, Object?>{},
|
||||
),
|
||||
throwsA(
|
||||
isA<DomainError>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
ErrorCode.internalError,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test('脚本尾部使用 CommonJS 导出时仍能安装并调用', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
|
||||
final manifest = await runtime.loadWidget(_commonJsExportsWidgetScript);
|
||||
expect(manifest.id, 'runtime-commonjs-test');
|
||||
expect(manifest.modules, hasLength(1));
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
manifest.id,
|
||||
'reportEnvironment',
|
||||
<String, Object?>{},
|
||||
);
|
||||
expect(result, <String, Object?>{
|
||||
'hasModule': true,
|
||||
'hasExports': true,
|
||||
'hasGlobal': true,
|
||||
'hasSelf': true,
|
||||
'exportedTitle': 'CommonJS Test',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('Widget.storage 支持 Web Storage 风格的 getItem/setItem 别名', () async {
|
||||
final runtime = QuickJsWidgetRuntime();
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_storageAliasWidgetScript);
|
||||
|
||||
final result = await runtime.invokeModuleFunction(
|
||||
'runtime-storage-alias-test',
|
||||
'roundTrip',
|
||||
<String, Object?>{},
|
||||
);
|
||||
expect(result, <String, Object?>{
|
||||
'writtenValue': 'session-token-42',
|
||||
'readBackValue': 'session-token-42',
|
||||
'valueAfterRemoval': null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test(
|
||||
'死循环脚本在 invocationTimeout 后被终止并卸载',
|
||||
() async {
|
||||
final runtime = QuickJsWidgetRuntime(
|
||||
invocationTimeout: const Duration(seconds: 2),
|
||||
);
|
||||
addTearDown(runtime.dispose);
|
||||
await runtime.loadWidget(_infiniteLoopWidgetScript);
|
||||
|
||||
await expectLater(
|
||||
runtime.invokeModuleFunction(
|
||||
'runtime-watchdog-test',
|
||||
'spinForever',
|
||||
<String, Object?>{},
|
||||
),
|
||||
throwsA(
|
||||
isA<DomainError>().having(
|
||||
(error) => error.code,
|
||||
'code',
|
||||
ErrorCode.serverTimeout,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(runtime.isWidgetLoaded('runtime-watchdog-test'), isFalse);
|
||||
},
|
||||
timeout: const Timeout(Duration(seconds: 15)),
|
||||
);
|
||||
},
|
||||
skip: fjsReady
|
||||
? false
|
||||
: '未找到 fjs 原生库(libfjs)。构建方式见本文件 _tryInitFjs 注释。',
|
||||
);
|
||||
|
||||
group('normalizeQuickJsValue', () {
|
||||
test(
|
||||
'Map<dynamic, dynamic> 与 List<dynamic> 递归规范化为 Map<String, dynamic>',
|
||||
() {
|
||||
final normalized = normalizeQuickJsValue(<dynamic, dynamic>{
|
||||
'items': <dynamic>[
|
||||
<dynamic, dynamic>{'id': 'foo', 'value': 1},
|
||||
<dynamic, dynamic>{'id': 'bar', 'value': 2},
|
||||
],
|
||||
'total': 2,
|
||||
});
|
||||
|
||||
expect(normalized, isA<Map<String, dynamic>>());
|
||||
final map = normalized! as Map<String, dynamic>;
|
||||
expect(map['total'], 2);
|
||||
final items = (map['items']! as List<Object?>)
|
||||
.cast<Map<String, dynamic>>();
|
||||
expect(items, hasLength(2));
|
||||
expect(items.first, <String, dynamic>{'id': 'foo', 'value': 1});
|
||||
},
|
||||
);
|
||||
|
||||
test('原始类型与 null 保持不变', () {
|
||||
expect(normalizeQuickJsValue(null), isNull);
|
||||
expect(normalizeQuickJsValue('hello'), 'hello');
|
||||
expect(normalizeQuickJsValue(42), 42);
|
||||
expect(normalizeQuickJsValue(true), true);
|
||||
});
|
||||
|
||||
test('非字符串键会被转为字符串键,避免下游 Map 类型转换失败', () {
|
||||
final normalized = normalizeQuickJsValue(<dynamic, dynamic>{
|
||||
1: 'one',
|
||||
2: 'two',
|
||||
});
|
||||
|
||||
expect(normalized, <String, dynamic>{'1': 'one', '2': 'two'});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const String _domHelpersWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-dom-test',
|
||||
title: 'DOM Helpers Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function inspectDomHelpers() {
|
||||
const document = Widget.html.load(
|
||||
'<section>'
|
||||
+ '<article class="videos-text-align" data-id="normal">'
|
||||
+ ' <span class="title">正常</span>'
|
||||
+ '</article>'
|
||||
+ '<div class="col-lg-8">'
|
||||
+ ' <article class="videos-text-align" data-id="honeypot">'
|
||||
+ ' <span class="title">蜜罐</span>'
|
||||
+ ' </article>'
|
||||
+ '</div>'
|
||||
+ '</section>'
|
||||
);
|
||||
const normalTitle = document('.videos-text-align').eq(0).find('.title');
|
||||
const honeypotTitle = document('.videos-text-align').eq(1).find('.title');
|
||||
|
||||
const honeypotAncestor = honeypotTitle.closest('.col-lg-8');
|
||||
const normalAncestor = normalTitle.closest('.col-lg-8');
|
||||
|
||||
const paragraphDocument = Widget.html.load('<p>你好世界</p>');
|
||||
const topLevelText = paragraphDocument.text().trim();
|
||||
|
||||
var consoleDebugCallable = false;
|
||||
try {
|
||||
console.debug('探针');
|
||||
consoleDebugCallable = true;
|
||||
} catch (error) {}
|
||||
|
||||
return {
|
||||
honeypotAncestorClass: honeypotAncestor.attr('class'),
|
||||
noAncestorForNormal: normalAncestor.length === 0,
|
||||
topLevelText: topLevelText,
|
||||
consoleDebugCallable: consoleDebugCallable
|
||||
};
|
||||
}
|
||||
''';
|
||||
|
||||
const String _domHandleWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-dom-handle-test',
|
||||
title: 'DOM Handle Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function inspectDomHandles() {
|
||||
const docHandle = Widget.dom.parse(
|
||||
'<main>'
|
||||
+ '<div class="wrap"><a class="item" href="/detail">示例</a></div>'
|
||||
+ '<a class="item" href="/other">其他</a>'
|
||||
+ '</main>'
|
||||
);
|
||||
const wrapHandles = Widget.dom.select(docHandle, '.wrap');
|
||||
const scopedItems = Widget.dom.select(wrapHandles[0], '.item');
|
||||
const allItems = Widget.dom.select(docHandle, '.item');
|
||||
return {
|
||||
itemText: Widget.dom.text(scopedItems[0]),
|
||||
itemHref: Widget.dom.attr(scopedItems[0], 'href'),
|
||||
scopedCount: scopedItems.length,
|
||||
outerScopedCount: allItems.length,
|
||||
docHtmlContainsItem: Widget.dom.html(docHandle).indexOf('class="item"') >= 0,
|
||||
missingAttr: Widget.dom.attr(scopedItems[0], 'data-missing')
|
||||
};
|
||||
}
|
||||
''';
|
||||
|
||||
const String _asyncReturnShapesWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-async-shapes-test',
|
||||
title: 'Async Return Shapes Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function returnList() {
|
||||
return [
|
||||
{ title: '正常', link: '/a' },
|
||||
{ title: '也是正常', link: '/b' }
|
||||
];
|
||||
}
|
||||
|
||||
async function returnNull() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function throwError() {
|
||||
throw new Error('模块脚本内的显式错误');
|
||||
}
|
||||
''';
|
||||
|
||||
const String _commonJsExportsWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-commonjs-test',
|
||||
title: 'CommonJS Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: [
|
||||
{
|
||||
title: '环境探测',
|
||||
functionName: 'reportEnvironment',
|
||||
params: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
async function reportEnvironment() {
|
||||
return {
|
||||
hasModule: typeof module === 'object' && module !== null,
|
||||
hasExports: typeof exports === 'object' && exports !== null,
|
||||
hasGlobal: typeof global === 'object' && global !== null,
|
||||
hasSelf: typeof self === 'object' && self !== null,
|
||||
exportedTitle: module.exports.metadata ? module.exports.metadata.title : ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
metadata: WidgetMetadata,
|
||||
reportEnvironment: reportEnvironment
|
||||
};
|
||||
''';
|
||||
|
||||
const String _storageAliasWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-storage-alias-test',
|
||||
title: 'Storage Alias Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function roundTrip() {
|
||||
Widget.storage.setItem('token', 'session-token-42');
|
||||
const written = Widget.storage.getItem('token');
|
||||
Widget.storage.removeItem('token');
|
||||
return {
|
||||
writtenValue: 'session-token-42',
|
||||
readBackValue: written,
|
||||
valueAfterRemoval: Widget.storage.getItem('token') || null
|
||||
};
|
||||
}
|
||||
''';
|
||||
|
||||
const String _infiniteLoopWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-watchdog-test',
|
||||
title: 'Watchdog Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function spinForever() {
|
||||
var counter = 0;
|
||||
while (true) {
|
||||
counter = (counter + 1) % 1000003;
|
||||
if (counter < 0) break;
|
||||
}
|
||||
return counter;
|
||||
}
|
||||
''';
|
||||
|
||||
const String _httpStatusCodeWidgetScript = r'''
|
||||
const WidgetMetadata = {
|
||||
id: 'runtime-http-test',
|
||||
title: 'HTTP StatusCode Test',
|
||||
version: '1.0.0',
|
||||
requiredVersion: '0.0.1',
|
||||
modules: []
|
||||
};
|
||||
|
||||
async function fetchItems() {
|
||||
const response = await Widget.http.get('https://sample.local/list');
|
||||
if (!response || response.statusCode !== 200) {
|
||||
throw new Error('意料之外的响应:' + (response && response.statusCode));
|
||||
}
|
||||
const document = Widget.html.load(response.data);
|
||||
return Array.from(document('.videos-text-align')).map(function(element) {
|
||||
const wrapper = document(element);
|
||||
const link = wrapper.find('a').attr('href');
|
||||
if (!link) return null;
|
||||
if (wrapper.closest('.col-lg-8').length > 0) return null;
|
||||
return {
|
||||
title: wrapper.find('.title').text().trim(),
|
||||
link: link
|
||||
};
|
||||
}).filter(function(item) { return item != null; });
|
||||
}
|
||||
''';
|
||||
|
||||
const String _sampleFakeListHtml =
|
||||
'<div>'
|
||||
'<div class="videos-text-align">'
|
||||
'<a href="https://sample.local/normal"></a>'
|
||||
'<span class="title">正常条目</span>'
|
||||
'</div>'
|
||||
'<div class="col-lg-8">'
|
||||
'<div class="videos-text-align">'
|
||||
'<a href="https://sample.local/honeypot"></a>'
|
||||
'<span class="title">蜜罐</span>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
'</div>';
|
||||
|
||||
class _StubbedScriptHttpClient extends SafeScriptHttpClient {
|
||||
final Map<String, String> responseHtmlByUrl;
|
||||
final Duration responseDelay;
|
||||
|
||||
_StubbedScriptHttpClient({
|
||||
required this.responseHtmlByUrl,
|
||||
this.responseDelay = Duration.zero,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<SafeScriptHttpResponse> request({
|
||||
required Uri uri,
|
||||
String method = 'GET',
|
||||
Map<String, String> headers = const <String, String>{},
|
||||
Map<String, Object?> queryParameters = const <String, Object?>{},
|
||||
Object? body,
|
||||
bool followRedirects = true,
|
||||
}) async {
|
||||
if (responseDelay > Duration.zero) {
|
||||
await Future<void>.delayed(responseDelay);
|
||||
}
|
||||
final resolvedUrl = uri.toString();
|
||||
final responseHtml = responseHtmlByUrl[resolvedUrl];
|
||||
if (responseHtml == null) {
|
||||
throw StateError('未注册 HTTP mock:$resolvedUrl');
|
||||
}
|
||||
return SafeScriptHttpResponse(
|
||||
data: responseHtml,
|
||||
text: responseHtml,
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
headers: const <String, String>{'content-type': 'text/html'},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/safe_script_http_client.dart';
|
||||
|
||||
void main() {
|
||||
group('脚本 HTTP 地址校验', () {
|
||||
test('允许公网 IPv4 与 IPv6 地址', () {
|
||||
expect(isPublicScriptAddress(InternetAddress('8.8.8.8')), isTrue);
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress('2606:4700:4700::1111')),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('拒绝真实威胁的 IPv4 地址(本机、LAN、云元数据、多播)', () {
|
||||
const blockedAddresses = <String>[
|
||||
'0.0.0.0',
|
||||
'10.0.0.1',
|
||||
'127.0.0.1',
|
||||
'169.254.169.254',
|
||||
'172.16.0.1',
|
||||
'192.0.0.1',
|
||||
'192.168.1.1',
|
||||
'224.0.0.1',
|
||||
'255.255.255.255',
|
||||
];
|
||||
|
||||
for (final address in blockedAddresses) {
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress(address)),
|
||||
isFalse,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'允许 CGNAT、文档、benchmark 段(代理 fake-ip 池会用到)',
|
||||
() {
|
||||
const allowedAddresses = <String>[
|
||||
'100.64.0.1',
|
||||
'192.0.2.1',
|
||||
'198.18.0.31',
|
||||
'198.51.100.1',
|
||||
'203.0.113.1',
|
||||
];
|
||||
|
||||
for (final address in allowedAddresses) {
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress(address)),
|
||||
isTrue,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('拒绝 IPv6 回环、ULA、链路本地和 IPv4 映射回环', () {
|
||||
const blockedAddresses = <String>[
|
||||
'::',
|
||||
'::1',
|
||||
'::ffff:127.0.0.1',
|
||||
'fc00::1',
|
||||
'fd12:3456::1',
|
||||
'fe80::1',
|
||||
];
|
||||
|
||||
for (final address in blockedAddresses) {
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress(address)),
|
||||
isFalse,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('允许 IPv6 文档段与 6to4 段(同样常见于 fake-ip 池)', () {
|
||||
const allowedAddresses = <String>[
|
||||
'2001:db8::1',
|
||||
'2002::1',
|
||||
'3fff::1',
|
||||
];
|
||||
|
||||
for (final address in allowedAddresses) {
|
||||
expect(
|
||||
isPublicScriptAddress(InternetAddress(address)),
|
||||
isTrue,
|
||||
reason: address,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('过滤可用地址', () {
|
||||
test('保留 DNS 里能用的公网地址,丢弃真正的保留段地址', () {
|
||||
final filtered = filterPublicScriptAddresses(
|
||||
host: 'example.com',
|
||||
addresses: <InternetAddress>[
|
||||
InternetAddress('fe80::1'),
|
||||
InternetAddress('8.8.8.8'),
|
||||
InternetAddress('10.0.0.5'),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
filtered.map((address) => address.address).toList(),
|
||||
<String>['8.8.8.8'],
|
||||
);
|
||||
});
|
||||
|
||||
test('全部为保留段地址时抛错并在消息里列出被拒地址', () {
|
||||
expect(
|
||||
() => filterPublicScriptAddresses(
|
||||
host: 'example.internal',
|
||||
addresses: <InternetAddress>[
|
||||
InternetAddress('10.0.0.1'),
|
||||
InternetAddress('192.168.1.1'),
|
||||
],
|
||||
),
|
||||
throwsA(
|
||||
isA<SafeScriptHttpException>().having(
|
||||
(exception) => exception.message,
|
||||
'message',
|
||||
allOf(
|
||||
contains('example.internal'),
|
||||
contains('10.0.0.1'),
|
||||
contains('192.168.1.1'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('地址列表为空时抛出 host 未解析错误', () {
|
||||
expect(
|
||||
() => filterPublicScriptAddresses(
|
||||
host: 'unresolved.example',
|
||||
addresses: const <InternetAddress>[],
|
||||
),
|
||||
throwsA(
|
||||
isA<SafeScriptHttpException>().having(
|
||||
(exception) => exception.message,
|
||||
'message',
|
||||
contains('unresolved.example'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('脚本 HTTP URI 校验', () {
|
||||
test('仅允许无用户信息的绝对 HTTP 与 HTTPS URI', () {
|
||||
expect(
|
||||
isValidScriptHttpUri(Uri.parse('https://example.com/video')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isValidScriptHttpUri(Uri.parse('http://example.com/video')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isValidScriptHttpUri(Uri.parse('https://user@example.com/video')),
|
||||
isFalse,
|
||||
);
|
||||
expect(isValidScriptHttpUri(Uri.parse('file:///private/video')), isFalse);
|
||||
expect(isValidScriptHttpUri(Uri.parse('/relative/video')), isFalse);
|
||||
});
|
||||
|
||||
test('拒绝非白名单 HTTP 方法且不会发起网络解析', () async {
|
||||
final client = SafeScriptHttpClient();
|
||||
|
||||
await expectLater(
|
||||
client.request(uri: Uri.parse('https://example.com'), method: 'TRACE'),
|
||||
throwsA(isA<SafeScriptHttpException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('脚本 HTTP 响应上限', () {
|
||||
test('接受不超过上限的分块响应', () async {
|
||||
final responseBytes = await collectScriptResponseBytes(
|
||||
Stream<List<int>>.fromIterable(<List<int>>[
|
||||
<int>[1, 2],
|
||||
<int>[3, 4],
|
||||
]),
|
||||
declaredLength: 4,
|
||||
maximumBytes: 4,
|
||||
);
|
||||
|
||||
expect(responseBytes, <int>[1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('在声明长度超过上限时立即拒绝', () async {
|
||||
await expectLater(
|
||||
collectScriptResponseBytes(
|
||||
const Stream<List<int>>.empty(),
|
||||
declaredLength: 5,
|
||||
maximumBytes: 4,
|
||||
),
|
||||
throwsA(isA<SafeScriptHttpException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('在实际流量超过上限时中止读取', () async {
|
||||
await expectLater(
|
||||
collectScriptResponseBytes(
|
||||
Stream<List<int>>.fromIterable(<List<int>>[
|
||||
<int>[1, 2, 3],
|
||||
<int>[4, 5],
|
||||
]),
|
||||
maximumBytes: 4,
|
||||
),
|
||||
throwsA(isA<SafeScriptHttpException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/script_widget.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_remote_gateway.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_repository.dart';
|
||||
import 'package:smplayer/core/domain/ports/script_widget_runtime.dart';
|
||||
import 'package:smplayer/core/domain/usecases/script_widget/script_widget_service.dart';
|
||||
import 'package:smplayer/core/infra/danmaku_api/script_widget_danmaku_gateway.dart';
|
||||
|
||||
class _SingleWidgetRepository implements ScriptWidgetRepository {
|
||||
_SingleWidgetRepository(this.widget);
|
||||
|
||||
final InstalledScriptWidget widget;
|
||||
|
||||
@override
|
||||
Future<InstalledScriptWidget?> findWidget(String widgetId) async {
|
||||
return widget.manifest.id == widgetId ? widget : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InstalledScriptWidget>> listWidgets() async {
|
||||
return <InstalledScriptWidget>[widget];
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _DanmakuScriptRuntime implements ScriptWidgetRuntime {
|
||||
String? lastFunctionName;
|
||||
Map<String, Object?>? lastParams;
|
||||
bool loaded = false;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
|
||||
@override
|
||||
Future<Object?> invokeModuleFunction(
|
||||
String widgetId,
|
||||
String functionName,
|
||||
Map<String, Object?> params,
|
||||
) async {
|
||||
lastFunctionName = functionName;
|
||||
lastParams = Map<String, Object?>.from(params);
|
||||
return switch (functionName) {
|
||||
'searchDanmu' => <String, Object?>{
|
||||
'animes': <Object?>[
|
||||
<String, Object?>{'animeId': 42, 'animeTitle': '示例剧集'},
|
||||
],
|
||||
},
|
||||
'getDetail' => <String, Object?>{
|
||||
'episodes': <Object?>[
|
||||
<String, Object?>{
|
||||
'episodeId': 101,
|
||||
'episodeTitle': '第 1 集',
|
||||
'episodeNumber': '1',
|
||||
},
|
||||
<String, Object?>{
|
||||
'episodeId': 102,
|
||||
'episodeTitle': '第 2 集',
|
||||
'episodeNumber': '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
'getComments' => <String, Object?>{
|
||||
'comments': <Object?>[
|
||||
<String, Object?>{'cid': 9, 'p': '1.5,1,16711680', 'm': '测试弹幕'},
|
||||
],
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ScriptWidgetManifest> inspectWidget(String scriptSource) async {
|
||||
return const ScriptWidgetManifest(id: 'danmaku-test', title: 'Danmaku');
|
||||
}
|
||||
|
||||
@override
|
||||
bool isWidgetLoaded(String widgetId) => loaded && widgetId == 'danmaku-test';
|
||||
|
||||
@override
|
||||
Future<ScriptWidgetManifest> loadWidget(String scriptSource) async {
|
||||
loaded = true;
|
||||
return const ScriptWidgetManifest(id: 'danmaku-test', title: 'Danmaku');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unloadWidget(String widgetId) async {
|
||||
if (widgetId == 'danmaku-test') loaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
class _UnusedRemoteGateway implements ScriptWidgetRemoteGateway {
|
||||
@override
|
||||
Future<String> fetchText(String url) {
|
||||
throw UnsupportedError('This test does not perform remote requests.');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _DanmakuScriptRuntime runtime;
|
||||
late ScriptWidgetDanmakuGateway gateway;
|
||||
|
||||
setUp(() {
|
||||
runtime = _DanmakuScriptRuntime();
|
||||
final timestamp = DateTime.utc(2026, 7, 12);
|
||||
final service = ScriptWidgetService(
|
||||
repository: _SingleWidgetRepository(
|
||||
InstalledScriptWidget(
|
||||
manifest: const ScriptWidgetManifest(
|
||||
id: 'danmaku-test',
|
||||
title: 'Danmaku',
|
||||
),
|
||||
scriptSource: 'widget source',
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
),
|
||||
),
|
||||
remoteGateway: _UnusedRemoteGateway(),
|
||||
runtime: runtime,
|
||||
);
|
||||
gateway = ScriptWidgetDanmakuGateway(loadService: () async => service);
|
||||
});
|
||||
|
||||
test('文件名中的季集信息映射到 searchDanmu 并筛选目标分集', () async {
|
||||
final matches = await gateway.match(
|
||||
'jsw://danmaku-test',
|
||||
'示例剧集 S02E02',
|
||||
1800,
|
||||
);
|
||||
|
||||
expect(matches, hasLength(1));
|
||||
expect(matches.single.episodeId, 102);
|
||||
expect(matches.single.animeId, 42);
|
||||
expect(matches.single.animeTitle, '示例剧集');
|
||||
});
|
||||
|
||||
test('getComments 响应映射为标准弹幕评论', () async {
|
||||
final comments = await gateway.fetchComments(
|
||||
'jsw://danmaku-test',
|
||||
102,
|
||||
chConvert: 1,
|
||||
);
|
||||
|
||||
expect(runtime.lastFunctionName, 'getComments');
|
||||
expect(runtime.lastParams, <String, Object?>{
|
||||
'commentId': 102,
|
||||
'chConvert': 1,
|
||||
});
|
||||
expect(comments.single.cid, 9);
|
||||
expect(comments.single.time, 1.5);
|
||||
expect(comments.single.text, '测试弹幕');
|
||||
expect(comments.single.color.toARGB32(), 0xFFFF0000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:smplayer/core/contracts/script_widget.dart';
|
||||
import 'package:smplayer/core/infra/storage/json_script_widget_repository.dart';
|
||||
|
||||
void main() {
|
||||
test('模块与订阅可以跨仓储实例持久化并按标题排序', () async {
|
||||
final temporaryDirectory = await Directory.systemTemp.createTemp(
|
||||
'smplayer-script-widget-repository-',
|
||||
);
|
||||
addTearDown(() => temporaryDirectory.delete(recursive: true));
|
||||
final repositoryFile = File(
|
||||
'${temporaryDirectory.path}${Platform.pathSeparator}widgets.json',
|
||||
);
|
||||
final repository = JsonScriptWidgetRepository(file: repositoryFile);
|
||||
final timestamp = DateTime.utc(2026, 7, 12);
|
||||
|
||||
await repository.upsertWidget(
|
||||
_installedWidget(id: 'zeta', title: '最后', timestamp: timestamp),
|
||||
);
|
||||
await repository.upsertWidget(
|
||||
_installedWidget(id: 'alpha', title: '开头', timestamp: timestamp),
|
||||
);
|
||||
await repository.upsertSubscription(
|
||||
ScriptWidgetSubscription(
|
||||
url: 'https://example.com/catalog.fwd',
|
||||
catalog: const ScriptWidgetCatalog(
|
||||
title: '示例订阅',
|
||||
widgets: <ScriptWidgetCatalogEntry>[
|
||||
ScriptWidgetCatalogEntry(
|
||||
id: 'alpha',
|
||||
title: '开头',
|
||||
url: 'https://example.com/alpha.js',
|
||||
),
|
||||
],
|
||||
),
|
||||
refreshedAt: timestamp,
|
||||
),
|
||||
);
|
||||
|
||||
final reloadedRepository = JsonScriptWidgetRepository(file: repositoryFile);
|
||||
final widgets = await reloadedRepository.listWidgets();
|
||||
final subscriptions = await reloadedRepository.listSubscriptions();
|
||||
|
||||
expect(widgets.map((widget) => widget.manifest.id), <String>[
|
||||
'alpha',
|
||||
'zeta',
|
||||
]);
|
||||
expect(subscriptions.single.catalog.title, '示例订阅');
|
||||
expect(
|
||||
subscriptions.single.catalog.widgets.single.url,
|
||||
'https://example.com/alpha.js',
|
||||
);
|
||||
});
|
||||
|
||||
test('损坏的仓储文件安全降级为空状态', () async {
|
||||
final temporaryDirectory = await Directory.systemTemp.createTemp(
|
||||
'smplayer-script-widget-corrupt-',
|
||||
);
|
||||
addTearDown(() => temporaryDirectory.delete(recursive: true));
|
||||
final repositoryFile = File(
|
||||
'${temporaryDirectory.path}${Platform.pathSeparator}widgets.json',
|
||||
);
|
||||
await repositoryFile.writeAsString('{not valid json');
|
||||
|
||||
final repository = JsonScriptWidgetRepository(file: repositoryFile);
|
||||
|
||||
expect(await repository.listWidgets(), isEmpty);
|
||||
expect(await repository.listSubscriptions(), isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
InstalledScriptWidget _installedWidget({
|
||||
required String id,
|
||||
required String title,
|
||||
required DateTime timestamp,
|
||||
}) {
|
||||
return InstalledScriptWidget(
|
||||
manifest: ScriptWidgetManifest(id: id, title: title),
|
||||
scriptSource: 'const WidgetMetadata = { id: "$id", title: "$title" };',
|
||||
installedAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:smplayer/core/infra/storage/search_history_store.dart';
|
||||
|
||||
void main() {
|
||||
test('旧 JSON 搜索历史读取后迁移为字符串列表', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'smplayer.search_history': jsonEncode(['电影', '剧集']),
|
||||
});
|
||||
|
||||
expect(await SearchHistoryStore().list(), ['电影', '剧集']);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getStringList('smplayer.search_history'), ['电影', '剧集']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:smplayer/core/infra/script_widget/widget_host_bridge.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('sharedCache 按模块隔离且不读取旧全局缓存', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'smplayer.script_widget.shared_cache.widget-a': jsonEncode(
|
||||
<String, Object?>{'owner': 'widget-a', 'value': 1},
|
||||
),
|
||||
'smplayer.script_widget.shared_cache.widget-b': jsonEncode(
|
||||
<String, Object?>{'owner': 'widget-b', 'value': 2},
|
||||
),
|
||||
'smplayer.script_widget.shared_cache': jsonEncode(<String, Object?>{
|
||||
'owner': 'legacy-global',
|
||||
}),
|
||||
});
|
||||
final bridge = WidgetHostBridge();
|
||||
|
||||
final widgetAData = await bridge.loadBootstrapData('widget-a');
|
||||
final widgetBData = await bridge.loadBootstrapData('widget-b');
|
||||
final widgetCData = await bridge.loadBootstrapData('widget-c');
|
||||
|
||||
expect(widgetAData.sharedCache, <String, Object?>{
|
||||
'owner': 'widget-a',
|
||||
'value': 1,
|
||||
});
|
||||
expect(widgetBData.sharedCache, <String, Object?>{
|
||||
'owner': 'widget-b',
|
||||
'value': 2,
|
||||
});
|
||||
expect(widgetCData.sharedCache, isEmpty);
|
||||
});
|
||||
|
||||
test('损坏的模块缓存不会污染其他模块的启动数据', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'smplayer.script_widget.shared_cache.invalid-widget': '{invalid',
|
||||
'smplayer.script_widget.shared_cache.valid-widget': jsonEncode(
|
||||
<String, Object?>{'ready': true},
|
||||
),
|
||||
});
|
||||
final bridge = WidgetHostBridge();
|
||||
|
||||
final invalidWidgetData = await bridge.loadBootstrapData('invalid-widget');
|
||||
final validWidgetData = await bridge.loadBootstrapData('valid-widget');
|
||||
|
||||
expect(invalidWidgetData.sharedCache, isEmpty);
|
||||
expect(validWidgetData.sharedCache, <String, Object?>{'ready': true});
|
||||
});
|
||||
|
||||
group('formatConsoleValue', () {
|
||||
test('展开 JS Error marker 为 name/message/stack 拼接', () {
|
||||
final formatted = formatConsoleValue(<String, Object?>{
|
||||
'__consoleErrorMarker': true,
|
||||
'name': 'TypeError',
|
||||
'message': '视频列表加载失败:网络超时',
|
||||
'stack': 'at getList (91porn_int.js:848)',
|
||||
});
|
||||
|
||||
expect(
|
||||
formatted,
|
||||
'TypeError: 视频列表加载失败:网络超时\nat getList (91porn_int.js:848)',
|
||||
);
|
||||
});
|
||||
|
||||
test('普通 Map 走 JSON 编码而不是 toString()', () {
|
||||
final formatted = formatConsoleValue(<String, Object?>{
|
||||
'status': 403,
|
||||
'reason': 'blocked',
|
||||
});
|
||||
|
||||
expect(formatted, '{"status":403,"reason":"blocked"}');
|
||||
});
|
||||
|
||||
test('字符串和基本类型保持原样', () {
|
||||
expect(formatConsoleValue('hello'), 'hello');
|
||||
expect(formatConsoleValue(42), '42');
|
||||
expect(formatConsoleValue(true), 'true');
|
||||
expect(formatConsoleValue(null), 'null');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user