import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/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('should_resolve_resume_position_of_episode_one_when_constructed', () 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, ['signal']); expect(logic.searchOpen, isFalse); logic.dispose(); }); test('should_reread_resume_position_when_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, ['signal', 'city']); logic.dispose(); }); test('should_resume_from_last_watch_when_list_card_has_no_episodes', () 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, ['signal']); logic.dispose(); }); test('should_have_no_resume_position_when_server_has_no_record', () async { final history = FakeWatchHistory(); final logic = DiscoverLogic( history: history, drama: _drama('signal', numbers: const []), ); expect(await logic.position, isNull); // 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次 expect(history.reads, ['signal']); logic.dispose(); }); test('should_show_search_when_flag_is_on_or_text_is_typed', () { 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('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () { 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('should_adopt_drama_only_when_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, ['vengeance']); // 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('should_open_and_focus_field_when_search_is_requested_and_page_is_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('should_keep_search_field_closed_when_page_is_hidden', ( 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('should_return_choice_and_forward_it_when_act_runs_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('should_show_artwork_notice_when_card_has_no_playable_media', ( 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('should_forward_chosen_drama_when_card_is_embedded', (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('should_do_nothing_when_notifying_after_dispose', () { final logic = DiscoverLogic(history: FakeWatchHistory(), drama: _drama('s')); var notifications = 0; logic.addListener(() => notifications++); logic.dispose(); logic.notify(); expect(notifications, 0); }); }