Music Shake 첫 박 시각 · 카운트다운 · 옛 일시 정지 판 정리

This commit is contained in:
Sn-Kinos
2026-10-06 12:23:47 +09:00
parent 4e8d989f29
commit 14fef64999
24 changed files with 300 additions and 34 deletions
+2 -2
View File
@@ -263,10 +263,10 @@ void main() {
test('일시 정지 세션은 HOME 줄에도 「분류 · 일시 정지 · n/m」로 보인다 — 10/6 A-1', () async {
final db = await _seed();
await db.doc('quests/shake').set({
'active': true, 'title': '박자 맞춰 흔들기', 'type': 'musicShake',
'active': true, 'title': '박자 맞춰 흔들기', 'type': 'musicShake', 'totalSegments': 32,
});
await db.doc('users/$_uid/questProgress/shake').set({
'session': {'state': 'paused', 'progress': 14, 'total': 32},
'session': {'state': 'paused', 'progress': 14, 'total': 32, 'firstBeatMs': 0},
});
final quest = (await FirestoreHomeRepository(_backend(db)).load()).quests.single;
expect(
+76
View File
@@ -19,6 +19,36 @@ void main() {
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<DateTime>();
addTearDown(shakes.close);
@@ -35,6 +65,7 @@ void main() {
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
countdown: Duration.zero,
shakes: shakes.stream,
player: (onEvent) {
player = onEvent;
@@ -141,6 +172,7 @@ void main() {
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
countdown: Duration.zero,
shakes: shakes.stream,
judge: judge,
player: (onEvent) {
@@ -181,4 +213,48 @@ void main() {
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<DateTime>();
addTearDown(shakes.close);
late ValueChanged<ShakePlayerEvent> 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<void>.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});
});
}
+28 -8
View File
@@ -408,7 +408,7 @@ service cloud.firestore {
}, SetOptions(merge: true));
await db.doc('quests/shake').set({
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
'totalSegments': 3, 'bpm': 120,
'totalSegments': 3, 'bpm': 120, 'firstBeatMs': 17000,
'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'},
});
final backend = _RecordingBackend(db);
@@ -423,20 +423,22 @@ service cloud.firestore {
expect(setup.videoId, 'WMweEpGlu_U');
expect(setup.title, 'BTS - Butter');
expect((setup.bpm, setup.segments), (120.0, 3));
expect(setup.firstBeat, const Duration(seconds: 17));
expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4));
// 판을 끝내기 전에는 서버를 부르지 않는다.
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
expect(backend.calls, isEmpty);
repo.recordShake(ShakeJudge(bpm: 120, segments: 3, windows: setup.windows)
..add(Duration.zero));
repo.recordShake(ShakeJudge(bpm: 120, segments: 3, firstBeat: setup.firstBeat, windows: setup.windows)
..add(setup.firstBeat));
await repo.verify(card);
final call = backend.calls.single;
expect(call.name, 'fnQuestVerifyMusicShake');
expect(call.payload['questId'], 'shake');
expect(call.payload['trackId'], 'WMweEpGlu_U');
expect(call.payload['totalSegments'], 3);
expect(call.payload['firstBeatMs'], 17000);
expect(call.payload['judgement'], {'perfect': 1, 'good': 0, 'miss': 2, 'maxCombo': 1});
});
@@ -661,7 +663,7 @@ service cloud.firestore {
final ref = db.doc('users/$_uid/questProgress/shake');
final repo = FirestoreQuestRepository(_RecordingBackend(db));
await ref.set({'session': {'state': 'inProgress', 'progress': 43, 'total': 60}});
await ref.set({'session': {'state': 'inProgress', 'progress': 43, 'total': 60, 'firstBeatMs': 0}});
var card = _card(await repo.load(), 'shake');
// 앱이 꺼진 진행중도 진행 화면 밖이므로 일시 정지다.
expect(card.stage, QuestStage.paused);
@@ -669,7 +671,7 @@ service cloud.firestore {
expect(QuestTab.inProgress.accepts(card), isTrue);
await ref.set({'session': {
'state': 'completed', 'progress': 60, 'total': 60,
'state': 'completed', 'progress': 60, 'total': 60, 'firstBeatMs': 0,
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
}});
card = _card(await repo.load(), 'shake');
@@ -680,7 +682,7 @@ service cloud.firestore {
test('목록에서 바로 받는 Music Shake 는 서버 세션의 판정표를 보낸다', () async {
final db = await shakeSeed();
await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'completed', 'progress': 60, 'total': 60,
'state': 'completed', 'progress': 60, 'total': 60, 'firstBeatMs': 0,
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
}});
final backend = _RecordingBackend(db);
@@ -733,11 +735,11 @@ service cloud.firestore {
'type': 'musicshake', 'title': '두 번째', 'totalSegments': 30, 'bpm': 100,
});
await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'paused', 'progress': 43, 'total': 60,
'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 0,
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(2000),
}});
await db.doc('users/$_uid/questProgress/shake2').set({'session': {
'state': 'paused', 'progress': 3, 'total': 30,
'state': 'paused', 'progress': 3, 'total': 30, 'firstBeatMs': 0,
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(1000),
}});
final repo = FirestoreQuestRepository(_RecordingBackend(db));
@@ -745,5 +747,23 @@ service cloud.firestore {
expect(card?.id, 'shake');
expect((card?.session?.progress, card?.session?.total), (43, 60));
});
test('운영값이 바뀐 옛 Music Shake 판은 일시 정지로 보이지 않고 시작하기다 (10/6)', () async {
final db = await shakeSeed();
await db.doc('quests/shake').update({'firstBeatMs': 24276});
await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 0,
}});
final repo = FirestoreQuestRepository(_RecordingBackend(db));
final card = _card(await repo.load(), 'shake');
expect((card.stage, card.session, card.staleShake), (QuestStage.notStarted, null, true));
expect(await repo.pausedQuest(), isNull);
// 맞는 판은 그대로 일시 정지다.
await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'paused', 'progress': 43, 'total': 60, 'firstBeatMs': 24276,
}});
final ok = _card(await repo.load(), 'shake');
expect((ok.stage, ok.staleShake), (QuestStage.paused, false));
});
});
}