Files
fanuzu/app/test/f5_screens_test.dart
T

604 lines
24 KiB
Dart

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<void> 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<CampaignSortChip>(
find.widgetWithText(CampaignSortChip, '최신순'),
);
expect(chip.selected, isTrue);
});
testWidgets('최신순은 최근 캠페인이 위에 온다', (tester) async {
await pumpWide(tester, CampaignListScreen());
await tester.pump();
final titles = tester
.widgetList<Text>(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<Text>(
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<FilledButton>(
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<Slider>(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<FilledButton>(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<FilledButton>(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<TextButton>(
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<TextButton>(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 <Widget>[
CampaignListScreen(),
CampaignSearchScreen(),
CampaignDetailScreen(campaignId: 'c1'),
CampaignRankingScreen(campaignId: 'c1'),
CampaignCreateScreen(),
]) {
await pumpWide(tester, screen);
await tester.pumpAndSettle();
for (final text in tester.widgetList<Text>(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 = <String>[];
@override
Future<CampaignDetail> 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<void> withdraw(String campaignId) async => withdrawn.add(campaignId);
}
/// 결과보고가 게시된 상세. 사진 URL 은 비워 둔다 — 위젯 테스트는 네트워크를 타지 않는다.
class _Reported extends SampleCampaignRepository {
@override
Future<CampaignDetail> 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),
),
);
}
}