chore(client): 归档原型阶段新增文件(配置、主题、模型与测试)

为什么:上一批归档只覆盖了「已跟踪文件的修改」,原型阶段新增的文件(app_config、theme、
home_feed/play_ticket 模型、catalog_api、测试目录等)还是未入库状态 —— 而已入库的代码会 import 它们,
留在工作区会导致 HEAD 处的代码编译不过。一并归档,让 HEAD 自洽。

纯归档,无行为改动。
This commit is contained in:
Vicky committed 2026-10-15 17:53:45 +08:00
1 parent e1b98df509
commit feecc349a0
19 files changed
+2210

No files matched your search

+50
View File
@@ -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';
}
}
+86
View File
@@ -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? ?? '',
);
}
}
+37
View File
@@ -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,
);
}
}
+36
View File
@@ -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, '返回数据格式异常');
}
}
+11
View File
@@ -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);
}
+44
View File
@@ -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),
);
}
}
+60
View File
@@ -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');
});
}
+92
View File
@@ -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]);
});
});
}
+51
View File
@@ -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() {}
+346
View File
@@ -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));
});
}
+76
View File
@@ -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/'));
});
}
+256
View File
@@ -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);
});
}
+479
View File
@@ -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();
}
}
+81
View File
@@ -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',
),
],
);
+44
View File
@@ -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}');
}
}