퀘스트 일시 정지 · 이어하기 · 진행 상태 서버 저장 · 목록 보상받기

This commit is contained in:
Sn-Kinos
2026-10-06 03:20:46 +09:00
parent ecf5998725
commit abe5f1ae50
28 changed files with 1023 additions and 118 deletions
+70
View File
@@ -7,6 +7,7 @@ import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_detail_screen.dart';
import 'package:fancake/features/quest/quest_list_screen.dart';
import 'package:fancake/features/quest/quest_reward_screen.dart';
import 'package:fancake/features/quest/widgets/paused_quest_capsule.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -516,6 +517,50 @@ void main() {
});
});
group('일시 정지 · 이어하기 · 목록 보상받기 (원장 C-83 · 명세 518:88405)', () {
testWidgets('목록 「보상받기」는 상세를 거쳐 바로 수령 흐름(보상 안내 모달)을 연다', (tester) async {
await pumpWide(tester, const QuestListScreen(repository: _sample));
await tester.pump();
await tester.tap(
find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.inProgress.label)),
);
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
await tester.pumpAndSettle();
expect(find.text('+10 기여 POP'), findsOneWidget);
});
testWidgets('일시 정지 Quest — 상세에 멈춘 진행값 · 「이어하기」, 진행중 탭 카드에 「일시 정지」', (tester) async {
await pumpWide(tester, const QuestDetailScreen(questId: 's1', repository: _PausedRepo()));
await tester.pump();
expect(find.text('일시 정지'), findsOneWidget);
expect(find.text('43 / 60'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '이어하기'), findsOneWidget);
await pumpWide(tester, const QuestListScreen(repository: _PausedRepo()));
await tester.pump();
await tester.tap(
find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.inProgress.label)),
);
await tester.pump();
expect(find.text('Music Shake · 일시 정지 · 43/60'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '이어하기'), findsOneWidget);
});
testWidgets('탭 위 캡슐 — 일시 정지 Quest 가 있을 때만 한 줄 · 누르면 그 상세', (tester) async {
await pumpWide(tester, const Scaffold(body: PausedQuestCapsule(repository: _PausedRepo())));
await tester.pump();
expect(find.text('일시 정지 · 응원 흔들기 · 43/60'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('paused-quest-capsule')));
await tester.pumpAndSettle();
expect(find.byType(QuestDetailScreen), findsOneWidget);
await pumpWide(tester, const Scaffold(body: PausedQuestCapsule(repository: _sample)));
await tester.pump();
expect(find.byKey(const ValueKey('paused-quest-capsule')), findsNothing);
});
});
testWidgets('F4 화면 문구에 내부 경로가 새지 않는다', (tester) async {
for (final screen in <Widget>[
const QuestListScreen(repository: _sample),
@@ -700,3 +745,28 @@ class _ReviewQuests extends SampleQuestRepository {
),
);
}
/// 일시 정지한 Music Shake 1건(43/60)이 있는 표본.
class _PausedRepo extends SampleQuestRepository {
const _PausedRepo();
static const _card = QuestCard(
id: 's1', title: '응원 흔들기', categoryName: 'Music Shake', type: QuestType.musicShake,
author: QuestAuthor.admin, stage: QuestStage.paused, goal: 120, progress: 0,
rewardPop: 3, participants: 0,
session: QuestSession(state: QuestStage.paused, progress: 43, total: 60),
);
@override
Future<List<QuestCard>> load({int limit = 10}) async => const [_card];
@override
Future<List<QuestCard>> more({required int offset, int limit = 10}) async => const [];
@override
Future<QuestDetail> detail(String questId) async =>
const QuestDetail(card: _card, imageLabel: '', description: '');
@override
Future<QuestCard?> pausedQuest() async => _card;
}
+47
View File
@@ -124,4 +124,51 @@ void main() {
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<DateTime>();
addTearDown(shakes.close);
late ValueChanged<ShakePlayerEvent> 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<ShakeJudge>(
MaterialPageRoute(
builder: (_) => MusicShakePlayScreen(
setup: setup,
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);
});
}
+120 -1
View File
@@ -1,5 +1,5 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions;
import 'package:cloud_firestore/cloud_firestore.dart' show SetOptions, Timestamp;
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_evidence.dart';
@@ -627,4 +627,123 @@ service cloud.firestore {
expect(live.capacity, 5);
});
});
group('진행 상태 저장 · 일시 정지 · 최신순 (원장 C-83 · 명세 518:88395)', () {
Future<FakeFirebaseFirestore> shakeSeed() async {
final db = await _seed();
await db.doc('quests/shake').set({
'type': 'musicshake', 'title': '응원 흔들기', 'dailyLimit': 1,
'totalSegments': 60, 'bpm': 120,
'link': {'url': 'https://youtu.be/WMweEpGlu_U'},
});
return db;
}
test('목록은 createdAt 내림차순으로 서버 정렬해 커서로 이어 읽는다', () async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_uid').set({'uid': _uid});
await db.doc('config/policy').set({'attendPop': 10});
for (var i = 0; i < 12; i++) {
await db.doc('quests/q$i').set({
'type': 'attend', 'title': 'Q$i', 'dailyLimit': 1,
'createdAt': Timestamp.fromMillisecondsSinceEpoch(1000000 + i * 1000),
});
}
final repo = FirestoreQuestRepository(_RecordingBackend(db));
final first = await repo.load();
final next = await repo.more(offset: first.length);
expect([...first, ...next].map((c) => c.id).toList(),
[for (var i = 11; i >= 0; i--) 'q$i']);
});
test('Music Shake 세션 — 화면 밖 진행중은 일시 정지 · 진행완료는 보상받기 · 진행중 탭이 받는다', () async {
final db = await shakeSeed();
final ref = db.doc('users/$_uid/questProgress/shake');
final repo = FirestoreQuestRepository(_RecordingBackend(db));
await ref.set({'session': {'state': 'inProgress', 'progress': 43, 'total': 60}});
var card = _card(await repo.load(), 'shake');
// 앱이 꺼진 진행중도 진행 화면 밖이므로 일시 정지다.
expect(card.stage, QuestStage.paused);
expect(card.stage.buttonLabel, '이어하기');
expect(QuestTab.inProgress.accepts(card), isTrue);
await ref.set({'session': {
'state': 'completed', 'progress': 60, 'total': 60,
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
}});
card = _card(await repo.load(), 'shake');
expect(card.stage, QuestStage.completed);
expect(QuestTab.inProgress.accepts(card), isTrue);
});
test('목록에서 바로 받는 Music Shake 는 서버 세션의 판정표를 보낸다', () async {
final db = await shakeSeed();
await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'completed', 'progress': 60, 'total': 60,
'judgement': {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30},
}});
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
await repo.verify(_card(await repo.load(), 'shake'));
final call = backend.calls.single;
expect(call.name, 'fnQuestVerifyMusicShake');
expect(call.payload['totalSegments'], 60);
expect(call.payload['judgement'], {'perfect': 50, 'good': 5, 'miss': 5, 'maxCombo': 30});
});
test('Walk 는 일시 정지 없이 오늘 걸음으로 진행중 · 진행완료를 낸다(C-62)', () async {
final db = await _seed();
await db.doc('users/$_uid/questProgress/steps').set({
'session': {'state': 'inProgress', 'progress': 0, 'total': 10000},
});
var steps = 4_000;
final repo = FirestoreQuestRepository(
_RecordingBackend(db),
todaySteps: () async => steps,
);
var card = _card(await repo.load(), 'steps');
expect((card.stage, card.progress), (QuestStage.inProgress, 4_000));
steps = 10_200; // 화면 밖에서 걸었다.
card = _card(await repo.load(), 'steps');
expect((card.stage, card.progress), (QuestStage.completed, 10_200));
});
test('진행 상태는 fnQuestProgress 로만 쓰고 중단은 none 이다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
await repo.advance('shake', QuestStage.paused,
session: const QuestSession(
state: QuestStage.paused, progress: 43, total: 60,
position: Duration(milliseconds: 21500), hits: [0.1, -1],
));
await repo.advance('steps', QuestStage.notStarted);
expect(backend.calls.map((c) => c.name), ['fnQuestProgress', 'fnQuestProgress']);
expect(backend.calls.first.payload, {
'questId': 'shake', 'state': 'paused', 'progress': 43, 'total': 60,
'positionMs': 21500, 'hits': [0.1, -1.0],
});
expect(backend.calls.last.payload, {'questId': 'steps', 'state': 'none'});
});
test('캡슐은 가장 최근에 멈춘 Quest 1건이다(QA 3-34)', () async {
final db = await shakeSeed();
await db.doc('quests/shake2').set({
'type': 'musicshake', 'title': '두 번째', 'totalSegments': 30, 'bpm': 100,
});
await db.doc('users/$_uid/questProgress/shake').set({'session': {
'state': 'paused', 'progress': 43, 'total': 60,
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(2000),
}});
await db.doc('users/$_uid/questProgress/shake2').set({'session': {
'state': 'paused', 'progress': 3, 'total': 30,
'updatedAt': Timestamp.fromMillisecondsSinceEpoch(1000),
}});
final repo = FirestoreQuestRepository(_RecordingBackend(db));
final card = await repo.pausedQuest();
expect(card?.id, 'shake');
expect((card?.session?.progress, card?.session?.total), (43, 60));
});
});
}