import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/monetization/rewarded_ad.dart'; import 'package:fancake/features/quest/place_picker_screen.dart'; import 'package:fancake/features/quest/quest_ad_screens.dart'; import 'package:fancake/features/quest/quest_create_screen.dart'; 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'; /// F4 퀘스트 — 화면 대조 원장 10화면. /// /// Figma 명세가 상태별 버튼 문구와 동작을 못박아 두었으므로 **문구까지 검증**한다. /// 문구가 바뀌면 화면 대조에서 어긋난다. /// 화면 검증은 표본 저장소로 한다. 저장소 선택은 `core/data/repositories.dart` 의 /// 몫이고, 화면은 주입받은 것을 그린다. const _sample = SampleQuestRepository(); Future pumpWide(WidgetTester tester, Widget child) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp(home: child)); await tester.pump(); } void main() { // 표본 저장소는 목 모드에서 「보상완료」를 기억한다(서버의 questProgress 자리). // 회차마다 지워 두지 않으면 앞 테스트가 받은 보상이 다음 테스트에 남는다. setUp(SampleQuestRepository.resetClaims); group('보상형 광고 (B-03 · 별첨 1 ⑥)', () { Future watchAd(WidgetTester tester, QuestRepository repo) async { await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '보상받기')); await tester.pumpAndSettle(); await tester.tap(find.text('광고 보고 추가 보상 받기')); await tester.pumpAndSettle(); } test('안내값 — 운영 Quest 는 기본 × (배수 − 1), 사용자 생성은 고정값 (B-03 · C-02)', () { const card = QuestCard( id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin, stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 30, participants: 0, ); expect(card.adBonusPop, 30); expect( QuestCard( id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin, stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 30, participants: 0, adMultiplier: 3, ).adBonusPop, 60, ); expect( const QuestCard( id: 'u', title: 't', categoryName: 'c', author: QuestAuthor.user, stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 500, participants: 0, ).adBonusPop, 10, ); }); testWidgets('끝까지 본 광고만 서버에 청구하고 지급액을 띄운다', (tester) async { final repo = _AdRepo(); await watchAd(tester, repo); expect(repo.claimed, ['imp-1']); expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget); }); testWidgets('불러오지 못하면 기본 보상만 받는다', (tester) async { final repo = _AdRepo( loadError: const BackendException('E-AD-01', '광고를 불러오지 못했어요.'), ); await watchAd(tester, repo); expect(repo.claimed, isEmpty); expect(find.text('+10 받았어요!'), findsOneWidget); expect(find.textContaining('광고 추가 보상'), findsNothing); }); testWidgets('중간에 닫으면 청구하지 않는다', (tester) async { final repo = _AdRepo(impression: null); await watchAd(tester, repo); expect(repo.claimed, isEmpty); expect(find.text('+10 받았어요!'), findsOneWidget); expect(find.textContaining('광고 추가 보상'), findsNothing); }); testWidgets('참여 조건 판정이 실패하면 광고 모달을 띄우지 않는다 (실기기 4-3)', (tester) async { final repo = _AdRepo( verifyError: const BackendException('E-QST-03', '기기의 위치 기능이 꺼져 있습니다.'), ); await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '보상받기')); await tester.pumpAndSettle(); expect(find.text('광고 보고 추가 보상 받기'), findsNothing); expect(find.text('기기의 위치 기능이 꺼져 있습니다.'), findsOneWidget); expect(repo.claimed, isEmpty); }); testWidgets('판정을 통과한 뒤에야 광고 모달을 띄운다', (tester) async { final repo = _AdRepo(); await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '보상받기')); await tester.pumpAndSettle(); expect(repo.verified, 1); expect(find.text('광고 보고 추가 보상 받기'), findsOneWidget); }); testWidgets('일일 한도(E-AD-02)를 알리고 기본 보상은 그대로 띄운다', (tester) async { final repo = _AdRepo( claimError: const BackendException('E-AD-02', '오늘 광고 보상 한도를 모두 사용했습니다.'), ); await watchAd(tester, repo); expect(find.text('오늘 광고 보상 한도를 모두 사용했습니다.'), findsOneWidget); expect(find.text('+10 받았어요!'), findsOneWidget); expect(find.textContaining('광고 추가 보상'), findsNothing); }); }); group('목록 헤더 · 실패 (2026-10-04 실기기)', () { testWidgets('헤더 보유 POP 은 목록을 읽을 때 지갑 값으로 다시 맞춘다', (tester) async { await pumpWide(tester, QuestListScreen(repository: _BalanceRepo(), myPop: 12290)); await tester.pumpAndSettle(); expect(find.text('12,260'), findsOneWidget); 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(); expect(find.byType(CircularProgressIndicator), findsNothing); }); }); group('진행 상태 (F4 · 518:88441)', () { test('명세가 정한 다섯 상태의 버튼 문구를 그대로 쓴다', () { expect(QuestStage.mine.buttonLabel, '자세히'); expect(QuestStage.notStarted.buttonLabel, '퀘스트 시작하기'); expect(QuestStage.inProgress.buttonLabel, '진행중'); expect(QuestStage.completed.buttonLabel, '보상받기'); expect(QuestStage.rewarded.buttonLabel, '보상 완료'); }); test('보상완료만 누를 수 없다', () { for (final stage in QuestStage.values) { expect(stage.tappable, stage != QuestStage.rewarded); } }); }); group('탭 필터 (F4 · 518:88377)', () { const card = QuestCard( id: 'x', title: 't', categoryName: 'c', author: QuestAuthor.admin, stage: QuestStage.inProgress, goal: 10, progress: 5, rewardPop: 10, participants: 1, ); test('전체는 모두 받는다', () { for (final stage in QuestStage.values) { expect(QuestTab.all.accepts(_stage(card, stage)), isTrue); } }); // 명세 `518:88405` — 진행완료는 진행중 탭, 보상완료만 완료 탭. test('진행완료는 진행중 탭, 보상완료만 완료 탭이 받는다', () { expect(QuestTab.inProgress.accepts(_stage(card, QuestStage.completed)), isTrue); expect(QuestTab.inProgress.accepts(_stage(card, QuestStage.inProgress)), isTrue); expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isFalse); expect(QuestTab.done.accepts(_stage(card, QuestStage.rewarded)), isTrue); expect(QuestTab.done.accepts(_stage(card, QuestStage.inProgress)), isFalse); }); test('내 퀘스트 탭은 내가 만든 것만 받는다', () { expect(QuestTab.mine.accepts(_stage(card, QuestStage.mine)), isTrue); expect(QuestTab.mine.accepts(_stage(card, QuestStage.notStarted)), isFalse); }); }); group('목록 화면 (F4 · 259:46109 · 264:46867)', () { testWidgets('네 탭이 모두 보이고 디폴트는 전체다', (tester) async { await pumpWide(tester, const QuestListScreen(repository: _sample)); await tester.pump(); for (final tab in QuestTab.values) { // 「진행중」은 탭 이름이자 카드 버튼 문구다. 탭만 본다. expect( find.descendant(of: find.byType(QuestTabStrip), matching: find.text(tab.label)), findsOneWidget, ); } // 시안(264:46867)대로 칩이 아니라 밑줄 탭이다. 선택 상태는 스트립이 들고 있다. final strip = tester.widget(find.byType(QuestTabStrip)); expect(strip.selected, QuestTab.all); final chip = ( selected: strip.selected == QuestTab.all, ); expect(chip.selected, isTrue); }); testWidgets('완료된 관리자 퀘스트는 목록에서 감춘다', (tester) async { await pumpWide(tester, const QuestListScreen(repository: _sample)); await tester.pump(); // 표본의 「오늘의 출석」은 관리자 퀘스트이고 보상완료 상태다. expect(find.text('오늘의 출석'), findsNothing); expect(find.text('만보기 챌린지'), findsOneWidget); }); 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.mine.label)), ); await tester.pump(); expect(find.text('데뷔일 챌린지 함께해요'), findsOneWidget); expect(find.text('만보기 챌린지'), findsNothing); }); testWidgets('헤더에 보유 POP 이 뜬다', (tester) async { await pumpWide(tester, const QuestListScreen(repository: _sample, myPop: 12540)); await tester.pump(); expect(find.text('12,540'), findsOneWidget); }); testWidgets('새 퀘스트 버튼이 생성 화면을 연다', (tester) async { await pumpWide(tester, const QuestListScreen(repository: _sample)); await tester.pump(); await tester.tap(find.text('새 퀘스트')); await tester.pumpAndSettle(); expect(find.byType(QuestCreateScreen), findsOneWidget); }); }); group('상세 화면 (F4 · 264:48835)', () { testWidgets('목표·진행·보상을 함께 보여 준다', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'q1', repository: _sample)); await tester.pump(); expect(find.text('만보기 챌린지'), findsOneWidget); expect(find.text('6,400 / 10,000'), findsOneWidget); expect(find.text('+50'), findsOneWidget); }); testWidgets('진행중에서 누르면 중단을 되묻고 취소하면 그대로다', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'q1', repository: _sample)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '진행중')); await tester.pumpAndSettle(); expect(find.text('퀘스트를 중단할까요?'), findsOneWidget); await tester.tap(find.text('취소')); await tester.pumpAndSettle(); expect(find.widgetWithText(FilledButton, '진행중'), findsOneWidget); }); testWidgets('중단하면 시작 전으로 돌아간다', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'q1', repository: _sample)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '진행중')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '중단')); await tester.pumpAndSettle(); expect(find.widgetWithText(FilledButton, '퀘스트 시작하기'), findsOneWidget); }); testWidgets('관리자 퀘스트 보상받기는 광고 추가 보상을 먼저 묻는다', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'q2', repository: _sample)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '보상받기')); await tester.pumpAndSettle(); // 별첨 B B-28 — 배수형 철회. 추가분은 고정형 운영값(C-02 초기 10 기여 POP). // 원장 C-116 — 「+N 기여 POP」 표기. expect(find.text('+10 기여 POP'), findsOneWidget); expect(find.textContaining('배'), findsNothing); // 별첨1 ⑥ No-Force — 보지 않아도 기본 보상을 받는다. expect(find.text('기본 보상 받기'), findsOneWidget); }); testWidgets('광고를 건너뛰면 기본 보상만 받는다', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'q2', repository: _sample)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '보상받기')); await tester.pumpAndSettle(); await tester.tap(find.text('기본 보상 받기')); await tester.pumpAndSettle(); expect(find.byType(QuestRewardScreen), findsOneWidget); expect(find.text('+10 받았어요!'), findsOneWidget); }); testWidgets('광고를 봐도 화면에 뜨는 금액은 서버가 지급한 값이다', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'q2', repository: _sample)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '보상받기')); await tester.pumpAndSettle(); await tester.tap(find.text('광고 보고 추가 보상 받기')); await tester.pumpAndSettle(); // 광고 시청 전 대기(494:14332) — 광고를 불러오는 동안 띄운다(표본은 2초). expect(find.byType(QuestAdWarpScreen), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.pumpAndSettle(); // 시청 후 대기(482:15084)는 지급이 끝나면 걷힌다. expect(find.byType(QuestAdWarpScreen), findsNothing); expect(find.byType(QuestRewardScreen), findsOneWidget); // 기본 보상과 광고 추가 보상은 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다 // (`fnAdReward.rewarded`). 운영 Quest 는 명세 `518:88685` 대로 둘의 합계(× 배수)를 크게 보인다. expect(find.text('+20 받았어요!'), findsOneWidget); expect(find.text('광고 추가 보상 +10 기여 POP'), findsOneWidget); }); testWidgets('스트리밍은 무보상 참여로 열려 있고 보상 · 광고 화면 없이 참여만 알린다(C-04)', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'q6', repository: _sample)); await tester.pump(); expect(find.text('이 퀘스트는 아직 참여할 수 없습니다.'), findsNothing); await tester.tap(find.widgetWithText(FilledButton, '퀘스트 시작하기')); await tester.pumpAndSettle(); expect(find.text('참여가 기록되었어요. 이 퀘스트는 POP 보상이 없어요.'), findsOneWidget); expect(find.text('기본 보상 받기'), findsNothing); // 내부 질의 번호 · 「발주사」 · MOCK 은 화면에 내보내지 않는다. expect(find.textContaining('Q-QC-'), findsNothing); expect(find.textContaining('발주사'), findsNothing); expect(find.textContaining('MOCK'), findsNothing); }); testWidgets('보상을 받고 나오면 보상완료가 되고 더 못 누른다', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'q2', repository: _sample)); await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, '보상받기')); await tester.pumpAndSettle(); await tester.tap(find.text('기본 보상 받기')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '완료')); await tester.pumpAndSettle(); final button = tester.widget( find.widgetWithText(FilledButton, '보상 완료'), ); expect(button.onPressed, isNull); }); }); group('생성 화면 (F4 · 254:164081)', () { testWidgets('완료 버튼은 값이 다 차기 전까지 비활성이다', (tester) async { await pumpWide(tester, const QuestCreateScreen(repository: _sample)); await tester.pump(); final done = tester.widget( find.widgetWithText(TextButton, '완료'), ); expect(done.onPressed, isNull); }); testWidgets('미리보기가 입력을 실시간으로 따라간다', (tester) async { await pumpWide(tester, const QuestCreateScreen(repository: _sample)); await tester.pump(); expect(find.text('옵션을 선택, 미리 보기를 확인하세요'), findsOneWidget); // 명세 523:18173 — 입력 필드는 카테고리를 고른 뒤에 나타난다. expect(find.widgetWithText(TextField, '퀘스트명'), findsNothing); await tester.tap(find.text('커뮤니티')); await tester.pump(); await tester.enterText( find.widgetWithText(TextField, '퀘스트명'), '아침 산책', ); await tester.pump(); expect(find.text('아침 산책'), findsWidgets); }); testWidgets('위치 분류를 고르면 장소 선택이 나타난다', (tester) async { await pumpWide(tester, const QuestCreateScreen(repository: _sample)); await tester.pump(); expect(find.textContaining('목표 위치'), findsNothing); await tester.tap(find.text('특정 위치')); await tester.pump(); expect(find.textContaining('목표 위치'), findsOneWidget); }); testWidgets('위치 분류에서는 장소를 고르기 전까지 완료가 열리지 않는다', (tester) async { await pumpWide(tester, const QuestCreateScreen(repository: _sample)); await tester.pump(); await tester.tap(find.text('특정 위치')); await tester.pump(); await _fill(tester); var done = tester.widget( find.widgetWithText(TextButton, '완료'), ); expect(done.onPressed, isNull); // 시안 서체·입력 테마를 적용하면 폼이 길어져 기본 창에서는 이 행이 접힌다. await tester.ensureVisible(find.textContaining('목표 위치')); await tester.pumpAndSettle(); await tester.tap(find.textContaining('목표 위치')); await tester.pumpAndSettle(); expect(find.byType(PlacePickerScreen), findsOneWidget); await tester.tap(find.text('홍대 팝업스토어')); await tester.pumpAndSettle(); await tester.tap(find.text('선택 완료')); await tester.pumpAndSettle(); done = tester.widget(find.widgetWithText(TextButton, '완료')); expect(done.onPressed, isNotNull); }); testWidgets('완료를 누르면 만들기 전에 되묻는다', (tester) async { await pumpWide(tester, const QuestCreateScreen(repository: _sample)); await tester.pump(); await tester.tap(find.text('커뮤니티')); await tester.pump(); await _fill(tester); await tester.tap(find.widgetWithText(TextButton, '완료')); await tester.pumpAndSettle(); expect(find.text('이 내용으로 만들까요?'), findsOneWidget); }); }); group('사용자 생성 — 활동별 입력 · 링크 · 검토 상태 (C-01 · C-05 · C-08)', () { testWidgets('활동을 고르면 그 활동에 필요한 입력만 나타난다', (tester) async { await pumpWide(tester, const QuestCreateScreen(repository: _sample)); await tester.pump(); await tester.tap(find.text('행성 활동')); await tester.pump(); expect(find.widgetWithText(TextField, '퀘스트명'), findsNothing); await tester.tap(find.text('투표 참여')); await tester.pump(); expect(find.widgetWithText(TextField, '링크 주소'), findsOneWidget); expect(find.textContaining('목표 위치'), findsNothing); }); testWidgets('링크 조회가 실패하면 제목을 받아야 완료가 열린다', (tester) async { await pumpWide(tester, const QuestCreateScreen(repository: _sample)); await tester.pump(); await tester.tap(find.text('행성 활동')); await tester.pump(); await tester.tap(find.text('투표 참여')); await tester.pump(); await _fill(tester); await tester.enterText(find.widgetWithText(TextField, '링크 주소'), 'https://vote.example.com/1'); await tester.tap(find.byTooltip('링크 확인')); await tester.pumpAndSettle(); var done = tester.widget(find.widgetWithText(TextButton, '완료')); expect(done.onPressed, isNull); await tester.enterText(find.widgetWithText(TextField, '링크 제목'), '연말 시상식 투표'); await tester.pump(); done = tester.widget(find.widgetWithText(TextButton, '완료')); expect(done.onPressed, isNotNull); }); testWidgets('검토 중 제출 건은 안내를 띄우고 버튼을 잠근다 · 24h 지연 안내', (tester) async { await pumpWide(tester, const QuestDetailScreen(questId: 'v1', repository: _ReviewQuests())); await tester.pumpAndSettle(); expect(find.text('작성자 설명'), findsOneWidget); final delayed = find.text('검토가 늦어지고 있어요. 운영팀이 확인하고 있습니다.'); await tester.scrollUntilVisible(delayed, 120, scrollable: find.byType(Scrollable).first); expect(delayed, findsOneWidget); expect(find.text('검토 중이에요. 승인되면 보상이 지급돼요.'), findsOneWidget); final button = tester.widget(find.widgetWithText(FilledButton, '검토 중')); expect(button.onPressed, isNull); }); }); group('위치 선택 (F4 · 371:356705)', () { testWidgets('고르기 전에는 확정 버튼이 비활성이다', (tester) async { await pumpWide(tester, const PlacePickerScreen()); final button = tester.widget( find.widgetWithText(FilledButton, '선택 완료'), ); expect(button.onPressed, isNull); }); testWidgets('검색으로 목록이 줄어든다', (tester) async { await pumpWide(tester, const PlacePickerScreen()); await tester.enterText(find.byType(TextField), '홍대'); await tester.pump(); expect(find.text('홍대 팝업스토어'), findsOneWidget); expect(find.text('고척 스카이돔'), findsNothing); }); testWidgets('없는 장소를 찾으면 빈 상태를 알린다', (tester) async { await pumpWide(tester, const PlacePickerScreen()); await tester.enterText(find.byType(TextField), '없는장소'); await tester.pump(); expect(find.text('검색 결과가 없습니다.'), findsOneWidget); }); }); 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 [ const QuestListScreen(repository: _sample), const QuestDetailScreen(questId: 'q1', repository: _sample), const QuestCreateScreen(repository: _sample), const PlacePickerScreen(), ]) { await pumpWide(tester, screen); await tester.pump(); for (final text in tester.widgetList(find.byType(Text))) { final data = text.data ?? ''; expect(data, isNot(contains('lib/'))); expect(data, isNot(contains('app/'))); expect(data, isNot(contains('.dart'))); } } }); group('Figma 설명 프레임 대조 (2026-10-06)', () { QuestCard ugc({int participants = 0, int? capacity, QuestStage stage = QuestStage.notStarted}) => QuestCard( id: 'u', title: 't', categoryName: 'c', author: QuestAuthor.user, stage: stage, goal: 1, progress: 0, rewardPop: 10, participants: participants, capacity: capacity, ); // 명세 `523:18176` 퀘스트 종료 시점 1 — 참여 인원 충족 시 종료. test('사용자 퀘스트는 모집 인원이 차면 마감이다', () { expect(questFull(ugc(participants: 5, capacity: 5)), isTrue); expect(questFull(ugc(participants: 4, capacity: 5)), isFalse); expect(questFull(ugc(participants: 3)), isFalse); }); // 명세 `518:88395` — 카테고리 값은 등록된 분류(건강 · 커뮤니티 …)다. 활동명(걷기)이 아니다. test('카드 카테고리는 활동이 아니라 분류 이름이다', () { expect(QuestType.steps.category, '건강'); expect(QuestType.community.category, '커뮤니티'); expect(QuestType.vote.category, '행성 활동'); }); // 명세 `518:88685` — 광고 시청 POP = 보상 POP × 배수. 운영 Quest 는 합계를 크게 보인다. testWidgets('운영 퀘스트는 광고를 보면 기본 + 증분 합계를 띄운다', (tester) async { const card = QuestCard( id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin, stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 20, participants: 0, ); await pumpWide( tester, const QuestRewardScreen( detail: QuestDetail(card: card, imageLabel: '', description: ''), reward: QuestReward(rewardedPop: 20, balanceAfter: 0), adBonusPop: 20, ), ); expect(find.text('+40 받았어요!'), findsOneWidget); expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget); }); }); } /// 지갑이 서버 값으로 바뀐 표본 — 생성 예치 · 수수료 뒤 헤더가 다시 맞는가. class _BalanceRepo extends SampleQuestRepository { @override 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 Future> load({int limit = 10}) async => throw const BackendException('E-PERM-01', 'denied'); } /// 광고 경계만 바꾼 표본 — B-03 「광고 실패 시 기본 보상 지급 · 추가 보상 없음」. class _AdRepo extends SampleQuestRepository { _AdRepo({this.loadError, this.impression = 'imp-1', this.claimError, this.verifyError}); final BackendException? loadError; final String? impression; final BackendException? claimError; final BackendException? verifyError; final claimed = []; var verified = 0; @override Future verify( QuestCard card, { PlaceProof proof = const PlaceProof(), }) async { verified += 1; if (verifyError case final error?) throw error; return super.verify(card); } @override Future loadRewardedAd(QuestReward base) async { if (loadError case final error?) throw error; return _Ad(impression); } @override Future claimAdReward(String impressionId, QuestReward base) async { claimed.add(impressionId); if (claimError case final error?) throw error; return 20; } } class _Ad implements RewardedAdHandle { _Ad(this.impression); final String? impression; @override Future show() async => impression; @override void dispose() {} } QuestCard _stage(QuestCard card, QuestStage stage) => QuestCard( id: card.id, title: card.title, categoryName: card.categoryName, author: card.author, stage: stage, goal: card.goal, progress: card.progress, rewardPop: card.rewardPop, participants: card.participants, ); Future _fill(WidgetTester tester) async { // 활동별 입력이 붙어 폼이 길다 — 목록 아래 칸은 스크롤해야 만들어진다. for (final (label, value) in [('퀘스트명', '아침 산책'), ('참여 인원', '10'), ('완료 보상 POP', '30')]) { final field = find.widgetWithText(TextField, label); await tester.scrollUntilVisible(field, 120, scrollable: find.byType(Scrollable).first); await tester.enterText(field, value); } await tester.pump(); } /// 검토 대기 제출 건이 있는 사용자 생성 Vote Quest. class _ReviewQuests extends SampleQuestRepository { const _ReviewQuests(); @override Future detail(String questId) async => QuestDetail( card: const QuestCard( id: 'v1', title: '연말 시상식 투표', categoryName: '투표 참여', type: QuestType.vote, author: QuestAuthor.user, stage: QuestStage.inProgress, goal: 1, progress: 0, rewardPop: 5, participants: 1, capacity: 10, ), imageLabel: '', description: '인증샷을 올려 주세요', link: const QuestLink(url: 'https://vote.example.com', status: 'preview_failed', title: '시상식 투표'), endsAt: DateTime.now().add(const Duration(hours: 5)), submission: QuestSubmission( id: 'v1_me', status: 'submitted', submittedAt: DateTime.now().subtract(const Duration(hours: 25)), ), ); } /// 일시 정지한 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> load({int limit = 10}) async => const [_card]; @override Future> more({required int offset, int limit = 10}) async => const []; @override Future detail(String questId) async => const QuestDetail(card: _card, imageLabel: '', description: ''); @override Future pausedQuest() async => _card; }