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

+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}');
}
}