chore(client): 归档原型阶段新增文件(配置、主题、模型与测试)
为什么:上一批归档只覆盖了「已跟踪文件的修改」,原型阶段新增的文件(app_config、theme、 home_feed/play_ticket 模型、catalog_api、测试目录等)还是未入库状态 —— 而已入库的代码会 import 它们, 留在工作区会导致 HEAD 处的代码编译不过。一并归档,让 HEAD 自洽。 纯归档,无行为改动。
This commit is contained in:
1 parent
e1b98df509
commit
feecc349a0
19 files changed
+2210
No files matched your search
@@ -0,0 +1,50 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 客户端与后端约定的运行时配置。
|
||||
///
|
||||
/// 接口地址的优先级:`--dart-define=API_BASE_URL=...` > 按运行环境判断的默认值。
|
||||
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。
|
||||
class AppConfig {
|
||||
const AppConfig._();
|
||||
|
||||
/// 开发机的局域网地址。真机上的 `127.0.0.1` 指向手机自己,只能走这个地址。
|
||||
///
|
||||
/// 这个地址跟着电脑所在的网络走,换网络后要用
|
||||
/// `ipconfig getifaddr en0` 重新确认并改这里;
|
||||
/// 也可以换成 Bonjour 名字 `http://<LocalHostName>.local:8081`(本机是
|
||||
/// `jiangdeMacBook-Air.local`),换网络不用改,但部分办公网会拦组播解析。
|
||||
static const String devHostBaseUrl =
|
||||
'http://jiangdeMacBook-Air.local:8081';
|
||||
|
||||
static const String _loopbackBaseUrl = 'http://127.0.0.1:8081';
|
||||
|
||||
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
|
||||
|
||||
static String get apiBaseUrl =>
|
||||
_overrideBaseUrl.isNotEmpty ? _overrideBaseUrl : _defaultBaseUrl;
|
||||
|
||||
static final String _defaultBaseUrl =
|
||||
_runsOnDevMachine ? _loopbackBaseUrl : devHostBaseUrl;
|
||||
|
||||
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
|
||||
/// Android 没有等价的探测方式,统一按真机处理(模拟器用
|
||||
/// `--dart-define=API_BASE_URL=http://10.0.2.2:8081` 覆盖)。
|
||||
static final bool _runsOnDevMachine =
|
||||
kIsWeb ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.linux ||
|
||||
(defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'));
|
||||
|
||||
static const Duration requestTimeout = Duration(seconds: 8);
|
||||
|
||||
/// 后端返回的是相对路径(/files/...),这里补上服务地址。
|
||||
static String mediaUrl(String path) {
|
||||
if (path.isEmpty) return '';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
return '$apiBaseUrl$path';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'drama.dart';
|
||||
|
||||
/// 首页/发现页聚合数据,一次请求拿到轮播与分区。
|
||||
class HomeFeed {
|
||||
const HomeFeed({this.banners = const [], this.sections = const []});
|
||||
|
||||
final List<FeedBanner> banners;
|
||||
final List<FeedSection> sections;
|
||||
|
||||
bool get isEmpty => sections.every((section) => section.dramas.isEmpty);
|
||||
|
||||
/// 分区打平后的短剧列表,供现有 UI 的横滑/竖滑复用;按 id 去重。
|
||||
List<Drama> get dramas {
|
||||
final seen = <String>{};
|
||||
final result = <Drama>[];
|
||||
for (final section in sections) {
|
||||
for (final drama in section.dramas) {
|
||||
if (seen.add(drama.id)) result.add(drama);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
factory HomeFeed.fromJson(Map<String, dynamic> json) {
|
||||
final banners = (json['banners'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(FeedBanner.fromJson)
|
||||
.toList();
|
||||
final sections = (json['sections'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(FeedSection.fromJson)
|
||||
.toList();
|
||||
return HomeFeed(banners: banners, sections: sections);
|
||||
}
|
||||
}
|
||||
|
||||
class FeedSection {
|
||||
const FeedSection({
|
||||
required this.code,
|
||||
required this.title,
|
||||
this.dramas = const [],
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String title;
|
||||
final List<Drama> dramas;
|
||||
|
||||
factory FeedSection.fromJson(Map<String, dynamic> json) {
|
||||
return FeedSection(
|
||||
code: json['code'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
dramas: (json['dramas'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FeedBanner {
|
||||
const FeedBanner({
|
||||
required this.id,
|
||||
required this.imageUrl,
|
||||
this.linkType = 'NONE',
|
||||
this.linkValue = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String imageUrl;
|
||||
final String linkType;
|
||||
final String linkValue;
|
||||
|
||||
/// 点击轮播要打开的短剧 id,非短剧跳转返回 null。
|
||||
String? get dramaId => linkType == 'DRAMA' && linkValue.isNotEmpty
|
||||
? linkValue
|
||||
: null;
|
||||
|
||||
factory FeedBanner.fromJson(Map<String, dynamic> json) {
|
||||
return FeedBanner(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
imageUrl: json['imageUrl'] as String? ?? '',
|
||||
linkType: json['linkType'] as String? ?? 'NONE',
|
||||
linkValue: json['linkValue'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// 播放票据:客户端据此决定直接播放、提示解锁还是提示暂无片源。
|
||||
class PlayTicket {
|
||||
const PlayTicket({
|
||||
required this.episodeId,
|
||||
required this.accessible,
|
||||
required this.accessType,
|
||||
this.playUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.unlockCoin = 0,
|
||||
});
|
||||
|
||||
static const String free = 'FREE';
|
||||
static const String coinRequired = 'COIN_REQUIRED';
|
||||
static const String vipRequired = 'VIP_REQUIRED';
|
||||
static const String noMedia = 'NO_MEDIA';
|
||||
|
||||
final String episodeId;
|
||||
final bool accessible;
|
||||
final String accessType;
|
||||
final String playUrl;
|
||||
final int durationSec;
|
||||
final int unlockCoin;
|
||||
|
||||
bool get needsUnlock =>
|
||||
accessType == coinRequired || accessType == vipRequired;
|
||||
|
||||
factory PlayTicket.fromJson(Map<String, dynamic> json) {
|
||||
return PlayTicket(
|
||||
episodeId: (json['episodeId'] ?? '').toString(),
|
||||
accessible: json['accessible'] == true,
|
||||
accessType: json['accessType'] as String? ?? noMedia,
|
||||
playUrl: json['playUrl'] as String? ?? '',
|
||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
||||
unlockCoin: (json['unlockCoin'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/play_ticket.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 客户端目录接口,对应后端 `/app/**`。
|
||||
class CatalogApi {
|
||||
const CatalogApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<HomeFeed> homeFeed() async {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed')));
|
||||
}
|
||||
|
||||
Future<HomeFeed> discoverFeed() async {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||
}
|
||||
|
||||
Future<Drama> dramaDetail(String dramaId) async {
|
||||
return Drama.fromDetailJson(
|
||||
_asMap(await _client.get('/app/dramas/$dramaId')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<PlayTicket> playTicket(String episodeId) async {
|
||||
return PlayTicket.fromJson(
|
||||
_asMap(await _client.get('/app/episodes/$episodeId/play')),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? data) {
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared colors. Blue is the primary action color.
|
||||
abstract final class AppColors {
|
||||
static const blue = Color(0xFF2F7BFF);
|
||||
static const blueSoft = Color(0xFF8FB4FF);
|
||||
static const ink = Color(0xFF0B0C10);
|
||||
static const bar = Color(0xFF101114);
|
||||
static const card = Color(0xFF17191F);
|
||||
static const muted = Color(0xFFB7BDC9);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
|
||||
class RemoteImage extends StatelessWidget {
|
||||
const RemoteImage(
|
||||
this.path, {
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = AppConfig.mediaUrl(path);
|
||||
if (url.isEmpty) return _fallback();
|
||||
return Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
errorBuilder: (_, _, _) => _fallback(),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return _fallback();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fallback() {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: const Color(0xFF1C1F26),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
|
||||
void main() {
|
||||
test('a list card carries one playable preview and the total count', () {
|
||||
final drama = Drama.fromCardJson(const {
|
||||
'id': 5,
|
||||
'title': '闪婚后',
|
||||
'synopsis': 'synopsis',
|
||||
'cover': '/files/cover.jpg',
|
||||
'category': '都市情感',
|
||||
'genre': '都市情感',
|
||||
'totalEpisodes': 77,
|
||||
'isHot': true,
|
||||
'viewCount': 3100000,
|
||||
'preview': {
|
||||
'id': 9,
|
||||
'number': 1,
|
||||
'title': '第 1 集',
|
||||
'isFree': true,
|
||||
'playUrl': '/files/episode/9.mp4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(drama.detailLoaded, isFalse);
|
||||
expect(drama.episodes, hasLength(1));
|
||||
expect(drama.episodes.single.videoUrl, '/files/episode/9.mp4');
|
||||
expect(drama.episodeCount, 77);
|
||||
expect(drama.isHot, isTrue);
|
||||
});
|
||||
|
||||
test('a list card without a playable preview has no episodes', () {
|
||||
final drama = Drama.fromCardJson(const {
|
||||
'id': 6,
|
||||
'title': '替嫁新娘',
|
||||
'totalEpisodes': 12,
|
||||
});
|
||||
|
||||
expect(drama.episodes, isEmpty);
|
||||
expect(drama.episodeCount, 12);
|
||||
});
|
||||
|
||||
test('a detail response counts the episodes it ships', () {
|
||||
final drama = Drama.fromDetailJson(const {
|
||||
'id': 5,
|
||||
'title': '闪婚后',
|
||||
'synopsis': 'synopsis',
|
||||
'cover': '/files/cover.jpg',
|
||||
'totalEpisodes': 77,
|
||||
'episodes': [
|
||||
{'id': 9, 'number': 1, 'title': '第 1 集', 'isFree': true},
|
||||
{'id': 10, 'number': 2, 'title': '第 2 集', 'isFree': false},
|
||||
],
|
||||
});
|
||||
|
||||
expect(drama.detailLoaded, isTrue);
|
||||
expect(drama.episodeCount, 77);
|
||||
expect(drama.episodes.first.id, '9');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/config/locale_controller.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
});
|
||||
|
||||
test('no stored choice keeps the device locale', () async {
|
||||
final controller = LocaleController();
|
||||
var notifications = 0;
|
||||
controller.addListener(() => notifications++);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.locale, isNull);
|
||||
expect(notifications, 1);
|
||||
});
|
||||
|
||||
test('a chosen language survives a restart', () async {
|
||||
final first = LocaleController();
|
||||
await first.select('zh');
|
||||
expect(first.locale, const Locale('zh'));
|
||||
|
||||
final reopened = LocaleController();
|
||||
await reopened.load();
|
||||
expect(reopened.locale, const Locale('zh'));
|
||||
});
|
||||
|
||||
test('selecting english clears a stored chinese preference', () async {
|
||||
await LocaleController().select('zh');
|
||||
|
||||
final reopened = LocaleController();
|
||||
await reopened.load();
|
||||
await reopened.select('en');
|
||||
|
||||
final third = LocaleController();
|
||||
await third.load();
|
||||
expect(third.locale, const Locale('en'));
|
||||
});
|
||||
|
||||
test('an unsupported code clears the locale instead of throwing', () async {
|
||||
final controller = LocaleController();
|
||||
await controller.select('zh');
|
||||
expect(controller.locale, const Locale('zh'));
|
||||
|
||||
await controller.select('tlh');
|
||||
|
||||
expect(controller.locale, isNull);
|
||||
});
|
||||
|
||||
test('an unsupported stored code resolves to the device locale', () async {
|
||||
await SharedPreferencesAsync().setString('ui.locale', 'fr');
|
||||
|
||||
final controller = LocaleController();
|
||||
await controller.load();
|
||||
|
||||
expect(controller.locale, isNull);
|
||||
});
|
||||
|
||||
test('a broken preference store never breaks load or select', () async {
|
||||
SharedPreferencesAsyncPlatform.instance = _BrokenPreferences();
|
||||
final controller = LocaleController();
|
||||
|
||||
await controller.load();
|
||||
expect(controller.locale, isNull);
|
||||
|
||||
await controller.select('zh');
|
||||
expect(controller.locale, const Locale('zh'));
|
||||
});
|
||||
}
|
||||
|
||||
/// Stands in for a platform whose preference store is unavailable.
|
||||
base class _BrokenPreferences extends InMemorySharedPreferencesAsync {
|
||||
_BrokenPreferences() : super.empty();
|
||||
|
||||
@override
|
||||
Future<String?> getString(String key, SharedPreferencesOptions options) =>
|
||||
Future<String?>.error(StateError('preferences unavailable'));
|
||||
|
||||
@override
|
||||
Future<bool> setString(
|
||||
String key,
|
||||
String value,
|
||||
SharedPreferencesOptions options,
|
||||
) => Future<bool>.error(StateError('preferences unavailable'));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/services/android_picture_in_picture.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = MethodChannel('test/pip_channel');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
AndroidPictureInPicture pip() => AndroidPictureInPicture(channel: channel);
|
||||
|
||||
/// Simulates a native call arriving on the channel.
|
||||
Future<void> sendToDart(String method, Object? arguments) => messenger
|
||||
.handlePlatformMessage(
|
||||
channel.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
MethodCall(method, arguments),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
|
||||
tearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
channel.setMethodCallHandler(null);
|
||||
});
|
||||
|
||||
group('platform support', () {
|
||||
test('android is the only supported target', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
expect(pip().isPlatformSupported, isTrue);
|
||||
|
||||
for (final platform in <TargetPlatform>[
|
||||
TargetPlatform.iOS,
|
||||
TargetPlatform.macOS,
|
||||
TargetPlatform.linux,
|
||||
TargetPlatform.windows,
|
||||
TargetPlatform.fuchsia,
|
||||
]) {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
expect(
|
||||
pip().isPlatformSupported,
|
||||
isFalse,
|
||||
reason: '$platform must not claim picture in picture support',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('enter', () {
|
||||
test('is refused without touching the channel off android', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return true;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
|
||||
expect(await pip().enter(), isFalse);
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
test('a missing native answer counts as a refusal', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async => null);
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
|
||||
expect(await pip().enter(), isFalse);
|
||||
});
|
||||
|
||||
test('a native success is reported as entered', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return true;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
|
||||
expect(await pip().enter(), isTrue);
|
||||
expect(calls.single.method, 'enter');
|
||||
expect(calls.single.arguments, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('state listener', () {
|
||||
test('pip state changes reach the listener in order', () async {
|
||||
final received = <bool>[];
|
||||
pip().setStateListener(received.add);
|
||||
|
||||
await sendToDart('onPipChanged', true);
|
||||
await sendToDart('onPipChanged', false);
|
||||
|
||||
expect(received, [true, false]);
|
||||
});
|
||||
|
||||
test('only a literal true is read as enabled', () async {
|
||||
final received = <bool>[];
|
||||
pip().setStateListener(received.add);
|
||||
|
||||
await sendToDart('onPipChanged', null);
|
||||
await sendToDart('onPipChanged', 'yes');
|
||||
await sendToDart('onPipChanged', 1);
|
||||
|
||||
expect(received, [false, false, false]);
|
||||
});
|
||||
|
||||
test('unrelated native methods are ignored', () async {
|
||||
final received = <bool>[];
|
||||
pip().setStateListener(received.add);
|
||||
|
||||
await sendToDart('somethingElse', true);
|
||||
await sendToDart('onPipChanged', true);
|
||||
|
||||
expect(received, [true]);
|
||||
});
|
||||
|
||||
test('clearing the listener silences later callbacks', () async {
|
||||
final received = <bool>[];
|
||||
final observer = pip()..setStateListener(received.add);
|
||||
await sendToDart('onPipChanged', true);
|
||||
|
||||
observer.setStateListener(null);
|
||||
await sendToDart('onPipChanged', true);
|
||||
|
||||
expect(received, [true]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/pages/discover/widgets/cover_carousel.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('the cube turns and keeps the title on the poster', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CoverCarousel(
|
||||
items: [
|
||||
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
|
||||
CoverItem(
|
||||
title: title,
|
||||
subtitle: 'Mock',
|
||||
image: '/files/poster.png',
|
||||
onOpen: _noop,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('The Signal'), findsOneWidget);
|
||||
expect(find.text('Mock'), findsOneWidget);
|
||||
|
||||
final box = tester.getRect(find.byType(CoverCarousel));
|
||||
final gesture = await tester.startGesture(Offset(box.right - 40, box.center.dy));
|
||||
await gesture.moveBy(const Offset(-500, 0));
|
||||
await tester.pump();
|
||||
|
||||
final turned = tester.widgetList<Transform>(find.byType(Transform)).any(
|
||||
(transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15,
|
||||
);
|
||||
expect(turned, isTrue);
|
||||
|
||||
await gesture.moveBy(const Offset(-200, 0));
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 450));
|
||||
|
||||
expect(find.text('City Lights'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
void _noop() {}
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/l10n/app_localizations.dart';
|
||||
import 'package:nontonki/pages/discover/discover_logic.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: [
|
||||
for (final number in numbers)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: 'Episode $number',
|
||||
videoUrl: 'https://example.test/clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('the constructor resolves the resume position of episode one', () async {
|
||||
final history = FakeWatchHistory()..saved = const Duration(seconds: 4);
|
||||
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 4));
|
||||
expect(history.reads, ['demo-local-v1|signal|signal-1']);
|
||||
expect(logic.category, 0);
|
||||
expect(logic.searchOpen, isFalse);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('refreshPosition re-reads after the drama is swapped', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
|
||||
|
||||
logic.drama = _drama('city');
|
||||
logic.refreshPosition();
|
||||
await logic.position;
|
||||
|
||||
expect(history.reads, [
|
||||
'demo-local-v1|signal|signal-1',
|
||||
'demo-local-v1|city|city-1',
|
||||
]);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('a list card without episodes resumes from the last watch', () async {
|
||||
final history = FakeWatchHistory()
|
||||
..saved = const Duration(seconds: 9)
|
||||
..lastEpisode = 'signal-2';
|
||||
final logic = DiscoverLogic(
|
||||
history: history,
|
||||
drama: _drama('signal', numbers: const []),
|
||||
);
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 9));
|
||||
expect(history.reads, ['demo-local-v1|signal|signal-2']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('a list card without history has no resume position', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = DiscoverLogic(
|
||||
history: history,
|
||||
drama: _drama('signal', numbers: const []),
|
||||
);
|
||||
|
||||
expect(await logic.position, isNull);
|
||||
expect(history.reads, isEmpty);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('selectCategory stores the index and notifies once', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.selectCategory(3);
|
||||
|
||||
expect(logic.category, 3);
|
||||
expect(notifications, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('search visibility follows the flag or the typed text', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
|
||||
expect(logic.showSearch, isFalse);
|
||||
|
||||
logic.search.text = 'vengeance';
|
||||
logic.onSearchChanged();
|
||||
expect(logic.showSearch, isTrue);
|
||||
|
||||
logic.search.clear();
|
||||
expect(logic.showSearch, isFalse);
|
||||
|
||||
logic.searchOpen = true;
|
||||
expect(logic.showSearch, isTrue);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('dramaFor matches by id and tolerates a missing id', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
final catalog = [_drama('signal'), _drama('city')];
|
||||
|
||||
expect(logic.dramaFor('city', catalog)?.id, 'city');
|
||||
expect(logic.dramaFor('missing', catalog), isNull);
|
||||
expect(logic.dramaFor(null, catalog), isNull);
|
||||
expect(logic.dramaFor('city', const []), isNull);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('syncDrama only adopts a drama while the page is active', () {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
|
||||
|
||||
void sync(
|
||||
Drama next, {
|
||||
required bool active,
|
||||
required bool wasActive,
|
||||
required bool dramaChanged,
|
||||
int searchRequest = 0,
|
||||
int previousSearchRequest = 0,
|
||||
}) => logic.syncDrama(
|
||||
active: active,
|
||||
wasActive: wasActive,
|
||||
nextDrama: next,
|
||||
dramaChanged: dramaChanged,
|
||||
searchRequest: searchRequest,
|
||||
previousSearchRequest: previousSearchRequest,
|
||||
isActive: () => active,
|
||||
);
|
||||
|
||||
// An inactive page keeps showing its own drama.
|
||||
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'signal');
|
||||
|
||||
// Becoming active adopts the incoming drama.
|
||||
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
|
||||
expect(logic.drama.id, 'city');
|
||||
|
||||
// Staying active with a changed drama re-reads history.
|
||||
history.reads.clear();
|
||||
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']);
|
||||
|
||||
// Staying active with no change leaves both drama and history alone.
|
||||
history.reads.clear();
|
||||
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, isEmpty);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('a search request opens and focuses the field when active', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TextField(
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
logic.syncDrama(
|
||||
active: true,
|
||||
wasActive: true,
|
||||
nextDrama: logic.drama,
|
||||
dramaChanged: false,
|
||||
searchRequest: 1,
|
||||
previousSearchRequest: 0,
|
||||
isActive: () => true,
|
||||
);
|
||||
|
||||
expect(logic.searchOpen, isTrue);
|
||||
await tester.pumpAndSettle();
|
||||
expect(logic.searchFocus.hasFocus, isTrue);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('a hidden page never opens or focuses the search field', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TextField(
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The search request branch is gated on `active`, so a page that is not on
|
||||
// screen must neither reveal the field nor pull focus away from the visible tab.
|
||||
logic.syncDrama(
|
||||
active: false,
|
||||
wasActive: false,
|
||||
nextDrama: logic.drama,
|
||||
dramaChanged: false,
|
||||
searchRequest: 1,
|
||||
previousSearchRequest: 0,
|
||||
isActive: () => false,
|
||||
);
|
||||
|
||||
expect(logic.searchOpen, isFalse);
|
||||
await tester.pumpAndSettle();
|
||||
expect(logic.searchFocus.hasFocus, isFalse);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('act returns the choice for a page and forwards it when embedded', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
final forwarded = <DiscoverPageAction>[];
|
||||
final popped = Completer<DiscoverPageAction?>();
|
||||
late BuildContext pageContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.of(context)
|
||||
.push<DiscoverPageAction>(
|
||||
MaterialPageRoute<DiscoverPageAction>(
|
||||
builder: (inner) {
|
||||
pageContext = inner;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
)
|
||||
.then(popped.complete),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
logic.act(
|
||||
pageContext,
|
||||
DiscoverPageAction.home,
|
||||
embedded: true,
|
||||
onAction: forwarded.add,
|
||||
);
|
||||
expect(forwarded, [DiscoverPageAction.home]);
|
||||
expect(Navigator.of(pageContext).canPop(), isTrue);
|
||||
|
||||
logic.act(pageContext, DiscoverPageAction.watchlist, embedded: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await expectLater(popped.future, completion(DiscoverPageAction.watchlist));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('a card without playable media shows the artwork notice', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
late BuildContext context;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (inner) {
|
||||
context = inner;
|
||||
return const Scaffold(body: SizedBox.shrink());
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
logic.openCard(context, null, embedded: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
expect(find.text(l10n.artworkOnlyNotice), findsOneWidget);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('an embedded card forwards the chosen drama', (tester) async {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
late BuildContext context;
|
||||
Drama? selected;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (inner) {
|
||||
context = inner;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
logic.openCard(
|
||||
context,
|
||||
_drama('city'),
|
||||
embedded: true,
|
||||
onDramaSelected: (drama) => selected = drama,
|
||||
);
|
||||
|
||||
expect(selected?.id, 'city');
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('notify after dispose is a no-op', () {
|
||||
final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s'));
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.dispose();
|
||||
logic.notify();
|
||||
|
||||
expect(notifications, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/remote/api_client.dart';
|
||||
import 'package:nontonki/pages/drama_detail/drama_detail_logic.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
totalEpisodes: 3,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: '$id-1',
|
||||
number: 1,
|
||||
title: '第 1 集',
|
||||
isFree: true,
|
||||
videoUrl: '/files/$id-1.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Drama _detail(String id) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
totalEpisodes: 3,
|
||||
detailLoaded: true,
|
||||
episodes: [
|
||||
for (var number = 1; number <= 3; number++)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: '第 $number 集',
|
||||
isFree: true,
|
||||
videoUrl: '/files/$id-$number.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('a list card is upgraded to the full episode list', () async {
|
||||
var calls = 0;
|
||||
final logic = DramaDetailLogic(
|
||||
drama: _card('signal'),
|
||||
history: FakeWatchHistory(),
|
||||
);
|
||||
|
||||
await logic.load(() async {
|
||||
calls++;
|
||||
return _detail('signal');
|
||||
});
|
||||
|
||||
expect(calls, 1);
|
||||
expect(logic.loading, isFalse);
|
||||
expect(logic.failed, isFalse);
|
||||
expect(logic.drama.episodes, hasLength(3));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('a failed load keeps the card and reports the reason', () async {
|
||||
final logic = DramaDetailLogic(
|
||||
drama: _card('signal'),
|
||||
history: FakeWatchHistory(),
|
||||
);
|
||||
|
||||
await logic.load(() async => throw const ApiException(500, '服务异常'));
|
||||
|
||||
expect(logic.failed, isTrue);
|
||||
expect(logic.failureMessage, '服务异常');
|
||||
expect(logic.drama.episodes, hasLength(1));
|
||||
|
||||
await logic.load(() async => throw StateError('offline'));
|
||||
expect(logic.failed, isTrue);
|
||||
expect(logic.failureMessage, isNull);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('an already loaded detail skips the request', () async {
|
||||
var calls = 0;
|
||||
final logic = DramaDetailLogic(
|
||||
drama: _detail('signal'),
|
||||
history: FakeWatchHistory(),
|
||||
);
|
||||
|
||||
await logic.load(() async {
|
||||
calls++;
|
||||
return _detail('signal');
|
||||
});
|
||||
|
||||
expect(calls, 0);
|
||||
expect(logic.drama.episodes, hasLength(3));
|
||||
logic.dispose();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/engagement_snapshot.dart';
|
||||
import 'package:nontonki/core/data/repositories/engagement_repository.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
/// Mirrors the private key used by [SharedPreferencesEngagementRepository].
|
||||
const _key = 'engagement.v1.demo-local-v1';
|
||||
|
||||
void main() {
|
||||
late SharedPreferencesAsync preferences;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
preferences = SharedPreferencesAsync();
|
||||
});
|
||||
|
||||
SharedPreferencesEngagementRepository repository() =>
|
||||
SharedPreferencesEngagementRepository(preferences: preferences);
|
||||
|
||||
Future<EngagementSnapshot> loadRaw(String raw) async {
|
||||
await preferences.setString(_key, raw);
|
||||
return repository().load();
|
||||
}
|
||||
|
||||
test('a first launch reads an empty snapshot', () async {
|
||||
final snapshot = await repository().load();
|
||||
|
||||
expect(snapshot.savedDramaIds, isEmpty);
|
||||
expect(snapshot.likedDramaIds, isEmpty);
|
||||
expect(snapshot.commentsByDrama, isEmpty);
|
||||
});
|
||||
|
||||
test('a snapshot survives a save and reload round trip', () async {
|
||||
await repository().save(
|
||||
const EngagementSnapshot(
|
||||
savedDramaIds: {'signal', 'city'},
|
||||
likedDramaIds: {'city'},
|
||||
commentsByDrama: {
|
||||
'signal': ['first note', 'second note'],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final loaded = await repository().load();
|
||||
|
||||
expect(loaded.savedDramaIds, {'signal', 'city'});
|
||||
expect(loaded.likedDramaIds, {'city'});
|
||||
expect(loaded.commentsByDrama, {
|
||||
'signal': ['first note', 'second note'],
|
||||
});
|
||||
});
|
||||
|
||||
test('an empty snapshot round trips as empty collections', () async {
|
||||
await repository().save(const EngagementSnapshot());
|
||||
|
||||
final loaded = await repository().load();
|
||||
|
||||
expect(loaded.savedDramaIds, isEmpty);
|
||||
expect(loaded.likedDramaIds, isEmpty);
|
||||
expect(loaded.commentsByDrama, isEmpty);
|
||||
});
|
||||
|
||||
test('corrupt or non-object payloads fall back to an empty snapshot', () async {
|
||||
for (final raw in <String>[
|
||||
'',
|
||||
'not json at all',
|
||||
'[]',
|
||||
'"a bare string"',
|
||||
'42',
|
||||
'null',
|
||||
]) {
|
||||
final snapshot = await loadRaw(raw);
|
||||
|
||||
expect(snapshot.savedDramaIds, isEmpty, reason: raw);
|
||||
expect(snapshot.likedDramaIds, isEmpty, reason: raw);
|
||||
expect(snapshot.commentsByDrama, isEmpty, reason: raw);
|
||||
}
|
||||
});
|
||||
|
||||
test('wrongly typed fields are dropped instead of throwing', () async {
|
||||
final snapshot = await loadRaw(
|
||||
jsonEncode({
|
||||
'saved': 'signal',
|
||||
'liked': {'city': true},
|
||||
'comments': {
|
||||
'signal': 'a plain string',
|
||||
'city': ['kept', 7, null, true],
|
||||
'town': <Object>[],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(snapshot.savedDramaIds, isEmpty);
|
||||
expect(snapshot.likedDramaIds, isEmpty);
|
||||
expect(snapshot.commentsByDrama, {
|
||||
'city': ['kept'],
|
||||
'town': <String>[],
|
||||
});
|
||||
});
|
||||
|
||||
test('a payload missing keys falls back per field', () async {
|
||||
final snapshot = await loadRaw(jsonEncode({'saved': ['signal']}));
|
||||
|
||||
expect(snapshot.savedDramaIds, {'signal'});
|
||||
expect(snapshot.likedDramaIds, isEmpty);
|
||||
expect(snapshot.commentsByDrama, isEmpty);
|
||||
});
|
||||
|
||||
test('save writes only json primitives', () async {
|
||||
await repository().save(
|
||||
const EngagementSnapshot(
|
||||
savedDramaIds: {'signal'},
|
||||
likedDramaIds: {'city'},
|
||||
commentsByDrama: {
|
||||
'signal': ['note'],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final raw = await preferences.getString(_key);
|
||||
expect(raw, isNotNull);
|
||||
|
||||
final decoded = jsonDecode(raw!) as Map<String, dynamic>;
|
||||
expect(decoded['saved'], ['signal']);
|
||||
expect(decoded['liked'], ['city']);
|
||||
expect(decoded['comments'], {
|
||||
'signal': ['note'],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/formatting/localized_content.dart';
|
||||
import 'package:nontonki/l10n/app_localizations_en.dart';
|
||||
import 'package:nontonki/l10n/app_localizations_zh.dart';
|
||||
|
||||
Drama _drama({required String id, String title = 'raw', String synopsis = 'raw'}) =>
|
||||
Drama(
|
||||
id: id,
|
||||
title: title,
|
||||
synopsis: synopsis,
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: const [],
|
||||
);
|
||||
|
||||
void main() {
|
||||
final en = AppLocalizationsEn();
|
||||
final zh = AppLocalizationsZh();
|
||||
|
||||
test('each seeded drama maps onto its own localized copy', () {
|
||||
final signal = _drama(id: 'the-signal-demo');
|
||||
final city = _drama(id: 'city-lights-demo');
|
||||
final vengeance = _drama(id: 'vengeance-demo');
|
||||
|
||||
expect(localizedDramaTitle(en, signal), en.dramaSignalTitle);
|
||||
expect(localizedDramaTitle(en, city), en.dramaCityTitle);
|
||||
expect(localizedDramaTitle(en, vengeance), en.dramaVengeanceTitle);
|
||||
|
||||
expect(localizedDramaSynopsis(en, signal), en.dramaSignalSynopsis);
|
||||
expect(localizedDramaSynopsis(en, city), en.dramaCitySynopsis);
|
||||
expect(localizedDramaSynopsis(en, vengeance), en.dramaVengeanceSynopsis);
|
||||
});
|
||||
|
||||
test('the mapping does not collapse two dramas onto one entry', () {
|
||||
final titles = {
|
||||
localizedDramaTitle(en, _drama(id: 'the-signal-demo')),
|
||||
localizedDramaTitle(en, _drama(id: 'city-lights-demo')),
|
||||
localizedDramaTitle(en, _drama(id: 'vengeance-demo')),
|
||||
};
|
||||
final synopses = {
|
||||
localizedDramaSynopsis(en, _drama(id: 'the-signal-demo')),
|
||||
localizedDramaSynopsis(en, _drama(id: 'city-lights-demo')),
|
||||
localizedDramaSynopsis(en, _drama(id: 'vengeance-demo')),
|
||||
};
|
||||
|
||||
expect(titles, hasLength(3));
|
||||
expect(synopses, hasLength(3));
|
||||
});
|
||||
|
||||
test('localized copy is translated rather than reused verbatim', () {
|
||||
final signal = _drama(id: 'the-signal-demo');
|
||||
|
||||
expect(localizedDramaTitle(zh, signal), zh.dramaSignalTitle);
|
||||
expect(localizedDramaSynopsis(zh, signal), zh.dramaSignalSynopsis);
|
||||
expect(localizedDramaTitle(zh, signal), isNot(en.dramaSignalTitle));
|
||||
});
|
||||
|
||||
test('an unknown drama keeps the catalog text in every locale', () {
|
||||
final custom = _drama(
|
||||
id: 'custom-demo',
|
||||
title: 'Custom Title',
|
||||
synopsis: 'Custom synopsis',
|
||||
);
|
||||
|
||||
expect(localizedDramaTitle(en, custom), 'Custom Title');
|
||||
expect(localizedDramaTitle(zh, custom), 'Custom Title');
|
||||
expect(localizedDramaSynopsis(en, custom), 'Custom synopsis');
|
||||
expect(localizedDramaSynopsis(zh, custom), 'Custom synopsis');
|
||||
});
|
||||
|
||||
test('the raw catalog text is ignored for a seeded drama', () {
|
||||
final signal = _drama(
|
||||
id: 'the-signal-demo',
|
||||
title: 'stale title',
|
||||
synopsis: 'stale synopsis',
|
||||
);
|
||||
|
||||
expect(localizedDramaTitle(en, signal), isNot('stale title'));
|
||||
expect(localizedDramaSynopsis(en, signal), isNot('stale synopsis'));
|
||||
});
|
||||
|
||||
test('episodes one to four use named titles', () {
|
||||
expect(localizedEpisodeTitle(en, 1), en.episodeOneTitle);
|
||||
expect(localizedEpisodeTitle(en, 2), en.episodeTwoTitle);
|
||||
expect(localizedEpisodeTitle(en, 3), en.episodeThreeTitle);
|
||||
expect(localizedEpisodeTitle(en, 4), en.episodeFourTitle);
|
||||
});
|
||||
|
||||
test('later episodes fall back to a numbered label', () {
|
||||
for (final number in <int>[5, 12, 100]) {
|
||||
expect(localizedEpisodeTitle(en, number), en.episodeLabel(number));
|
||||
expect(localizedEpisodeTitle(en, number), contains('$number'));
|
||||
}
|
||||
expect(localizedEpisodeTitle(zh, 12), zh.episodeLabel(12));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/remote/api_client.dart';
|
||||
import 'package:nontonki/core/data/remote/catalog_api.dart';
|
||||
|
||||
/// 联调测试:直连本机客户端接口服务(8081)。
|
||||
/// 服务没启动时自动跳过,不影响日常 `flutter test`。
|
||||
void main() {
|
||||
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
|
||||
final api = CatalogApi(client);
|
||||
|
||||
tearDownAll(client.close);
|
||||
|
||||
Future<bool> serverUp() async {
|
||||
try {
|
||||
await api.homeFeed();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('首页聚合:能解析出轮播与分区', () async {
|
||||
if (!await serverUp()) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
return;
|
||||
}
|
||||
final feed = await api.homeFeed();
|
||||
expect(feed.sections, isNotEmpty);
|
||||
expect(feed.dramas, isNotEmpty);
|
||||
final card = feed.dramas.first;
|
||||
expect(card.id, isNotEmpty);
|
||||
expect(card.title, isNotEmpty);
|
||||
expect(card.posterUrl, startsWith('/files/'));
|
||||
});
|
||||
|
||||
test('详情:剧集按下发顺序排列,可播放集带地址', () async {
|
||||
if (!await serverUp()) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
return;
|
||||
}
|
||||
final feed = await api.homeFeed();
|
||||
final card = feed.dramas.first;
|
||||
final detail = await api.dramaDetail(card.id);
|
||||
|
||||
expect(detail.id, card.id);
|
||||
expect(detail.episodes, isNotEmpty);
|
||||
final numbers = detail.episodes.map((episode) => episode.number).toList();
|
||||
expect(numbers, orderedEquals([...numbers]..sort()));
|
||||
for (final episode in detail.episodes) {
|
||||
if (episode.locked) {
|
||||
expect(episode.videoUrl, isEmpty);
|
||||
} else {
|
||||
expect(episode.videoUrl, startsWith('/files/'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('播放票据:可播放时返回地址', () async {
|
||||
if (!await serverUp()) {
|
||||
markTestSkipped('客户端接口服务未启动,已跳过联调测试');
|
||||
return;
|
||||
}
|
||||
final feed = await api.homeFeed();
|
||||
final detail = await api.dramaDetail(feed.dramas.first.id);
|
||||
final playable = detail.episodes
|
||||
.where((episode) => episode.videoUrl.isNotEmpty)
|
||||
.toList();
|
||||
if (playable.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ticket = await api.playTicket(playable.first.id);
|
||||
expect(ticket.accessible, isTrue);
|
||||
expect(ticket.playUrl, startsWith('/files/'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/config/locale_controller.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/l10n/app_localizations.dart';
|
||||
import 'package:nontonki/pages/mine/mine_logic.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: [
|
||||
for (final number in numbers)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: 'Episode $number',
|
||||
videoUrl: 'https://example.test/clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('the constructor keeps the requested section and reads episode one', () async {
|
||||
final history = FakeWatchHistory()..saved = const Duration(seconds: 7);
|
||||
final logic = MineLogic(
|
||||
history: history,
|
||||
drama: _drama('signal'),
|
||||
initialSection: 1,
|
||||
);
|
||||
|
||||
expect(logic.section, 1);
|
||||
expect(logic.filter, 0);
|
||||
expect(await logic.position, const Duration(seconds: 7));
|
||||
expect(history.reads, ['demo-local-v1|signal|signal-1']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('a list card without episodes resumes from the last watch', () async {
|
||||
final history = FakeWatchHistory()
|
||||
..saved = const Duration(seconds: 7)
|
||||
..lastEpisode = 'signal-3';
|
||||
final logic = MineLogic(
|
||||
history: history,
|
||||
drama: _drama('signal', numbers: const []),
|
||||
initialSection: 1,
|
||||
);
|
||||
|
||||
expect(await logic.position, const Duration(seconds: 7));
|
||||
expect(history.reads, ['demo-local-v1|signal|signal-3']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('section and filter selections notify', () {
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.selectSection(1);
|
||||
expect(logic.section, 1);
|
||||
expect(notifications, 1);
|
||||
|
||||
logic.selectFilter(2);
|
||||
expect(logic.filter, 2);
|
||||
expect(notifications, 2);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('syncPage only adopts a drama while the page is active', () {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = MineLogic(
|
||||
history: history,
|
||||
drama: _drama('signal'),
|
||||
initialSection: 0,
|
||||
);
|
||||
|
||||
void sync(
|
||||
Drama next, {
|
||||
required bool active,
|
||||
required bool wasActive,
|
||||
required bool dramaChanged,
|
||||
int sectionRequest = 0,
|
||||
int previousSectionRequest = 0,
|
||||
int initialSection = 0,
|
||||
}) => logic.syncPage(
|
||||
active: active,
|
||||
wasActive: wasActive,
|
||||
nextDrama: next,
|
||||
dramaChanged: dramaChanged,
|
||||
sectionRequest: sectionRequest,
|
||||
previousSectionRequest: previousSectionRequest,
|
||||
initialSection: initialSection,
|
||||
);
|
||||
|
||||
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'signal');
|
||||
|
||||
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
|
||||
expect(logic.drama.id, 'city');
|
||||
|
||||
history.reads.clear();
|
||||
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, ['demo-local-v1|vengeance|vengeance-1']);
|
||||
|
||||
history.reads.clear();
|
||||
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
|
||||
expect(logic.drama.id, 'vengeance');
|
||||
expect(history.reads, isEmpty);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('a section request resets the section even while the page is inactive', () {
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
|
||||
logic.syncPage(
|
||||
active: false,
|
||||
wasActive: false,
|
||||
nextDrama: logic.drama,
|
||||
dramaChanged: false,
|
||||
sectionRequest: 1,
|
||||
previousSectionRequest: 0,
|
||||
initialSection: 1,
|
||||
);
|
||||
|
||||
expect(logic.section, 1);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('act returns the choice for a page and forwards it when embedded', (
|
||||
tester,
|
||||
) async {
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
final forwarded = <MyPageAction>[];
|
||||
final popped = Completer<MyPageAction?>();
|
||||
late BuildContext pageContext;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.of(context)
|
||||
.push<MyPageAction>(
|
||||
MaterialPageRoute<MyPageAction>(
|
||||
builder: (inner) {
|
||||
pageContext = inner;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
)
|
||||
.then(popped.complete),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
logic.act(
|
||||
pageContext,
|
||||
MyPageAction.discover,
|
||||
embedded: true,
|
||||
onAction: forwarded.add,
|
||||
);
|
||||
expect(forwarded, [MyPageAction.discover]);
|
||||
expect(Navigator.of(pageContext).canPop(), isTrue);
|
||||
|
||||
logic.act(pageContext, MyPageAction.resume, embedded: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await expectLater(popped.future, completion(MyPageAction.resume));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
testWidgets('the language sheet switches the app locale', (tester) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final locale = LocaleController();
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
late BuildContext context;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<LocaleController>.value(
|
||||
value: locale,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (inner) {
|
||||
context = inner;
|
||||
return const Scaffold(body: SizedBox.shrink());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
unawaited(logic.showLanguage(context));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
expect(find.text(l10n.english), findsOneWidget);
|
||||
expect(find.text(l10n.simplifiedChinese), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text(l10n.simplifiedChinese));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(locale.locale, const Locale('zh'));
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('notify after dispose is a no-op', () {
|
||||
final logic = MineLogic(
|
||||
history: FakeWatchHistory(),
|
||||
drama: _drama('s'),
|
||||
initialSection: 0,
|
||||
);
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.dispose();
|
||||
logic.notify();
|
||||
|
||||
expect(notifications, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/models/player_port.dart';
|
||||
import 'package:nontonki/core/data/services/android_picture_in_picture.dart';
|
||||
import 'package:nontonki/core/data/services/playback_session.dart';
|
||||
import 'package:nontonki/pages/player/player_logic.dart';
|
||||
import 'package:nontonki/pages/player/player_view.dart';
|
||||
|
||||
import '../support/fake_watch_history.dart';
|
||||
|
||||
Drama _drama(
|
||||
String id, {
|
||||
List<int> numbers = const [1, 2, 3],
|
||||
bool isFree = true,
|
||||
}) => Drama(
|
||||
id: id,
|
||||
title: id,
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '/files/poster.png',
|
||||
episodes: [
|
||||
for (final number in numbers)
|
||||
Episode(
|
||||
id: '$id-$number',
|
||||
number: number,
|
||||
title: 'Episode $number',
|
||||
// 与后端一致:只有可播放的剧集才带地址,锁定集为空
|
||||
videoUrl: isFree ? 'https://example.test/clip.mp4' : '',
|
||||
isFree: isFree,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
AndroidPictureInPicture _pip() =>
|
||||
AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip'));
|
||||
|
||||
PlayerLogic _logic(FakeWatchHistory history) =>
|
||||
PlayerLogic(history: history, pictureInPicture: _pip());
|
||||
|
||||
/// Drains the session's serialized work so a test never ends mid-disposal.
|
||||
///
|
||||
/// `start` kicks off selection without awaiting it, so the first read of
|
||||
/// `playbackSettled` can still be the untouched initial future. Pumping the
|
||||
/// event queue between reads closes that race.
|
||||
Future<void> _drain(PlayerLogic logic) async {
|
||||
for (var attempt = 0; attempt < 4; attempt++) {
|
||||
await pumpEventQueue();
|
||||
await logic.session.playbackSettled;
|
||||
await logic.session.preloadSettled;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('displayEpisode', () {
|
||||
test('falls back until a number is picked for that drama', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
final drama = _drama('signal');
|
||||
final fallback = drama.episodes.first;
|
||||
|
||||
expect(logic.displayEpisode(drama, fallback).number, 1);
|
||||
|
||||
logic.pickedEpisode['signal'] = 3;
|
||||
expect(logic.displayEpisode(drama, fallback).number, 3);
|
||||
|
||||
// Picks are scoped to their own drama.
|
||||
final city = _drama('city');
|
||||
expect(logic.displayEpisode(city, city.episodes.first).number, 1);
|
||||
});
|
||||
|
||||
test('an unknown picked number stops overriding the fallback', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
final drama = _drama('signal');
|
||||
final fallback = drama.episodes.first;
|
||||
|
||||
logic.pickedEpisode['signal'] = 99;
|
||||
|
||||
expect(logic.displayEpisode(drama, fallback).number, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('nextVisibleIndex', () {
|
||||
test('walks the visible list and stops at the end', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.visibleIndices = [0, 2, 4];
|
||||
|
||||
expect(logic.nextVisibleIndex(0), 2);
|
||||
expect(logic.nextVisibleIndex(2), 4);
|
||||
expect(logic.nextVisibleIndex(4), isNull);
|
||||
});
|
||||
|
||||
test('an index outside the visible list has no successor', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.visibleIndices = [0, 2, 4];
|
||||
|
||||
expect(logic.nextVisibleIndex(1), isNull);
|
||||
expect(logic.nextVisibleIndex(9), isNull);
|
||||
expect(logic.nextVisibleIndex(-1), isNull);
|
||||
});
|
||||
|
||||
test('an empty visible list has no successor', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.visibleIndices = [];
|
||||
|
||||
expect(logic.nextVisibleIndex(0), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('surface flags', () {
|
||||
test('clearing the screen hides the chrome until it is restored', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
final immersive = <bool>[];
|
||||
logic.onImmersiveChanged = immersive.add;
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
expect(logic.chromeHidden, isFalse);
|
||||
expect(logic.immersive, isFalse);
|
||||
|
||||
logic.setScreenCleared(true);
|
||||
expect(logic.chromeHidden, isTrue);
|
||||
expect(notifications, 1);
|
||||
expect(immersive, [true]);
|
||||
|
||||
// Repeating the same value is not a state change.
|
||||
logic.setScreenCleared(true);
|
||||
expect(notifications, 1);
|
||||
expect(immersive, [true]);
|
||||
|
||||
logic.setScreenCleared(false);
|
||||
expect(logic.chromeHidden, isFalse);
|
||||
expect(notifications, 2);
|
||||
expect(immersive, [true, false]);
|
||||
});
|
||||
|
||||
test('a picture in picture callback outranks the cleared screen flag', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
final immersive = <bool>[];
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.onImmersiveChanged = immersive.add;
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
logic.onPipChanged(true);
|
||||
|
||||
expect(logic.pip, isTrue);
|
||||
expect(logic.pipPreparing, isFalse);
|
||||
expect(logic.immersive, isTrue);
|
||||
expect(logic.chromeHidden, isTrue);
|
||||
expect(immersive.last, isTrue);
|
||||
|
||||
logic.onPipChanged(false);
|
||||
expect(logic.pip, isFalse);
|
||||
expect(logic.immersive, isFalse);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('a picture in picture callback is ignored once unmounted', () {
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.isMounted = () => false;
|
||||
|
||||
logic.onPipChanged(true);
|
||||
|
||||
expect(logic.pip, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('history writes', () {
|
||||
test('progress writes are serialized in call order', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = _logic(history);
|
||||
final drama = _drama('signal');
|
||||
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[0],
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[1],
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
expect(history.writes, [
|
||||
'demo-local-v1|signal|signal-1|1000',
|
||||
'demo-local-v1|signal|signal-2|2000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a failed write does not drop the writes after it', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = _logic(history);
|
||||
final drama = _drama('signal');
|
||||
|
||||
history.failWrites = true;
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[0],
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
|
||||
history.failWrites = false;
|
||||
await logic.queueProgress(
|
||||
drama,
|
||||
drama.episodes[1],
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
expect(history.writes, ['demo-local-v1|signal|signal-2|2000']);
|
||||
});
|
||||
|
||||
test('saving with no tracked player writes nothing', () async {
|
||||
final history = FakeWatchHistory();
|
||||
final logic = _logic(history);
|
||||
|
||||
await logic.saveCurrentProgress();
|
||||
await logic.saveCurrentProgress();
|
||||
|
||||
expect(history.writes, isEmpty);
|
||||
expect(history.reads, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('start', () {
|
||||
test('a detail route plays a single series', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.queue.length, 3);
|
||||
expect(logic.visibleIndices, [0, 1, 2]);
|
||||
expect(logic.session.currentIndex, 0);
|
||||
expect(factory.created, hasLength(2));
|
||||
expect(factory.created.first.plays, 1);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
expect(factory.live, 0);
|
||||
});
|
||||
|
||||
test('home mode plays one episode per feed drama', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final feed = [_drama('signal'), _drama('city'), _drama('vengeance')];
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(
|
||||
drama: feed.first,
|
||||
initialIndex: 0,
|
||||
homeMode: true,
|
||||
feedDramas: feed,
|
||||
),
|
||||
isMounted: () => true,
|
||||
);
|
||||
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.queue.length, 3);
|
||||
expect(logic.queue.entryAt(1).drama.id, 'city');
|
||||
expect(logic.queue.entryAt(1).episode.number, 1);
|
||||
expect(logic.visibleIndices, [0, 1, 2]);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('a locked episode never reaches the player factory', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('locked', isFree: false);
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.session.isLocked, isTrue);
|
||||
expect(factory.created, isEmpty);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('starting from a later index only preloads what follows it', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 2),
|
||||
isMounted: () => true,
|
||||
);
|
||||
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.session.currentIndex, 2);
|
||||
// The last episode has no successor to warm.
|
||||
expect(factory.created, hasLength(1));
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('an inactive route holds no players until it becomes visible', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0, active: false),
|
||||
isMounted: () => true,
|
||||
);
|
||||
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
expect(factory.created, isEmpty);
|
||||
expect(logic.session.loading, isFalse);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
});
|
||||
|
||||
group('lifecycle', () {
|
||||
test('backgrounding pauses the active player and resuming plays it', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
final player = factory.created.first;
|
||||
expect(player.plays, 1);
|
||||
expect(player.pauses, 0);
|
||||
|
||||
logic.onLifecycle(AppLifecycleState.paused);
|
||||
await _drain(logic);
|
||||
expect(player.pauses, 1);
|
||||
|
||||
logic.onLifecycle(AppLifecycleState.resumed);
|
||||
await _drain(logic);
|
||||
expect(player.plays, 2);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('an inactive route stops playing without losing the intent', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
final start = PlayerPage(drama: drama, initialIndex: 0);
|
||||
logic.attach(start, isMounted: () => true);
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
final player = factory.created.first;
|
||||
expect(player.plays, 1);
|
||||
|
||||
logic.onPageUpdated(start);
|
||||
await _drain(logic);
|
||||
expect(player.plays, 1);
|
||||
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0, active: false),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.onPageUpdated(start);
|
||||
await _drain(logic);
|
||||
expect(player.pauses, 1);
|
||||
expect(logic.session.userWantsPlay, isTrue);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
});
|
||||
|
||||
group('dispose', () {
|
||||
test('is safe to repeat and releases every player', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
expect(factory.live, greaterThan(0));
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
|
||||
expect(factory.live, 0);
|
||||
expect(() => logic.dispose(), returnsNormally);
|
||||
});
|
||||
|
||||
test('disables further notifications', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final drama = _drama('signal');
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(drama: drama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
var notifications = 0;
|
||||
logic.addListener(() => notifications++);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
logic.notify();
|
||||
|
||||
expect(notifications, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Counts native-style allocations so leaks are visible to a test.
|
||||
class _RecordingFactory {
|
||||
final List<_RecordingPlayer> created = [];
|
||||
int live = 0;
|
||||
|
||||
PlayerPort create(Episode episode) {
|
||||
live++;
|
||||
final player = _RecordingPlayer(onDispose: () => live--);
|
||||
created.add(player);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
class _RecordingPlayer implements PlayerPort {
|
||||
_RecordingPlayer({required this.onDispose});
|
||||
|
||||
final void Function() onDispose;
|
||||
int plays = 0;
|
||||
int pauses = 0;
|
||||
bool disposed = false;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> play() async => plays++;
|
||||
|
||||
@override
|
||||
Future<void> pause() async => pauses++;
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
onDispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/models/home_feed.dart';
|
||||
import 'package:nontonki/core/data/models/play_ticket.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
|
||||
class FakeCatalogRepository implements CatalogRepository {
|
||||
FakeCatalogRepository({List<Drama>? dramas}) : dramas = dramas ?? demoDramas;
|
||||
|
||||
final List<Drama> dramas;
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchHomeFeed() async => _feed();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => _feed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
return dramas.firstWhere((drama) => drama.id == dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PlayTicket> resolvePlay(String episodeId) async {
|
||||
return PlayTicket(
|
||||
episodeId: episodeId,
|
||||
accessible: true,
|
||||
accessType: PlayTicket.free,
|
||||
playUrl: 'https://example.test/$episodeId.mp4',
|
||||
);
|
||||
}
|
||||
|
||||
HomeFeed _feed() => HomeFeed(
|
||||
sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)],
|
||||
);
|
||||
}
|
||||
|
||||
final List<Drama> demoDramas = [
|
||||
_drama(
|
||||
'the-signal-demo',
|
||||
'The Signal',
|
||||
'Suspense · Mystery',
|
||||
format: 1,
|
||||
),
|
||||
_drama(
|
||||
'city-lights-demo',
|
||||
'City Lights & Velvet Nights',
|
||||
'Urban Romance',
|
||||
format: 8,
|
||||
),
|
||||
_drama(
|
||||
'vengeance-demo',
|
||||
'Vengeance Rising',
|
||||
'Action · Thriller',
|
||||
format: 15,
|
||||
),
|
||||
];
|
||||
|
||||
Drama _drama(
|
||||
String id,
|
||||
String title,
|
||||
String genre, {
|
||||
required int format,
|
||||
}) => Drama(
|
||||
id: id,
|
||||
title: title,
|
||||
synopsis: '$title synopsis',
|
||||
posterUrl: '/files/$id.png',
|
||||
genre: genre,
|
||||
detailLoaded: true,
|
||||
episodes: [
|
||||
for (var index = 0; index < 7; index++)
|
||||
Episode(
|
||||
id: '$id-${index + 1}',
|
||||
number: index + 1,
|
||||
title: 'Episode ${index + 1}',
|
||||
isFree: true,
|
||||
videoUrl: 'https://example.test/$format-$index.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:nontonki/core/data/repositories/watch_history_repository.dart';
|
||||
|
||||
/// Records every history interaction so tests can assert on the key it used.
|
||||
class FakeWatchHistory implements WatchHistoryRepository {
|
||||
final List<String> reads = [];
|
||||
final List<String> writes = [];
|
||||
|
||||
/// Position returned by [positionFor] when [failReads] is false.
|
||||
Duration? saved;
|
||||
|
||||
/// Last episode returned by [lastEpisodeId].
|
||||
String? lastEpisode;
|
||||
|
||||
bool failReads = false;
|
||||
bool failWrites = false;
|
||||
|
||||
@override
|
||||
Future<Duration?> positionFor(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
) async {
|
||||
reads.add('$profileId|$dramaId|$episodeId');
|
||||
if (failReads) throw StateError('history unavailable');
|
||||
return saved;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> lastEpisodeId(String profileId, String dramaId) async {
|
||||
if (failReads) throw StateError('history unavailable');
|
||||
return lastEpisode;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(
|
||||
String profileId,
|
||||
String dramaId,
|
||||
String episodeId,
|
||||
Duration position,
|
||||
) async {
|
||||
if (failWrites) throw StateError('history unavailable');
|
||||
writes.add('$profileId|$dramaId|$episodeId|${position.inMilliseconds}');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user