이클립스 방어 횟수 즉시 표시·진동 · Music Shake 영상 재생 중 흔들기 판정 · 결과 공유
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user