import 'package:fancake/core/ui/relative_time.dart'; import 'package:fancake/features/campaign/campaign_create_screen.dart'; import 'package:fancake/features/campaign/campaign_data.dart'; import 'package:fancake/features/campaign/campaign_detail_screen.dart'; import 'package:fancake/features/campaign/campaign_list_screen.dart'; import 'package:fancake/features/campaign/campaign_ranking_screen.dart'; import 'package:fancake/features/campaign/campaign_search_screen.dart'; import 'package:fancake/features/campaign/widgets/campaign_period_sheet.dart'; import 'package:flutter/material.dart'; import 'package:fancake/core/ui/app_dialog.dart'; import 'package:flutter_test/flutter_test.dart'; /// F5 캠페인 — 화면 대조 원장 8화면. Future pumpWide(WidgetTester tester, Widget child) async { tester.view.physicalSize = const Size(1080, 2600); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp(home: child)); await tester.pump(); } CampaignCardData _card({ int likes = 0, int comments = 0, int views = 0, int shares = 0, int goal = 100, int raised = 50, }) => CampaignCardData( id: 'x', title: 't', body: 'b', authorName: 'a', createdAt: DateTime(2026, 9, 1), status: CampaignStatus.funding, goalPop: goal, raisedPop: raised, supporters: 1, likes: likes, comments: comments, views: views, shares: shares, ); void main() { group('내 캠페인 철회 (B-58)', () { testWidgets('검수 중이면 등록비 반환을 알리고 철회하면 철회 상태로 바뀐다', (tester) async { final repo = _Mine(CampaignStatus.review); await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1', repository: repo, myPop: 0)); await tester.pumpAndSettle(); expect(find.text('운영 검수 중인 캠페인입니다.'), findsOneWidget); await tester.tap(find.text('캠페인 철회')); await tester.pumpAndSettle(); expect(find.textContaining('돌려드립니다'), findsOneWidget); await tester.tap(find.text('철회하기')); await tester.pumpAndSettle(); expect(repo.withdrawn, ['c1']); expect(find.text('개설자가 철회한 캠페인입니다.'), findsOneWidget); expect(find.text('캠페인 철회'), findsNothing); }); testWidgets('공개 후 첫 후원 전이면 비환불을 알린다', (tester) async { await pumpWide( tester, CampaignDetailScreen(campaignId: 'c1', repository: _Mine(CampaignStatus.funding), myPop: 0), ); await tester.pumpAndSettle(); await tester.tap(find.text('캠페인 철회')); await tester.pumpAndSettle(); expect(find.textContaining('돌려드리지 않습니다'), findsOneWidget); }); testWidgets('첫 후원 뒤에는 철회 버튼이 없다', (tester) async { await pumpWide( tester, CampaignDetailScreen( campaignId: 'c1', repository: _Mine(CampaignStatus.funding, supporters: 1), myPop: 0, ), ); await tester.pumpAndSettle(); expect(find.text('캠페인 철회'), findsNothing); }); test('운영 취소는 반환 안내를 보인다', () { expect(CampaignStatus.cancelled.notice, contains('돌려드렸습니다')); expect(CampaignStatus.funding.notice, isNull); }); }); group('상대 시각 · 반응 수치 (F5 · 523:18399)', () { final now = DateTime(2026, 9, 1, 12); test('일주일이 넘으면 날짜로 바뀐다', () { expect(relativeTime(now.subtract(const Duration(days: 6)), now: now), '6일 전'); expect( relativeTime(DateTime(2026, 1, 1), now: now), '2026.01.01', ); }); test('반응 수치는 K 단위로 줄인다', () { expect(formatReaction(999), '999'); expect(formatReaction(1200), '1.2K'); expect(formatReaction(3000), '3K'); expect(formatReaction(9999), '10K'); expect(formatReaction(12400), '12K'); }); }); group('달성률과 인기 정렬', () { test('목표가 0이면 달성률도 0이다', () { expect(_card(goal: 0, raised: 100).achievedPercent, 0); }); test('달성률을 반올림한다', () { expect(_card(goal: 3, raised: 1).achievedPercent, 33); }); test('인기순은 좋아요 → 댓글 → 조회수 → 공유수 순으로 본다', () { final a = _card(likes: 10, comments: 1); final b = _card(likes: 10, comments: 5); // 좋아요가 같으면 댓글이 많은 쪽이 앞선다. expect(a.comparePopularity(b) > 0, isTrue); final c = _card(likes: 10, comments: 5, views: 1); final d = _card(likes: 10, comments: 5, views: 9); expect(c.comparePopularity(d) > 0, isTrue); final e = _card(likes: 20); expect(e.comparePopularity(a) < 0, isTrue); }); }); group('목록 (F5 · 272:241184)', () { testWidgets('필터 세 개가 있고 디폴트는 최신순이다', (tester) async { await pumpWide(tester, CampaignListScreen()); await tester.pump(); for (final sort in CampaignSort.values) { expect(find.widgetWithText(CampaignSortChip, sort.label), findsOneWidget); } final chip = tester.widget( find.widgetWithText(CampaignSortChip, '최신순'), ); expect(chip.selected, isTrue); }); testWidgets('최신순은 최근 캠페인이 위에 온다', (tester) async { await pumpWide(tester, CampaignListScreen()); await tester.pump(); final titles = tester .widgetList(find.byType(Text)) .map((w) => w.data ?? '') .where((s) => s.contains('광고') || s.contains('커피차')) .toList(); expect(titles.first, contains('LED')); }); testWidgets('기여중 필터는 내가 기여한 것만 남긴다', (tester) async { await pumpWide(tester, CampaignListScreen()); await tester.pump(); await tester.tap(find.widgetWithText(CampaignSortChip, '기여중')); await tester.pump(); await tester.pump(); expect(find.textContaining('강남역'), findsOneWidget); expect(find.textContaining('커피차'), findsNothing); }); testWidgets('본문은 두 줄에서 잘린다', (tester) async { await pumpWide(tester, CampaignListScreen()); await tester.pump(); final body = tester.widget( find.textContaining('도심을 BTS의 색으로'), ); expect(body.maxLines, 2); expect(body.overflow, TextOverflow.ellipsis); }); testWidgets('새 캠페인 버튼이 생성 화면을 연다', (tester) async { await pumpWide(tester, CampaignListScreen()); await tester.pump(); await tester.tap(find.text('새 캠페인')); await tester.pumpAndSettle(); expect(find.byType(CampaignCreateScreen), findsOneWidget); }); }); group('검색 (F5 · 272:241681 · 272:241724)', () { testWidgets('처음에는 최근 검색어가 보인다', (tester) async { await pumpWide(tester, CampaignSearchScreen()); await tester.pump(); expect(find.text('최근 검색어'), findsOneWidget); expect(find.text('강남역'), findsOneWidget); }); testWidgets('검색어 칩의 ✕ 는 그 검색어만 지운다', (tester) async { await pumpWide(tester, CampaignSearchScreen()); await tester.pump(); await tester.tap(find.byTooltip('강남역 삭제')); await tester.pumpAndSettle(); expect(find.text('강남역'), findsNothing); expect(find.text('LED'), findsOneWidget); }); testWidgets('전체 삭제하면 빈 안내가 뜬다', (tester) async { await pumpWide(tester, CampaignSearchScreen()); await tester.pump(); await tester.tap(find.text('전체 삭제')); await tester.pumpAndSettle(); expect(find.text('최신 검색어가 없습니다'), findsOneWidget); }); testWidgets('검색하면 결과 화면으로 바뀐다', (tester) async { await pumpWide(tester, CampaignSearchScreen()); await tester.pump(); await tester.enterText(find.byType(TextField), '커피차'); await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pumpAndSettle(); expect(find.textContaining('커피차'), findsWidgets); expect(find.text('최근 검색어'), findsNothing); }); testWidgets('없는 말을 찾으면 빈 결과를 알린다', (tester) async { await pumpWide(tester, CampaignSearchScreen()); await tester.pump(); await tester.enterText(find.byType(TextField), '없는캠페인'); await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pumpAndSettle(); expect(find.text('검색 결과가 없습니다'), findsOneWidget); }); testWidgets('검색어가 최근 목록 맨 앞에 쌓인다', (tester) async { final repo = SampleCampaignRepository(); await pumpWide(tester, CampaignSearchScreen(repository: repo)); await tester.pump(); await repo.search('커피차'); final recent = await repo.recentQueries(); expect(recent.first, '커피차'); // 중복 제거 — 이미 있던 「강남역」을 다시 찾아도 하나다. await repo.search('강남역'); final again = await repo.recentQueries(); expect(again.where((q) => q == '강남역').length, 1); expect(again.first, '강남역'); }); }); group('상세 (F5 · 272:245738)', () { testWidgets('디폴트 탭은 캠페인 정보다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); await tester.pumpAndSettle(); expect(find.text('캠페인 정보'), findsOneWidget); expect(find.textContaining('응원 게시판'), findsOneWidget); expect(find.text('92%'), findsOneWidget); }); testWidgets('사진 개수만큼 인디케이터가 생긴다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); await tester.pumpAndSettle(); expect(find.bySemanticsLabel('대표 이미지'), findsOneWidget); expect(find.byKey(const ValueKey('campaign-media')), findsOneWidget); }); testWidgets('좋아요는 누르면 늘고 다시 누르면 줄어든다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); await tester.pumpAndSettle(); expect(find.text('820'), findsOneWidget); await tester.tap(find.byTooltip('좋아요')); await tester.pump(); expect(find.text('821'), findsOneWidget); await tester.tap(find.byTooltip('좋아요')); await tester.pump(); expect(find.text('820'), findsOneWidget); }); testWidgets('내 캠페인에는 기여하기 버튼이 없다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c3')); await tester.pumpAndSettle(); expect(find.widgetWithText(FilledButton, '기여하기'), findsNothing); }); testWidgets('모금 중이 아닌 타인 캠페인(달성 · 미달 환불 · 결과보고 · 취소)에는 기여하기 버튼이 없다(10/8 r11)', (tester) async { for (final (status, shown) in [ (CampaignStatus.funding, findsOneWidget), (CampaignStatus.success, findsNothing), (CampaignStatus.failed, findsNothing), (CampaignStatus.reported, findsNothing), (CampaignStatus.cancelled, findsNothing), ]) { await pumpWide( tester, CampaignDetailScreen( key: ValueKey(status), campaignId: 'c1', repository: _Mine(status, supporters: 1, owned: false), myPop: 0, ), ); await tester.pumpAndSettle(); expect(find.widgetWithText(FilledButton, '기여하기'), shown, reason: status.name); } }); testWidgets('타인 캠페인에는 기여하기 버튼이 있다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); await tester.pumpAndSettle(); expect(find.widgetWithText(FilledButton, '기여하기'), findsOneWidget); }); testWidgets('기여 시트는 값을 정하기 전까지 완료가 잠겨 있다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '기여하기')); await tester.pumpAndSettle(); expect(find.text('기여 POP 설정'), findsOneWidget); final done = tester.widget( find.widgetWithText(FilledButton, '완료'), ); expect(done.onPressed, isNull); }); testWidgets('보유 POP 을 넘겨 끌 수 없다', (tester) async { await pumpWide( tester, CampaignDetailScreen(campaignId: 'c1', myPop: 300), ); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '기여하기')); await tester.pumpAndSettle(); final slider = tester.widget(find.byType(Slider)); expect(slider.max, 300); expect(find.text('나의 보유 POP : 300'), findsOneWidget); }); testWidgets('응원 게시판 탭으로 넘어가면 댓글이 보인다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); await tester.pumpAndSettle(); await tester.tap(find.textContaining('응원 게시판')); await tester.pumpAndSettle(); expect(find.text('꼭 성공했으면 좋겠어요!'), findsOneWidget); }); testWidgets('공유 시트에 네 경로가 뜬다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('공유')); await tester.pumpAndSettle(); for (final target in const ['인스타그램', '틱톡', 'X', '링크 복사']) { expect(find.byTooltip(target), findsOneWidget); } }); testWidgets('「실시간 랭킹」에서 랭킹 화면으로 간다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); await tester.pumpAndSettle(); final list = find.byKey(const ValueKey('campaign-info')); // 끝까지 굴린다. 하단 고정 버튼이 마지막 줄을 덮지 않는지도 함께 본다. await tester.drag(list, const Offset(0, -1200)); await tester.pumpAndSettle(); await tester.tap(find.text('실시간 랭킹')); await tester.pumpAndSettle(); expect(find.byType(CampaignRankingScreen), findsOneWidget); }); }); group('랭킹 (F5 · 333:70971)', () { testWidgets('내 순위를 맨 위에 고정한다', (tester) async { await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1')); await tester.pumpAndSettle(); expect(find.text('나의 기여 랭킹'), findsOneWidget); expect(find.text('나'), findsWidgets); }); testWidgets('필터 바텀시트에 세 구간이 있다', (tester) async { await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1')); await tester.pumpAndSettle(); await tester.tap(find.text('실시간 랭킹')); await tester.pumpAndSettle(); for (final w in RankWindow.values) { expect(find.text(w.label), findsWidgets); } expect(find.text('취소'), findsOneWidget); }); testWidgets('구간을 바꾸면 라벨이 따라 바뀐다', (tester) async { await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1')); await tester.pumpAndSettle(); await tester.tap(find.text('실시간 랭킹')); await tester.pumpAndSettle(); await tester.tap(find.text('주간').last); await tester.pumpAndSettle(); expect(find.text('주간 랭킹'), findsOneWidget); }); }); group('새 캠페인 (F5 · 287:16991)', () { testWidgets('검수를 거친다는 사실을 먼저 알린다', (tester) async { await pumpWide(tester, CampaignCreateScreen()); expect(find.textContaining('운영 검수를 지난 뒤 모금이 열립니다'), findsOneWidget); }); testWidgets('진행 기간은 사본 캘린더 시트로 고른다(333:72497 · 333:72719)', (tester) async { DateTimeRange? picked; await pumpWide( tester, Builder( builder: (context) => TextButton( onPressed: () async => picked = await showCampaignPeriodSheet( context, today: DateTime(2026, 4, 1), ), child: const Text('열기'), ), ), ); await tester.tap(find.text('열기')); await tester.pumpAndSettle(); expect(find.text('2026년 4월'), findsOneWidget); await tester.tap(find.text('9')); await tester.pump(); // 끝을 고르기 전에는 「기간 선택」이 잠겨 있다. expect( tester.widget(find.byType(FilledButton)).onPressed, isNull, ); await tester.tap(find.text('23')); await tester.pump(); await tester.tap(find.text('4월 9일 ~ 4월 23일')); await tester.pumpAndSettle(); expect( picked, DateTimeRange(start: DateTime(2026, 4, 9), end: DateTime(2026, 4, 23)), ); }); testWidgets('오늘 이전 날짜와 1년 뒤 달은 고를 수 없다', (tester) async { await pumpWide( tester, Scaffold(body: CampaignPeriodSheet(today: DateTime(2026, 4, 5))), ); await tester.tap(find.text('3').first); await tester.pump(); expect( tester.widget(find.byType(FilledButton)).onPressed, isNull, ); // 12번 넘기면 2027년 4월(오늘+365일이 든 달)에서 멈춘다. for (var i = 0; i < 14; i++) { await tester.tap(find.bySemanticsLabel('다음 달')); await tester.pump(); } expect(find.text('2027년 4월'), findsOneWidget); }); testWidgets('앱바에 개발용 MOCK 표시를 띄우지 않는다', (tester) async { await pumpWide(tester, CampaignCreateScreen()); expect(find.textContaining('MOCK'), findsNothing); expect(find.bySemanticsLabel(RegExp('MOCK')), findsNothing); }); testWidgets('값이 다 차기 전까지 완료가 잠겨 있다', (tester) async { await pumpWide(tester, CampaignCreateScreen()); var done = tester.widget( find.widgetWithText(TextButton, '완료'), ); expect(done.onPressed, isNull); await tester.enterText( find.widgetWithText(TextField, '캠페인 제목을 입력해주세요.'), '커피차', ); await tester.enterText( find.widgetWithText(TextField, '캠페인 설명을 작성해주세요.'), '스태프 응원', ); // 정책 원장 B-32 · B-59 — 목표는 입력이 아니라 상품 선택. C-14 — 권장 기간 자동 입력. expect(find.widgetWithText(TextField, '목표 POP'), findsNothing); await tester.tap(find.text('커피차 서포트')); await tester.pump(); expect(find.text('500,000 POP'), findsOneWidget); expect(find.text('날짜 선택'), findsNothing); done = tester.widget(find.widgetWithText(TextButton, '완료')); expect(done.onPressed, isNotNull); }); testWidgets('올리기 전에 되묻는다', (tester) async { await pumpWide(tester, CampaignCreateScreen()); await tester.enterText( find.widgetWithText(TextField, '캠페인 제목을 입력해주세요.'), '커피차', ); await tester.enterText( find.widgetWithText(TextField, '캠페인 설명을 작성해주세요.'), '스태프 응원', ); await tester.tap(find.text('커피차 서포트')); await tester.pump(); await tester.tap(find.widgetWithText(TextButton, '완료')); await tester.pumpAndSettle(); expect(find.text('이 내용으로 올릴까요?'), findsOneWidget); expect( find.descendant( of: find.byType(AppDialog), matching: find.textContaining('500,000 POP · 14일'), ), findsOneWidget, ); // 등록비를 올리기 전에 알리고, 올린 뒤에는 심사 대기임을 알린다. expect(find.textContaining('등록비 100 POP'), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, '올리기')); await tester.pumpAndSettle(); expect(find.text('검수 대기 중입니다'), findsOneWidget); }); }); group('결과보고 (FR-CMP-06 · B-35)', () { testWidgets('게시된 결과보고의 사진 · 본문 · 예산 · 영수증을 보여 준다', (tester) async { await pumpWide( tester, CampaignDetailScreen(campaignId: 'c3', repository: _Reported()), ); await tester.pumpAndSettle(); await tester.drag( find.byKey(const ValueKey('campaign-info')), const Offset(0, -2000), ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('campaign-report')), findsOneWidget); expect(find.text('광고 집행 완료'), findsOneWidget); expect(find.text('광고 집행'), findsOneWidget); expect(find.text('500 POP'), findsOneWidget); expect(find.text('영수증'), findsOneWidget); }); testWidgets('성공한 내 캠페인에도 작성 버튼은 없다 — 운영이 올린다', (tester) async { await pumpWide(tester, CampaignDetailScreen(campaignId: 'c3')); await tester.pumpAndSettle(); await tester.drag( find.byKey(const ValueKey('campaign-info')), const Offset(0, -2000), ); await tester.pumpAndSettle(); expect(find.textContaining('결과보고'), findsNothing); }); }); testWidgets('F5 화면 문구에 내부 경로가 새지 않는다', (tester) async { for (final screen in [ CampaignListScreen(), CampaignSearchScreen(), CampaignDetailScreen(campaignId: 'c1'), CampaignRankingScreen(campaignId: 'c1'), CampaignCreateScreen(), ]) { await pumpWide(tester, screen); await tester.pumpAndSettle(); for (final text in tester.widgetList(find.byType(Text))) { final data = text.data ?? ''; expect(data, isNot(contains('lib/'))); expect(data, isNot(contains('.dart'))); } } }); } /// 내 캠페인 상세 — 상태 · 후원 수를 바꿔 철회 버튼을 본다(B-58). class _Mine extends SampleCampaignRepository { _Mine(this.status, {this.supporters = 0, this.owned = true}); final CampaignStatus status; final int supporters; final bool owned; final withdrawn = []; @override Future detail(String campaignId) async { final base = await super.detail(campaignId); final c = base.card; return CampaignDetail( card: CampaignCardData( id: c.id, title: c.title, body: c.body, authorName: c.authorName, createdAt: c.createdAt, status: withdrawn.isEmpty ? status : CampaignStatus.withdrawn, goalPop: c.goalPop, raisedPop: supporters == 0 ? 0 : 10, supporters: supporters, mine: owned, likes: 0, comments: 0, views: 0, shares: 0, ), mediaLabels: base.mediaLabels, endsAt: base.endsAt, topContributors: const [], ); } @override Future withdraw(String campaignId) async => withdrawn.add(campaignId); } /// 결과보고가 게시된 상세. 사진 URL 은 비워 둔다 — 위젯 테스트는 네트워크를 타지 않는다. class _Reported extends SampleCampaignRepository { @override Future detail(String campaignId) async { final base = await super.detail(campaignId); return CampaignDetail( card: base.card, mediaLabels: base.mediaLabels, endsAt: base.endsAt, topContributors: base.topContributors, report: CampaignReport( body: '광고 집행 완료', photoUrls: const [], receiptUrls: const [], budget: const [(label: '광고 집행', amount: 450), (label: '제작비', amount: 50)], createdAt: DateTime(2026, 9, 20), ), ); } }