703 lines
31 KiB
Dart
703 lines
31 KiB
Dart
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:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
/// F4 퀘스트 — 화면 대조 원장 10화면.
|
|
///
|
|
/// Figma 명세가 상태별 버튼 문구와 동작을 못박아 두었으므로 **문구까지 검증**한다.
|
|
/// 문구가 바뀌면 화면 대조에서 어긋난다.
|
|
|
|
/// 화면 검증은 표본 저장소로 한다. 저장소 선택은 `core/data/repositories.dart` 의
|
|
/// 몫이고, 화면은 주입받은 것을 그린다.
|
|
const _sample = SampleQuestRepository();
|
|
|
|
Future<void> 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<void> 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<QuestTabStrip>(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<FilledButton>(
|
|
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<TextButton>(
|
|
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<TextButton>(
|
|
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<TextButton>(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<TextButton>(find.widgetWithText(TextButton, '완료'));
|
|
expect(done.onPressed, isNull);
|
|
await tester.enterText(find.widgetWithText(TextField, '링크 제목'), '연말 시상식 투표');
|
|
await tester.pump();
|
|
done = tester.widget<TextButton>(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<FilledButton>(find.widgetWithText(FilledButton, '검토 중'));
|
|
expect(button.onPressed, isNull);
|
|
});
|
|
});
|
|
|
|
group('위치 선택 (F4 · 371:356705)', () {
|
|
testWidgets('고르기 전에는 확정 버튼이 비활성이다', (tester) async {
|
|
await pumpWide(tester, const PlacePickerScreen());
|
|
final button = tester.widget<FilledButton>(
|
|
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);
|
|
});
|
|
});
|
|
|
|
testWidgets('F4 화면 문구에 내부 경로가 새지 않는다', (tester) async {
|
|
for (final screen in <Widget>[
|
|
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<Text>(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<int?> 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<List<QuestCard>> load({int limit = 10}) async {
|
|
loads += 1;
|
|
return [for (var i = 0; i < 10; i++) _hidden(i)];
|
|
}
|
|
|
|
@override
|
|
Future<List<QuestCard>> 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<List<QuestCard>> 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 = <String>[];
|
|
var verified = 0;
|
|
|
|
@override
|
|
Future<QuestReward> verify(
|
|
QuestCard card, {
|
|
PlaceProof proof = const PlaceProof(),
|
|
}) async {
|
|
verified += 1;
|
|
if (verifyError case final error?) throw error;
|
|
return super.verify(card);
|
|
}
|
|
|
|
@override
|
|
Future<RewardedAdHandle> loadRewardedAd(QuestReward base) async {
|
|
if (loadError case final error?) throw error;
|
|
return _Ad(impression);
|
|
}
|
|
|
|
@override
|
|
Future<int> 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<String?> 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<void> _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<QuestDetail> 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)),
|
|
),
|
|
);
|
|
}
|