import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; 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'; import 'package:fancake/features/quest/quest_repository_firestore.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// 퀘스트 서버 연동 계층 — 화면 대조 원장 F4 의 데이터 경로. /// /// **무엇을 읽어 무엇으로 옮기는지**와 **어느 함수를 부르는지**를 본다. 규칙이 틀리면 /// 화면은 조용히 0 을 그리거나 엉뚱한 함수를 불러 지급이 일어나므로, 눈으로는 잡히지 /// 않는다. 지급 자체의 판정은 서버 쪽 `firebase/functions/test/quest_e2e.test.ts` 가 본다. const _uid = 'u-quest'; /// Cloud Functions 호출을 기록하는 경계. 실제 함수는 에뮬레이터에서 따로 검증한다. class _RecordingBackend extends Backend { _RecordingBackend(FakeFirebaseFirestore db, {Map? reply}) : _reply = reply ?? const {'txId': 'tx-1', 'balanceAfter': 60, 'rewarded': 10}, super( firestore: db, auth: MockFirebaseAuth( signedIn: true, mockUser: MockUser(uid: _uid, displayName: '테스터'), ), ); final Map _reply; final List<({String name, Map payload})> calls = []; @override Future> call( String name, [ Map payload = const {}, ]) async { calls.add((name: name, payload: payload)); return _reply; } } String _today() => DateTime.now() .toUtc() .add(const Duration(hours: 9)) .toIso8601String() .substring(0, 10); Future _seed() async { final db = FakeFirebaseFirestore(); await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'}); // 걸음 목표는 운영값이다 — 앱에 하드코딩하지 않는다(별첨 B 「운영값 관리 원칙」). await db.doc('config/policy').set({ 'stepsGoal': 10000, 'stepsPop': 50, 'attendPop': 10, }); await db.doc('quests/attend').set({ 'type': 'attend', 'title': '오늘의 출석', 'rewardPop': 50, 'dailyLimit': 1, }); await db.doc('quests/steps').set({ 'type': 'walk', 'title': '만보기', 'rewardPop': 50, 'dailyLimit': 1, }); await db.doc('quests/place').set({ 'type': 'location', 'title': '팝업 방문', 'rewardPop': 100, 'dailyLimit': 1, 'placeId': 'hongdae', }); await db.doc('quests/stream').set({ 'type': 'streaming', 'title': '신곡 완청', 'rewardPop': 10, 'dailyLimit': 1, }); return db; } QuestCard _card(List cards, String id) => cards.firstWhere((c) => c.id == id); void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { // 걸음 기준선은 단말에 남는다. 회차마다 빈 저장소에서 시작해야 값이 겹치지 않는다. SharedPreferences.setMockInitialValues({}); }); group('퀘스트 목록 — Firestore 구현', () { test('UGC 퀘스트의 제출 건 조회가 거부돼도 목록 전체는 뜬다 (2026-10-04 실기기)', () async { // 제출 건 읽기를 막은 규칙 — 배포 전 규칙에서 없는 문서 get 이 permission-denied 였다. final db = FakeFirebaseFirestore(securityRules: ''' service cloud.firestore { match /databases/{database}/documents { match /quests/{id} { allow read, write: if true; } match /config/{id} { allow read, write: if true; } match /users/{document=**} { allow read, write: if true; } } }'''); await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'dailyLimit': 1}); await db.doc('quests/ugc').set({ 'type': 'community', 'title': '내가 만든 퀘스트', 'authorUid': 'someone', 'status': 'published', 'goal': 1, }); final cards = await FirestoreQuestRepository(_RecordingBackend(db)).load(); expect(cards.map((c) => c.id).toSet(), {'attend', 'ugc'}); expect(_card(cards, 'ugc').stage, QuestStage.notStarted); }); test('정의 문서를 카드로 옮기고 유형을 별칭까지 읽는다', () async { final db = await _seed(); final cards = await FirestoreQuestRepository( _RecordingBackend(db), ).load(); expect(cards.map((c) => c.id).toSet(), { 'attend', 'steps', 'place', 'stream', }); expect(_card(cards, 'steps').type, QuestType.steps); expect(_card(cards, 'place').type, QuestType.place); // 출석은 문서의 50 이 아니라 서버가 지급하는 config/policy.attendPop 이다. expect(_card(cards, 'attend').rewardPop, 10); // 걸음 목표는 `config/policy` 에서 온다. 나머지 유형은 일일 한도가 목표다. expect(_card(cards, 'steps').goal, 10000); expect(_card(cards, 'attend').goal, 1); expect(_card(cards, 'attend').stage, QuestStage.notStarted); }); test('당일 한도를 채우면 보상완료로 본다', () async { final db = await _seed(); await db.doc('users/$_uid/questProgress/attend').set({ 'count': 1, 'dayKey': _today(), }); final cards = await FirestoreQuestRepository( _RecordingBackend(db), ).load(); expect(_card(cards, 'attend').stage, QuestStage.rewarded); }); test('어제 진행분은 오늘로 세지 않는다', () async { final db = await _seed(); await db.doc('users/$_uid/questProgress/attend').set({ 'count': 1, 'dayKey': '2026-01-01', }); final cards = await FirestoreQuestRepository( _RecordingBackend(db), ).load(); expect(_card(cards, 'attend').stage, QuestStage.notStarted); }); test('닫힌 퀘스트는 감추고 기간 필드가 없는 문서는 그대로 보여 준다', () async { final db = await _seed(); await db.doc('quests/closed').set({ 'type': 'attend', 'title': '지난 이벤트', 'rewardPop': 10, 'active': false, }); final cards = await FirestoreQuestRepository( _RecordingBackend(db), ).load(); expect(cards.map((c) => c.id), isNot(contains('closed'))); // 시연 데이터에도 Admin 문서에도 없는 필드를 근거로 감추지 않는다. expect(cards.map((c) => c.id), contains('attend')); }); test('비활성 문서가 첫 페이지를 채워도 11번째 이후 퀘스트가 보인다 (2026-10-06 실기기)', () async { final db = FakeFirebaseFirestore(); // 문서 ID 순으로 앞 10건이 비활성(daily-*) — 걸러낸 뒤 개수로 끝을 판정하면 뒤가 사라진다. for (var i = 0; i < 10; i++) { await db.doc('quests/a-daily-$i').set({'type': 'attend', 'title': 'd$i', 'active': false}); } await db.doc('quests/z-music').set({'type': 'music_shake', 'title': '박자 맞춰 흔들기'}); final repo = FirestoreQuestRepository(_RecordingBackend(db)); final first = await repo.load(); expect(first.map((c) => c.id), contains('z-music')); // 10장보다 적으면 끝 — 다음 페이지는 비어 있다. expect(first.length, lessThan(10)); expect(await repo.more(offset: first.length), isEmpty); }); test('보이는 퀘스트가 10건을 넘으면 다음 페이지에서 11번째부터 온다', () async { final db = FakeFirebaseFirestore(); for (var i = 0; i < 12; i++) { await db.doc('quests/q${i.toString().padLeft(2, '0')}').set({'type': 'attend', 'title': 'q$i'}); } final repo = FirestoreQuestRepository(_RecordingBackend(db)); final first = await repo.load(); expect(first, hasLength(10)); final next = await repo.more(offset: first.length); expect(next, hasLength(2)); expect({...first, ...next}.map((c) => c.id).toSet(), hasLength(12)); }); test('내가 만든 퀘스트는 내 퀘스트 상태로 온다', () async { final db = await _seed(); await db.doc('quests/mine').set({ 'type': 'attend', 'title': '내가 만든 것', 'rewardPop': 10, 'authorUid': _uid, }); final cards = await FirestoreQuestRepository( _RecordingBackend(db), ).load(); expect(_card(cards, 'mine').stage, QuestStage.mine); expect(_card(cards, 'mine').author, QuestAuthor.user); }); }); group('퀘스트 참여 — 지급은 전부 Cloud Functions 를 지난다', () { test('출석은 fnQuestAttend 를 부르고 서버 값을 그대로 옮긴다', () async { final db = await _seed(); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository(backend); final cards = await repo.load(); final reward = await repo.verify(_card(cards, 'attend')); expect(backend.calls.single.name, 'fnQuestAttend'); expect(reward.rewardedPop, 10); expect(reward.balanceAfter, 60); }); test('걸음은 하루 기준선 대비 오늘치를 보낸다', () async { final db = await _seed(); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository( backend, // 부팅 이후 누적값이 들어온다. 그대로 보내면 며칠 켜 둔 단말이 공짜로 통과한다. stepCount: () => Stream.fromIterable([40_000, 45_000, 50_400]), ); final cards = await repo.load(); final card = _card(cards, 'steps'); expect(await repo.progressStream(card).toList(), [0, 5_000, 10_400]); await repo.verify(card); expect(backend.calls.single.name, 'fnQuestVerifySteps'); expect(backend.calls.single.payload['steps'], 10_400); // 진행 문서와 일일 한도를 퀘스트 단위로 세도록 문서 ID 를 함께 보낸다. expect(backend.calls.single.payload['questId'], 'steps'); // 집계 축은 소속 팬덤이다 — `activityLogs.fandomId`(설계서 §2.1). expect(backend.calls.single.payload['fandomId'], 'bts'); expect(backend.calls.single.payload['occurredAt'], isA()); }); test('Health 가 있으면 그 날 0시부터의 합계를 센서보다 먼저 쓴다 (B-02)', () async { final db = await _seed(); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository( backend, todaySteps: () async => 7_300, stepCount: () => Stream.fromIterable([40_000]), ); final card = _card(await repo.load(), 'steps'); expect(await repo.progressStream(card).first, 7_300); await repo.verify(card); expect(backend.calls.single.payload['steps'], 7_300); }); test('앱을 껐다 켜도 오늘치가 0 으로 돌아가지 않는다', () async { final db = await _seed(); // 첫 실행 — 기준선 40,000 을 잡고 5,000 걷는다. final first = FirestoreQuestRepository( _RecordingBackend(db), stepCount: () => Stream.fromIterable([40_000, 45_000]), ); final card = _card(await first.load(), 'steps'); expect(await first.progressStream(card).toList(), [0, 5_000]); // 앱을 다시 열었다. 저장소도 기준선도 새로 만들지만 단말에 남은 값을 읽는다. final backend = _RecordingBackend(db); final second = FirestoreQuestRepository( backend, stepCount: () => Stream.fromIterable([46_200]), ); expect(await second.progressStream(card).toList(), [6_200]); await second.verify(card); expect(backend.calls.single.payload['steps'], 6_200); }); test('출석도 퀘스트 문서 ID 를 함께 보낸다', () async { final db = await _seed(); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository(backend); final cards = await repo.load(); await repo.verify(_card(cards, 'attend')); expect(backend.calls.single.payload['questId'], 'attend'); }); test('걸음 수를 아직 세지 못했으면 서버를 부르지 않는다', () async { final db = await _seed(); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository(backend); final cards = await repo.load(); await expectLater( repo.verify(_card(cards, 'steps')), throwsA( isA().having((e) => e.code, 'code', 'E-QST-01'), ), ); expect(backend.calls, isEmpty); }); test('위치는 좌표와 모의 위치 여부를 함께 보낸다', () async { final db = await _seed(); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository( backend, placeFix: () async => const PlaceFix( lat: 37.5563, lng: 126.9236, accuracyM: 12, mocked: true, ), ); final cards = await repo.load(); await repo.verify(_card(cards, 'place')); final call = backend.calls.single; expect(call.name, 'fnQuestVerifyPlace'); expect(call.payload['placeId'], 'hongdae'); expect(call.payload['questId'], 'place'); expect(call.payload['lat'], 37.5563); // 판정은 서버가 한다. 앱은 모의 위치 여부를 감추지도, 스스로 막지도 않는다. expect(call.payload['mocked'], isTrue); }); test('위치 Quest 의 인증 방법을 상세에 싣고 QR 은 GPS 와 함께 보낸다 (C-03 · PRD-05)', () async { final db = await _seed(); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository( backend, placeFix: () async => const PlaceFix(lat: 37.5, lng: 126.9, accuracyM: 5, mocked: false), ); // 수단을 적지 않은 운영 Quest 는 GPS 하나. expect((await repo.detail('place')).verifyMethods, ['gps']); await db.doc('quests/place').update({'verifyMethods': ['gps', 'qr']}); await db.doc('questSecrets/place').set({'authorUid': 'other', 'qrCode': 'secret'}); final detail = await repo.detail('place'); expect(detail.verifyMethods, ['gps', 'qr']); expect(detail.qrCode, isNull, reason: '참여자에게는 QR 값을 주지 않는다'); await repo.verify(detail.card, proof: const PlaceProof(qr: 'scanned')); final call = backend.calls.single; expect(call.payload['qr'], 'scanned'); expect(call.payload['lat'], 37.5); expect(call.payload.containsKey('photo'), isFalse); }); test('장소가 등록되지 않은 위치 퀘스트는 서버를 부르지 않는다', () async { final db = await _seed(); await db.doc('quests/place').set({ 'type': 'location', 'title': '장소 미등록', 'rewardPop': 100, 'dailyLimit': 1, }); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository(backend); final cards = await repo.load(); await expectLater( repo.verify(_card(cards, 'place')), throwsA( isA().having((e) => e.code, 'code', 'E-QST-03'), ), ); expect(backend.calls, isEmpty); }); test('시청 · 스트리밍은 무보상 참여 함수만 부른다(C-04) — 보상은 0', () async { final db = await _seed(); final backend = _RecordingBackend(db, reply: const {'rewarded': 0, 'remaining': 0}); final repo = FirestoreQuestRepository(backend); final card = _card(await repo.load(), 'stream'); expect(card.type.verifiable, isTrue); // 문서에 rewardPop 10 이 남아 있어도 화면에 보상을 약속하지 않는다. expect(card.rewardPop, 0); final reward = await repo.verify(card); expect(backend.calls.single.name, 'fnQuestStreamParticipate'); expect(backend.calls.single.payload, {'questId': 'stream'}); expect(reward.rewardedPop, 0); }); test('Music Shake — 영상 · BPM · 구간 수를 퀘스트에서 읽고 판정표 · 곡 ID 를 서버에 보낸다', () async { final db = await _seed(); 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, 'totalSegments': 3, 'bpm': 120, 'firstBeatMs': 17000, 'link': {'url': 'https://youtu.be/WMweEpGlu_U', 'title': 'BTS - Butter'}, }); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository(backend); final card = _card(await repo.load(), 'shake'); expect(card.type.verifiable, isTrue); // 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.firstBeat, const Duration(seconds: 17)); expect((setup.windows.perfect, setup.windows.good), (0.1, 0.4)); // 판을 끝내기 전에는 서버를 부르지 않는다. await expectLater(repo.verify(card), throwsA(isA())); expect(backend.calls, isEmpty); 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}); }); 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())); expect(_card(await repo.load(), 'shake').goal, 0); }); test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async { final db = await _seed(); final backend = _RecordingBackend(db, reply: const {'questId': 'q-new'}); final repo = FirestoreQuestRepository(backend); final id = await repo.create( QuestDraft( clientSeq: 's1', activityKey: 'vote', title: '투표', rewardPop: 5, participants: 10, endDate: DateTime(2026, 10, 3), url: 'https://x.com/a', manualTitle: '제목', ), ); expect(id, 'q-new'); final call = backend.calls.single; expect(call.name, 'fnQuestCreate'); expect(call.payload, containsPair('endDate', '2026-10-03')); expect(call.payload, containsPair('capacity', 10)); expect(call.payload, containsPair('manualTitle', '제목')); expect( call.payload['utcOffsetMinutes'], DateTime(2026, 10, 3, 23, 59, 59).timeZoneOffset.inMinutes, ); // 앱은 quests 에 직접 쓰지 않는다. expect((await db.collection('quests').get()).docs, hasLength(4)); }); test( '분류 마스터 — 노출 · ugcAllowed · 순서를 따르고 만들 활동이 없는 분류는 감춘다(C-01)', () async { final db = await _seed(); await db.doc('config/policy').update({ 'ugcFeeMin': 20, 'ugcMaxDays': 14, }); await db.doc('config/questCatalog').set({ 'categories': [ {'key': 'location', 'label': '특정 위치', 'order': 2, 'visible': true}, {'key': 'planet', 'label': '행성 활동', 'order': 1, 'visible': true}, {'key': 'health', 'label': '건강', 'order': 3, 'visible': true}, {'key': 'community', 'label': '커뮤니티', 'order': 4, 'visible': false}, ], 'activities': [ { 'key': 'sns', 'categoryKey': 'planet', 'order': 2, 'visible': true, 'ugcAllowed': true, 'inputSchema': [ {'field': 'link', 'required': true}, ], }, { 'key': 'vote', 'categoryKey': 'planet', 'order': 1, 'visible': true, 'ugcAllowed': true, 'inputSchema': [ {'field': 'link', 'required': true}, ], }, { 'key': 'youtube', 'categoryKey': 'planet', 'order': 3, 'visible': true, 'ugcAllowed': false, }, { 'key': 'walk', 'categoryKey': 'health', 'order': 1, 'visible': true, 'ugcAllowed': false, }, { 'key': 'location', 'categoryKey': 'location', 'order': 1, 'visible': true, 'ugcAllowed': true, 'inputSchema': [ {'field': 'place', 'required': true}, ], }, { 'key': 'qr', 'categoryKey': 'location', 'order': 2, 'visible': true, 'ugcAllowed': false, }, { 'key': 'community', 'categoryKey': 'community', 'order': 1, 'visible': true, 'ugcAllowed': true, }, ], }); final catalog = await FirestoreQuestRepository( _RecordingBackend(db), ).catalog(); expect(catalog.categories.map((c) => c.id), ['planet', 'location']); expect(catalog.categories.first.activities.map((a) => a.key), [ 'vote', 'sns', ]); expect(catalog.categories.first.activities.first.fields, {'link'}); expect(catalog.categories.last.activities.single.fields, {'place'}); expect(catalog.feeOf(50), 20); // Q-UQ-02 권고 A안 — 보상 없는 Quest 는 수수료 0. expect(catalog.feeOf(0), 0); expect(catalog.maxDays, 14); }, ); test('제출 · 검토 · 수정은 각 함수로만 간다', () async { final db = await _seed(); final backend = _RecordingBackend(db); final repo = FirestoreQuestRepository(backend); await repo.submit('q1', url: 'https://x.com/p', text: ''); await repo.review( 'q1_u', approve: false, reason: '불명확', resubmitAllowed: true, ); await repo.edit('q1', title: 't', description: 'd'); expect(backend.calls.map((c) => c.name), [ 'fnQuestSubmit', 'fnQuestReview', 'fnQuestEdit', ]); expect(backend.calls.first.payload, { 'questId': 'q1', 'url': 'https://x.com/p', }); expect(backend.calls[1].payload, { 'submissionId': 'q1_u', 'action': 'reject', 'reason': '불명확', 'resubmitAllowed': true, }); }); test('마감이 지난 남의 사용자 생성 Quest 는 목록에서 빠진다(G-04)', () async { final db = await _seed(); await db.doc('quests/ugc-old').set({ 'type': 'vote', 'title': '지난 투표', 'authorUid': 'someone', 'status': 'published', 'endsAt': DateTime.now().subtract(const Duration(minutes: 1)), }); await db.doc('quests/ugc-live').set({ 'type': 'vote', 'title': '진행 투표', 'authorUid': 'someone', 'status': 'published', 'endsAt': DateTime.now().add(const Duration(hours: 3)), 'capacity': 5, }); final cards = await FirestoreQuestRepository( _RecordingBackend(db), ).load(limit: 20); expect(cards.map((c) => c.id), isNot(contains('ugc-old'))); final live = _card(cards, 'ugc-live'); expect(live.type, QuestType.vote); expect(live.author, QuestAuthor.user); expect(live.capacity, 5); }); }); group('진행 상태 저장 · 일시 정지 · 최신순 (원장 C-83 · 명세 518:88395)', () { Future 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, 'firstBeatMs': 0}}); 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, 'firstBeatMs': 0, '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, 'firstBeatMs': 0, '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, 'firstBeatMs': 0, 'updatedAt': Timestamp.fromMillisecondsSinceEpoch(2000), }}); await db.doc('users/$_uid/questProgress/shake2').set({'session': { 'state': 'paused', 'progress': 3, 'total': 30, 'firstBeatMs': 0, '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)); }); 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)); }); }); }