From 82662b9b9faa41f6bade1365a3400aa699c30bb8 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 02:19:40 +0900 Subject: [PATCH] =?UTF-8?q?=ED=80=98=EC=8A=A4=ED=8A=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=8C=90=EC=A0=95=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=BA=A0=ED=8E=98=EC=9D=B8=20=EC=9D=91=EC=9B=90=20=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=A0=EC=9C=A0=20=ED=9B=84=EC=9B=90=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/src/fandom/fandom.ts | 6 +- .../functions/test/campaign_settle.test.ts | 9 +++ .../campaign/campaign_detail_screen.dart | 3 +- app/lib/features/quest/quest_data.dart | 3 + app/lib/features/quest/quest_list_screen.dart | 59 ++++++++++++++----- .../quest/quest_repository_firestore.dart | 21 ++++++- app/test/f4_screens_test.dart | 53 +++++++++++++++++ app/test/quest_repository_test.dart | 28 +++++++++ 8 files changed, 164 insertions(+), 18 deletions(-) diff --git a/app/firebase/functions/src/fandom/fandom.ts b/app/firebase/functions/src/fandom/fandom.ts index fe3fc40..df13147 100644 --- a/app/firebase/functions/src/fandom/fandom.ts +++ b/app/firebase/functions/src/fandom/fandom.ts @@ -1155,6 +1155,8 @@ export async function fundCampaign(input: { const fandomId = campaign.get('fandomId'); let goal = 0; let raised = 0; + // 처음 후원하는가 — 「응원 중」(donorCount)은 고유 후원자 수다. 후원 건수가 아니다(2026-10-06 실기기). + let firstBacker = false; const result = await spendPop({ uid, @@ -1178,6 +1180,8 @@ export async function fundCampaign(input: { goal = (snap.get('targetPop') as number | undefined) ?? (snap.get('goalPop') as number | undefined) ?? 0; raised = (snap.get('raisedPop') as number | undefined) ?? 0; + // 후원자 표식(backers/{uid})이 없으면 첫 후원이다. 읽기는 쓰기보다 먼저 — 같은 트랜잭션. + firstBacker = !(await tx.get(campaignRef.collection('backers').doc(uid))).exists; // 잔여분으로 깎아 받지 않고 거부한다(B-34 「잔여 목표액까지만 후원 가능」). if (goal > 0 && raised + amount > goal) { fail(ERR.campaignOverGoal, '남은 모금 목표보다 큰 금액입니다.'); @@ -1204,7 +1208,7 @@ export async function fundCampaign(input: { }, { merge: true }); tx.set(campaignRef, { raisedPop: FieldValue.increment(amount), - donorCount: FieldValue.increment(1), + ...(firstBacker ? { donorCount: FieldValue.increment(1) } : {}), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); }, diff --git a/app/firebase/functions/test/campaign_settle.test.ts b/app/firebase/functions/test/campaign_settle.test.ts index 33de469..4f2cf81 100644 --- a/app/firebase/functions/test/campaign_settle.test.ts +++ b/app/firebase/functions/test/campaign_settle.test.ts @@ -315,6 +315,15 @@ describe('정산 분기', () => { expect(short.get('refundDueAt').toMillis() - Date.now()).toBeGreaterThan(71 * 3600_000); }); + it('「응원 중」(donorCount)은 고유 후원자 수다 — 같은 사람의 두 번째 후원은 늘리지 않는다', async () => { + await fundedCampaign('cmp-donors', 1_000, [[A, 100], [B, 50]]); + await db().doc('campaigns/cmp-donors').update({ endsAt: Timestamp.fromMillis(Date.now() + 60_000) }); + await fundCampaign({ uid: A, campaignId: 'cmp-donors', clientSeq: '2', amount: 30 }); + const snap = await db().doc('campaigns/cmp-donors').get(); + expect(snap.get('donorCount')).toBe(2); + expect(snap.get('raisedPop')).toBe(180); + }); + it('마감이 지난 캠페인은 정산 전이라도 후원을 받지 않는다', async () => { await fundedCampaign('cmp-late', 1_000, [[A, 100]]); expect(await code(() => fundCampaign({ uid: A, campaignId: 'cmp-late', clientSeq: '2', amount: 50 }))) diff --git a/app/lib/features/campaign/campaign_detail_screen.dart b/app/lib/features/campaign/campaign_detail_screen.dart index a8b5a64..d1597f2 100644 --- a/app/lib/features/campaign/campaign_detail_screen.dart +++ b/app/lib/features/campaign/campaign_detail_screen.dart @@ -618,7 +618,8 @@ class _CampaignDetailScreenState extends State { Row( children: [ _stat( - formatPop(card.supporters + (_spent > 0 ? 1 : 0)), + // 서버 donorCount(고유 후원자 수)를 그대로 쓴다. 후원 뒤 상세를 다시 읽으므로 화면이 더하지 않는다. + formatPop(card.supporters), context.l10n.campaignSupporting, ), const SizedBox(width: 6), diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index 28b4072..900cd42 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -561,6 +561,9 @@ class QuestDraft { abstract interface class QuestRepository { /// 명세 — *퀘스트 생성 최신순 소팅(최초 10개 노출, 스크롤 시 10개씩 로드)*. + /// + /// [limit] 장보다 적게 돌려주면 더 읽을 문서가 없다는 뜻이다 — 화면은 이것으로 끝을 판정한다. + /// 구현은 걸러져 모자란 만큼 다음 페이지를 이어 읽어야 한다. Future> load({int limit = 10}); Future> more({required int offset, int limit = 10}); diff --git a/app/lib/features/quest/quest_list_screen.dart b/app/lib/features/quest/quest_list_screen.dart index 3df65f2..abd7b8c 100644 --- a/app/lib/features/quest/quest_list_screen.dart +++ b/app/lib/features/quest/quest_list_screen.dart @@ -85,11 +85,20 @@ class _QuestListScreenState extends State { setState(() { _cards = cards; _loading = false; + // 저장소는 걸러져 모자란 만큼 이어 읽고, 10장보다 적으면 문서가 끝난 것이다(QuestRepository.load). _end = cards.length < 10; if (pop != null) _pop = pop; }); + _fill(); } + /// 보이는 카드가 화면을 못 채우면(탭으로 걸러져 비었을 때 포함) 스크롤 없이도 다음 페이지를 읽는다. + /// 스크롤 리스너만 두면 걸러진 탭에서 다음 페이지로 갈 방법이 없다. + void _fill() => WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _end || _more || _loading) return; + if (!_scroll.hasClients || _scroll.position.maxScrollExtent <= 240) _loadMore(); + }); + Future _loadMore() async { if (_more || _end) return; setState(() => _more = true); @@ -98,8 +107,9 @@ class _QuestListScreenState extends State { setState(() { _cards = [..._cards, ...next]; _more = false; - _end = next.isEmpty; + _end = next.length < 10; }); + _fill(); } List get _visible { @@ -295,25 +305,44 @@ class _QuestListScreenState extends State { preferredSize: const Size.fromHeight(44), child: QuestTabStrip( selected: _tab, - onSelect: (tab) => setState(() => _tab = tab), + // 탭을 바꿀 때마다 다시 읽는다 — 「내 퀘스트」 검토 중 → 반려됨이 재진입 없이 바뀌게(2026-10-06 실기기). + onSelect: (tab) { + setState(() => _tab = tab); + _load(); + }, ), ), ), body: _loading ? const Center(child: CircularProgressIndicator()) - : _visible.isEmpty - ? Center( - child: Text( - context.l10n.questTabEmpty, - style: Theme.of(context).textTheme.bodyMedium, - ), - ) - : ListView( - controller: _scroll, - // 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다. - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16), - children: _body(), - ), + // 당겨서 새로고침 — 상태(검토 중 · 반려됨)와 헤더 보유 POP 을 다시 읽는다. + : RefreshIndicator( + onRefresh: _load, + child: _visible.isEmpty + ? LayoutBuilder( + builder: (context, box) => ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: box.maxHeight, + child: Center( + child: Text( + context.l10n.questTabEmpty, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ], + ), + ) + : ListView( + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + // 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다. + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16), + children: _body(), + ), + ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: Material( color: const Color(0xFF19191C), diff --git a/app/lib/features/quest/quest_repository_firestore.dart b/app/lib/features/quest/quest_repository_firestore.dart index dfa99d7..d128889 100644 --- a/app/lib/features/quest/quest_repository_firestore.dart +++ b/app/lib/features/quest/quest_repository_firestore.dart @@ -60,6 +60,7 @@ class FirestoreQuestRepository implements QuestRepository { @override Future> load({int limit = 10}) async { _cursor = null; + _exhausted = false; return _page(limit); } @@ -67,19 +68,37 @@ class FirestoreQuestRepository implements QuestRepository { Future> more({required int offset, int limit = 10}) => _page(limit); + /// 원본 문서를 끝까지 읽었는가. 끝은 **걸러내기 전 문서 수**로 판정한다 — 비활성 · + /// 마감 문서가 한 페이지를 채워도 뒤 문서가 사라지지 않게(2026-10-06 실기기: 11번째 이후 누락). + bool _exhausted = false; + + /// 보이는 카드가 [limit] 장 모일 때까지(또는 문서가 끝날 때까지) 페이지를 이어 읽는다. + /// 그래서 [limit] 장보다 적게 돌려주면 문서가 끝났다는 뜻이다(`QuestRepository` 계약). Future> _page(int limit) async { + final cards = []; + while (cards.length < limit && !_exhausted) { + cards.addAll(await _batch(limit)); + } + return cards; + } + + Future> _batch(int limit) async { final uid = _backend.requireUid; final db = _backend.db; // 정렬하지 않는다. 설계서 §2.8 의 `quests` 에 정렬 키가 없고, Admin 이 쓰는 // 문서에는 `createdAt` 이 없다(`fn-admin-write` 는 `updatedAt` 만 찍는다). // `orderBy('createdAt')` 로 두면 그 문서들이 목록에서 조용히 사라진다. - var query = db.collection('quests').limit(limit); + // 커서는 기본 순서(문서 ID)를 따른다. 커서를 limit 보다 먼저 건다 — 서버는 순서를 가리지 않지만 + // 테스트용 fake 는 적은 순서대로 적용한다. + Query> query = db.collection('quests'); final cursor = _cursor; if (cursor != null) query = query.startAfterDocument(cursor); + query = query.limit(limit); final snap = await query.get(); if (snap.docs.isNotEmpty) _cursor = snap.docs.last; + if (snap.docs.length < limit) _exhausted = true; // 타임어택 창은 서버 시각이다. 회차가 붙은 Quest 가 있을 때만 서버 시계를 잰다(세션당 1회). if (snap.docs.any((d) => d.data()['timeAttack'] is Map)) { diff --git a/app/test/f4_screens_test.dart b/app/test/f4_screens_test.dart index f70b6db..a88243b 100644 --- a/app/test/f4_screens_test.dart +++ b/app/test/f4_screens_test.dart @@ -132,6 +132,31 @@ void main() { expect(find.text('12,290'), findsNothing); }); + testWidgets('첫 10장이 모두 걸러져도 11번째 이후 퀘스트가 보인다 (2026-10-06 실기기)', (tester) async { + await pumpWide(tester, QuestListScreen(repository: _PagedRepo())); + await tester.pumpAndSettle(); + expect(find.text('열한 번째 퀘스트'), findsOneWidget); + }); + + testWidgets('탭 전환 · 당겨서 새로고침이 목록을 다시 읽는다 (내 퀘스트 상태 · 2026-10-06 실기기)', (tester) async { + final repo = _PagedRepo(); + await pumpWide(tester, QuestListScreen(repository: repo)); + await tester.pumpAndSettle(); + final before = repo.loads; + await tester.tap( + find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.mine.label)), + ); + await tester.pumpAndSettle(); + expect(repo.loads, before + 1); + await tester.tap( + find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.all.label)), + ); + await tester.pumpAndSettle(); + await tester.fling(find.text('열한 번째 퀘스트'), const Offset(0, 400), 1000); + await tester.pumpAndSettle(); + expect(repo.loads, before + 3); + }); + testWidgets('목록 조회가 실패해도 로딩이 끝난다', (tester) async { await pumpWide(tester, QuestListScreen(repository: _FailingList())); await tester.pumpAndSettle(); @@ -513,6 +538,34 @@ class _BalanceRepo extends SampleQuestRepository { Future popBalance() async => 12260; } +/// 첫 페이지 10장이 모두 감춰지는(보상완료 관리자) 표본 — 11번째가 다음 페이지에 있다. +class _PagedRepo extends SampleQuestRepository { + var loads = 0; + + static QuestCard _hidden(int i) => QuestCard( + id: 'h$i', title: '숨김 $i', categoryName: '출석', type: QuestType.attend, + author: QuestAuthor.admin, stage: QuestStage.rewarded, goal: 1, progress: 1, + rewardPop: 10, participants: 0, + ); + + @override + Future> load({int limit = 10}) async { + loads += 1; + return [for (var i = 0; i < 10; i++) _hidden(i)]; + } + + @override + Future> more({required int offset, int limit = 10}) async => offset >= 11 + ? const [] + : const [ + QuestCard( + id: 'u11', title: '열한 번째 퀘스트', categoryName: '건강', type: QuestType.steps, + author: QuestAuthor.user, stage: QuestStage.notStarted, goal: 1, progress: 0, + rewardPop: 10, participants: 0, + ), + ]; +} + /// 목록이 실패하는 표본 — 로딩이 끝나지 않으면 탭 전체가 무한 로딩이다. class _FailingList extends SampleQuestRepository { @override diff --git a/app/test/quest_repository_test.dart b/app/test/quest_repository_test.dart index 26bdad2..e7b7600 100644 --- a/app/test/quest_repository_test.dart +++ b/app/test/quest_repository_test.dart @@ -179,6 +179,34 @@ service cloud.firestore { 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({