From feecc349a09c11c5420c02e1462136e008fb6cb7 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 17:53:45 +0800 Subject: [PATCH] =?UTF-8?q?chore(client):=20=E5=BD=92=E6=A1=A3=E5=8E=9F?= =?UTF-8?q?=E5=9E=8B=E9=98=B6=E6=AE=B5=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E9=85=8D=E7=BD=AE=E3=80=81=E4=B8=BB=E9=A2=98=E3=80=81?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8E=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:上一批归档只覆盖了「已跟踪文件的修改」,原型阶段新增的文件(app_config、theme、 home_feed/play_ticket 模型、catalog_api、测试目录等)还是未入库状态 —— 而已入库的代码会 import 它们, 留在工作区会导致 HEAD 处的代码编译不过。一并归档,让 HEAD 自洽。 纯归档,无行为改动。 --- lib/core/config/app_config.dart | 50 ++ lib/core/data/models/home_feed.dart | 86 ++++ lib/core/data/models/play_ticket.dart | 37 ++ lib/core/data/remote/catalog_api.dart | 36 ++ lib/core/theme/app_colors.dart | 11 + lib/core/widgets/remote_image.dart | 44 ++ test/catalog/drama_model_test.dart | 60 +++ test/config/locale_controller_test.dart | 92 ++++ .../core/android_picture_in_picture_test.dart | 132 +++++ test/discover/cover_carousel_test.dart | 51 ++ test/discover/discover_logic_test.dart | 346 +++++++++++++ .../drama_detail/drama_detail_logic_test.dart | 98 ++++ .../engagement_repository_test.dart | 135 +++++ test/formatting/localized_content_test.dart | 96 ++++ test/integration/live_catalog_test.dart | 76 +++ test/mine/mine_logic_test.dart | 256 ++++++++++ test/player/player_logic_test.dart | 479 ++++++++++++++++++ test/support/fake_catalog.dart | 81 +++ test/support/fake_watch_history.dart | 44 ++ 19 files changed, 2210 insertions(+) create mode 100644 lib/core/config/app_config.dart create mode 100644 lib/core/data/models/home_feed.dart create mode 100644 lib/core/data/models/play_ticket.dart create mode 100644 lib/core/data/remote/catalog_api.dart create mode 100644 lib/core/theme/app_colors.dart create mode 100644 lib/core/widgets/remote_image.dart create mode 100644 test/catalog/drama_model_test.dart create mode 100644 test/config/locale_controller_test.dart create mode 100644 test/core/android_picture_in_picture_test.dart create mode 100644 test/discover/cover_carousel_test.dart create mode 100644 test/discover/discover_logic_test.dart create mode 100644 test/drama_detail/drama_detail_logic_test.dart create mode 100644 test/engagement/engagement_repository_test.dart create mode 100644 test/formatting/localized_content_test.dart create mode 100644 test/integration/live_catalog_test.dart create mode 100644 test/mine/mine_logic_test.dart create mode 100644 test/player/player_logic_test.dart create mode 100644 test/support/fake_catalog.dart create mode 100644 test/support/fake_watch_history.dart diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart new file mode 100644 index 0000000..a989a8b --- /dev/null +++ b/lib/core/config/app_config.dart @@ -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://.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'; + } +} diff --git a/lib/core/data/models/home_feed.dart b/lib/core/data/models/home_feed.dart new file mode 100644 index 0000000..f16f468 --- /dev/null +++ b/lib/core/data/models/home_feed.dart @@ -0,0 +1,86 @@ +import 'drama.dart'; + +/// 首页/发现页聚合数据,一次请求拿到轮播与分区。 +class HomeFeed { + const HomeFeed({this.banners = const [], this.sections = const []}); + + final List banners; + final List sections; + + bool get isEmpty => sections.every((section) => section.dramas.isEmpty); + + /// 分区打平后的短剧列表,供现有 UI 的横滑/竖滑复用;按 id 去重。 + List get dramas { + final seen = {}; + final result = []; + 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 json) { + final banners = (json['banners'] as List? ?? const []) + .whereType>() + .map(FeedBanner.fromJson) + .toList(); + final sections = (json['sections'] as List? ?? const []) + .whereType>() + .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 dramas; + + factory FeedSection.fromJson(Map json) { + return FeedSection( + code: json['code'] as String? ?? '', + title: json['title'] as String? ?? '', + dramas: (json['dramas'] as List? ?? const []) + .whereType>() + .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 json) { + return FeedBanner( + id: (json['id'] ?? '').toString(), + imageUrl: json['imageUrl'] as String? ?? '', + linkType: json['linkType'] as String? ?? 'NONE', + linkValue: json['linkValue'] as String? ?? '', + ); + } +} diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart new file mode 100644 index 0000000..b09aab7 --- /dev/null +++ b/lib/core/data/models/play_ticket.dart @@ -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 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, + ); + } +} diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart new file mode 100644 index 0000000..54352b4 --- /dev/null +++ b/lib/core/data/remote/catalog_api.dart @@ -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() async { + return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed'))); + } + + Future discoverFeed() async { + return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); + } + + Future dramaDetail(String dramaId) async { + return Drama.fromDetailJson( + _asMap(await _client.get('/app/dramas/$dramaId')), + ); + } + + Future playTicket(String episodeId) async { + return PlayTicket.fromJson( + _asMap(await _client.get('/app/episodes/$episodeId/play')), + ); + } + + Map _asMap(Object? data) { + if (data is Map) return data; + throw const ApiException(-1, '返回数据格式异常'); + } +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..56de8ad --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -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); +} diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart new file mode 100644 index 0000000..48e0117 --- /dev/null +++ b/lib/core/widgets/remote_image.dart @@ -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), + ); + } +} diff --git a/test/catalog/drama_model_test.dart b/test/catalog/drama_model_test.dart new file mode 100644 index 0000000..6fb9778 --- /dev/null +++ b/test/catalog/drama_model_test.dart @@ -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'); + }); +} diff --git a/test/config/locale_controller_test.dart b/test/config/locale_controller_test.dart new file mode 100644 index 0000000..7ca480b --- /dev/null +++ b/test/config/locale_controller_test.dart @@ -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 getString(String key, SharedPreferencesOptions options) => + Future.error(StateError('preferences unavailable')); + + @override + Future setString( + String key, + String value, + SharedPreferencesOptions options, + ) => Future.error(StateError('preferences unavailable')); +} diff --git a/test/core/android_picture_in_picture_test.dart b/test/core/android_picture_in_picture_test.dart new file mode 100644 index 0000000..99d28cf --- /dev/null +++ b/test/core/android_picture_in_picture_test.dart @@ -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 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.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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + 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 = []; + final observer = pip()..setStateListener(received.add); + await sendToDart('onPipChanged', true); + + observer.setStateListener(null); + await sendToDart('onPipChanged', true); + + expect(received, [true]); + }); + }); +} diff --git a/test/discover/cover_carousel_test.dart b/test/discover/cover_carousel_test.dart new file mode 100644 index 0000000..28e60e8 --- /dev/null +++ b/test/discover/cover_carousel_test.dart @@ -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(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() {} diff --git a/test/discover/discover_logic_test.dart b/test/discover/discover_logic_test.dart new file mode 100644 index 0000000..671f76c --- /dev/null +++ b/test/discover/discover_logic_test.dart @@ -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 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 = []; + final popped = Completer(); + late BuildContext pageContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context) + .push( + MaterialPageRoute( + 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); + }); +} diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart new file mode 100644 index 0000000..e6e5b53 --- /dev/null +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -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(); + }); +} diff --git a/test/engagement/engagement_repository_test.dart b/test/engagement/engagement_repository_test.dart new file mode 100644 index 0000000..c2701be --- /dev/null +++ b/test/engagement/engagement_repository_test.dart @@ -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 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 [ + '', + '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': [], + }, + }), + ); + + expect(snapshot.savedDramaIds, isEmpty); + expect(snapshot.likedDramaIds, isEmpty); + expect(snapshot.commentsByDrama, { + 'city': ['kept'], + 'town': [], + }); + }); + + 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; + expect(decoded['saved'], ['signal']); + expect(decoded['liked'], ['city']); + expect(decoded['comments'], { + 'signal': ['note'], + }); + }); +} diff --git a/test/formatting/localized_content_test.dart b/test/formatting/localized_content_test.dart new file mode 100644 index 0000000..6c79427 --- /dev/null +++ b/test/formatting/localized_content_test.dart @@ -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 [5, 12, 100]) { + expect(localizedEpisodeTitle(en, number), en.episodeLabel(number)); + expect(localizedEpisodeTitle(en, number), contains('$number')); + } + expect(localizedEpisodeTitle(zh, 12), zh.episodeLabel(12)); + }); +} diff --git a/test/integration/live_catalog_test.dart b/test/integration/live_catalog_test.dart new file mode 100644 index 0000000..a3698de --- /dev/null +++ b/test/integration/live_catalog_test.dart @@ -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 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/')); + }); +} diff --git a/test/mine/mine_logic_test.dart b/test/mine/mine_logic_test.dart new file mode 100644 index 0000000..c94dba8 --- /dev/null +++ b/test/mine/mine_logic_test.dart @@ -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 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 = []; + final popped = Completer(); + late BuildContext pageContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context) + .push( + MaterialPageRoute( + 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.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); + }); +} diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart new file mode 100644 index 0000000..1dedbf4 --- /dev/null +++ b/test/player/player_logic_test.dart @@ -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 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 _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 = []; + 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 = []; + 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 initialize() async {} + + @override + Future play() async => plays++; + + @override + Future pause() async => pauses++; + + @override + Future dispose() async { + if (disposed) return; + disposed = true; + onDispose(); + } +} diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart new file mode 100644 index 0000000..393315f --- /dev/null +++ b/test/support/fake_catalog.dart @@ -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? dramas}) : dramas = dramas ?? demoDramas; + + final List dramas; + + @override + Future fetchHomeFeed() async => _feed(); + + @override + Future fetchDiscoverFeed() async => _feed(); + + @override + Future fetchDrama(String dramaId) async { + return dramas.firstWhere((drama) => drama.id == dramaId); + } + + @override + Future 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 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', + ), + ], +); diff --git a/test/support/fake_watch_history.dart b/test/support/fake_watch_history.dart new file mode 100644 index 0000000..57f6ad4 --- /dev/null +++ b/test/support/fake_watch_history.dart @@ -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 reads = []; + final List 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 positionFor( + String profileId, + String dramaId, + String episodeId, + ) async { + reads.add('$profileId|$dramaId|$episodeId'); + if (failReads) throw StateError('history unavailable'); + return saved; + } + + @override + Future lastEpisodeId(String profileId, String dramaId) async { + if (failReads) throw StateError('history unavailable'); + return lastEpisode; + } + + @override + Future save( + String profileId, + String dramaId, + String episodeId, + Duration position, + ) async { + if (failWrites) throw StateError('history unavailable'); + writes.add('$profileId|$dramaId|$episodeId|${position.inMilliseconds}'); + } +}