Files
fanuzu/app/test/quest_repository_test.dart
T

770 lines
31 KiB
Dart

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<String, dynamic>? 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<String, dynamic> _reply;
final List<({String name, Map<String, dynamic> payload})> calls = [];
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> 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<FakeFirebaseFirestore> _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<QuestCard> 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<String>());
});
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<BackendException>().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<BackendException>().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<BackendException>()));
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<BackendException>()));
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<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, '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));
});
});
}