이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유

This commit is contained in:
Sn-Kinos
2026-10-05 21:39:22 +09:00
parent 65669b969e
commit 17c658b272
30 changed files with 1337 additions and 144 deletions
+6 -6
View File
@@ -213,12 +213,12 @@ void main() {
});
group('Music Shake 보상 표시 (원장 B-24)', () {
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 stepsPop 을 기본액으로 적는다', () {
expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), 50);
expect(
QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, const {'stepsPop': 50}),
30,
);
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 musicShake.pop 을 기본액으로 적는다', () {
const policy = {'stepsPop': 50, 'musicShake': {'pop': 1}};
// 걸음 보상(stepsPop)을 빌려 쓰지 않는다.
expect(QuestType.musicShake.rewardFrom(const {}, policy), 1);
expect(QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, policy), 30);
expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), isNull);
expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull);
});
});
+127
View File
@@ -0,0 +1,127 @@
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,
);
testWidgets('재생 중 흔들기만 영상 시각으로 판정 · 끝나면 판정표를 돌려준다', (tester) async {
final shakes = StreamController<DateTime>();
addTearDown(shakes.close);
late ValueChanged<ShakePlayerEvent> player;
ShakeJudge? result;
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => TextButton(
onPressed: () async =>
result = await Navigator.of(context).push<ShakeJudge>(
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
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<void> 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<void>.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'));
});
}
+27 -27
View File
@@ -1,4 +1,5 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions;
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_evidence.dart';
@@ -335,52 +336,51 @@ service cloud.firestore {
expect(reward.rewardedPop, 0);
});
test('Music Shake — 박마다 구간을 세고 판정표 · 곡 ID 를 서버에 보낸다', () async {
test('Music Shake — 영상 · BPM · 구간 수를 퀘스트에서 읽고 판정표 · 곡 ID 를 서버에 보낸다', () async {
final db = await _seed();
// 600 BPM = 박 100ms · 3 구간. 박 0 과 박 1 근처에서만 흔든다.
await db.doc('config/policy').set({
'musicShake': {'perfectWindow': 0.1, 'goodWindow': 0.4, 'pop': 3},
}, SetOptions(merge: true));
await db.doc('quests/shake').set({
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
'trackIds': ['track-a'], 'totalSegments': 3, 'bpm': 600,
'totalSegments': 3, 'bpm': 120,
'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'},
});
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(
backend,
shakes: () async* {
final start = DateTime.now();
yield start;
await Future<void>.delayed(const Duration(milliseconds: 100));
yield start.add(const Duration(milliseconds: 140));
},
);
final repo = FirestoreQuestRepository(backend);
final card = _card(await repo.load(), 'shake');
expect(card.type.verifiable, isTrue);
expect(card.goal, 3);
expect(card.progress, 0);
// Figma 「목표BPM」 — 목표 줄은 곡 BPM 이다. 보상은 운영값 musicShake.pop.
expect(card.goal, 120);
expect(card.rewardPop, 3);
final setup = await repo.musicShakeSetup('shake');
expect(setup.videoId, 'WMweEpGlu_U');
expect(setup.title, 'BTS - Butter');
expect((setup.bpm, setup.segments), (120.0, 3));
expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4));
// 판을 끝내기 전에는 서버를 부르지 않는다.
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
expect(backend.calls, isEmpty);
expect(await repo.progressStream(card).toList(), [1, 2, 3]);
repo.recordShake(ShakeJudge(bpm: 120, segments: 3, windows: setup.windows)
..add(Duration.zero));
await repo.verify(card);
final call = backend.calls.single;
expect(call.name, 'fnQuestVerifyMusicShake');
expect(call.payload['questId'], 'shake');
expect(call.payload['trackId'], 'track-a');
expect(call.payload['trackId'], 'WMweEpGlu_U');
expect(call.payload['totalSegments'], 3);
final j = call.payload['judgement'] as Map;
expect(j['perfect']! + j['good']! + j['miss']!, 3);
expect(j['miss'], 1);
expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1});
});
test('ShakeJudge — 박과의 거리로 perfect · good · miss 를 가르고 최장 연속을 센다', () {
final start = DateTime(2026, 10, 5);
final judge = ShakeJudge(bpm: 60, segments: 4, start: start)
..add(start.add(const Duration(milliseconds: 100))) // 박 0 · perfect
..add(start.add(const Duration(milliseconds: 1400))) // 박 1 · good
..add(start.add(const Duration(milliseconds: 3000))); // 박 3 · perfect, 박 2 miss
expect(judge.judgement(), {'perfect': 2, 'good': 1, 'miss': 1, 'maxCombo': 2});
expect(judge.end, start.add(const Duration(milliseconds: 3500)));
test('Music Shake — 영상 · BPM · 구간 수가 없으면 판을 열지 않는다(값을 지어내지 않는다)', () async {
final db = await _seed();
await db.doc('quests/shake').set({'type': 'musicshake', 'title': '흔들기', 'totalSegments': 3});
final repo = FirestoreQuestRepository(_RecordingBackend(db));
await expectLater(repo.musicShakeSetup('shake'), throwsA(isA<BackendException>()));
expect(_card(await repo.load(), 'shake').goal, 0);
});
test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async {
+59
View File
@@ -0,0 +1,59 @@
import 'package:fancake/features/quest/quest_evidence.dart';
import 'package:flutter_test/flutter_test.dart';
Duration ms(int v) => Duration(milliseconds: v);
void main() {
test('Music Shake 실시간 판정 — 영상 시각 기준 · 창이 닫힌 박만 센다 · 콤보', () {
// 60 BPM = 박 1초 · 창 perfect ±0.2 · good ±0.5 박(임시 운영값).
final j = ShakeJudge(bpm: 60, segments: 4)
..add(ms(100)) // 박 0 · perfect
..add(ms(1400)) // 박 1 · good
..add(ms(3050)); // 박 3 · perfect, 박 2 miss
expect(j.live(ms(400)).judged, 0);
final mid = j.live(ms(1600));
expect(
(mid.judged, mid.perfect, mid.good, mid.combo, mid.last),
(2, 1, 1, 2, ShakeHit.good),
);
final after2 = j.live(ms(2600));
expect((after2.miss, after2.combo, after2.last), (1, 0, ShakeHit.miss));
expect(j.end, ms(3500));
expect(j.judgement(), {'perfect': 2, 'good': 1, 'miss': 1, 'maxCombo': 2});
});
test('흔들기는 가장 가까운 박 하나에만 붙고 · 오지 않은 박은 miss 다', () {
final j = ShakeJudge(bpm: 60, segments: 3)
..add(ms(500))
..add(ms(600));
// 0.5 는 박 1 쪽으로 반올림(창 경계 0.5 포함) · 0.6 도 박 1 — 박 1 good 하나.
expect(j.judgement(), {'perfect': 0, 'good': 1, 'miss': 2, 'maxCombo': 1});
});
test('판정 창은 운영값 config/policy.musicShake 가 덮는다', () {
final w = ShakeWindows.from({
'musicShake': {'perfectWindow': 0.1, 'goodWindow': 0.3},
});
expect((w.perfect, w.good), (0.1, 0.3));
final d = ShakeWindows.from(const {});
expect((d.perfect, d.good), (0.2, 0.5));
});
test('YouTube 영상 ID', () {
expect(
youtubeVideoId('https://www.youtube.com/watch?v=WMweEpGlu_U'),
'WMweEpGlu_U',
);
expect(youtubeVideoId('https://youtu.be/WMweEpGlu_U?si=x'), 'WMweEpGlu_U');
expect(
youtubeVideoId('https://music.youtube.com/watch?v=WMweEpGlu_U'),
'WMweEpGlu_U',
);
expect(
youtubeVideoId('https://youtube.com/shorts/WMweEpGlu_U'),
'WMweEpGlu_U',
);
expect(youtubeVideoId('https://vimeo.com/123'), isNull);
expect(youtubeVideoId('https://www.youtube.com/watch?v=short'), isNull);
});
}