import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:share_plus/share_plus.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/features/quest/music_shake_play_screen.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_evidence.dart'; import 'package:fancake/features/quest/quest_reward_screen.dart'; /// Music Shake 진행 화면 — 영상 시각으로 판정하고 Perfect · Good · Miss · Combo 를 띄운다(PRD-04). void main() { const setup = MusicShakeSetup( videoId: 'WMweEpGlu_U', title: 'BTS - Butter', bpm: 60, segments: 3, ); test('첫 박 · 판 길이가 바뀐 뒤의 옛 일시 정지는 이어하지 않는다 (10/6 대표 지적)', () { const fresh = MusicShakeSetup( videoId: 'v', title: 't', bpm: 114, segments: 368, firstBeat: Duration(milliseconds: 24276), ); // 첫 박 도입 전(32박, 첫 박 0)에 저장된 판. const old = QuestSession(state: QuestStage.paused, progress: 19, total: 32); expect(old.fitsShake(fresh), isFalse); const same = QuestSession( state: QuestStage.paused, progress: 19, total: 368, firstBeat: Duration(milliseconds: 24276), ); expect(same.fitsShake(fresh), isTrue); }); test('새 판은 첫 박 leadIn 전부터 틀고, 첫 박이 더 이르면 0 이다 (10/6 B안)', () { const s = MusicShakeSetup( videoId: 'v', title: 't', bpm: 114, segments: 368, firstBeat: Duration(milliseconds: 24276), ); expect(s.startAt, const Duration(milliseconds: 21276)); expect( const MusicShakeSetup( videoId: 'v', title: 't', bpm: 60, segments: 3, firstBeat: Duration(seconds: 2), ).startAt, Duration.zero, ); }); testWidgets('재생 중 흔들기만 영상 시각으로 판정 · 끝나면 판정표를 돌려준다', (tester) async { final shakes = StreamController(); addTearDown(shakes.close); late ValueChanged player; ShakeJudge? result; await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: Builder( builder: (context) => TextButton( onPressed: () async => result = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => MusicShakePlayScreen( setup: setup, countdown: Duration.zero, shakes: shakes.stream, player: (onEvent) { player = onEvent; return const SizedBox(); }, ), ), ), child: const Text('go'), ), ), ), ); await tester.tap(find.text('go')); await tester.pumpAndSettle(); expect(find.text('BTS - Butter'), findsOneWidget); // 재생 전 흔들기는 세지 않는다. shakes.add(DateTime.now()); await tester.pump(); Future at(int ms, {bool shake = false, bool playing = true}) async { player(( playing: playing, position: Duration(milliseconds: ms), ended: false, )); if (shake) shakes.add(DateTime.now()); await tester.pump(const Duration(milliseconds: 60)); } await at(0, shake: true); // 박 0 perfect await at(1000, shake: true); // 박 1 perfect await at(1600); expect(find.text('PERFECT'), findsOneWidget); expect(find.text('2 COMBO'), findsOneWidget); await at(3000); // 마지막 박(2) 창이 닫힘 → 종료 await tester.pumpAndSettle(); expect(result, isNotNull); expect(result!.judgement(), { 'perfect': 2, 'good': 0, 'miss': 1, 'maxCombo': 2, }); }); testWidgets('결과 화면 — 판정 합계와 결과 카드 SNS 공유(이미지 + 문구)', (tester) async { ShareParams? shared; const card = QuestCard( id: 'shake', title: '응원 흔들기', categoryName: 'Music Shake', type: QuestType.musicShake, author: QuestAuthor.admin, stage: QuestStage.completed, goal: 120, progress: 0, rewardPop: 3, participants: 0, ); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: QuestRewardScreen( detail: const QuestDetail( card: card, imageLabel: 'Music Shake', description: '', ), reward: const QuestReward(rewardedPop: 3, balanceAfter: 10), shake: const {'perfect': 5, 'good': 2, 'miss': 1, 'maxCombo': 4}, share: (p) async => shared = p, ), ), ); await tester.pump(); expect(find.text('PERFECT'), findsOneWidget); expect(find.text('MAX COMBO'), findsOneWidget); expect(find.text('5'), findsOneWidget); await tester.runAsync(() async { await tester.tap(find.byType(OutlinedButton)); await Future.delayed(const Duration(milliseconds: 300)); }); await tester.pump(); expect(shared, isNotNull); expect(shared!.text, contains('PERFECT 5 · GOOD 2 · MISS 1 · MAX COMBO 4')); expect(shared!.text, contains('#FANUZU')); }); testWidgets('중간에 나가면 null · 넘긴 판정표에 판정과 영상 시각이 남고, 이어하면 그 판정을 잇는다(C-83)', (tester) async { final shakes = StreamController(); addTearDown(shakes.close); late ValueChanged player; // 이어하기 — 박 0 은 멈추기 전에 perfect 였다. final judge = ShakeJudge(bpm: 60, segments: 3, hits: const [0, -1, -1], position: const Duration(seconds: 1)); Object? result = 'unset'; await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: Builder( builder: (context) => TextButton( onPressed: () async => result = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => MusicShakePlayScreen( setup: setup, countdown: Duration.zero, shakes: shakes.stream, judge: judge, player: (onEvent) { player = onEvent; return const SizedBox(); }, ), ), ), child: const Text('go'), ), ), ), ); await tester.tap(find.text('go')); await tester.pumpAndSettle(); player((playing: true, position: const Duration(milliseconds: 1000), ended: false)); shakes.add(DateTime.now()); await tester.pump(const Duration(milliseconds: 60)); player((playing: true, position: const Duration(milliseconds: 1600), ended: false)); await tester.pump(const Duration(milliseconds: 60)); expect(find.text('2 COMBO'), findsOneWidget); await tester.pageBack(); await tester.pumpAndSettle(); expect(result, isNull); expect(judge.position, const Duration(milliseconds: 1600)); expect(judge.hits.take(2).every((h) => h >= 0), isTrue); expect(judge.live(judge.position).judged, 2); }); test('이어하기면 플레이어가 준비되자마자 저장 위치로 옮겨 튼다 — 처음 시작은 사용자가 누른다 (10/6 A-2)', () { final resume = YoutubeEmbed.html( 'vid', startSeconds: 9, resumeAt: const Duration(milliseconds: 9910), ); expect(resume, contains('player.seekTo(9.91,true);player.playVideo();')); expect(YoutubeEmbed.html('vid'), isNot(contains('playVideo'))); }); testWidgets('재생을 시작하면 3·2·1 을 세는 동안 판정하지 않고, 0 이 되면 판정이 시작된다', (tester) async { final shakes = StreamController(); addTearDown(shakes.close); late ValueChanged player; final judge = ShakeJudge(bpm: 60, segments: 3); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: MusicShakePlayScreen( setup: setup, countdown: const Duration(milliseconds: 500), // 화면은 단말 시계로 센다 shakes: shakes.stream, judge: judge, player: (onEvent) { player = onEvent; return const SizedBox(); }, ), ), ); player((playing: true, position: Duration.zero, ended: false)); await tester.pump(const Duration(milliseconds: 60)); expect(find.text('1'), findsOneWidget); shakes.add(DateTime.now()); // 세는 동안의 흔들기는 세지 않는다. await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 700))); await tester.pump(const Duration(milliseconds: 60)); await tester.pump(const Duration(milliseconds: 60)); expect(find.text('1'), findsNothing); player((playing: true, position: Duration.zero, ended: false)); shakes.add(DateTime.now()); await tester.pump(const Duration(milliseconds: 60)); expect(judge.hits[0], inInclusiveRange(0, 0.01)); await tester.pumpWidget(const SizedBox()); }); test('박은 firstBeat 부터 — 인트로의 흔들기는 박이 아니다', () { final j = ShakeJudge(bpm: 60, segments: 2, firstBeat: const Duration(seconds: 17)) ..add(const Duration(seconds: 1)) ..add(const Duration(seconds: 17)) ..add(const Duration(milliseconds: 18300)); expect(j.end, const Duration(milliseconds: 18500)); expect(j.judgement(), {'perfect': 1, 'good': 1, 'miss': 0, 'maxCombo': 2}); }); }