앱 단위·화면·통합 테스트

This commit is contained in:
Sn-Kinos
2026-10-04 01:43:10 +09:00
parent de44739e63
commit 4f6e1eac61
63 changed files with 14700 additions and 230 deletions
+3 -2
View File
@@ -17,8 +17,9 @@ import 'package:integration_test/integration_test.dart';
/// ///
/// 실행: /// 실행:
/// firebase emulators:start --only auth,firestore,functions --project demo-fanuzu /// firebase emulators:start --only auth,firestore,functions --project demo-fanuzu
/// flutter test integration_test/auth_emulator_test.dart \ /// flutter drive --driver=test_driver/perf_driver.dart \
/// --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 -d <device> /// --target=integration_test/auth_emulator_test.dart \
/// --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 --no-dds -d <device>
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+61
View File
@@ -0,0 +1,61 @@
// 구현 확인물 캡처 공용 도구 — 화면군마다 `capture_f*_test.dart` 가 이것을 쓴다.
//
// **앱 코드를 고치지 않는다.** 앱이 쓰는 위젯과 테마를 그대로 기기 위에 띄우고
// 화면마다 PNG 를 한 장씩 뽑는다. `adb shell input` 으로 41 화면을 눌러 다니는
// 방식은 이 에뮬레이터에서 한 번 누르는 데 최대 60초가 걸려 재현되지 않았다.
//
// 화면군을 파일로 나눈 이유 — 한 실행이 중간에 끊기면 드라이버가 그때까지 모은
// 캡처를 통째로 잃는다. 군마다 실행을 나누면 한 군이 실패해도 나머지는 남는다.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:fancake/core/theme/app_theme.dart';
/// 캡처 도구 한 벌. `main()` 첫 줄에서 만든다.
class Capture {
Capture() : binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final IntegrationTestWidgetsFlutterBinding binding;
/// Android 는 이걸 부르지 않으면 화면을 이미지로 뜨지 못한다.
Future<void> prepare() => binding.convertFlutterSurfaceToImage();
/// 정해진 시간만큼 프레임을 돌린다.
///
/// `pumpAndSettle` 은 로딩 스피너처럼 끝나지 않는 애니메이션이 하나라도 있으면
/// 던진다. 캡처는 그런 화면에서도 나와야 하고, 저장소를 읽고 다시 그리는
/// 시간도 필요하므로 고정 프레임 수로 돌린다.
Future<void> settle(WidgetTester tester, {int frames = 16}) async {
for (var i = 0; i < frames; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
/// 화면 하나를 앱 테마 그대로 띄운다.
///
/// [tag] 로 키를 준다. **없으면 안 된다** — 같은 종류의 화면을 연달아 띄우면
/// Flutter 가 앞 화면의 State 를 그대로 재사용해 새 인자가 무시되고
/// 앞 화면이 한 번 더 찍힌다(q1 상세가 q4 상세 자리에 찍히는 식).
Future<void> open(WidgetTester tester, String tag, Widget screen) async {
await tester.pumpWidget(
MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
darkTheme: buildAppTheme(),
themeMode: ThemeMode.dark,
home: KeyedSubtree(key: ValueKey(tag), child: screen),
),
);
await settle(tester);
}
Future<void> shot(String name) => binding.takeScreenshot(name);
/// 띄우고 찍기.
Future<void> capture(WidgetTester tester, String name, Widget screen) async {
await open(tester, name, screen);
await shot(name);
}
}
+55
View File
@@ -0,0 +1,55 @@
// F0 공통 2화면 캡처. 방법은 `capture_common.dart` 참고.
//
// 하단 탭은 부분이 아니라 **앱 전체**를 띄워서 찍는다 — 탭 목록은 원격 플래그가
// 정하므로 셸을 손으로 흉내내면 실제 빌드와 달라질 수 있다.
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/app.dart';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/notification/notification_screen.dart';
import 'capture_common.dart';
void main() {
final cap = Capture();
setUpAll(cap.prepare);
testWidgets('F0 공통', (tester) async {
// 실기기에서는 소셜 로그인을 통과해야 셸에 닿는데, 확정 4종 가운데
// Google 만 열려 있고 그 계정은 이 에뮬레이터에 없다. 복원된 세션으로 연다 —
// 로그인 이후의 화면이라는 사실은 같다.
final auth = AuthRepository(
session: const _RestoredSession(
AuthAccount(uid: 'capture-user', provider: AuthProviderKind.google),
),
releaseMode: true,
);
addTearDown(auth.dispose);
await tester.pumpWidget(FancakeApp(auth: auth));
await cap.settle(tester);
await cap.shot('F0_01_하단탭');
await cap.capture(
tester,
'F0_02_알림',
const NotificationScreen(repository: SampleNotificationRepository()),
);
});
}
final class _RestoredSession implements AuthSession {
const _RestoredSession(this.currentAccount);
@override
final AuthAccount? currentAccount;
@override
Stream<AuthAccount?> get accountChanges => const Stream.empty();
@override
Future<void> signOut() async {}
}
+68
View File
@@ -0,0 +1,68 @@
// F2 GALAXY 화면군 캡처. 방법은 `capture_common.dart` 참고.
//
// 검토회신서 3절(K-2 — 「HUD 가 행성 연계가 아니라 화면 최상단 고정 패널」)에
// 대한 응답을 **화면으로** 보이는 것이 목적이다. 그래서 정지 화면 한 장으로
// 끝내지 않고 중심 전환 중 한 장을 함께 찍는다 — HUD 가 행성에 붙어 같이
// 움직인다는 사실은 멈춘 그림 하나로는 구분되지 않는다.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'capture_common.dart';
void main() {
final cap = Capture();
setUpAll(cap.prepare);
testWidgets('F2 GALAXY', (tester) async {
await cap.open(
tester,
'F2',
GalaxyScreen(
repository: SampleGalaxyRepository(),
planetRepository: samplePlanetRepository,
// 캡처는 이클립스 배너·침식 HUD 가 든 상태를 함께 남긴다.
eclipse: SampleEclipseRepository(),
),
);
await cap.shot('F2_01_GALAXY_내_행성_HUD');
// 키는 이름이 아니라 문서 ID 다 — 동명 팬덤이 둘이면 이름으로는 못 집는다.
// 바깥 궤도는 계속 돌므로 어느 행성이 탭 가능한 자리에 있을지 정해지지
// 않는다. 표본을 차례로 눌러 HUD 수치가 바뀐 첫 행성에서 멈춘다.
const others = {
'newjeans': '271,905',
'seventeen': '254,120',
'ive': '198,740',
'aespa': '176,250',
'lesserafim': '152,080',
};
for (final e in others.entries) {
await tester.tap(find.byKey(ValueKey(e.key)), warnIfMissed: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 120));
if (find.text(e.value).evaluate().isNotEmpty) break;
}
// 전환이 끝나기 전 — HUD 가 행성을 따라 화면 한가운데로 날아가는 중이다.
await cap.shot('F2_02_GALAXY_중심_전환_중');
await tester.pump(const Duration(milliseconds: 700));
await tester.pump();
await cap.shot('F2_03_GALAXY_전환_완료_HUD_연동');
await tester.fling(
find.byType(InteractiveViewer),
const Offset(-140, -40),
900,
);
await tester.pump(const Duration(milliseconds: 260));
await tester.pump();
await cap.shot('F2_04_GALAXY_드래그_관성');
});
}
+90
View File
@@ -0,0 +1,90 @@
// F4 퀘스트 10화면 캡처. 방법은 `capture_common.dart` 참고.
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/planet/moderation.dart';
import 'package:fancake/features/quest/place_picker_screen.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 'capture_common.dart';
/// 캡처는 표본 저장소로 찍는다 — 확인물은 화면 배치·문구를 보이는 것이 목적이다.
const _sample = SampleQuestRepository();
void main() {
final cap = Capture();
setUpAll(cap.prepare);
testWidgets('F4 퀘스트', (tester) async {
await cap.capture(tester, 'F4_01_퀘스트_목록', const QuestListScreen(repository: _sample));
// 명세 518:88377 — 전체/진행중/완료/내 퀘스트 탭 전환.
// 카드의 버튼 라벨에도 「진행중」이 있어 탭 띠 안으로 범위를 좁힌다.
await tester.tap(
find.descendant(
of: find.byType(QuestTabStrip),
matching: find.text('진행중'),
),
);
await cap.settle(tester);
await cap.shot('F4_02_퀘스트_목록_진행중');
await cap.capture(
tester,
'F4_03_퀘스트_상세_진행중',
const QuestDetailScreen(questId: 'q1', repository: _sample),
);
await cap.capture(
tester,
'F4_04_퀘스트_상세_시작전',
const QuestDetailScreen(questId: 'q4', repository: _sample),
);
// 광고 시청 전 — 관리자 퀘스트(q2 · 진행 완료)에서 「보상받기」를 누르면 뜬다.
await cap.open(tester, 'q2', const QuestDetailScreen(questId: 'q2', repository: _sample));
await tester.tap(find.text('보상받기'));
await cap.settle(tester);
await cap.shot('F4_05_퀘스트_상세_광고시청전');
// 광고 시청 후 — 「광고 보고 더 받기」 → 보상 화면(추가 보상 ×3).
await tester.tap(find.text('광고 보고 더 받기'));
await cap.settle(tester);
await cap.shot('F4_06_퀘스트_상세_광고시청후');
// 보상 화면은 루트 내비게이터에 얹힌다. 걷어내지 않으면 다음 화면을
// 아무리 띄워도 이 화면이 그 위에 남아 같은 그림이 계속 찍힌다.
await tester.tap(find.text('완료'));
await cap.settle(tester);
// 광고를 보지 않았을 때의 보상 — 기본 보상만 들어간다.
final detail = await const SampleQuestRepository().detail('q2');
await cap.capture(
tester,
'F4_07_퀘스트_완료_보상',
QuestRewardScreen(detail: detail),
);
await cap.capture(
tester,
'F4_08_퀘스트_상세_신고하기',
const ReportScreen(
target: ModerationTarget(
authorName: '신곡 3분 완청',
title: '신곡 3분 완청 퀘스트',
),
),
);
await cap.capture(tester, 'F4_09_퀘스트_생성', const QuestCreateScreen(repository: _sample));
await cap.capture(
tester,
'F4_10_퀘스트_생성_위치선택',
const PlacePickerScreen(),
);
});
}
+58
View File
@@ -0,0 +1,58 @@
// F5 캠페인(HOT ARCHIVE) 8화면 캡처. 방법은 `capture_common.dart` 참고.
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/campaign/campaign_create_screen.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/planet/moderation.dart';
import 'capture_common.dart';
void main() {
final cap = Capture();
setUpAll(cap.prepare);
testWidgets('F5 캠페인', (tester) async {
await cap.capture(tester, 'F5_01_HOT_ARCHIVE_목록', CampaignListScreen());
await cap.capture(tester, 'F5_02_HOT_ARCHIVE_검색', CampaignSearchScreen());
// 최근 검색어 칩을 눌러 검색을 돌린다. 소프트 키보드의 검색 액션을 흉내내는
// 방식은 실기기에서 `onSubmitted` 가 오지 않아 결과 화면이 뜨지 않았다.
await tester.tap(find.text('강남역'));
await cap.settle(tester);
await cap.shot('F5_03_HOT_ARCHIVE_검색결과');
await cap.capture(
tester,
'F5_04_캠페인_상세',
CampaignDetailScreen(campaignId: 'c1'),
);
// 공유 오버레이 — Figma 480:14594.
await tester.tap(find.byTooltip('공유'));
await cap.settle(tester);
await cap.shot('F5_05_캠페인_상세_공유');
await cap.capture(
tester,
'F5_06_캠페인_상세_사용자_랭킹',
CampaignRankingScreen(campaignId: 'c1'),
);
await cap.capture(
tester,
'F5_07_캠페인_상세_신고하기',
const ReportScreen(
target: ModerationTarget(
authorName: '다이브짱',
title: '강남역 초대형 LED 광고',
),
),
);
await cap.capture(tester, 'F5_08_새_캠페인', CampaignCreateScreen());
});
}
@@ -0,0 +1,89 @@
// F6 MY 21화면 캡처 — 앞 11화면. 방법은 `capture_common.dart` 참고.
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/my/account_link_screen.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_lists_screen.dart';
import 'package:fancake/features/my/my_screen.dart';
import 'package:fancake/features/my/notice_screens.dart';
import 'package:fancake/features/my/pop_history_screen.dart';
import 'package:fancake/features/my/settings_screen.dart';
import 'capture_common.dart';
void main() {
final cap = Capture();
setUpAll(cap.prepare);
testWidgets('F6 MY 1-11', (tester) async {
final sample = SampleMyRepository();
await cap.capture(tester, 'F6_01_MY', MyScreen(repository: sample));
await cap.capture(
tester,
'F6_02_MY_내_인사이드',
const MyListsScreen(kind: MyListKind.inside),
);
await cap.capture(
tester,
'F6_03_MY_내_캠페인',
const MyListsScreen(kind: MyListKind.campaign),
);
await cap.capture(tester, 'F6_04_MY_설정', SettingsScreen(repository: sample));
// 연동 화면은 제공자만 다르다. 확정 4종(별첨 B v1.4)은 Google·Apple·Kakao·LINE
// 이며 X 는 목록에서 빠졌다 — 원장의 `MY/설정/X연결` 자리를 LINE 이 대신한다.
await cap.capture(
tester,
'F6_05_MY_설정_Apple연결',
AccountLinkScreen(
provider: AuthProviderKind.apple,
linker: SampleAccountLinker(),
),
);
await cap.capture(
tester,
'F6_06_MY_설정_Google연결',
AccountLinkScreen(
provider: AuthProviderKind.google,
linkedEmail: 'fan@gmail.com',
linker: SampleAccountLinker(
seed: const {AuthProviderKind.google: 'fan@gmail.com'},
),
),
);
await cap.capture(
tester,
'F6_07_MY_설정_Kakao연결',
AccountLinkScreen(
provider: AuthProviderKind.kakao,
linkedEmail: 'fan@kakao.com',
linker: SampleAccountLinker(
seed: const {AuthProviderKind.kakao: 'fan@kakao.com'},
),
),
);
await cap.capture(
tester,
'F6_08_MY_설정_LINE연결',
AccountLinkScreen(
provider: AuthProviderKind.line,
linker: SampleAccountLinker(),
),
);
await cap.capture(tester, 'F6_09_MY_설정_POP_상세', PopHistoryScreen(repository: sample));
await cap.capture(tester, 'F6_10_MY_설정_공지사항', NoticeListScreen(repository: sample));
final notices = await sample.notices();
await cap.capture(
tester,
'F6_11_MY_설정_공지사항_상세',
NoticeDetailScreen(notice: notices.first),
);
});
}
@@ -0,0 +1,65 @@
// F6 MY 21화면 캡처 — 뒤 10화면. 방법은 `capture_common.dart` 참고.
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/payment_screens.dart';
import 'package:fancake/features/my/photo_picker.dart';
import 'package:fancake/features/my/profile_edit_screen.dart';
import 'package:fancake/features/my/push_settings_screen.dart';
import 'package:fancake/features/my/support_screens.dart';
import 'package:fancake/features/my/tier_screens.dart';
import 'capture_common.dart';
void main() {
final cap = Capture();
setUpAll(cap.prepare);
testWidgets('F6 MY 12-21', (tester) async {
final sample = SampleMyRepository();
await cap.capture(
tester,
'F6_12_MY_설정_서비스_결제_내역',
PaymentHistoryScreen(repository: sample),
);
await cap.capture(
tester,
'F6_13_MY_설정_결제_내역_상세',
PaymentDetailScreen(paymentId: 'p1', repository: sample),
);
await cap.capture(
tester,
'F6_14_MY_설정_결제_내역_환불_요청',
RefundRequestScreen(paymentId: 'p1', repository: sample),
);
await cap.capture(tester, 'F6_15_MY_티어_정보', const TierInfoScreen());
final profile = await sample.profile();
await cap.capture(
tester,
'F6_16_MY_티어_상세',
TierDetailScreen(tiers: profile.planetTiers),
);
await cap.capture(
tester,
'F6_17_MY_프로필_설정',
ProfileEditScreen(nickname: profile.nickname, repository: sample),
);
await cap.capture(
tester,
'F6_18_MY_프로필_사진_설정',
const ProfilePhotoScreen(pickPhoto: _noPhoto),
);
await cap.capture(tester, 'F6_19_MY_1대1_문의접수', InquiryScreen(repository: sample));
await cap.capture(tester, 'F6_20_MY_설정_푸시_알림_설정', PushSettingsScreen(repository: sample));
await cap.capture(tester, 'F6_21_MY_설정_차단_관리', BlockedScreen(repository: sample));
});
}
/// 캡처는 사진 선택기를 띄우지 않는다. 실기기 선택기가 뜨면 캡처가 그 화면에서 멈춘다.
Future<PickedPhoto?> _noPhoto(PhotoSource source) async => null;
@@ -0,0 +1,46 @@
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
/// 기기 위에서 재는 갤럭시 맵 프레임 타이밍.
///
/// 위젯 테스트는 실제로 래스터화하지 않으므로 「끊기는가」를 답하지 못한다.
/// 이 테스트는 프로파일 빌드로 기기에 올라가 **실제 프레임 시간**을 남긴다.
/// 별첨 1 ① 판정 기준 「기준 단말에서 행성/맵/애니메이션 체감 지연 없음」의 근거다.
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('갤럭시 맵을 팬·줌하는 동안의 프레임 시간', (tester) async {
await binding.watchPerformance(() async {
await tester.pumpWidget(
MaterialApp(
home: GalaxyScreen(
repository: SampleGalaxyRepository(),
planetRepository: samplePlanetRepository,
eclipse: SampleEclipseRepository(),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
final field = find.byType(GalaxyScreen);
expect(field, findsOneWidget);
// 궤도가 도는 동안 드래그를 섞는다. 애니메이션과 제스처가 겹치는 구간이
// 가장 무거우므로, 나눠 재면 실제로 끊기는 순간을 놓친다.
for (var round = 0; round < 6; round++) {
await tester.drag(field, const Offset(-120, -40));
await tester.pump(const Duration(milliseconds: 16));
await tester.drag(field, const Offset(120, 40));
await tester.pump(const Duration(milliseconds: 16));
for (var frame = 0; frame < 20; frame++) {
await tester.pump(const Duration(milliseconds: 16));
}
}
}, reportKey: 'galaxy_pan');
});
}
@@ -0,0 +1,133 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:fancake/features/home/home_repository_firestore.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_repository_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
/// POP → 행성 반영 실증 — 별첨 1 ② 완료 시나리오 1 의 **읽는 쪽**.
///
/// **무엇을 재는가.** 서버가 원장을 지나 쓴 값을 앱이 그대로 읽어 HOME·MY 수치로
/// 옮기는지를 잰다. 가짜 Firestore 가 아니라 **실제 Firestore 에뮬레이터**를 물고,
/// 문서도 앱이 만든 것이 아니라 **Cloud Functions 가 쓴 것**을 읽는다. 두 단계가
/// 서로 다른 프로세스라, 필드 이름이 한 글자만 어긋나도 여기서 0 이 나온다.
///
/// 실행 — 두 단계를 순서대로 돈다.
///
/// ```bash
/// # 1. 서버가 상태를 만든다(출석 지급 → 원장 → 기여 차감 → 등급·멤버 갱신)
/// java -jar ~/.cache/firebase/emulators/cloud-firestore-emulator-v1.22.0.jar \
/// --host=0.0.0.0 --port=8080 &
/// CI=1 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 GCLOUD_PROJECT=demo-fanuzu \
/// npm --prefix firebase/functions test
///
/// # 2. 앱이 그 상태를 읽는다
/// fvm flutter test integration_test/pop_loop_emulator_test.dart \
/// -d emulator-5554 --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2
/// ```
///
/// `10.0.2.2` 는 Android 에뮬레이터가 보는 호스트 PC 주소다.
///
/// **로그인만 흉내 낸다.** Auth 에뮬레이터는 `firebase` CLI 안에만 있어 이 환경에서
/// 띄우지 못한다(BUILD.md 2-1). UID 는 서버 테스트가 쓴 것과 같은 값을 넣고, 읽는
/// 경로는 실제 구현을 그대로 지난다.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
/// `firebase/functions/test/quest_e2e.test.ts` 가 쓰는 UID.
const uid = 'test-quest-e2e';
/// 출석 퀘스트의 **문서 ID**. 진행 문서도 같은 ID 로 선다(설계서 §2.1).
const attendQuest = 'daily-checkin';
/// 호스트 PC 주소. Android 에뮬레이터는 `10.0.2.2` 로 본다.
const host = String.fromEnvironment('FANUZU_EMULATOR_HOST', defaultValue: '10.0.2.2');
late Backend backend;
setUpAll(() async {
// `FirebaseBoot.init()` 을 쓰지 않는다. 그 경로는 Auth 에뮬레이터(9099)에도
// 붙는데, Auth 는 `firebase` CLI 안에만 있어 이 환경에서 뜨지 않는다(BUILD.md 2-1).
// 거기서 실패하면 Firestore 연결까지 건너뛰어 앱이 실 서버를 바라본다.
//
// 이름 있는 앱을 따로 띄운다. Android 는 `google-services.json` 으로 `[DEFAULT]`
// 앱을 자동 초기화하므로 그 자리를 덮을 수 없고, 덮지 않으면 프로젝트 ID 가
// 달라 에뮬레이터의 다른 칸을 읽는다.
final app = await Firebase.initializeApp(
name: 'fanuzu-emulator-check',
options: const FirebaseOptions(
apiKey: 'demo-key',
appId: '1:0:android:0',
messagingSenderId: '0',
projectId: 'demo-fanuzu',
),
);
final db = FirebaseFirestore.instanceFor(app: app)
..useFirestoreEmulator(host, 8080);
backend = Backend(
firestore: db,
auth: MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: uid, email: 'fan@fanuzu.co.kr'),
),
);
});
testWidgets('에뮬레이터를 물고 서버가 쓴 문서가 보인다', (tester) async {
final user = await backend.db.doc('users/$uid').get();
expect(user.exists, isTrue,
reason: '서버 E2E(quest_e2e.test.ts)를 먼저 돌려 상태를 만드십시오');
// 출석·후원을 지난 뒤의 서버 값이다. 앱이 쓴 값이 아니다.
expect(user.data()!['totalContributedPop'], 3000);
// 레벨은 누적 기여 POP 이 아니라 User EXP 에서 나온다(C-18) — 출석 1건 × 100(후원은 EXP 대상 아님).
expect(user.data()!['userExp'], 100);
expect(user.data()!['userLevel'], 1);
expect(user.data()!['passportTier'], 'SILVER');
});
testWidgets('HOME 이 출석 보상 잔액과 행성 성장 수치를 서버 값에서 읽는다', (tester) async {
final snapshot = await FirestoreHomeRepository(backend).load();
// 출석 1회 보상 10 POP — `fn-quest-attend` 가 원장을 지나 올린 활동 잔액.
expect(snapshot.popBalance, 10);
// 공동 성장 = 출석 보상 10 + 후원 3,000. 개인 기여와 구별한다.
expect(snapshot.growth.currentPop, 3010);
// 공동 Stage가 아직 없는 시나리오이므로 진행률은 확정하지 않는다.
expect(snapshot.growth.growthPercent, isNull);
expect(snapshot.growth.fandomName, '퀘스트 루프 팬덤');
});
testWidgets('MY 가 누적 기여와 등급을 서버 값에서 읽는다', (tester) async {
final profile = await FirestoreMyRepository(backend).profile();
expect(profile.contributedPop, 3000);
// 레벨은 User EXP 기반이다(C-18). 기여 POP 3,000 이 칭호를 올리지 않는다.
expect(profile.userLevel, greaterThanOrEqualTo(1));
expect(profile.passportTier, PassportTier.silver);
expect(profile.balancePop, 10);
expect(profile.planetTiers.single.fandomName, '퀘스트 루프 팬덤');
expect(profile.planetTiers.single.contributedPop, 3000);
});
testWidgets('같은 퀘스트를 다시 참여하면 서버가 막은 자국이 남아 있다', (tester) async {
// 중복 참여 거부(E-POP-03)는 서버가 판정한다. 앱에서 보이는 자국은 진행 카운터다 —
// 같은 날 두 번 눌러도 1 을 넘지 않는다.
//
// **경로가 퀘스트 문서 ID 다.** 앱이 목록을 그릴 때 읽는 자리와 서버가 쓰는 자리가
// 같아야 화면이 「보상완료」로 넘어간다.
final progress =
await backend.db.doc('users/$uid/questProgress/$attendQuest').get();
expect(progress.exists, isTrue);
expect(progress.data()!['count'], 1);
expect(progress.data()!['questId'], attendQuest);
final ledger = await backend.db
.collection('users/$uid/popTransactions')
.where('reasonCode', isEqualTo: 'QUEST_ATTEND')
.get();
expect(ledger.size, 1);
});
}
@@ -0,0 +1,107 @@
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/firebase/firebase_boot.dart';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
/// 퀘스트 참여 실왕복 — 별첨 1 ② 「클라이언트 단독 지급 0건」과 ③ 「서버검증 경유」.
///
/// **앱에서 Cloud Functions 를 직접 부른다.** 앞선 `pop_loop_emulator_test.dart` 는
/// 서버가 쓴 값을 앱이 읽는 쪽만 봤고, 여기서는 **앱 → Callable → 원장 → 앱** 한 바퀴를
/// 돈다. 로그인도 실제 Auth 에뮬레이터를 지난다.
///
/// 실행 — 에뮬레이터 세 개를 함께 띄운다. **저장소 안의 firebase-tools** 를 쓴다.
///
/// ```bash
/// adb reverse tcp:8080 tcp:8080 && adb reverse tcp:9099 tcp:9099 \
/// && adb reverse tcp:5001 tcp:5001
/// cd firebase
/// JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \
/// test/node_modules/.bin/firebase emulators:exec \
/// --only auth,firestore,functions --project demo-fanuzu \
/// "bash ../tool/emulator_quest_roundtrip.sh"
/// ```
///
/// 시드(퀘스트 정의·운영값)는 그 스크립트가 에뮬레이터 REST 로 넣는다 — 보안 규칙이
/// `quests` 쓰기를 마스터로 막아 두어 앱이 만들 수 없다(그게 맞다).
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
/// 시드 스크립트가 넣는 퀘스트 문서 ID.
const questId = 'daily-checkin';
late AuthRepository auth;
late Repositories repositories;
setUpAll(() async {
await FirebaseBoot.init();
auth = AuthRepository();
// 매 회차 새 계정이다. `fn-auth-oncreate` 트리거가 계정·지갑 문서를 만드는 것까지
// 이 왕복에 들어간다 — 앱은 `users/{uid}` 를 직접 만들지 못한다(보안 규칙).
final email = 'quest+${DateTime.now().microsecondsSinceEpoch}@fanuzu.co.kr';
final outcome = await auth.signInWithEmail(
email: email,
password: 'password12',
signUp: true,
);
expect(outcome, isA<AuthSucceeded>(), reason: 'Auth 에뮬레이터에 붙지 못했습니다');
repositories = Repositories(live: true);
});
testWidgets('에뮬레이터를 물고 로그인까지 지난다', (tester) async {
expect(FirebaseBoot.useEmulator, isTrue,
reason: '--dart-define=FANUZU_EMULATOR_HOST 를 주고 실행하십시오');
expect(FirebaseBoot.isLive, isTrue, reason: FirebaseBoot.reason);
expect(auth.isSignedIn, isTrue);
});
testWidgets('출석 참여 한 번이 서버 지급과 원장까지 간다', (tester) async {
final quests = await repositories.quest.load();
final attend = quests.firstWhere((card) => card.id == questId);
expect(attend.type, QuestType.attend);
expect(attend.stage, QuestStage.notStarted);
// 앱이 부르는 것은 Firestore 쓰기가 아니라 Callable 이다.
final reward = await repositories.quest.verify(attend);
expect(reward.rewardedPop, 10);
expect(reward.balanceAfter, 10);
expect(reward.txId, isNotNull);
// 원장 한 행 — 앱이 쓴 것이 아니라 서버가 쓴 것이다.
final uid = repositories.backend.requireUid;
final ledger =
await repositories.backend.db.collection('users/$uid/popTransactions').get();
expect(ledger.size, 1);
expect(ledger.docs.single.data()['reasonCode'], 'QUEST_ATTEND');
expect(ledger.docs.single.data()['serverVerified'], isTrue);
});
testWidgets('보유 POP 과 퀘스트 상태가 서버 값으로 다시 그려진다', (tester) async {
// HOME 은 지갑을 읽는다. 출석 보상 10 POP 이 그대로 보인다.
final home = await repositories.home.load();
expect(home.popBalance, 10);
// 목록을 다시 읽으면 서버 진행 문서로 「보상완료」다 — 앱이 기억한 값이 아니다.
final again = await repositories.quest.load();
expect(again.firstWhere((card) => card.id == questId).stage, QuestStage.rewarded);
});
testWidgets('같은 날 두 번째 참여는 서버가 거부한다', (tester) async {
final quests = await repositories.quest.load();
final attend = quests.firstWhere((card) => card.id == questId);
await expectLater(
repositories.quest.verify(attend),
// 화면은 gRPC 상태가 아니라 에러 카탈로그 코드로 분기한다.
throwsA(isA<BackendException>().having((e) => e.code, 'code', 'E-POP-03')),
);
final uid = repositories.backend.requireUid;
final ledger =
await repositories.backend.db.collection('users/$uid/popTransactions').get();
expect(ledger.size, 1, reason: '거부된 호출이 원장을 늘리면 안 된다');
});
}
@@ -0,0 +1,65 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/ui/adaptive_planet_effects.dart';
import 'package:fancake/core/ui/planet_25d.dart';
import 'package:fancake/core/ui/planet_evolution.dart';
FrameTiming frame(int cost) => FrameTiming(
vsyncStart: 0,
buildStart: 0,
buildFinish: cost,
rasterStart: cost,
rasterFinish: cost * 2,
rasterFinishWallTime: cost * 2,
);
void main() {
testWidgets('일시 지연은 무시하고 지속 지연에서 단계적으로 축소하며 진화 결과는 유지한다', (tester) async {
Future<void> show(int stage) => tester.pumpWidget(
MaterialApp(
home: AdaptivePlanetEffects(
child: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
stage: stage,
),
),
),
),
);
int level() => tester
.widget<PlanetEffectsQuality>(find.byType(PlanetEffectsQuality))
.level;
Future<void> timings(int count, int cost) async {
tester.binding.platformDispatcher.onReportTimings!(
List.generate(count, (_) => frame(cost)),
);
await tester.pump();
}
await show(3);
await timings(60, 40000); // 시작 시 리소스 로딩은 판정 제외.
expect(level(), 0);
await timings(20, 40000);
await timings(100, 2000);
expect(level(), 0, reason: '짧은 지연만으로 품질을 떨어뜨리지 않는다');
await timings(120, 40000);
expect(level(), 1);
await timings(120, 40000);
expect(level(), 2);
expect(
tester.widget<PlanetEvolution>(find.byType(PlanetEvolution)).lowEffects,
true,
);
await show(4);
expect(find.text('이상 반응'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1800));
expect(find.text('Stage 4 · 동행'), findsOneWidget);
await timings(240, 2000);
expect(level(), 2, reason: '세션 중 품질이 반복 왕복하지 않는다');
await tester.pumpWidget(const SizedBox());
expect(tester.takeException(), isNull);
});
}
+92 -31
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:fancake/app.dart'; import 'package:fancake/app.dart';
import 'package:fancake/core/config/feature_flags.dart'; import 'package:fancake/core/config/feature_flags.dart';
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/firebase/firebase_boot.dart'; import 'package:fancake/core/firebase/firebase_boot.dart';
import 'package:fancake/core/router/app_router.dart'; import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth.dart';
@@ -96,6 +97,25 @@ void main() {
auth.dispose(); auth.dispose();
}); });
testWidgets('회원가입 직후에는 가입 완료 화면(182:29344)을 거쳐 셸로 들어간다', (tester) async {
final auth = AuthRepository(
providers: [_SignupProvider()],
releaseMode: true,
);
await tester.pumpWidget(FancakeApp(auth: auth));
await tester.pumpAndSettle();
await auth.signIn(SocialProvider.google, signUp: true);
await tester.pumpAndSettle();
expect(find.text('회원가입이 완료되었습니다'), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
await tester.tap(find.text('시작'));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
auth.dispose();
});
testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async { testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async {
await _enterShell(tester); await _enterShell(tester);
@@ -157,9 +177,10 @@ void main() {
await source.close(); await source.close();
}); });
test('STATION 라우트는 남아 있다 — 비노출이지 삭제가 아니다', () { test('STATION 라우트와 저장소는 남아 있다 — 비노출이지 삭제가 아니다', () {
final station = appTabs.firstWhere((t) => t.label == 'STATION'); final station = appTabs.firstWhere((t) => t.label == 'STATION');
expect(station.path, '/station'); expect(station.path, '/station');
expect(Repositories(live: false).station, isNotNull);
}); });
}); });
@@ -249,44 +270,51 @@ void main() {
testWidgets('3. 탭을 전환하면 각 탭 루트 화면이 바뀐다', (tester) async { testWidgets('3. 탭을 전환하면 각 탭 루트 화면이 바뀐다', (tester) async {
await _enterShell(tester); await _enterShell(tester);
const roots = { // 5탭 모두 실제 화면이 들어갔다. 자리표시가 남아 있으면 발주사가 설치본에서
'HOME': ('S-H-HOME', '오늘의 퀘스트'), // 「2주차 범위」 문구를 보게 되므로, 탭마다 그 화면의 고정 구획명으로 확인한다.
'INSIDE': ('S-I-LOUNGE', '팬덤 라운지'),
'GALAXY': ('S-G-PLANET', '내 행성'),
// STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다. // STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다.
'MY': ('S-M-HOME', '마이 페이지'), const implemented = {
'HOME': 'TODAY’S QUEST',
'INSIDE': '최신순',
'GALAXY': 'GALAXY',
'MY': '행성/성운',
}; };
final roots = implemented.keys;
for (final entry in roots.entries) { for (final tab in roots) {
await tester.tap( await tester.tap(
find.descendant( find.descendant(
of: find.byType(NavigationBar), of: find.byType(NavigationBar),
matching: find.text(entry.key), matching: find.text(tab),
), ),
); );
await tester.pumpAndSettle(); // GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 영원히 기다린다.
// 탭 전환은 프레임 몇 장이면 끝나므로 시간을 정해 넘긴다.
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
final (screenId, screenName) = entry.value; // GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 끝나지 않는다.
expect( expect(
find.text(screenId), find.text(implemented[tab]!),
findsOneWidget,
reason: '${entry.key} → $screenId',
);
// AppBar 제목과 본문 제목 두 군데에 나온다.
expect(
find.text(screenName),
findsWidgets, findsWidgets,
reason: '${entry.key} → $screenName', reason: '$tab → ${implemented[tab]}',
);
// 자리표시가 되살아나면 여기서 잡힌다.
expect(
find.textContaining('2주차 범위'),
findsNothing,
reason: '$tab 이 자리표시로 돌아갔다',
); );
expect( expect(
find.textContaining('wireframe/'), find.textContaining('wireframe/'),
findsNothing, findsNothing,
reason: '${entry.key} 화면에 내부 저장소 경로가 노출되면 안 된다', reason: '$tab 화면에 내부 저장소 경로가 노출되면 안 된다',
); );
expect( expect(
find.textContaining('screen-spec'), find.textContaining('screen-spec'),
findsNothing, findsNothing,
reason: '${entry.key} 화면에 내부 명세 경로가 노출되면 안 된다', reason: '\$tab 화면에 내부 명세 경로가 노출되면 안 된다',
); );
} }
}); });
@@ -297,6 +325,13 @@ void main() {
/// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로, /// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로,
/// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable). /// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable).
Future<void> _enterShell(WidgetTester tester) async { Future<void> _enterShell(WidgetTester tester) async {
// 홈 히어로가 시안대로 첫 화면의 78% 를 차지하므로 기본 800×600 창에서는 아래
// 구획이 지연 빌드되지 않는다. 실기기에 가까운 세로 길이를 준다(F3 테스트와 같다).
// 목 모드에서는 이클립스 경보 배너와 라이브 띠가 히어로 아래에 더 붙으므로
// 2400 으로는 TODAY'S QUEST 가 지연 빌드 밖으로 밀린다.
tester.view.physicalSize = const Size(1080, 3200);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(FancakeApp(auth: AuthRepository())); await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.longPress(find.byKey(loginLogoKey)); await tester.longPress(find.byKey(loginLogoKey));
@@ -319,6 +354,10 @@ Future<void> _enterShell(WidgetTester tester) async {
/// ///
/// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면 /// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면
/// 계약 대조가 조용히 사라진다. /// 계약 대조가 조용히 사라진다.
///
/// **사본 표류도 실패로 끝낸다.** 후보가 둘 이상 보이면(환경 변수로 가리킨 정본과
/// 저장소 내 사본이 함께 있는 경우) `APP_TABS` 줄을 서로 비교한다. 사본만 고치거나
/// 정본만 고치면 저장소마다 다른 답이 나오고, 그건 대조가 아니라 우연이다.
List<String> _contractTabs() { List<String> _contractTabs() {
final candidates = [ final candidates = [
if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?) if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?)
@@ -326,23 +365,30 @@ List<String> _contractTabs() {
'design/craft-allowlist.yaml', 'design/craft-allowlist.yaml',
'../design/craft-allowlist.yaml', '../design/craft-allowlist.yaml',
]; ];
final file = candidates final found = candidates.map(File.new).where((f) => f.existsSync()).toList();
.map(File.new) if (found.isEmpty) {
.firstWhere( throw StateError(
(f) => f.existsSync(),
orElse: () => throw StateError(
'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. ' 'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. '
'찾아본 곳: ${candidates.join(' · ')}', '찾아본 곳: ${candidates.join(' · ')}',
),
); );
final line = file.readAsLinesSync().firstWhere( }
final lines = {for (final file in found) _appTabsLine(file)};
if (lines.length > 1) {
throw StateError(
'craft-allowlist.yaml 의 APP_TABS 가 사본마다 다르다. 정본과 사본을 맞추라. '
'${found.map((f) => f.path).join(' · ')} → ${lines.join(' / ')}',
);
}
final inner = RegExp(r'\[(.*)\]').firstMatch(lines.single)!.group(1)!;
return inner.split(',').map((s) => s.trim()).toList();
}
/// `APP_TABS:` 한 줄. 없으면 파일 이름을 붙여 실패한다.
String _appTabsLine(File file) => file.readAsLinesSync().firstWhere(
(l) => l.trimLeft().startsWith('APP_TABS:'), (l) => l.trimLeft().startsWith('APP_TABS:'),
orElse: () => orElse: () =>
throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'), throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
); );
final inner = RegExp(r'\[(.*)\]').firstMatch(line)!.group(1)!;
return inner.split(',').map((s) => s.trim()).toList();
}
final class _SmokeSession implements AuthSession { final class _SmokeSession implements AuthSession {
const _SmokeSession(this.currentAccount); const _SmokeSession(this.currentAccount);
@@ -356,3 +402,18 @@ final class _SmokeSession implements AuthSession {
@override @override
Future<void> signOut() async {} Future<void> signOut() async {}
} }
final class _SignupProvider implements SocialAuthProvider {
@override
AuthProviderKind get provider => SocialProvider.google;
@override
bool get isLive => true;
@override
List<String> get missingConfig => const [];
@override
Future<AuthOutcome> signIn() async => const AuthSucceeded(
AuthAccount(uid: 'new-user', provider: SocialProvider.google),
);
@override
Future<void> signOut() async {}
}
+21
View File
@@ -37,6 +37,27 @@ void main() {
} }
}); });
test('회원가입 모드 성공만 가입 완료 화면 플래그를 세우고, 시작하면 내린다', () async {
const account = AuthAccount(uid: 'new', provider: SocialProvider.google);
final auth = AuthRepository(
providers: [
_FakeProvider(
SocialProvider.google,
result: const AuthSucceeded(account),
),
],
releaseMode: true,
);
await auth.signIn(SocialProvider.google);
expect(auth.justSignedUp, isFalse);
await auth.signOut();
await auth.signIn(SocialProvider.google, signUp: true);
expect(auth.justSignedUp, isTrue);
auth.finishSignup();
expect(auth.justSignedUp, isFalse);
auth.dispose();
});
test('4개 provider의 취소는 실패 안내나 로그인 상태를 만들지 않는다', () async { test('4개 provider의 취소는 실패 안내나 로그인 상태를 만들지 않는다', () async {
for (final provider in SocialProvider.values) { for (final provider in SocialProvider.values) {
final auth = AuthRepository( final auth = AuthRepository(
File diff suppressed because it is too large Load Diff
-68
View File
@@ -1,68 +0,0 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter_test/flutter_test.dart';
/// 서버 연동 계층 — 로그인 강제와 문서 값 읽기 규칙.
///
/// 문서 값 읽기가 틀리면 화면은 조용히 0 이나 빈 칸을 그리므로 눈으로는 잡히지 않는다.
void main() {
group('로그인 강제', () {
test('로그인하지 않았으면 카탈로그 코드 E-AUTH-03 으로 끊는다', () {
final backend = Backend(
firestore: FakeFirebaseFirestore(),
auth: MockFirebaseAuth(),
);
expect(backend.uid, isNull);
expect(
() => backend.requireUid,
throwsA(isA<BackendException>().having((e) => e.code, 'code', 'E-AUTH-03')),
);
});
test('로그인했으면 UID 를 돌려준다', () {
final backend = Backend(
firestore: FakeFirebaseFirestore(),
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: 'u-test')),
);
expect(backend.requireUid, 'u-test');
});
});
group('문서 값 읽기', () {
test('없는 필드는 기본값으로 받는다', () {
final doc = <String, dynamic>{};
expect(doc.intOr('pop'), 0);
expect(doc.strOr('name', '이름 없음'), '이름 없음');
expect(doc.boolOr('active'), isFalse);
expect(doc.mapsOr('items'), isEmpty);
});
test('실수는 반올림해 정수로 받는다', () {
expect(<String, dynamic>{'pop': 12.6}.intOr('pop'), 13);
});
test('빈 문자열은 없는 값으로 본다', () {
expect(<String, dynamic>{'name': ''}.strOr('name', '팬'), '팬');
});
test('서버 시각은 DateTime 으로 옮긴다', () {
final at = DateTime(2026, 9, 1, 9);
final doc = <String, dynamic>{'createdAt': Timestamp.fromDate(at)};
expect(doc.dateOr('createdAt', DateTime(2000)), at);
expect(doc.dateOr('missing', DateTime(2000)), DateTime(2000));
});
test('목록 안의 맵만 골라 받는다', () {
final doc = <String, dynamic>{
'items': [
{'id': 1},
'x',
{'id': 2},
],
};
expect(doc.mapsOr('items').map((m) => m['id']), [1, 2]);
});
});
}
+416
View File
@@ -0,0 +1,416 @@
import 'package:cloud_firestore/cloud_firestore.dart' show FieldValue;
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/campaign/campaign_data.dart';
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
import 'package:fancake/features/campaign/campaign_repository_firestore.dart';
import 'package:fancake/features/my/profile_edit_screen.dart';
import 'package:fancake/features/translation/translation_comments.dart';
import 'package:fancake/features/translation/translation_data.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// 캠페인 응원 게시판 댓글 — 서버 문서 모양 · 최신순 · 페이지 · 신고 · 차단.
///
/// 규칙 판정(공개 캠페인 · 표시명 · 시각 · 1,000자 · 신고 ID)은 `firebase/test` 의
/// 규칙 테스트가, 댓글 수 집계는 functions 테스트가 본다.
const _me = 'u-me';
Future<(FakeFirebaseFirestore, FirestoreCampaignRepository)> _repo() async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_me').set({'displayName': '김실명', 'nickname': '나'});
await db.doc('campaigns/c1').set({'status': 'funding', 'commentCount': 3});
final backend = Backend(
firestore: db,
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _me)),
);
return (db, FirestoreCampaignRepository(backend));
}
Future<void> _seed(FakeFirebaseFirestore db, String uid, int i) =>
db.collection('campaigns/c1/comments').add({
'authorUid': uid,
'authorName': uid,
'body': '$uid$i',
'visibility': 'visible',
'createdAt': DateTime(2026, 9, 1).add(Duration(minutes: i)),
});
Future<void> _pump(
WidgetTester tester,
CampaignRepository repo, {
TranslationRepository? translation,
bool board = true,
}) async {
tester.view.physicalSize = const Size(1080, 2600);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
// 차단하면 상세가 닫히므로 아래에 첫 화면을 하나 둔다.
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CampaignDetailScreen(
campaignId: 'c1',
repository: repo,
translation: translation,
),
),
),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
if (!board) return;
await tester.tap(find.textContaining('응원 게시판'));
await tester.pumpAndSettle();
}
Future<void> _menu(WidgetTester tester, String body, String item) async {
await tester.tap(
find.descendant(
of: find.ancestor(of: find.text(body), matching: find.byType(Row)).first,
matching: find.byType(PopupMenuButton<String>),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text(item).last);
await tester.pumpAndSettle();
}
void main() {
group('응원 게시판 — Firestore', () {
test('댓글은 규칙이 받는 필드로만, 공개 닉네임으로 남는다', () async {
final (db, repo) = await _repo();
final c = await repo.addComment('c1', '응원해요');
final data = (await db.doc('campaigns/c1/comments/${c.id}').get())
.data()!;
expect(data.keys.toSet(), {
'authorUid',
'authorName',
'body',
'visibility',
'createdAt',
'updatedAt',
});
expect(data['authorName'], '나');
expect(c.isMine, isTrue);
// 작성 자격 — 공개 닉네임. 비었거나 이메일 형태면 없다(규칙 hasPublicNickname).
expect(await repo.hasPublicNickname(), isTrue);
await db.doc('users/$_me').update({'nickname': 'me@mail.com'});
expect(await repo.hasPublicNickname(), isFalse);
await db.doc('users/$_me').update({'nickname': FieldValue.delete()});
expect(await repo.hasPublicNickname(), isFalse);
await repo.deleteComment('c1', c.id);
expect(
(await db.doc('campaigns/c1/comments/${c.id}').get()).exists,
isFalse,
);
});
test('탭의 댓글 수는 서버가 센 commentCount 다', () async {
final (_, repo) = await _repo();
expect((await repo.detail('c1')).card.comments, 3);
});
test('최신순으로 한 장씩 읽고, 숨김 · 차단 작성자의 댓글은 뺀다', () async {
final (db, repo) = await _repo();
for (var i = 0; i < 25; i++) {
await _seed(db, 'fan', i);
}
await db.collection('campaigns/c1/comments').add({
'authorUid': 'fan',
'body': '숨김',
'visibility': 'hidden',
'createdAt': DateTime(2027),
});
final first = await repo.comments('c1');
expect(first.length, campaignCommentPageSize);
expect(first.first.body, 'fan24');
final next = await repo.comments('c1', more: true);
expect(next.map((c) => c.body), ['fan4', 'fan3', 'fan2', 'fan1', 'fan0']);
await _seed(db, 'troll', 99);
final troll = (await repo.comments('c1')).first;
expect(troll.body, 'troll99');
await repo.blockAuthor(troll);
expect((await db.doc('users/$_me/blocks/troll').get()).exists, isTrue);
expect((await repo.comments('c1')).first.body, 'fan24');
});
test('신고는 reports 에 댓글 + 신고자 ID 로 쌓인다', () async {
final (db, repo) = await _repo();
await _seed(db, 'troll', 1);
final c = (await repo.comments('c1')).single;
await repo.reportComment('c1', c, '스팸 및 영리 목적 광고');
final report =
(await db.doc('reports/campaignComment-c1-${c.id}-$_me').get())
.data()!;
expect(report['targetType'], 'campaignComment');
expect(report['targetPath'], 'campaigns/c1/comments/${c.id}');
expect(report['reasonCode'], 'spam');
expect(report['status'], 'pending');
expect(
repo.reportComment('c1', c, '목록에 없는 사유'),
throwsA(isA<BackendException>()),
);
});
});
group('좋아요 · 캠페인 신고 · 개설자 차단 — Firestore', () {
test('댓글 좋아요는 내 문서 하나로 남고, 목록은 서버 수와 내 상태를 읽는다', () async {
final (db, repo) = await _repo();
await _seed(db, 'fan', 1);
var c = (await repo.comments('c1')).single;
expect((c.likes, c.liked), (0, false));
await repo.likeComment('c1', c.id, true);
final like = await db
.doc('campaigns/c1/comments/${c.id}/likes/$_me')
.get();
expect(like.data()!.keys, ['createdAt']);
// 수는 서버 트리거가 적는다.
await db.doc('campaigns/c1/comments/${c.id}').update({'likeCount': 1});
c = (await repo.comments('c1')).single;
expect((c.likes, c.liked), (1, true));
await repo.likeComment('c1', c.id, false);
expect((await repo.comments('c1')).single.liked, isFalse);
});
test('캠페인 신고는 campaign-{캠페인}-{신고자} 로 남는다', () async {
final (db, repo) = await _repo();
await repo.reportCampaign('c1', '스팸 및 영리 목적 광고');
final r = (await db.doc('reports/campaign-c1-$_me').get()).data()!;
expect(r['targetType'], 'campaign');
expect(r['targetPath'], 'campaigns/c1');
expect(r['reasonCode'], 'spam');
});
test('개설자를 차단하면 그 캠페인과 그 사람의 댓글이 빠진다', () async {
final (db, repo) = await _repo();
await db.doc('campaigns/c1').update({'creatorUid': 'maker'});
await db.doc('campaigns/c2').set({
'status': 'funding',
'creatorUid': 'other',
});
await _seed(db, 'maker', 1);
final card = (await repo.detail('c1')).card;
expect(card.creatorUid, 'maker');
await repo.blockCreator(card);
expect((await db.doc('users/$_me/blocks/maker').get()).exists, isTrue);
expect((await repo.load()).map((c) => c.id), ['c2']);
expect(await repo.comments('c1'), isEmpty);
// 차단 목록은 읽을 때마다 새로 읽는다 — MY 에서 풀면 바로 다시 보인다.
await db.doc('users/$_me/blocks/maker').delete();
expect((await repo.load()).map((c) => c.id).toSet(), {'c1', 'c2'});
expect(await repo.comments('c1'), hasLength(1));
});
});
group('응원 게시판 — 화면', () {
testWidgets('기본 입력은 일반 댓글이고 맨 위에 붙어 탭 수가 는다', (tester) async {
final repo = SampleCampaignRepository();
await _pump(tester, repo);
expect(find.text('응원 게시판 24'), findsOneWidget);
await tester.enterText(find.byType(TextField), '화이팅');
await tester.tap(find.byTooltip('보내기'));
await tester.pumpAndSettle();
expect(find.text('화이팅'), findsOneWidget);
expect(find.text('응원 게시판 25'), findsOneWidget);
expect(
tester.getTopLeft(find.text('화이팅')).dy,
lessThan(tester.getTopLeft(find.text('꼭 성공했으면 좋겠어요!')).dy),
);
// 내 댓글은 삭제만 — 확인하면 목록과 수에서 빠진다.
await _menu(tester, '화이팅', '삭제하기');
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(find.text('화이팅'), findsNothing);
expect(find.text('응원 게시판 24'), findsOneWidget);
});
testWidgets('남의 댓글은 신고 · 차단 — 차단하면 그 작성자의 댓글이 사라진다', (tester) async {
final repo = _Spy();
await _pump(tester, repo);
await _menu(tester, '꼭 성공했으면 좋겠어요!', '신고하기');
await tester.tap(find.text('스팸 및 영리 목적 광고'));
await tester.pumpAndSettle();
await tester.tap(find.text('신고'));
await tester.pumpAndSettle();
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(repo.reported, ['m1']);
expect(find.text('신고를 접수했습니다.'), findsOneWidget);
await _menu(tester, '꼭 성공했으면 좋겠어요!', '차단하기');
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(find.text('꼭 성공했으면 좋겠어요!'), findsNothing);
expect(find.text('조감도 너무 예쁘네요'), findsOneWidget);
});
testWidgets('한 장이 꽉 차면 더 보기로 다음 장을 붙인다', (tester) async {
await _pump(tester, _Paged());
expect(find.text('p0'), findsOneWidget);
expect(find.text('next'), findsNothing);
await tester.ensureVisible(find.text('더 보기'));
await tester.pumpAndSettle();
await tester.tap(find.text('더 보기'));
await tester.pumpAndSettle();
expect(find.text('next'), findsOneWidget);
expect(find.text('더 보기'), findsNothing);
});
testWidgets('댓글 좋아요는 저장 뒤에 수가 바뀌고 다시 누르면 취소된다', (tester) async {
final repo = _Spy();
await _pump(tester, repo);
// 댓글 카드의 하트는 14 — 캠페인 본문의 하트(16)와 가른다.
Finder heart(IconData icon) => find.byWidgetPredicate(
(w) => w is Icon && w.icon == icon && w.size == 14,
);
await tester.tap(heart(Icons.favorite_border).first);
await tester.pumpAndSettle();
expect(repo.liked, [('m1', true)]);
expect(heart(Icons.favorite), findsOneWidget);
await tester.tap(heart(Icons.favorite));
await tester.pumpAndSettle();
expect(repo.liked.last, ('m1', false));
expect(heart(Icons.favorite), findsNothing);
});
testWidgets('댓글의 지구본으로 그 댓글을 번역하면 대댓글 자리에 붙는다', (tester) async {
await _pump(
tester,
SampleCampaignRepository(),
translation: SampleTranslationRepository(uid: 'me'),
);
await tester.tap(find.byTooltip('번역 댓글 작성').first);
await tester.pumpAndSettle();
expect(find.textContaining('님 댓글 번역'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'Good luck');
await tester.tap(find.byTooltip('보내기'));
await tester.pumpAndSettle();
final card = tester.widget<TranslationCommentCard>(
find.byType(TranslationCommentCard),
);
expect(card.entry.targetPath, 'campaigns/c1/comments/m1');
expect(card.reply, isTrue);
});
testWidgets('캠페인 신고는 저장소에 남고, 개설자 차단은 상세를 닫는다', (tester) async {
final repo = _Spy();
await _pump(tester, repo, board: false);
Future<void> appMenu(String item) async {
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byType(PopupMenuButton<String>),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text(item).last);
await tester.pumpAndSettle();
}
await appMenu('신고하기');
await tester.tap(find.text('스팸 및 영리 목적 광고'));
await tester.pumpAndSettle();
await tester.tap(find.text('신고'));
await tester.pumpAndSettle();
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(repo.reported, ['campaign:c1']);
await appMenu('차단하기');
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(repo.blockedCreators, ['c1']);
expect(find.byType(CampaignDetailScreen), findsNothing);
});
testWidgets('닉네임이 없으면 입력 대신 안내와 닉네임 정하기 버튼이 뜬다', (tester) async {
final repo = _NoNickname();
await _pump(tester, repo);
expect(find.text('닉네임을 먼저 정해 주세요'), findsOneWidget);
expect(find.byType(CommentInputBar), findsNothing);
await tester.tap(find.text('닉네임 정하기'));
await tester.pumpAndSettle();
expect(find.byType(ProfileEditScreen), findsOneWidget);
// 닉네임을 정하고 돌아오면 입력이 열린다.
repo.named = true;
Navigator.of(tester.element(find.byType(ProfileEditScreen))).pop();
await tester.pumpAndSettle();
expect(find.byType(CommentInputBar), findsOneWidget);
});
});
}
class _Spy extends SampleCampaignRepository {
final reported = <String>[];
final liked = <(String, bool)>[];
final blockedCreators = <String>[];
@override
Future<void> likeComment(
String campaignId,
String commentId,
bool on,
) async => liked.add((commentId, on));
@override
Future<void> reportCampaign(String campaignId, String reason) async =>
reported.add('campaign:$campaignId');
@override
Future<void> blockCreator(CampaignCardData card) async =>
blockedCreators.add(card.id);
@override
Future<void> reportComment(
String campaignId,
CampaignComment comment,
String reason,
) async => reported.add(comment.id);
}
class _Paged extends SampleCampaignRepository {
@override
Future<List<CampaignComment>> comments(
String campaignId, {
bool more = false,
}) async => [
if (more)
CampaignComment(
id: 'n',
authorName: 'b',
body: 'next',
createdAt: DateTime(2026),
)
else
for (var i = 0; i < campaignCommentPageSize; i++)
CampaignComment(
id: 'p$i',
authorName: 'a',
body: 'p$i',
createdAt: DateTime(2026),
),
];
}
class _NoNickname extends SampleCampaignRepository {
bool named = false;
@override
Future<bool> hasPublicNickname() async => named;
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:fancake/features/campaign/campaign_share.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/data/invite_code.dart';
/// 공유 문구 — 정책 원장 **B-78** 의 판정 기준을 코드로 고정한다.
///
/// *콘텐츠 유형별로 해시태그 구성이 달라짐 · 고정 3종 하드코딩 0건 · 최대 3개 초과 0건*
void main() {
group('해시태그 (B-78)', () {
test('브랜드·팬덤·주제 셋이 다 있으면 세 개다', () {
expect(
shareHashtags(fandomName: 'BTS', topic: '캠페인'),
['#FANUZU', '#BTS', '#캠페인'],
);
});
test('팬덤을 모르면 두 개로 줄어든다 — 고정 3종이 아니다', () {
expect(shareHashtags(topic: '캠페인'), ['#FANUZU', '#캠페인']);
expect(shareHashtags(fandomName: '', topic: '캠페인').length, 2);
});
test('주제가 바뀌면 구성이 따라 바뀐다', () {
expect(
shareHashtags(fandomName: 'NewJeans', topic: '퀘스트').last,
'#퀘스트',
);
});
test('셋을 넘겨도 세 개를 넘지 않는다', () {
expect(
shareHashtags(fandomName: 'BTS', topic: '캠페인', maxHashtags: 3).length,
lessThanOrEqualTo(3),
);
});
test('공백과 해시는 태그를 끊으므로 지운다', () {
expect(shareHashtags(fandomName: 'New Jeans')[1], '#NewJeans');
expect(shareHashtags(fandomName: '#BTS')[1], '#BTS');
});
});
group('캠페인 공유 문구', () {
test('웹 브릿지 주소가 없으면 링크를 넣지 않는다', () {
final text = campaignShareText(
title: '강남역 초대형 LED 광고',
summary: '도심 한가운데에서 함께 축하해요.',
fandomName: 'BTS',
);
expect(text, contains('강남역 초대형 LED 광고'));
expect(text, contains('도심 한가운데에서'));
expect(text, contains('BTS 행성'));
expect(text, contains('#FANUZU #BTS #캠페인'));
// 주소 설정(FANUZU_SHARE_BASE_URL)이 없으면 지어낸 URL 이 새지 않는지 본다.
expect(text, isNot(contains('http')));
expect(text, isNot(contains('fanuzu.co.kr')));
});
test('행성을 모르면 행성 줄과 팬덤 해시태그가 함께 빠진다', () {
final text = campaignShareText(title: '커피차 보내기', summary: '한 잔.');
expect(text, isNot(contains('행성')));
expect(text, contains('#FANUZU #캠페인'));
});
test('소개가 비면 그 줄을 빼고 붙인다', () {
final text = campaignShareText(title: '제목만', fandomName: 'IVE');
expect(text.split('\n').first, '제목만');
expect(text, contains('IVE 행성'));
});
});
group('공유 링크', () {
test('주소가 있으면 /c/<캠페인> 이 문구에 들어가고, 없으면 null', () {
expect(campaignShareLink('cmp1', base: ''), isNull);
final link = campaignShareLink('cmp 1', base: 'https://link.example/');
expect(link, 'https://link.example/c/cmp%201');
final text = campaignShareText(title: 't', fandomName: 'BTS', link: link);
expect(text, contains('https://link.example/c/cmp%201'));
expect(shareLink(['b', 'bts', '2026-09-28'], base: 'https://link.example'),
'https://link.example/b/bts/2026-09-28');
expect(shareLink(['c', ''], base: 'https://link.example'), isNull);
});
test('내 초대 코드가 있으면 ?ref= 를 붙이고, 없으면 붙이지 않는다', () {
addTearDown(InviteCode.reset);
InviteCode.reset();
expect(campaignShareLink('c1', base: 'https://l.example'), 'https://l.example/c/c1');
InviteCode.current = 'ABCDEFGH23';
expect(campaignShareLink('c1', base: 'https://l.example'),
'https://l.example/c/c1?ref=ABCDEFGH23');
expect(shareLink(['b', 'bts', '2026-09-28'], base: 'https://l.example'),
'https://l.example/b/bts/2026-09-28?ref=ABCDEFGH23');
// 주소 설정이 없으면 코드가 있어도 링크가 없다.
expect(campaignShareLink('c1', base: ''), isNull);
});
});
}
+270
View File
@@ -0,0 +1,270 @@
@Tags(['design'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
import 'package:fancake/features/home/home_onboarding_screen.dart';
import 'package:fancake/features/home/nebula_create_screen.dart';
import 'package:fancake/features/home/planet_create_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../eclipse_stubs.dart';
import 'home_fixtures.dart';
/// 디자인 일치 그룹 A(F1 홈 · F7 이클립스) 대조 렌더.
///
/// 공유 하네스(`design_match_test.dart`)와 같은 방식으로 찍되, 표본을 Figma 사본의
/// 기본 상태에 맞춘다 — 홈은 라이브 띠·이클립스 경보가 없는 「ZAP 닫힘」 상태다.
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/a_test.dart`
const _width = 375.0;
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
typedef Build = Future<Widget> Function(Repositories r);
class Shot {
const Shot(
this.id, {
this.build,
this.tab,
this.h = 812,
this.after,
this.repos = QuietRepos.new,
});
final String id;
final QuietRepos Function() repos;
final Build? build;
final String? tab;
final double h;
final Future<void> Function(WidgetTester t)? after;
}
/// 이클립스 화면 — 사본 문구의 「60초」가 나오도록 마감을 60초 뒤로 둔다.
Widget _defend(EclipseState state) => EclipseDefendScreen(
repository: FakeEclipseRepository(state: state),
fandomId: 'bts',
);
DateTime get _in60 => DateTime.now().add(const Duration(seconds: 60));
final shots = <Shot>[
// 사본 주 프레임(`252:159354`)은 헤더 지갑의 「아이템 구입」 시트가 열린 상태다.
Shot(
'F1-01',
tab: '/home',
after: (t) async {
await t.tap(find.byTooltip('아이템'));
await _settle(t);
},
),
// 기본 상태(`343:205364`) · ZAP 옵션 「아이템 선택」(`343:204868`) — 그룹 대조 폴더용.
Shot('F1-01d', tab: '/home'),
// 아래 구획(TODAY'S QUEST 등) 눈 대조용 긴 렌더.
Shot('F1-01L', tab: '/home', h: 1400),
Shot(
'F1-01i',
tab: '/home',
after: (t) async {
final hold = await t.startGesture(t.getCenter(find.text('기여')));
await t.pump(const Duration(seconds: 2));
await hold.up();
await _settle(t);
await t.tap(find.text('아이템'));
await _settle(t);
},
),
Shot('F1-02', build: (r) async => const NebulaCreateScreen()),
// 성장 완료는 HOME 탭 성운 상태를 그대로 찍는다(ZAP 은 HOME 의 갤럭시 연결로 뜬다).
Shot('F1-03', tab: '/home', repos: NebulaRepos.new),
Shot('F1-04', build: (r) async => const PlanetCreateScreen()),
// 이름 입력 뒤(`340:75644`) · 색 단계(`373:358639` 의 칩 줄) — 그룹 대조 폴더에서만 쓴다.
Shot(
'F1-02n',
build: (r) async => const NebulaCreateScreen(),
after: (t) async {
await t.enterText(find.byType(TextField), 'BTS_RM');
await _settle(t);
},
),
Shot(
'F1-04c',
build: (r) async => const PlanetCreateScreen(),
after: (t) async {
await t.tap(find.text('다음'));
await _settle(t);
},
),
// 사본 주 프레임은 「+」 시트가 열린 상태다.
Shot(
'F1-05',
build: (r) async => const HomeOnboardingScreen(),
after: (t) async {
await t.tap(find.bySemanticsLabel('행성 생성 또는 가입'));
await _settle(t);
},
),
Shot(
'F7-01',
h: 815,
build: (r) async => _defend(eclipseFixture(endsAt: _in60)),
),
// 흔드는 중 — 사본 문구의 「10초」 자리는 남은 시간이다.
Shot(
'F7-02',
h: 815,
build: (r) async => _defend(
eclipseFixture(endsAt: DateTime.now().add(const Duration(seconds: 10))),
),
after: (t) async {
await t.tap(find.text('흔들기 어려우면 눌러서 참여'));
await _settle(t);
},
),
Shot(
'F7-03',
h: 815,
build: (r) async => _defend(eclipseFixture(defended: true)),
),
// 실패 변형(`494:14292`) — 공유 비교에는 없는 ID 라 그룹 대조 폴더에서만 쓴다.
Shot(
'F7-02f',
h: 815,
build: (r) async => _defend(eclipseFixture(result: EclipseResult.failure)),
),
];
Future<void> _settle(WidgetTester t) async {
for (var i = 0; i < 6; i++) {
await t.pump(const Duration(milliseconds: 300));
}
}
Future<void> _loadFonts() async {
final manifest =
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
for (final font in manifest.cast<Map<String, dynamic>>()) {
final loader = FontLoader(font['family'] as String);
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
}
await loader.load();
}
await (FontLoader(
'Roboto',
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
}
void main() {
final out = Platform.environment['DESIGN_MATCH_OUT'];
if (out == null) return;
setUpAll(() async {
Directory(out).createSync(recursive: true);
await _loadFonts();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
(_) async => null,
);
messenger.setMockStreamHandler(
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
});
for (final s in shots) {
testWidgets(s.id, (t) async {
t.view.devicePixelRatio = 2;
t.view.physicalSize = Size(_width * 2, s.h * 2);
t.view.padding = _padding;
t.view.viewPadding = _padding;
addTearDown(t.view.reset);
final repos = s.repos();
final boundary = GlobalKey();
final navigator = GlobalKey<NavigatorState>();
Widget? pushed;
Widget app;
if (s.tab != null) {
final router = buildRouter(
AuthRepository()..signInWithMockSession(),
repositories: repos,
);
router.go(s.tab!);
app = MaterialApp.router(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
routerConfig: router,
);
} else {
pushed = (await t.runAsync(() => s.build!(repos)))!;
app = MaterialApp(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
navigatorKey: navigator,
home: const SizedBox(),
);
}
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
if (pushed case final screen?) {
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => screen),
);
}
await _settle(t);
await s.after?.call(t);
await t.runAsync(() async {
for (final e in find.byType(Image).evaluate().toList()) {
await precacheImage((e.widget as Image).image, e);
}
});
await _settle(t);
await t.runAsync(() async {
final image =
await (boundary.currentContext!.findRenderObject()!
as RenderRepaintBoundary)
.toImage(pixelRatio: 2);
final png = await image.toByteData(format: ui.ImageByteFormat.png);
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
image.dispose();
});
final texts = [
for (final e in find.byType(RichText).evaluate())
if (e.renderObject case final RenderParagraph p
when p.attached && p.text.toPlainText().trim().isNotEmpty)
{
'text': p.text.toPlainText(),
'family': p.text.style?.fontFamily,
'size': p.text.style?.fontSize,
'weight': p.text.style?.fontWeight?.value,
'color': p.text.style?.color?.toARGB32().toRadixString(16),
'rect': [
for (final v in [
p.localToGlobal(Offset.zero).dx,
p.localToGlobal(Offset.zero).dy,
p.size.width,
p.size.height,
])
v.roundToDouble(),
],
},
];
File(
'$out/${s.id}.texts.json',
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
expect(t.takeException(), isNull);
});
}
}
+308
View File
@@ -0,0 +1,308 @@
@Tags(['design'])
library;
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/galaxy/galaxy_search_screen.dart';
import 'package:fancake/features/planet/fpi_ranking_screen.dart';
import 'package:fancake/features/planet/lounge_post_screens.dart';
import 'package:fancake/features/planet/moderation.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/planet/planet_detail_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../eclipse_stubs.dart';
import 'design_match_test.dart' as base;
/// 디자인 일치 2단계 **그룹 B**(F2 갤럭시 · F3 행성/성운 상세) 렌더.
///
/// 공유 하네스([base.main])를 그대로 돌리되 shots 만 이 그룹 것으로 바꾼다.
/// 표본은 Figma 사본 문구와 같게 여기서 만든다(공유 표본 저장소는 건드리지 않는다).
///
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/b_test.dart`
void main() {
base.shots
..clear()
..addAll(_shots);
base.main();
}
Future<void> _settle(WidgetTester t) async {
for (var i = 0; i < 6; i++) {
await t.pump(const Duration(milliseconds: 300));
}
}
Future<void> _tap(WidgetTester t, Finder f) async {
await t.tap(f.first, warnIfMissed: false);
await _settle(t);
}
Future<void> _overlay(
WidgetTester t,
void Function(BuildContext c) open,
) async {
open(t.element(find.byType(Scaffold).last));
await _settle(t);
}
final _now = DateTime.now();
const _photo = 'assets/figma/lounge-post-sample.png';
LoungePost _post(
String author,
Duration ago,
String text, {
int likes = 8,
int comments = 24,
List<String> images = const [],
String? photo,
Duration? video,
}) => LoungePost(
id: author,
authorName: author,
createdAt: _now.subtract(ago),
title: '',
body: text,
likes: likes,
comments: comments,
images: images,
authorPhoto: photo,
videoDuration: video,
);
/// F3-05 `244:22509` — 첫 댓글 전 글.
final _hongPost = _post(
'BTS_Hong',
Duration.zero,
'안녕하세요! BTS 팬이에요!',
likes: 0,
comments: 0,
images: const [_photo],
photo: 'assets/figma/avatar-hong.png',
video: const Duration(seconds: 36),
);
/// F3-07 `252:163306` — 사진 세 장 글.
final _ilaPost = _post(
'ILA_WITH',
const Duration(days: 3),
'여러분 지금 미국 컴백 무대 보셨나요?\n사진 공유해드릴게요 모두 온라인 직관하세요',
likes: 8,
comments: 5,
images: const [_photo, _photo, _photo],
);
FpiScore _score(FpiMetric m, int v, int rank, int trend) =>
FpiScore(metric: m, value: v, rank: rank, trend: trend, myTopPercent: 1);
/// 시안 수치 그대로의 행성 · 성운 상세.
class _FigmaPlanet extends SamplePlanetRepository {
const _FigmaPlanet();
@override
Future<PlanetDetail> load({required bool nebula}) async => nebula
? PlanetDetail(
fandomId: 'bts-rm',
name: 'BTS_RM',
isNebula: true,
stage: 0,
totalFpi: 0,
members: 1,
ownerName: 'BTS_Hong',
ownerPhoto: 'assets/figma/avatar-hong.png',
scores: const [],
posts: const [],
)
: PlanetDetail(
fandomId: 'bts',
name: 'BTS',
isNebula: false,
totalFpi: 1220,
members: 850,
ownerName: 'BTS_Maker',
ownerPhoto: 'assets/figma/avatar-maker.png',
scores: [
_score(FpiMetric.bpm, 34200, 2, 1),
_score(FpiMetric.health, 1180000, 4, -1),
_score(FpiMetric.referral, 34200, 3, 0),
_score(FpiMetric.contribution, 450100, 2, 2),
_score(FpiMetric.community, 1000, 4, -2),
],
posts: [
_post(
'arrrmy_',
const Duration(minutes: 3),
'I love BTS sooooo much 😭😭😭',
),
_post(
'AR_NNJO',
const Duration(hours: 3),
'NAMJOON so handsome isn’t it???',
images: const [_photo],
),
_ilaPost,
],
);
@override
Future<List<LoungePost>> morePosts({
required int offset,
int limit = 5,
}) async => const [];
@override
Future<FpiRanking> planetRanking({
required FpiMetric metric,
int limit = 30,
}) async => FpiRanking(
mine: const FpiRankRow(
rank: 24,
id: 'bts',
name: 'BTS',
value: 34200,
change: 12,
isMine: true,
),
rows: _rows(const [
(1, 'Bigbnag', 782200, 1),
(2, 'NewJeans', 703200, 0),
(3, 'Blackpink', 689700, 1),
(5, 'Twice', 531100, -1),
(6, 'ITZY', 527800, 2),
(7, 'Aespa', 422400, 3),
(8, 'Seventeen', 329600, 8),
(9, 'Eighteen', 311200, -1),
(10, 'Nineteen', 134500, 1),
]),
);
@override
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
}) async => FpiRanking(
mine: const FpiRankRow(
rank: 82,
id: 'me',
name: 'BTS_Hong',
value: 3,
change: 1,
),
rows: _rows(const [
(1, 'GLAS_SMILE', 3000, 0),
(2, 'Suyom_mo', 2600, 0),
(3, 'Bool_huuu', 1400, 0),
(4, 'Wachooo', 1200, 1),
(5, 'Jeans_Bewn', 1200, 0),
(6, 'pinkyy', 1100, 1),
(7, 'T_T', 1000, -1),
(8, 'Yogi_ITZY', 98, 2),
]),
);
}
List<FpiRankRow> _rows(List<(int, String, int, int)> t) => [
for (final (rank, name, value, change) in t)
FpiRankRow(rank: rank, id: name, name: name, value: value, change: change),
];
Widget _detail({bool nebula = false}) => PlanetDetailScreen(
nebula: nebula,
repository: const _FigmaPlanet(),
eclipse: const NoEclipseRepository(),
);
const _report = ModerationTarget(
authorName: 'BTS_Hong',
title: '공방 신청 꿀팁 공유합니다.',
);
/// 이클립스가 없는 표본 저장소. 탭 화면은 공유 하네스가 저장소를 직접 만들어서
/// 바꿀 수 없으므로, 탭 셸을 통째로 한 번 더 띄운다(하단 탭까지 그려진다).
class _NoEclipseRepos extends Repositories {
_NoEclipseRepos() : super(live: false);
@override
EclipseRepository get eclipse => const NoEclipseRepository();
}
Widget _tab(String path) {
final router = buildRouter(
AuthRepository()..signInWithMockSession(),
repositories: _NoEclipseRepos(),
)..go(path);
return MaterialApp.router(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
routerConfig: router,
);
}
final _shots = <base.Shot>[
// F2 갤럭시
base.Shot('F2-01', build: (_) async => _tab('/galaxy')),
base.Shot(
'F2-02',
build: (r) async => GalaxySearchScreen(repository: r.galaxy),
),
// F3 행성/성운 상세
base.Shot('F3-01', h: 1042, build: (_) async => _detail(nebula: true)),
base.Shot(
'F3-02',
build: (_) async => const ReportScreen(
target: ModerationTarget(authorName: 'BTS_Hong', title: 'BTS_RM'),
),
),
base.Shot(
'F3-03',
build: (_) async => _detail(nebula: true),
after: (t) => _overlay(
t,
(c) => confirmBlock(c, 'BTS_Hong', message: '이 성운을 차단할까요?'),
),
),
base.Shot('F3-04', h: 1243, build: (_) async => _detail()),
base.Shot('F3-05', build: (_) async => LoungePostScreen(post: _hongPost)),
base.Shot('F3-06', build: (_) async => const ReportScreen(target: _report)),
base.Shot(
'F3-07',
build: (_) async => LoungePostScreen(post: _ilaPost),
after: (t) => _overlay(t, (c) => confirmBlock(c, 'ILA_WITH')),
),
base.Shot('F3-09', build: (_) async => const LoungeComposeScreen()),
base.Shot(
'F3-10',
build: (_) async => const FpiRankingScreen(
metric: FpiMetric.bpm,
repository: _FigmaPlanet(),
),
),
base.Shot(
'F3-11',
build: (_) async => const FpiUserRankingScreen(
metric: FpiMetric.bpm,
fandomId: 'bts',
planetName: 'BTS',
repository: _FigmaPlanet(),
),
),
base.Shot(
'F3-12',
build: (_) async => _detail(),
after: (t) async {
await _tap(t, find.text('팬덤 라운지'));
await _tap(
t,
find.image(const AssetImage('assets/figma/icon-post-more.png')),
);
},
),
base.Shot('F3-13', build: (_) async => const ReportScreen(target: _report)),
];
+499
View File
@@ -0,0 +1,499 @@
@Tags(['design'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/auth/auth_repository.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:fancake/features/planet/moderation.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/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 디자인 일치 그룹 C(F4 퀘스트 · F5 캠페인) 전용 하네스. 본체는 design_match_test.dart 와 같다.
///
/// Figma 「계약 시점 사본」 화면과 나란히 놓을 앱 렌더를 뽑는다.
///
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match` (단일: `--plain-name F6-01`)
/// 출력: `$DESIGN_MATCH_OUT/<id>.png`. 변수가 없으면 일반 `flutter test` 에서 건너뛴다. 비교는 tools/design/compare.py.
///
/// 화면 ID·Figma 노드는 디자인 대조 자료(화면 목록 · 매핑 근거).
/// 여기 없는 ID 는 앱 화면이 없거나(없음) 계약 제외(이메일·X·WeChat 가입 등)다.
/// Figma 프레임 폭. 높이는 화면마다 Figma 프레임 높이를 따른다.
const _width = 375.0;
/// iPhone X 계열 Figma 프레임의 상태 표시줄(48) · 홈 인디케이터(34).
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
typedef Build = Future<Widget> Function(Repositories r);
typedef After = Future<void> Function(WidgetTester t);
class Shot {
const Shot(this.id, {this.build, this.tab, this.h = 812, this.after});
final String id;
/// 탭 밖 화면. [tab] 과 둘 중 하나만 준다.
final Build? build;
/// 탭 루트 화면 — 실제 셸(하단 탭 포함)을 라우터로 띄운다.
final String? tab;
final double h;
/// 시트·다이얼로그·탭 전환처럼 화면을 띄운 뒤 한 번 더 조작해야 하는 상태.
final After? after;
}
Future<void> _tap(WidgetTester t, String text) async {
await t.tap(find.text(text).first, warnIfMissed: false);
await _settle(t);
}
/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다.
Future<void> _settle(WidgetTester t) async {
for (var i = 0; i < 6; i++) {
await t.pump(const Duration(milliseconds: 300));
}
}
Future<void> _period(WidgetTester t, List<String> days) async {
showCampaignPeriodSheet(
t.element(find.byType(Scaffold).first),
today: DateTime(2026, 4, 1),
);
await _settle(t);
for (final d in days) {
await t.tap(
find.descendant(
of: find.byType(CampaignPeriodSheet),
matching: find.text(d),
).first,
warnIfMissed: false,
);
await _settle(t);
}
}
final shots = <Shot>[
// F4 퀘스트 — 표본은 Figma 문구·수치와 같은 _F4Quests.
Shot(
'F4-01',
build: (r) async => QuestListScreen(repository: _F4Quests.mine(), myPop: 12540),
after: (t) => _tap(t, '내 퀘스트'),
),
Shot(
'F4-02',
build: (r) async =>
QuestDetailScreen(questId: 'm1', repository: _F4Quests.detail()),
),
Shot(
'F4-03',
build: (r) async => const ReportScreen(
target: ModerationTarget(authorName: 'BTS_Hong', title: '만보기'),
),
),
Shot('F4-04', build: (r) async => QuestCreateScreen(repository: _F4Quests.all())),
Shot('F4-05', build: (r) async => const PlacePickerScreen()),
Shot(
'F4-06',
build: (r) async => QuestListScreen(repository: _F4Quests.all(), myPop: 12540),
),
// 추가 보상 모달 — 관리자 퀘스트에서 「보상받기」를 누른 상태.
Shot(
'F4-07',
build: (r) async =>
QuestDetailScreen(questId: 'a0', repository: _F4Quests.attend()),
after: (t) => _tap(t, '보상받기'),
),
Shot('F4-08', build: (r) async => const QuestAdWarpScreen.before()),
Shot('F4-09', build: (r) async => const QuestAdWarpScreen.after()),
Shot(
'F4-10',
build: (r) async => QuestRewardScreen(
detail: await _F4Quests.all().detail('u1'),
reward: const QuestReward(rewardedPop: 10, balanceAfter: 12550),
),
),
// F5 캠페인 — 표본은 Figma 문구·수치와 같은 _F5Campaigns.
Shot('F5-01', build: (r) async => CampaignListScreen(repository: _F5Campaigns())),
// 시안은 검색어 「BTS」를 입력한 채 최근 검색어를 보여 준다(제출 전).
Shot(
'F5-02',
build: (r) async => CampaignSearchScreen(repository: _F5Campaigns()),
after: (t) async {
await t.enterText(find.byType(TextField), 'BTS');
await _settle(t);
},
),
Shot(
'F5-03',
build: (r) async => CampaignSearchScreen(repository: _F5Campaigns()),
after: (t) async {
await t.enterText(find.byType(TextField), 'BTS');
await t.testTextInput.receiveAction(TextInputAction.search);
await _settle(t);
},
),
Shot(
'F5-04',
build: (r) async =>
CampaignDetailScreen(campaignId: 'c1', repository: _F5Campaigns()),
after: (t) async {
await t.tap(find.byTooltip('공유'));
await _settle(t);
},
),
Shot(
'F5-05',
h: 975,
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
),
// 캘린더 시트 두 상태 — 사본 `333:72497`(시작일 9 만) · `333:72719`(9 ~ 23).
// 사본 달력이 2026년 4월이라 「오늘」을 4월 1일로 고정한다(1~8 도 고를 수 있는 날).
Shot(
'F5-05__333-72497',
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
after: (t) => _period(t, ['9']),
),
Shot(
'F5-05__333-72719',
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
after: (t) => _period(t, ['9', '23']),
),
// Figma 기본 프레임은 「기여하기」 시트가 열린 상태다.
Shot(
'F5-06',
build: (r) async =>
CampaignDetailScreen(campaignId: 'c1', repository: _F5Campaigns()),
after: (t) => _tap(t, '기여하기'),
),
Shot(
'F5-07',
build: (r) async =>
CampaignRankingScreen(campaignId: 'c1', repository: _F5Campaigns()),
),
Shot(
'F5-08',
build: (r) async => const ReportScreen(
target: ModerationTarget(authorName: 'BTS_Hong', title: '강남역 초대형 LED 광고'),
),
),
];
/// F5 표본 — Figma 사본(272:241184 · 272:245738 · 333:70971 · 272:241681)의 문구·수치.
/// F4 표본 — 시안 `264:46867`(전체) · `259:46109`(내 퀘스트) · `264:48835`(상세) 문구·수치.
class _F4Quests extends SampleQuestRepository {
const _F4Quests(this.cards);
_F4Quests.all()
: cards = const [
QuestCard(
id: 'a1', title: '출석체크', categoryName: '오늘의 출석체크',
type: QuestType.attend, author: QuestAuthor.admin,
stage: QuestStage.notStarted, goal: 1, progress: 0, rewardPop: 10,
participants: 0,
),
QuestCard(
id: 'a2', title: '좋아요', categoryName: '커뮤니티',
author: QuestAuthor.admin, stage: QuestStage.notStarted,
goal: 5, progress: 0, rewardPop: 10, participants: 0,
),
QuestCard(
id: 'a3', title: '댓글', categoryName: '커뮤니티',
author: QuestAuthor.admin, stage: QuestStage.notStarted,
goal: 5, progress: 0, rewardPop: 10, participants: 0,
),
QuestCard(
id: 'u1', title: '댓글', categoryName: '커뮤니티',
author: QuestAuthor.user, stage: QuestStage.completed,
goal: 5, progress: 5, rewardPop: 10, participants: 0, capacity: 5,
),
QuestCard(
id: 'u2', title: '좋아요', categoryName: '커뮤니티',
author: QuestAuthor.user, stage: QuestStage.inProgress,
goal: 10, progress: 0, rewardPop: 15, participants: 0, capacity: 5,
),
QuestCard(
id: 'u3', title: '만보기', categoryName: '건강',
type: QuestType.steps, author: QuestAuthor.user,
stage: QuestStage.notStarted, goal: 10000, progress: 0,
rewardPop: 150, participants: 0, capacity: 5,
),
];
_F4Quests.mine()
: cards = const [
QuestCard(
id: 'm1', title: '만보기', categoryName: '건강',
type: QuestType.steps, author: QuestAuthor.user,
stage: QuestStage.mine, goal: 10000, progress: 0, rewardPop: 150,
participants: 0, capacity: 5,
),
];
_F4Quests.attend()
: cards = const [
QuestCard(
id: 'a0', title: '출석 체크 보상을 받아보세요',
categoryName: '오늘의 출석체크', type: QuestType.attend,
author: QuestAuthor.admin, stage: QuestStage.completed,
goal: 1, progress: 1, rewardPop: 50, participants: 0,
),
];
/// 상세 시안은 목록과 제목이 다르다.
_F4Quests.detail()
: cards = const [
QuestCard(
id: 'm1', title: '뉴진스 미니앨범 축하 퀘스트', categoryName: '건강',
type: QuestType.steps, author: QuestAuthor.user,
stage: QuestStage.mine, goal: 10000, progress: 0, rewardPop: 150,
participants: 0, capacity: 5,
),
];
final List<QuestCard> cards;
@override
Future<List<QuestCard>> load({int limit = 10}) async => cards;
@override
Future<List<QuestCard>> more({required int offset, int limit = 10}) async =>
const [];
/// 시안의 카테고리 네 개(C-01 분류 키).
@override
Future<QuestCatalog> catalog() async => const QuestCatalog(categories: [
QuestCategory(id: 'health', name: '건강', activities: [QuestActivity(key: 'walk', name: '걷기')]),
QuestCategory(id: 'planet', name: '행성 활동', activities: [QuestActivity(key: 'vote', name: '투표 참여')]),
QuestCategory(id: 'community', name: '커뮤니티', activities: [QuestActivity(key: 'community', name: '커뮤니티')]),
QuestCategory(id: 'location', name: '특정 위치', activities: [QuestActivity(key: 'location', name: '특정 위치', fields: {'place'})]),
]);
@override
Future<QuestDetail> detail(String questId) async => QuestDetail(
card: cards.firstWhere((c) => c.id == questId),
imageLabel: '',
description: '',
);
}
class _F5Campaigns extends SampleCampaignRepository {
static final _now = DateTime.now();
static CampaignCardData _card(String id) => CampaignCardData(
id: id,
title: '강남역 초대형 LED 광고',
body: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다.\n도심을 BTS의 색으로 물들여주세요.',
authorName: '다이브짱',
createdAt: _now.subtract(const Duration(minutes: 10)),
status: CampaignStatus.funding,
goalPop: 1000000,
raisedPop: 920000,
supporters: 1834,
likes: 48,
comments: 24,
views: 82,
shares: 856,
);
static const _ranks = [
ContributorRank(rank: 1, name: 'GLAS_SMILE', pop: 3000),
ContributorRank(rank: 2, name: 'Suyom_mo', pop: 2600),
ContributorRank(rank: 3, name: 'Bool_huuu', pop: 1400),
ContributorRank(rank: 4, name: 'Wachooo', pop: 1200, change: 1),
ContributorRank(rank: 5, name: 'Jeans_Bewn', pop: 1200),
ContributorRank(rank: 6, name: 'pinkyy', pop: 1100, change: 1),
ContributorRank(rank: 7, name: 'T_T', pop: 1000, change: -1),
ContributorRank(rank: 8, name: 'Yogi_ITZY', pop: 98, change: 2),
ContributorRank(rank: 9, name: 'SaunaSupa', pop: 97, change: 3),
ContributorRank(rank: 10, name: '17_10', pop: 88, change: 8),
ContributorRank(rank: 11, name: 'Getting_OLD', pop: 87, change: -1),
];
final _queries = ['BTS', 'BTS 광화문 공연', '아미', '방탄소년단', '지민'];
@override
Future<List<CampaignCardData>> load({
CampaignSort sort = CampaignSort.latest,
int limit = 10,
}) async => [_card('c1'), _card('c2')];
@override
Future<List<CampaignCardData>> more({required int offset, int limit = 10}) async => const [];
@override
Future<CampaignDetail> detail(String campaignId) async => CampaignDetail(
card: _card(campaignId),
mediaLabels: const ['대표 이미지'],
endsAt: DateTime.now().add(
const Duration(days: 6, hours: 23, minutes: 59, seconds: 6, milliseconds: 900),
),
topContributors: _ranks.take(7).toList(),
);
@override
Future<List<ContributorRank>> ranking(
String campaignId, {
RankWindow window = RankWindow.live,
}) async => [
..._ranks,
const ContributorRank(rank: 82, name: 'BTS_Hong', pop: 3, change: 1, isMe: true),
];
@override
Future<List<String>> recentQueries() async => [..._queries];
@override
Future<List<CampaignCardData>> search(String query) async => [_card('c1'), _card('c2')];
@override
Future<int> balance() async => 12500;
}
Future<void> _loadFonts() async {
final manifest =
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
for (final font in manifest.cast<Map<String, dynamic>>()) {
final loader = FontLoader(font['family'] as String);
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
}
await loader.load();
}
// 글꼴을 지정하지 않은 글자는 테스트 기본(Ahem 네모)으로 그려진다. 기기에서는 시스템 글꼴이므로 본문 글꼴로 대신한다.
await (FontLoader(
'Roboto',
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
}
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
for (final e in find.byType(Image).evaluate().toList()) {
await precacheImage((e.widget as Image).image, e);
}
});
void main() {
final out = Platform.environment['DESIGN_MATCH_OUT'];
if (out == null) return;
setUpAll(() async {
Directory(out).createSync(recursive: true);
await _loadFonts();
// 이클립스 방어의 흔들기 센서. 플러그인이 없는 테스트 환경에서는 조용히 비운다.
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
(_) async => null,
);
messenger.setMockStreamHandler(
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
});
for (final s in shots) {
testWidgets(s.id, (t) async {
t.view.devicePixelRatio = 2;
t.view.physicalSize = Size(_width * 2, s.h * 2);
t.view.padding = _padding;
t.view.viewPadding = _padding;
addTearDown(t.view.reset);
final repos = Repositories(live: false);
final boundary = GlobalKey();
final navigator = GlobalKey<NavigatorState>();
Widget? pushed;
Widget app;
if (s.tab != null) {
final router = buildRouter(
AuthRepository()..signInWithMockSession(),
repositories: repos,
);
router.go(s.tab!);
app = MaterialApp.router(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
routerConfig: router,
);
} else {
final screen = (await t.runAsync(() => s.build!(repos)))!;
// 빈 화면 위에 push 해야 AppBar 가 뒤로가기 버튼을 그린다(앱에서도 push 로 연다).
app = MaterialApp(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
navigatorKey: navigator,
home: const SizedBox(),
);
pushed = screen;
}
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
if (pushed != null) {
final screen = pushed;
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => screen),
);
}
await _settle(t);
await s.after?.call(t);
await _precacheImages(t);
await _settle(t);
await t.runAsync(() async {
final image =
await (boundary.currentContext!.findRenderObject()!
as RenderRepaintBoundary)
.toImage(pixelRatio: 2);
final png = await image.toByteData(format: ui.ImageByteFormat.png);
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
image.dispose();
});
// 글자 대조용 — compare.py 가 Figma spec 의 TEXT 노드와 맞춰 본다.
final texts = [
for (final e in find.byType(RichText).evaluate())
if (e.renderObject case final RenderParagraph p
when p.attached && p.text.toPlainText().trim().isNotEmpty)
{
'text': p.text.toPlainText(),
'family': p.text.style?.fontFamily,
'size': p.text.style?.fontSize,
'weight': p.text.style?.fontWeight?.value,
'color': p.text.style?.color?.toARGB32().toRadixString(16),
'rect': [
for (final v in [
p.localToGlobal(Offset.zero).dx,
p.localToGlobal(Offset.zero).dy,
p.size.width,
p.size.height,
])
v.roundToDouble(),
],
},
];
File(
'$out/${s.id}.texts.json',
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
// 렌더 예외(오버플로 등)는 실패로 올린다 — 비교 전에 고쳐야 할 것이다.
expect(t.takeException(), isNull);
});
}
}
+415
View File
@@ -0,0 +1,415 @@
@Tags(['design'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/campaign/campaign_data.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_lists_screen.dart';
import 'package:fancake/features/my/notice_screens.dart';
import 'package:fancake/features/my/payment_screens.dart';
import 'package:fancake/features/my/pop_history_screen.dart';
import 'package:fancake/features/my/profile_edit_screen.dart';
import 'package:fancake/features/my/push_settings_screen.dart';
import 'package:fancake/features/my/settings_screen.dart';
import 'package:fancake/features/my/support_screens.dart';
import 'package:fancake/features/my/tier_screens.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 그룹 D(F6 MY) 디자인 대조 렌더 — 표본을 Figma 사본 문구에 맞춘다.
///
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/d_test.dart`
/// 렌더 방식은 공유 하네스(design_match_test.dart)와 같고, 저장소만 [_FigmaMy] 로 바꾼다.
const _width = 375.0;
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
/// Figma 사본의 표본 문구(BTS_Hong · hon******@gmail.com …). 숫자는 원장 체계(C-18)로 담는다.
class _FigmaMy extends SampleMyRepository {
@override
Future<MyProfile> profile() async => const MyProfile(
nickname: 'BTS_Hong',
email: 'hon******@gmail.com',
contributedPop: 350,
userExp: 100,
userLevel: 1,
balancePop: 12540,
steps: 1000,
completedQuests: 3,
planetTiers: [
PlanetTier(fandomName: 'BTS', contributedPop: 100, isPrimary: true),
PlanetTier(fandomName: 'NewJeans', contributedPop: 100),
PlanetTier(fandomName: 'IVE', contributedPop: 100),
PlanetTier(fandomName: 'aespa', contributedPop: 100),
PlanetTier(fandomName: 'SEVENTEEN', contributedPop: 100),
PlanetTier(fandomName: 'LE SSERAFIM', contributedPop: 100),
],
);
@override
Future<List<PopLedgerEntry>> popHistory({
int limit = 20,
PopCursor? after,
}) async => after != null
? const []
: [
for (final (d, title, type, delta, balance) in const [
(5, 'BTS', '행성 기여', -100, 12640),
(4, '만보기', '퀘스트 생성', -750, 13390),
(4, '좋아요', '퀘스트 완료', 150, 13240),
(3, '강남역 초대형 LED 광고', '캠페인 기여', -300, 13540),
(2, 'BTS_RM', '성운 기여', -100, 13640),
(1, 'BTS 컴백 알려주세요!', '인사이드 생성', -100, 13640),
(0, '친구 초대', '레퍼럴', 150, 13640),
(-1, '여기 화면 어떻게 하나요?', '인사이드 채택', 150, 13640),
])
PopLedgerEntry(
at: DateTime(2026, 5, d, 12),
title: title,
type: type,
kind: PopKind.general,
txType: delta < 0 ? PopTxType.spend : PopTxType.grant,
delta: delta,
balanceAfter: balance,
cursor: PopCursor(at: DateTime(2026, 5, d, 12), id: title),
),
];
@override
Future<List<NoticeItem>> notices() async => [
for (final (i, (title, m, d)) in const [
('[공지] 2026년 하반기 업데이트 관련 안내', 4, 30),
('[공지] 2026년 하반기 업데이트 관련 안내', 4, 30),
('서비스 첫 출시 관련 수정 안내', 4, 30),
('새로운 기능 추가 및 성능 개선', 5, 10),
('봄맞이 할인 이벤트 안내', 5, 5),
('서버 정기 점검 안내', 5, 15),
('이용약관 변경 안내', 5, 20),
('신규 사용자 가이드 배포', 5, 25),
('휴대폰 앱 업데이트 권장', 5, 30),
('긴급 보안 패치 적용', 6, 1),
].indexed)
NoticeItem(
id: 'n$i',
title: title,
// 시안 본문의 「팬케이크」는 사용자 노출 브랜드 규칙으로 「팬우주」.
body:
'안녕하세요. 팬우주 운영진 입니다.\n2026년 하반기를 앞두고 아래의 업데이트 관련 내용을 전달드리려고 합니다!\n\n아래의 내용들은 이미지와 함께 첨부했으니 확인해주시기 바랍니다.\n\n감사합니다.',
at: DateTime(2026, m, d),
),
];
@override
Future<List<InquiryItem>> myInquiries() async => [
for (final (i, s) in const [
InquiryStatus.answered,
InquiryStatus.inProgress,
InquiryStatus.pending,
].indexed)
InquiryItem(
id: 'q$i',
title: i == 0 ? '구매 상품 환불 관련 문의 접수' : '서비스 이용 관련 문의 접수',
body: '문의 내용',
at: DateTime(2026, 4, 30),
status: s,
),
];
@override
Future<List<PaymentEntry>> payments() async => [
for (final (i, (m, price)) in const [
(5, 3900),
(6, 4500),
(7, 5200),
(8, 6100),
(9, 2800),
(10, 4750),
(11, 3600),
(12, 5000),
// 시안 「13.05 · 14.05 · 15.05」 는 달력에 없는 날짜다. 달을 넘겨 01~03.05 로 그린다.
(13, 6450),
(14, 4900),
(15, 3750),
].indexed)
PaymentEntry(
id: 'IAP2405110${i + 1}',
at: DateTime(2026, m, 5),
itemName: '아이템${i + 1}',
price: price,
method: 'iOS',
state: PaymentState.paid,
),
];
@override
Future<PaymentEntry> payment(String id) async =>
(await payments()).firstWhere((p) => p.id == id);
@override
Future<({String current, bool latest})> appVersion() async =>
(current: '0.4.0', latest: true);
}
/// 시안 `370:229665` 의 내 캠페인 한 장.
class _FigmaCampaign extends SampleCampaignRepository {
@override
Future<List<CampaignCardData>> load({
CampaignSort sort = CampaignSort.latest,
int limit = 10,
}) async => [
CampaignCardData(
id: 'c-mine',
title: 'BTS 인스타 광고 캠페인',
body:
'BTS 인스타 광고 캠페인 진행하실 아미분들!!!\n매년 찾아오는 인스타 광고에서 저희 방탄을 위해 화력을 보여주세요!!',
authorName: 'BTS_Hong',
createdAt: DateTime.now(),
status: CampaignStatus.funding,
goalPop: 30000,
raisedPop: 0,
supporters: 0,
likes: 0,
comments: 0,
views: 0,
shares: 0,
mine: true,
),
];
}
class _Repos extends Repositories {
_Repos() : super(live: false);
@override
late final MyRepository my = _FigmaMy();
@override
late final CampaignRepository campaign = _FigmaCampaign();
@override
AccountLinker linker(List<SocialAuthProvider> providers) =>
SampleAccountLinker();
}
typedef Build = Future<Widget> Function(Repositories r);
class Shot {
const Shot(this.id, {this.build, this.tab, this.h = 812, this.after});
final String id;
final Build? build;
final String? tab;
final double h;
final Future<void> Function(WidgetTester t)? after;
}
Future<void> _settle(WidgetTester t) async {
for (var i = 0; i < 6; i++) {
await t.pump(const Duration(milliseconds: 300));
}
}
Widget _settings(Repositories r) =>
SettingsScreen(repository: r.my, linker: r.linker(const []));
final shots = <Shot>[
Shot('F6-01', tab: '/my'),
Shot(
'F6-02',
build: (r) async => const MyListsScreen(kind: MyListKind.inside),
),
Shot(
'F6-03',
build: (r) async =>
MyListsScreen(kind: MyListKind.campaign, campaigns: r.campaign),
),
Shot('F6-04', h: 1344, build: (r) async => _settings(r)),
// F6-05~07 은 설정 위에 뜨는 OS 제공자 시트다. 시트 자체는 OS 가 그리므로(계약 제외)
// 아래 깔린 설정 화면만 대조한다.
Shot('F6-05', build: (r) async => _settings(r)),
Shot('F6-06', build: (r) async => _settings(r)),
Shot('F6-07', build: (r) async => _settings(r)),
Shot('F6-08', h: 959, build: (r) async => PopHistoryScreen(repository: r.my)),
Shot(
'F6-10',
h: 1276,
build: (r) async => NoticeListScreen(repository: r.my),
),
Shot(
'F6-11',
build: (r) async =>
NoticeDetailScreen(notice: (await r.my.notices()).first),
),
Shot('F6-12', build: (r) async => PaymentHistoryScreen(repository: r.my)),
Shot(
'F6-13',
build: (r) async =>
PaymentDetailScreen(paymentId: 'IAP24051101', repository: r.my),
),
Shot(
'F6-14',
build: (r) async =>
RefundRequestScreen(paymentId: 'IAP24051101', repository: r.my),
),
Shot(
'F6-15',
build: (r) async =>
TierDetailScreen(tiers: (await r.my.profile()).planetTiers),
),
Shot('F6-16', build: (r) async => const TierInfoScreen()),
// 시안은 앨범에서 사진을 고른 뒤(완료 활성) 상태다.
Shot(
'F6-17',
build: (r) async {
final bytes = (await rootBundle.load(
'assets/figma/avatar-sample.png',
)).buffer.asUint8List();
return ProfilePhotoScreen(
pickPhoto: (_) async => PickedPhoto(
bytes: bytes,
contentType: 'image/png',
name: 'avatar-sample.png',
),
);
},
after: (t) async {
await t.tap(find.bySemanticsLabel('앨범에서 고르기'));
await _settle(t);
},
),
// 시안은 닉네임을 바꾼 뒤(완료 활성) 상태다.
Shot(
'F6-18',
build: (r) async => ProfileEditScreen(nickname: 'BTS', repository: r.my),
after: (t) async {
await t.enterText(find.byType(TextField), 'BTS_Hong');
FocusManager.instance.primaryFocus?.unfocus();
await _settle(t);
},
),
Shot('F6-19', build: (r) async => InquiryScreen(repository: r.my)),
// 시안 기본 상태는 기기 알림이 꺼진 쪽이다(켜진 쪽은 변형 `357:223611`).
Shot(
'F6-20',
build: (r) async =>
PushSettingsScreen(repository: r.my, deviceAllowed: false),
),
];
Future<void> _loadFonts() async {
final manifest =
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
for (final font in manifest.cast<Map<String, dynamic>>()) {
final loader = FontLoader(font['family'] as String);
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
}
await loader.load();
}
await (FontLoader(
'Roboto',
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
}
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
for (final e in find.byType(Image).evaluate().toList()) {
await precacheImage((e.widget as Image).image, e);
}
});
void main() {
final out = Platform.environment['DESIGN_MATCH_OUT'];
if (out == null) return;
setUpAll(() async {
Directory(out).createSync(recursive: true);
await _loadFonts();
});
for (final s in shots) {
testWidgets(s.id, (t) async {
t.view.devicePixelRatio = 2;
t.view.physicalSize = Size(_width * 2, s.h * 2);
t.view.padding = _padding;
t.view.viewPadding = _padding;
addTearDown(t.view.reset);
final repos = _Repos();
final boundary = GlobalKey();
final navigator = GlobalKey<NavigatorState>();
Widget? pushed;
Widget app;
if (s.tab != null) {
final router = buildRouter(
AuthRepository()..signInWithMockSession(),
repositories: repos,
);
router.go(s.tab!);
app = MaterialApp.router(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
routerConfig: router,
);
} else {
pushed = (await t.runAsync(() => s.build!(repos)))!;
app = MaterialApp(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
navigatorKey: navigator,
home: const SizedBox(),
);
}
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
if (pushed != null) {
final screen = pushed;
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => screen),
);
}
await _settle(t);
await s.after?.call(t);
await _precacheImages(t);
await _settle(t);
await t.runAsync(() async {
final image =
await (boundary.currentContext!.findRenderObject()!
as RenderRepaintBoundary)
.toImage(pixelRatio: 2);
final png = await image.toByteData(format: ui.ImageByteFormat.png);
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
image.dispose();
});
final texts = [
for (final e in find.byType(RichText).evaluate())
if (e.renderObject case final RenderParagraph p
when p.attached && p.text.toPlainText().trim().isNotEmpty)
{
'text': p.text.toPlainText(),
'size': p.text.style?.fontSize,
'rect': [
for (final v in [
p.localToGlobal(Offset.zero).dx,
p.localToGlobal(Offset.zero).dy,
p.size.width,
p.size.height,
])
v.roundToDouble(),
],
},
];
File(
'$out/${s.id}.texts.json',
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
expect(t.takeException(), isNull);
});
}
}
@@ -0,0 +1,205 @@
@Tags(['design'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/my/profile_edit_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// Figma 「계약 시점 사본」 화면과 나란히 놓을 앱 렌더를 뽑는다.
///
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match` (단일: `--plain-name F6-01`)
/// 출력: `$DESIGN_MATCH_OUT/<id>.png`. 변수가 없으면 일반 `flutter test` 에서 건너뛴다. 비교는 tools/design/compare.py.
///
/// 화면 ID·Figma 노드는 디자인 대조 자료(화면 목록 · 매핑 근거).
/// 여기 없는 ID 는 앱 화면이 없거나(없음) 계약 제외(이메일·X·WeChat 가입 등)다.
/// Figma 프레임 폭. 높이는 화면마다 Figma 프레임 높이를 따른다.
const _width = 375.0;
/// iPhone X 계열 Figma 프레임의 상태 표시줄(48) · 홈 인디케이터(34).
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
typedef Build = Future<Widget> Function(Repositories r);
typedef After = Future<void> Function(WidgetTester t);
class Shot {
const Shot(
this.id, {
this.build,
this.tab,
this.h = 812,
this.after,
this.repos,
});
final String id;
/// 표본 저장소. 없으면 기본 표본(`Repositories(live: false)`).
final Repositories Function()? repos;
/// 탭 밖 화면. [tab] 과 둘 중 하나만 준다.
final Build? build;
/// 탭 루트 화면 — 실제 셸(하단 탭 포함)을 라우터로 띄운다.
final String? tab;
final double h;
/// 시트·다이얼로그·탭 전환처럼 화면을 띄운 뒤 한 번 더 조작해야 하는 상태.
final After? after;
}
/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다.
Future<void> _settle(WidgetTester t) async {
for (var i = 0; i < 6; i++) {
await t.pump(const Duration(milliseconds: 300));
}
}
/// 화면별 표본은 그룹 파일(a~e_test.dart)이 가진다 — 같은 ID 를 두 곳에서 그리면 실행 순서에 따라
/// 결과가 바뀌므로, 여기에는 그룹 파일에 없는 ID 만 둔다(쓰레기통 F6-21 · 점수 제외).
/// b_test 는 이 목록을 비우고 자기 표본을 채워 [main] 을 그대로 쓴다.
final shots = <Shot>[
Shot(
'F6-21',
build: (r) async =>
ProfileEditScreen(nickname: 'BTS_Hong', repository: r.my),
),
];
Future<void> _loadFonts() async {
final manifest =
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
for (final font in manifest.cast<Map<String, dynamic>>()) {
final loader = FontLoader(font['family'] as String);
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
}
await loader.load();
}
// 글꼴을 지정하지 않은 글자는 테스트 기본(Ahem 네모)으로 그려진다. 기기에서는 시스템 글꼴이므로 본문 글꼴로 대신한다.
await (FontLoader(
'Roboto',
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
}
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
for (final e in find.byType(Image).evaluate().toList()) {
await precacheImage((e.widget as Image).image, e);
}
});
void main() {
final out = Platform.environment['DESIGN_MATCH_OUT'];
if (out == null) return;
setUpAll(() async {
Directory(out).createSync(recursive: true);
await _loadFonts();
// 이클립스 방어의 흔들기 센서. 플러그인이 없는 테스트 환경에서는 조용히 비운다.
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
(_) async => null,
);
messenger.setMockStreamHandler(
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
});
for (final s in shots) {
testWidgets(s.id, (t) async {
t.view.devicePixelRatio = 2;
t.view.physicalSize = Size(_width * 2, s.h * 2);
t.view.padding = _padding;
t.view.viewPadding = _padding;
addTearDown(t.view.reset);
final repos = s.repos?.call() ?? Repositories(live: false);
final boundary = GlobalKey();
final navigator = GlobalKey<NavigatorState>();
Widget? pushed;
Widget app;
if (s.tab != null) {
final router = buildRouter(
AuthRepository()..signInWithMockSession(),
repositories: repos,
);
router.go(s.tab!);
app = MaterialApp.router(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
routerConfig: router,
);
} else {
final screen = (await t.runAsync(() => s.build!(repos)))!;
// 빈 화면 위에 push 해야 AppBar 가 뒤로가기 버튼을 그린다(앱에서도 push 로 연다).
app = MaterialApp(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
navigatorKey: navigator,
home: const SizedBox(),
);
pushed = screen;
}
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
if (pushed != null) {
final screen = pushed;
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => screen),
);
}
await _settle(t);
await s.after?.call(t);
await _precacheImages(t);
await _settle(t);
await t.runAsync(() async {
final image =
await (boundary.currentContext!.findRenderObject()!
as RenderRepaintBoundary)
.toImage(pixelRatio: 2);
final png = await image.toByteData(format: ui.ImageByteFormat.png);
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
image.dispose();
});
// 글자 대조용 — compare.py 가 Figma spec 의 TEXT 노드와 맞춰 본다.
final texts = [
for (final e in find.byType(RichText).evaluate())
if (e.renderObject case final RenderParagraph p
when p.attached && p.text.toPlainText().trim().isNotEmpty)
{
'text': p.text.toPlainText(),
'family': p.text.style?.fontFamily,
'size': p.text.style?.fontSize,
'weight': p.text.style?.fontWeight?.value,
'color': p.text.style?.color?.toARGB32().toRadixString(16),
'rect': [
for (final v in [
p.localToGlobal(Offset.zero).dx,
p.localToGlobal(Offset.zero).dy,
p.size.width,
p.size.height,
])
v.roundToDouble(),
],
},
];
File(
'$out/${s.id}.texts.json',
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
// 렌더 예외(오버플로 등)는 실패로 올린다 — 비교 전에 고쳐야 할 것이다.
expect(t.takeException(), isNull);
});
}
}
+328
View File
@@ -0,0 +1,328 @@
@Tags(['design'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/auth/login_screen.dart';
import 'package:fancake/features/auth/signup_screens.dart';
import 'package:fancake/features/inside/inside_data.dart';
import 'package:fancake/features/inside/inside_detail_screen.dart';
import 'package:fancake/features/inside/inside_search_screen.dart';
import 'package:fancake/features/inside/inside_write_screen.dart';
import 'package:fancake/features/notification/notification_screen.dart';
import 'package:fancake/features/planet/moderation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 디자인 일치 2단계 그룹 E — INSIDE(F9) · 로그인(F10) · 알림(F0-02).
///
/// 러너는 `design_match_test.dart` 와 같고, 화면 목록과 표본만 이 그룹 것이다.
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/e_test.dart`
/// Figma 프레임 폭. 높이는 화면마다 Figma 프레임 높이를 따른다.
const _width = 375.0;
/// iPhone X 계열 Figma 프레임의 상태 표시줄(48) · 홈 인디케이터(34).
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
typedef Build = Future<Widget> Function(Repositories r);
typedef After = Future<void> Function(WidgetTester t);
class Shot {
const Shot(this.id, {this.build, this.tab, this.h = 812, this.after});
final String id;
/// 탭 밖 화면. [tab] 과 둘 중 하나만 준다.
final Build? build;
/// 탭 루트 화면 — 실제 셸(하단 탭 포함)을 라우터로 띄운다.
final String? tab;
final double h;
/// 시트·다이얼로그·탭 전환처럼 화면을 띄운 뒤 한 번 더 조작해야 하는 상태.
final After? after;
}
Future<void> _tap(WidgetTester t, String text) async {
await t.tap(find.text(text).first, warnIfMissed: false);
await _settle(t);
}
/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다.
Future<void> _settle(WidgetTester t) async {
for (var i = 0; i < 6; i++) {
await t.pump(const Duration(milliseconds: 300));
}
}
Future<void> _overlay(
WidgetTester t,
void Function(BuildContext c) open,
) async {
open(t.element(find.byType(Scaffold).first));
await _settle(t);
}
/// 시안 알림(229:11265) 표본. 날짜 머리글이 「오늘 · 어제 · 과거」로 갈리도록 지금 기준으로 잡는다.
List<AppNotification> _notifications() {
final now = DateTime.now();
DateTime at(int daysAgo, int h, int m) =>
DateTime(now.year, now.month, now.day - daysAgo, h, m);
return [
AppNotification(
id: '1',
kind: NotificationKind.comment,
title: '내 INSIDE에 새로운 댓글이 달렸어요.',
at: at(0, 10, 40),
source: 'INSIDE',
),
AppNotification(
id: '2',
kind: NotificationKind.comment,
title: '내 캠페인에 새로운 댓글이 달렸어요.',
at: at(0, 10, 40),
source: 'CAMPAIGN',
),
AppNotification(
id: '3',
kind: NotificationKind.like,
title: '내 INSIDE에 좋아요가 추가됐어요.',
at: at(0, 9, 28),
source: 'INSIDE',
),
AppNotification(
id: '4',
kind: NotificationKind.eclipse,
title: '내 행성의 이클립스를 방어했어요.',
at: at(1, 10, 40),
source: 'BTS',
),
AppNotification(
id: '5',
kind: NotificationKind.eclipse,
title: '내 행성의 이클립스가 발생했어요.',
at: at(1, 10, 40),
source: 'BTS',
),
AppNotification(
id: '6',
kind: NotificationKind.itemUsed,
title: '팜하니짱님의 고귀한 기여로 영웅의 선물이 발동되었습니다!',
at: at(2, 16, 27),
source: '아이템',
),
AppNotification(
id: '7',
kind: NotificationKind.quest,
title: '[만보기] 퀘스트를 완료했어요!',
at: at(2, 13, 27),
source: '퀘스트',
),
];
}
Future<InsideQuestion> _question(Repositories r, String id) async =>
(await r.inside.load()).questions.firstWhere((q) => q.id == id);
final shots = <Shot>[
Shot(
'F0-02',
build: (r) async => NotificationScreen(
repository: SampleNotificationRepository(items: _notifications()),
),
),
// F9 INSIDE
Shot('F9-01', build: (r) async => InsideSearchScreen(repository: r.inside)),
Shot(
'F9-02',
build: (r) async => InsideSearchScreen(repository: r.inside),
after: (t) async {
await t.enterText(find.byType(TextField), 'BTS');
await t.testTextInput.receiveAction(TextInputAction.search);
await _settle(t);
await _tap(t, 'POP순');
// 커서 깜빡임을 없앤다.
FocusManager.instance.primaryFocus?.unfocus();
await _settle(t);
},
),
Shot(
'F9-03',
h: 1360,
build: (r) async => InsideDetailScreen(
question: await _question(r, 'q-1'),
answers: sampleInsideAnswers,
),
),
Shot(
'F9-04',
build: (r) async => const ReportScreen(
target: ModerationTarget(
authorName: 'BTS_Hong',
title: '공방 신청 꿀팁 공유합니다.',
),
),
),
Shot(
'F9-05',
build: (r) async => InsideDetailScreen(
question: await _question(r, 'q-1'),
answers: sampleInsideAnswers,
),
after: (t) => _overlay(t, (c) => confirmBlock(c, '다이브짱')),
),
Shot('F9-06', build: (r) async => const InsideWriteScreen(myPop: 12500)),
Shot('F9-07', tab: '/inside', after: (t) => _tap(t, 'POP순')),
Shot('F9-08', tab: '/inside', after: (t) => _tap(t, '인기순')),
Shot(
'F9-09',
h: 1301,
build: (r) async => InsideDetailScreen(question: await _question(r, 'q-4')),
),
Shot('F9-10', tab: '/inside'),
// F10 로그인
Shot('F10-01', build: (r) async => LoginScreen(auth: AuthRepository())),
Shot('F10-02', build: (r) async => SimpleLoginScreen(auth: AuthRepository())),
Shot(
'F10-12',
build: (r) async => SimpleLoginScreen(auth: AuthRepository(), signUp: true),
),
Shot('F10-15', h: 906, build: (r) async => const TermsScreen()),
Shot('F10-16', build: (r) async => const SignupCompleteScreen()),
];
Future<void> _loadFonts() async {
final manifest =
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
for (final font in manifest.cast<Map<String, dynamic>>()) {
final loader = FontLoader(font['family'] as String);
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
}
await loader.load();
}
// 글꼴을 지정하지 않은 글자는 테스트 기본(Ahem 네모)으로 그려진다. 기기에서는 시스템 글꼴이므로 본문 글꼴로 대신한다.
await (FontLoader(
'Roboto',
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
}
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
for (final e in find.byType(Image).evaluate().toList()) {
await precacheImage((e.widget as Image).image, e);
}
});
void main() {
final out = Platform.environment['DESIGN_MATCH_OUT'];
if (out == null) return;
setUpAll(() async {
Directory(out).createSync(recursive: true);
await _loadFonts();
// 이클립스 방어의 흔들기 센서. 플러그인이 없는 테스트 환경에서는 조용히 비운다.
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
(_) async => null,
);
messenger.setMockStreamHandler(
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
MockStreamHandler.inline(onListen: (_, _) {}),
);
});
for (final s in shots) {
testWidgets(s.id, (t) async {
t.view.devicePixelRatio = 2;
t.view.physicalSize = Size(_width * 2, s.h * 2);
t.view.padding = _padding;
t.view.viewPadding = _padding;
addTearDown(t.view.reset);
final repos = Repositories(live: false);
final boundary = GlobalKey();
final navigator = GlobalKey<NavigatorState>();
Widget? pushed;
Widget app;
if (s.tab != null) {
final router = buildRouter(
AuthRepository()..signInWithMockSession(),
repositories: repos,
);
router.go(s.tab!);
app = MaterialApp.router(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
routerConfig: router,
);
} else {
final screen = (await t.runAsync(() => s.build!(repos)))!;
// 빈 화면 위에 push 해야 AppBar 가 뒤로가기 버튼을 그린다(앱에서도 push 로 연다).
app = MaterialApp(
theme: buildAppTheme(),
debugShowCheckedModeBanner: false,
navigatorKey: navigator,
home: const SizedBox(),
);
pushed = screen;
}
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
if (pushed != null) {
final screen = pushed;
navigator.currentState!.push(
MaterialPageRoute<void>(builder: (_) => screen),
);
}
await _settle(t);
await s.after?.call(t);
await _precacheImages(t);
await _settle(t);
await t.runAsync(() async {
final image =
await (boundary.currentContext!.findRenderObject()!
as RenderRepaintBoundary)
.toImage(pixelRatio: 2);
final png = await image.toByteData(format: ui.ImageByteFormat.png);
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
image.dispose();
});
// 글자 대조용 — compare.py 가 Figma spec 의 TEXT 노드와 맞춰 본다.
final texts = [
for (final e in find.byType(RichText).evaluate())
if (e.renderObject case final RenderParagraph p
when p.attached && p.text.toPlainText().trim().isNotEmpty)
{
'text': p.text.toPlainText(),
'family': p.text.style?.fontFamily,
'size': p.text.style?.fontSize,
'weight': p.text.style?.fontWeight?.value,
'color': p.text.style?.color?.toARGB32().toRadixString(16),
'rect': [
for (final v in [
p.localToGlobal(Offset.zero).dx,
p.localToGlobal(Offset.zero).dy,
p.size.width,
p.size.height,
])
v.roundToDouble(),
],
},
];
File(
'$out/${s.id}.texts.json',
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
// 렌더 예외(오버플로 등)는 실패로 올린다 — 비교 전에 고쳐야 할 것이다.
expect(t.takeException(), isNull);
});
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/home/home_data.dart';
import '../eclipse_stubs.dart';
/// 디자인 대조용 HOME 표본 — 공유 하네스(`design_match_test.dart`)와 A 그룹(`a_test.dart`)이 같이 쓴다.
/// 사본 기본 홈 — 이클립스·라이브 이벤트 없음.
class QuietRepos extends Repositories {
QuietRepos() : super(live: false);
@override
EclipseRepository get eclipse => const NoEclipseRepository();
@override
HomeRepository get home => const QuietHome();
}
/// 사본 `홈_성장 완료`(`344:213778`) — HOME 탭의 성운 상태. 성운 가입자라 ZAP 이 뜬다.
class NebulaRepos extends QuietRepos {
@override
HomeRepository get home => const QuietHome(
growth: PlanetGrowth(
stage: 0,
fandomName: 'BTS_RM',
contributorName: 'BTS_Hong',
contributedPop: 1240,
growthPercent: 100,
currentPop: 1240,
requiredPop: 1000,
members: 12,
requiredMembers: 10,
isFounder: true,
),
);
}
class QuietHome extends SampleHomeRepository {
const QuietHome({this.growth});
final PlanetGrowth? growth;
@override
Future<HomeSnapshot> load() async {
final s = await super.load();
return HomeSnapshot(
popBalance: s.popBalance,
liveTicker: null,
fandomId: s.fandomId,
growth: growth ?? s.growth,
quests: s.quests,
briefings: s.briefings,
campaign: s.campaign,
insides: s.insides,
items: s.items,
);
}
}
+589
View File
@@ -0,0 +1,589 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/data/shared_stream.dart';
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/eclipse/widgets/live_proof.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'eclipse_stubs.dart';
/// 이클립스 방어·침식·실시간 동기화·라이브 소셜 프루프.
///
/// 판정 근거
/// - 별첨 1 ① 완료 시나리오 4 「이클립스 발동 → 워닝 → 방어 → 침식률 회복」
/// - 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프가 타 사용자 화면에
/// 실시간 노출」
/// - 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱 워닝 배너 병행」
/// - 별첨 1 ② 서버검증 원칙 「클라이언트 단독 지급 0건」 — 화면이 잔액을 스스로
/// 확정하지 않고 서버 응답으로 덮는지 본다.
///
/// **스트림을 밖에서 밀어 넣어 본다.** 화면이 첫 값만 그리고 갱신을 놓치는 것이
/// 실시간 동기화에서 가장 흔한 고장이고, 눈으로는 「조금 늦는다」로만 보인다.
/// 궤도 맵은 궤도 회전이 멈추지 않아 `pumpAndSettle` 이 끝나지 않는다.
Future<void> _pumpGalaxy(
WidgetTester tester,
EclipseRepository eclipse, {
GalaxyRepository? galaxy,
}) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: GalaxyScreen(
repository: galaxy ?? const SampleGalaxyRepository(),
planetRepository: samplePlanetRepository,
eclipse: eclipse,
),
),
);
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
/// 흔들어 방어 화면. 센서가 없는 테스트 환경이라 눌러서 참여하는 경로로 본다.
Future<void> _pumpDefend(WidgetTester tester, EclipseRepository repo) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: EclipseDefendScreen(repository: repo, fandomId: 'bts'),
),
);
await tester.pump();
await _settle(tester);
}
Future<void> _settle(WidgetTester tester) async {
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
void main() {
group('이클립스 경보 배너 (별첨 1 ① 「인앱 워닝 배너 병행」)', () {
testWidgets('활성 이클립스가 없으면 배너가 뜨지 않는다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
expect(find.textContaining('침식'), findsNothing);
expect(find.textContaining('방어 '), findsNothing);
});
testWidgets('발동하면 침식률과 방어 진행이 배너에 뜬다', (tester) async {
final repo = FakeEclipseRepository(
state: eclipseFixture(erosionRate: 40, defense: 60000, targetDefense: 100000),
);
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
expect(find.textContaining('이클립스 발동 — BTS 침식 40%'), findsOneWidget);
// 행성 HUD 도 같은 침식률을 띄운다(스펙 B — 갤럭시·행성 상세 HUD).
expect(find.textContaining('침식 40% · 방어 60%'), findsOneWidget);
expect(
find.textContaining('유효 참여 60,000 / 100,000명'),
findsOneWidget,
);
});
testWidgets('스트림이 새 집계를 내면 배너가 따라 바뀐다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture(erosionRate: 40));
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
// 배너와 행성 HUD 가 같은 집계를 보므로 둘 다 잡힌다 — 값이 갈리면 안 된다.
expect(find.textContaining('침식 40%'), findsWidgets);
// 다른 단말의 방어가 집계에 반영된 상황.
repo.push(eclipseFixture(erosionRate: 15, defense: 85000, defenseCount: 40));
await _settle(tester);
expect(find.textContaining('침식 40%'), findsNothing);
expect(find.textContaining('침식 15%'), findsWidgets);
expect(find.textContaining('유효 참여 85,000 / 100,000명'), findsOneWidget);
});
testWidgets('방어에 성공하면 감소 없음 문구로 바뀐다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture(erosionRate: 20));
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
repo.push(eclipseFixture(
erosionRate: 0,
defense: 100000,
defended: true,
));
await _settle(tester);
expect(find.textContaining('방어 성공 — BTS FPI 감소 없음'), findsOneWidget);
});
});
group('ZAP — 행성 성장 기여 (별첨 B B-23)', () {
testWidgets('ZAP 은 이클립스가 없어도 눌린다', (tester) async {
// 방어는 POP 을 쓰지 않는 공동 Shake 참여이고(설계서 v1.1 §9-#5) ZAP 은
// 행성 성장 기여다. 둘이 분리되어 있으므로 이클립스 유무와 무관하다.
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository();
await _pumpGalaxy(tester, repo, galaxy: galaxy);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
expect(galaxy.zaps, hasLength(1));
// 표본의 내 행성은 `bts` 다 — ZAP 은 내 행성의 성장에 기여한다.
expect(galaxy.zaps.single.$1, 'bts');
expect(galaxy.zaps.single.$3, 1);
// 방어 호출은 나가지 않는다 — 이클립스 샤드와 분리되어 있다.
expect(repo.calls, isEmpty);
});
testWidgets('연타는 한 번의 호출로 묶이고 count 가 누적된다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository();
await _pumpGalaxy(tester, repo, galaxy: galaxy);
for (var i = 0; i < 5; i++) {
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 60));
}
await _settle(tester);
// 호출 1회 · count 5 — 원장 한 행에 묶음 횟수가 남아야 MY 가 「Zap 5회」를 센다.
expect(galaxy.zaps, hasLength(1));
expect(galaxy.zaps.single.$3, 5);
});
testWidgets('시간창이 닫힌 뒤의 연타는 새 묶음이고 멱등키가 다르다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository()..balanceAfter = 9_000;
await _pumpGalaxy(tester, repo, galaxy: galaxy);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
expect(galaxy.zaps, hasLength(2));
// 멱등키가 같으면 서버가 두 번째를 같은 거래로 보아 기여가 사라진다.
expect(galaxy.zaps[0].$2, isNot(galaxy.zaps[1].$2));
});
testWidgets('잔액은 서버가 확정한 값으로 덮인다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository()..balanceAfter = 7_777;
await _pumpGalaxy(tester, repo, galaxy: galaxy);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
final button = tester.widget<ZapButton>(find.byType(ZapButton));
expect(button.myPop, 7_777);
});
testWidgets('잔액 부족은 서버 오류 코드로 안내한다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository()
..failWith = const BackendException('E-POP-01', 'POP 잔액이 부족합니다.');
await _pumpGalaxy(tester, repo, galaxy: galaxy);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
expect(find.textContaining('POP 이 모자라'), findsOneWidget);
});
testWidgets('자동(오토 도네이션) 옵션은 없다', (tester) async {
// 별첨 1 ① 시나리오 4 가 적었으나 별첨 B 에 없는 기능이다(공통 기준 2항).
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
final hold = await tester.startGesture(tester.getCenter(find.byType(ZapButton)));
await tester.pump(ZapButton.longPress);
await hold.up();
await _settle(tester);
expect(find.text('기여'), findsOneWidget);
expect(find.text('아이템'), findsOneWidget);
expect(find.text('자동'), findsNothing);
});
});
group('라이브 소셜 프루프 (별첨 1 ① 시나리오 5)', () {
testWidgets('갤럭시 섬광 배너가 다른 사용자의 방어를 흘린다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
repo.pushEvents([
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
]);
await _settle(tester);
expect(
find.text('[LIVE] 버니즈님이 이클립스 방어에 참여했습니다! (+30)'),
findsOneWidget,
);
});
testWidgets('새 이벤트가 오면 문구가 갈린다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
repo.pushEvents([
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
]);
await _settle(tester);
repo.pushEvents([
LiveEvent(id: 'e2', nickname: '캐럿', defense: 50, at: DateTime(2026, 9, 14, 1)),
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
]);
await _settle(tester);
expect(find.textContaining('버니즈'), findsNothing);
expect(
find.text('[LIVE] 캐럿님이 이클립스 방어에 참여했습니다! (+50)'),
findsOneWidget,
);
});
testWidgets('홈 라이브 띠가 라이브 이벤트를 그린다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: HomeScreen(
repository: const SampleHomeRepository(),
questRepository: const SampleQuestRepository(),
eclipse: repo,
),
),
);
await tester.pumpAndSettle();
// 이벤트가 없으면 스냅샷의 폴백 문구를 그린다.
expect(find.textContaining('아이템 사용으로 버프가 발동'), findsOneWidget);
repo.pushEvents([
LiveEvent(id: 'e1', nickname: '무민', defense: 20, at: DateTime(2026, 9, 14)),
]);
await tester.pumpAndSettle();
// 이벤트가 오면 그 문구가 폴백을 대체한다.
expect(find.textContaining('아이템 사용으로 버프가 발동'), findsNothing);
expect(
find.text('[LIVE] 무민님이 이클립스 방어에 참여했습니다! (+20)'),
findsOneWidget,
);
});
test('문구는 종류마다 한 곳에서 만든다', () {
final at = DateTime(2026, 9, 14);
expect(
liveEventMessage(LiveEvent(id: 'x', nickname: '나', defense: 1200, at: at)),
'[LIVE] 나님이 이클립스 방어에 참여했습니다! (+1,200)',
);
// 별첨 B 「Global Ticker」 — 다른 종류가 같은 컬렉션에 들어온다.
expect(
liveEventMessage(LiveEvent(
id: 'y',
nickname: '나',
at: at,
kind: LiveEventKind.heroGift,
)),
'[LIVE] 나님의 영웅의 선물이 도착했습니다!',
);
});
});
group('실시간 구독 — 해제와 백그라운드 복귀 (스펙 C)', () {
testWidgets('화면이 사라지면 구독을 끊는다', (tester) async {
final repo = CountingEclipseRepository(eclipseFixture());
await _pumpGalaxy(tester, repo);
expect(repo.subscriptions, 1);
expect(repo.active, 1);
// 화면을 치운다. 구독이 남으면 백그라운드에서 읽기 비용이 계속 든다.
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
await _settle(tester);
expect(repo.active, 0);
});
testWidgets('백그라운드에서 돌아오면 다시 구독한다', (tester) async {
final repo = CountingEclipseRepository(eclipseFixture());
await _pumpGalaxy(tester, repo);
expect(repo.subscriptions, 1);
// 오래 잠든 리스너는 서버와 끊긴 채 남을 수 있다. 복귀 때 새로 열어
// 첫 스냅샷을 다시 받는다 — 별첨 1 ① 「백그라운드 복귀 시 렌더링 정상 재개」.
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await _settle(tester);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await _settle(tester);
expect(repo.subscriptions, 2);
// 옛 구독은 남지 않는다 — 남으면 같은 문서를 두 번 읽는다.
expect(repo.active, 1);
expect(find.textContaining('이클립스 발동'), findsWidgets);
});
});
group('흔들어 방어 화면 (B-55 공동 Shake)', () {
testWidgets('POP 을 쓰지 않는다는 것을 화면이 말한다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
expect(find.textContaining('POP은 소모되지 않습니다'), findsOneWidget);
expect(find.textContaining('흔들어 방어'), findsWidgets);
});
testWidgets('누르면 흔들기가 묶여 서버로 나간다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
// 센서가 없는 단말용 버튼. 판정은 서버가 하므로 흔들기와 같은 경로다.
for (var i = 0; i < 4; i++) {
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
await tester.pump(const Duration(milliseconds: 80));
}
await _settle(tester);
expect(repo.calls, hasLength(1));
expect(repo.calls.single.$3, 4);
});
testWidgets('서버가 받아들인 값만 내 참여로 그린다', (tester) async {
// 단말이 센 값을 그대로 믿으면 서버가 깎은 뒤 숫자가 줄어 보인다.
final repo = FakeEclipseRepository(state: eclipseFixture())
..acceptedOverride = 2;
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
// 다섯 번 참여했는데 서버가 둘만 받아들인 상황.
for (var i = 0; i < 5; i++) {
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
await tester.pump(const Duration(milliseconds: 80));
}
await _settle(tester);
expect(repo.calls.single.$3, 5);
expect(find.textContaining('참여 상한에 닿아'), findsOneWidget);
});
testWidgets('성공한 이클립스에서는 참여 버튼이 닫힌다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture(defended: true));
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing);
expect(find.text('운석 충돌을\n성공적으로 막았어요!'), findsOneWidget);
});
testWidgets('마감은 판정 대기이며 서버 실패·취소만 해당 결과로 표시한다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1))));
await tester.pump();
await _settle(tester);
expect(find.text('방어 결과 확인 중'), findsOneWidget);
expect(find.textContaining('FAIL'), findsNothing);
expect(find.byType(FilledButton), findsNothing);
expect(repo.calls, isEmpty);
repo.push(eclipseFixture(result: EclipseResult.failure));
await _settle(tester);
expect(find.text('아쉽게 행성 방어를\n실패했어요'), findsOneWidget);
repo.push(eclipseFixture(result: EclipseResult.cancelled));
await _settle(tester);
expect(find.text('이클립스 취소'), findsOneWidget);
expect(find.textContaining('FPI는 차감되지 않았습니다'), findsOneWidget);
expect(find.textContaining('FAIL'), findsNothing);
});
testWidgets('계속 입력해도 700ms마다 전송하고 멈춘 뒤 남은 입력을 보낸다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
for (var i = 0; i < 12; i++) {
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
await tester.pump(const Duration(milliseconds: 100));
}
expect(repo.calls, isNotEmpty);
await _settle(tester);
expect(repo.calls.fold<int>(0, (sum, call) => sum + call.$3), 12);
expect(repo.calls.length, greaterThan(1));
});
testWidgets('응답 유실은 마감 후에도 같은 회차·요청ID·횟수로 재확인한다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture())
..failWith = const BackendException('unavailable', 'private backend error');
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
await _settle(tester);
expect(repo.calls, hasLength(1));
final original = repo.calls.single;
expect(find.textContaining('private backend error'), findsNothing);
repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1))));
await _settle(tester);
repo.failWith = null;
await tester.tap(find.text('같은 참여 다시 확인'));
await _settle(tester);
expect(repo.calls, [original, original]);
expect(find.text('같은 참여 다시 확인'), findsNothing);
});
testWidgets('진행 중인 이클립스가 없으면 그 사실을 알린다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
expect(find.text('진행 중인 이클립스가 없습니다'), findsOneWidget);
});
});
group('구독 공유 — 팬덤당 하나 (검수 D5)', () {
test('여러 화면이 같은 원본 하나를 나눠 쓴다', () async {
// 화면마다 snapshots() 를 열면 한 팬덤에 리스너가 화면 수만큼 붙는다.
var opened = 0;
final controller = StreamController<int>.broadcast();
addTearDown(controller.close);
final shared = SharedStream<int>(() {
opened += 1;
return controller.stream;
});
final a = <int>[];
final b = <int>[];
final subA = shared.stream.listen(a.add);
final subB = shared.stream.listen(b.add);
await Future<void>.delayed(Duration.zero);
controller.add(7);
await Future<void>.delayed(Duration.zero);
// 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다.
expect(opened, 1);
expect(a, [7]);
expect(b, [7]);
await subA.cancel();
await subB.cancel();
});
test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async {
final controller = StreamController<int>.broadcast();
addTearDown(controller.close);
final shared = SharedStream<int>(() => controller.stream);
final first = <int>[];
final subA = shared.stream.listen(first.add);
await Future<void>.delayed(Duration.zero);
controller.add(42);
await Future<void>.delayed(Duration.zero);
// 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다.
final late = <int>[];
final subB = shared.stream.listen(late.add);
await Future<void>.delayed(Duration.zero);
expect(late, [42]);
await subA.cancel();
await subB.cancel();
});
test('마지막 화면이 떠나면 원본을 닫는다', () async {
var cancelled = false;
final controller = StreamController<int>(onCancel: () => cancelled = true);
addTearDown(controller.close);
final shared = SharedStream<int>(() => controller.stream);
final sub = shared.stream.listen((_) {});
await Future<void>.delayed(Duration.zero);
expect(cancelled, isFalse);
await sub.cancel();
await Future<void>.delayed(Duration.zero);
// 남겨 두면 화면이 없는 동안에도 읽기 비용이 계속 든다.
expect(cancelled, isTrue);
});
});
group('이클립스 상태 계산', () {
test('진행률은 목표가 0 이면 0 이다', () {
expect(eclipseFixture(targetDefense: 0, defense: 10).progress, 0);
});
test('진행률은 1 을 넘지 않는다', () {
expect(
eclipseFixture(targetDefense: 100, defense: 400).progress,
1.0,
);
});
test('마감이 지났으면 남은 시간을 말하지 않는다', () {
final now = DateTime(2026, 9, 14, 12);
expect(
eclipseFixture(endsAt: now.subtract(const Duration(minutes: 1)))
.remaining(now),
isNull,
);
expect(
eclipseFixture(endsAt: now.add(const Duration(minutes: 20)))
.remaining(now),
const Duration(minutes: 20),
);
});
test('서버 kind 문자열을 모르는 값이면 공지로 읽는다', () {
// 티커가 새 종류에 막혀 멈추지 않게 한다(별첨 B Global Ticker).
expect(LiveEventKind.of('eclipse_defense'), LiveEventKind.eclipseDefense);
expect(LiveEventKind.of('hero_gift'), LiveEventKind.heroGift);
expect(LiveEventKind.of('무엇인가'), LiveEventKind.notice);
});
test('마감이 지난 이클립스는 만료로 읽는다 (검수 D3)', () {
final now = DateTime(2026, 9, 14, 12);
expect(
eclipseFixture(endsAt: now.subtract(const Duration(seconds: 1)))
.isExpired(now),
isTrue,
);
expect(
eclipseFixture(endsAt: now.add(const Duration(minutes: 1))).isExpired(now),
isFalse,
);
});
});
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/eclipse/eclipse_repository_firestore.dart';
void main() {
test('추가 스냅샷 없이 마감시각에 위험 상태가 끝난다', () async {
final db = FakeFirebaseFirestore();
final repo = FirestoreEclipseRepository(
Backend(firestore: db, auth: MockFirebaseAuth()),
);
await db.doc('fandoms/bts/eclipseRounds/one').set({
'status': 'active',
'startAt': Timestamp.now(),
'endsAt': Timestamp.fromDate(
DateTime.now().add(const Duration(milliseconds: 150)),
),
});
final states = <EclipseState?>[];
final sub = repo.watch(fandomId: 'bts').listen(states.add);
addTearDown(sub.cancel);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last?.eclipseId, 'one');
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(states.last, isNull);
});
test('활성 회차의 성공·종료·새 회차를 샤드 구독 중에도 반영한다', () async {
final db = FakeFirebaseFirestore();
final repo = FirestoreEclipseRepository(
Backend(firestore: db, auth: MockFirebaseAuth()),
);
final doc = db.doc('fandoms/bts/eclipseRounds/one');
final data = {
'status': 'active',
'startAt': Timestamp.now(),
'endsAt': Timestamp.fromDate(
DateTime.now().add(const Duration(hours: 1)),
),
'targetPop': 60, // 옛 필드는 목표 인원으로 재해석하지 않는다.
'target': 2,
'defended': false,
};
await doc.set(data);
await doc.collection('shards').doc('0').set({'defense': 600, 'defenseCount': 25, 'qualifiedParticipants': 1});
final states = <EclipseState?>[];
final sub = repo.watch(fandomId: 'bts').listen(states.add);
addTearDown(sub.cancel);
Future<void> flush() =>
Future<void>.delayed(const Duration(milliseconds: 20));
await flush();
expect(states.last?.defense, 1);
expect(states.last?.targetDefense, 2);
expect(states.last?.progress, 0.5);
await doc.update({'defended': true});
await flush();
expect(states.last?.defended, true);
await doc.update({'status': 'ended'});
await flush();
expect(states.last, isNull);
await db.doc('fandoms/bts/eclipseRounds/two').set(data);
await flush();
expect(states.last?.eclipseId, 'two');
await doc.collection('shards').doc('0').update({'defense': 99});
await flush();
expect(states.last?.eclipseId, 'two');
});
test('방어 화면 구독은 마감 뒤에도 같은 회차의 서버 결과를 받는다', () async {
final db = FakeFirebaseFirestore();
final repo = FirestoreEclipseRepository(Backend(firestore: db, auth: MockFirebaseAuth()));
final ref = db.doc('fandoms/bts/eclipseRounds/final');
await ref.set({
'status': 'active', 'startAt': Timestamp.now(),
'endsAt': Timestamp.fromDate(DateTime.now().add(const Duration(milliseconds: 150))),
'target': 2, 'qualifiedParticipants': 1,
});
final results = <EclipseState?>[];
final banner = <EclipseState?>[];
final sub = repo.watch(fandomId: 'bts', includeResults: true).listen(results.add);
final active = repo.watch(fandomId: 'bts').listen(banner.add);
addTearDown(sub.cancel);
addTearDown(active.cancel);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(banner.last, isNull);
expect(results.last?.eclipseId, 'final');
expect(results.last?.canDefend, isFalse);
expect(results.last?.result, isNull);
await ref.update({'status': 'ended', 'result': 'failed'});
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(results.last?.result, EclipseResult.failure);
expect(banner.last, isNull);
await ref.update({'status': 'cancelled', 'result': 'cancelled_error'});
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(results.last?.result, EclipseResult.cancelled);
});
}
+211
View File
@@ -0,0 +1,211 @@
import 'dart:async';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
/// 활성 이클립스가 없는 상태.
///
/// **이클립스와 무관한 화면 검증의 기본값이다.** [SampleEclipseRepository] 는 목
/// 모드 시연을 위해 늘 활성 이클립스를 내므로, 그것을 쓰면 배너와 라이브 띠가
/// 화면 상단을 차지해 F1·F2·F3 의 레이아웃 검증이 이클립스 유무에 딸려 흔들린다.
class NoEclipseRepository implements EclipseRepository {
const NoEclipseRepository();
@override
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) => Stream.value(null);
@override
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
Stream.value(const []);
@override
Future<DefendResult> defend({
required String fandomId,
required String eclipseId,
required String clientSeq,
required int shakes,
}) async =>
const DefendResult(accepted: 0, clamped: 0, myTotal: 0, duplicate: false);
}
/// 값을 밖에서 밀어 넣는 저장소. 스트림이 바뀔 때 화면이 따라오는지 본다.
///
/// 방어 호출은 실제로 나가지 않고 [calls] 에 기록만 한다 — 연타가 **몇 번의
/// 호출로 묶였는지**와 `count` 합이 맞는지가 검증 대상이다.
class FakeEclipseRepository implements EclipseRepository {
FakeEclipseRepository({EclipseState? state}) {
if (state != null) push(state);
}
final _eclipse = StreamController<EclipseState?>.broadcast();
final _events = StreamController<List<LiveEvent>>.broadcast();
/// 마지막으로 밀어 넣은 값. 새 구독자가 곧바로 받는다.
EclipseState? _last;
List<LiveEvent> _lastEvents = const [];
/// (eclipseId, clientSeq, shakes) 기록.
final List<(String, String, int)> calls = [];
/// 다음 방어 호출이 던질 실패. null 이면 성공한다.
Object? failWith;
/// 서버가 받아들였다고 답할 양. null 이면 보낸 값을 그대로 받아들인다.
int? acceptedOverride;
void push(EclipseState? state) {
_last = state;
if (_eclipse.hasListener) _eclipse.add(state);
}
void pushEvents(List<LiveEvent> events) {
_lastEvents = events;
if (_events.hasListener) _events.add(events);
}
Future<void> dispose() async {
await _eclipse.close();
await _events.close();
}
@override
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) async* {
yield _last;
yield* _eclipse.stream;
}
@override
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) async* {
yield _lastEvents;
yield* _events.stream;
}
@override
Future<DefendResult> defend({
required String fandomId,
required String eclipseId,
required String clientSeq,
required int shakes,
}) async {
calls.add((eclipseId, clientSeq, shakes));
final failure = failWith;
if (failure != null) throw failure;
final accepted = acceptedOverride ?? shakes;
return DefendResult(
accepted: accepted,
clamped: shakes - accepted,
myTotal: calls.fold(0, (a, c) => a + c.$3),
duplicate: false,
);
}
}
/// 검증에 쓰는 이클립스 한 건. 값은 인자로 덮는다.
EclipseState eclipseFixture({
String fandomId = 'bts',
String eclipseId = 'ecl-1',
int erosionRate = 40,
int defense = 60_000,
int targetDefense = 100_000,
int defenseCount = 12,
bool defended = false,
EclipseResult? result,
DateTime? endsAt,
}) => EclipseState(
fandomId: fandomId,
eclipseId: eclipseId,
fandomName: 'BTS',
erosionRate: erosionRate,
defense: defense,
targetDefense: targetDefense,
defenseCount: defenseCount,
defended: defended,
result: result,
// 마감이 없으면 화면이 「지났는가」를 물을 수 없다. 기본을 넉넉히 둔다.
endsAt: endsAt ?? DateTime.now().add(const Duration(minutes: 20)),
);
/// 구독을 몇 번 열었고 몇 개가 살아 있는지 센다.
///
/// 실시간 동기화의 두 고장은 **끊지 않는 것**(백그라운드에서 읽기 비용이 계속
/// 든다)과 **다시 열지 않는 것**(복귀한 화면이 잠들기 전 값을 그린다)이다. 둘 다
/// 화면만 보고는 구분되지 않으므로 구독 수를 직접 센다.
class CountingEclipseRepository implements EclipseRepository {
CountingEclipseRepository(this.state);
final EclipseState? state;
/// 지금까지 열린 구독 수(누적).
int subscriptions = 0;
/// 아직 살아 있는 구독 수.
int active = 0;
@override
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) {
late StreamController<EclipseState?> controller;
controller = StreamController<EclipseState?>(
onListen: () {
subscriptions += 1;
active += 1;
controller.add(state);
},
onCancel: () {
active -= 1;
},
);
return controller.stream;
}
@override
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
Stream.value(const []);
@override
Future<DefendResult> defend({
required String fandomId,
required String eclipseId,
required String clientSeq,
required int shakes,
}) async =>
const DefendResult(accepted: 0, clamped: 0, myTotal: 0, duplicate: false);
}
/// ZAP(행성 성장 기여) 호출을 기록하는 갤럭시 저장소.
///
/// 별첨 B **B-23** — ZAP 은 이클립스 방어가 아니라 행성 성장 기여다. 이 스텁으로
/// 「ZAP 이 방어 함수가 아니라 POP 소비 경로로 나가는가」를 본다.
class RecordingGalaxyRepository implements GalaxyRepository {
RecordingGalaxyRepository({this.snapshot});
/// 돌려줄 스냅샷. 없으면 표본을 쓴다.
final GalaxySnapshot? snapshot;
/// (fandomId, clientSeq, count) 기록.
final List<(String, String, int)> zaps = [];
/// 서버가 확정했다고 답할 잔액. -1 이면 「모름」이다.
int balanceAfter = -1;
/// 다음 호출이 던질 실패. null 이면 성공한다.
Object? failWith;
@override
Future<void> setPrimary(String fandomId) async {}
@override
Future<GalaxySnapshot> load() async =>
snapshot ?? await const SampleGalaxyRepository().load();
@override
Future<ZapResult> zap({
required String fandomId,
required int count,
required String clientSeq,
}) async {
zaps.add((fandomId, clientSeq, count));
final failure = failWith;
if (failure != null) throw failure;
return ZapResult(txId: 'tx-${zaps.length}', balanceAfter: balanceAfter);
}
}
+202
View File
@@ -0,0 +1,202 @@
import 'package:fancake/core/ui/app_icons.dart';
import 'package:fancake/features/notification/notification_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// F0 공통 — 화면 대조 원장 2화면.
/// Bottom Navigation(`350:215853`)은 5탭 셸이라 `app_smoke_test` 가 검증한다.
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() {
group('날짜 머리글 (F0 · 518:88353)', () {
final now = DateTime(2026, 9, 1, 12);
test('오늘과 어제만 이름으로 부른다', () {
expect(notificationDayLabel(DateTime(2026, 9, 1, 3), now: now), '오늘');
expect(notificationDayLabel(DateTime(2026, 8, 31, 23), now: now), '어제');
expect(
notificationDayLabel(DateTime(2026, 8, 30), now: now),
'2026.08.30',
);
});
test('생성일시는 명세 형식을 그대로 쓴다', () {
expect(
notificationTimestamp(DateTime(2026, 1, 2, 3, 4)),
'2026.01.02 03:04',
);
});
});
group('날짜 그룹화', () {
final now = DateTime(2026, 9, 1, 12);
test('날짜로 묶고 최신이 앞에 온다', () {
final items = [
AppNotification(
id: 'a',
kind: NotificationKind.like,
title: 'a',
at: DateTime(2026, 8, 30, 9),
),
AppNotification(
id: 'b',
kind: NotificationKind.like,
title: 'b',
at: DateTime(2026, 9, 1, 9),
),
AppNotification(
id: 'c',
kind: NotificationKind.like,
title: 'c',
at: DateTime(2026, 9, 1, 11),
),
];
final groups = groupNotifications(items, now: now);
expect(groups.map((g) => g.label).toList(), ['오늘', '2026.08.30']);
expect(groups.first.items.map((i) => i.id).toList(), ['c', 'b']);
});
});
group('종류별 이동 (F0 · 518:88353)', () {
test('이클립스와 아이템 사용은 이동하지 않는다', () {
expect(NotificationKind.eclipse.navigable, isFalse);
expect(NotificationKind.itemUsed.navigable, isFalse);
});
test('나머지 넷은 갈 곳이 있다', () {
for (final kind in const [
NotificationKind.like,
NotificationKind.comment,
NotificationKind.campaign,
NotificationKind.quest,
]) {
expect(kind.navigable, isTrue, reason: kind.label);
}
});
});
group('알림 화면 (F0 · 229:11265)', () {
testWidgets('날짜 머리글과 항목을 함께 보여 준다', (tester) async {
// 표본은 DateTime.now() 기준이라 자정 전후로 「오늘/어제」가 갈린다.
// 시각을 못박아 넣는다.
final now = DateTime.now();
await pumpWide(
tester,
NotificationScreen(
repository: SampleNotificationRepository(
items: [
AppNotification(
id: 'q',
kind: NotificationKind.quest,
title: '만보기 퀘스트를 완료했습니다',
at: now,
),
AppNotification(
id: 'o',
kind: NotificationKind.like,
title: '오래된 알림',
at: now.subtract(const Duration(days: 9)),
),
],
),
),
);
expect(find.text('오늘'), findsOneWidget);
expect(find.textContaining('만보기 퀘스트를 완료'), findsOneWidget);
expect(find.text('오래된 알림'), findsOneWidget);
});
testWidgets('이동하지 않는 알림에는 화살표를 그리지 않는다', (tester) async {
await pumpWide(
tester,
NotificationScreen(
repository: SampleNotificationRepository(
items: [
AppNotification(
id: 'e',
kind: NotificationKind.eclipse,
title: '이클립스',
at: DateTime.now(),
),
],
),
),
);
expect(
find.byWidgetPredicate(
(w) => w is ImageIcon && w.image == AppIcons.chevronRight,
),
findsNothing,
);
});
testWidgets('누르면 읽음으로 바뀌고 뒤로가기가 읽은 목록을 돌려준다', (tester) async {
final items = [
AppNotification(
id: 'n1',
kind: NotificationKind.quest,
title: '만보기 퀘스트를 완료했습니다',
at: DateTime.now(),
),
];
Set<String>? returned;
await pumpWide(
tester,
Builder(
builder: (context) => TextButton(
onPressed: () async {
returned = await Navigator.of(context).push<Set<String>>(
MaterialPageRoute(
builder: (_) => NotificationScreen(
repository: SampleNotificationRepository(items: items),
),
),
);
},
child: const Text('열기'),
),
),
);
await tester.tap(find.text('열기'));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('만보기 퀘스트를 완료'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(returned, contains('n1'));
});
testWidgets('비어 있으면 그렇게 알린다', (tester) async {
await pumpWide(
tester,
NotificationScreen(
repository: const SampleNotificationRepository(items: []),
),
);
expect(find.text('알림이 없습니다.'), findsOneWidget);
});
testWidgets('문구에 내부 경로가 새지 않는다', (tester) async {
await pumpWide(
tester,
NotificationScreen(repository: const SampleNotificationRepository()),
);
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('.dart')));
}
});
});
}
+349
View File
@@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'eclipse_stubs.dart';
import 'package:fancake/core/data/backend.dart' show BackendException;
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/home/growth_complete_screen.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/home/home_onboarding_screen.dart';
import 'package:fancake/features/home/nebula_create_screen.dart';
import 'package:fancake/features/home/planet_create_screen.dart';
/// 생성 호출을 기록하는 표본. [error] 가 있으면 서버 거부를 흉내 낸다.
class _CreateRepo extends SampleHomeRepository {
_CreateRepo({this.error, this.primaryChanged = true});
final BackendException? error;
final bool primaryChanged;
final calls = <String>[];
@override
Future<({String fandomId, bool primaryChanged})> createNebula(String name) async {
calls.add(name);
if (error case final e?) throw e;
return (fandomId: 'new-nebula', primaryChanged: primaryChanged);
}
@override
Future<void> createPlanet({
required String fandomId,
required String planetType,
required String colorKey,
}) async {
calls.add('$fandomId · $planetType · $colorKey');
if (error case final e?) throw e;
}
}
/// 운영값을 바꾼 `config/policy` 흉내.
class _PolicyRepo extends SampleHomeRepository {
@override
Future<NebulaTargets> nebulaTargets() async =>
nebulaTargetsFrom({'nebulaMinPop': 2000, 'nebulaMinMembers': 20, 'nebulaDays': 5});
}
/// 생성 화면을 위에 얹고, 닫힐 때 돌려준 값을 받는다.
Future<List<Object?>> _pushed(WidgetTester tester, Widget screen) async {
final results = <Object?>[];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => TextButton(
onPressed: () async => results.add(
await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => screen),
),
),
child: const Text('열기'),
),
),
),
);
await tester.tap(find.text('열기'));
await tester.pumpAndSettle();
return results;
}
Future<void> _pump(WidgetTester tester, Widget screen) async {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
await tester.pumpAndSettle();
}
void main() {
group('성운 생성 (F1 · 340:75466)', () {
testWidgets('이름을 넣기 전에는 「성운 생성」이 없고 목표는 원장 고정값이다', (tester) async {
await _pump(tester, const NebulaCreateScreen());
expect(find.text('성운 생성'), findsNothing);
// 정책 원장 B-10 — 1,000 POP · 10명 · 7일. 사본의 슬라이더는 조작할 수 없다.
expect(find.text('1,000P / 10'), findsOneWidget);
expect(find.byType(Slider), findsNothing);
expect(find.textContaining('7일내'), findsOneWidget);
});
testWidgets('목표는 config/policy 운영값(서버와 같은 키)을 따른다', (tester) async {
await _pump(tester, NebulaCreateScreen(repository: _PolicyRepo()));
expect(find.text('2,000P / 20'), findsOneWidget);
expect(find.textContaining('5일내'), findsOneWidget);
});
test('운영값이 없거나 잘못되면 원장 기본값 1,000 · 10 · 7', () {
expect(nebulaTargetsFrom(null), defaultNebulaTargets);
expect(nebulaTargetsFrom({'nebulaMinPop': -1, 'nebulaMinMembers': 'x'}),
(pop: 1000, members: 10, days: 7));
});
testWidgets('서버가 쓰이는 이름(E-FDM-01)이라 하면 입력칸 오류로 알리고 만들 수 없다', (tester) async {
final repo = _CreateRepo(
error: const BackendException('E-FDM-01', '이미 사용중인 이름입니다.'),
);
await _pump(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS');
await tester.pumpAndSettle();
// 중복 판정은 서버만 한다 — 입력만으로는 오류를 띄우지 않는다.
expect(find.text('이미 사용중인 이름입니다.'), findsNothing);
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(find.text('이미 사용중인 이름입니다.'), findsOneWidget);
final btn = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '성운 생성'),
);
expect(btn.onPressed, isNull);
});
testWidgets('이름을 넣고 만들면 기본 행성 변경을 되묻는다', (tester) async {
await _pump(tester, const NebulaCreateScreen());
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
// Figma 문구 그대로여야 한다.
expect(find.text('선택한 행성을 기본 행성으로 변경할까요?'), findsOneWidget);
expect(find.text('취소'), findsOneWidget);
expect(find.text('변경'), findsOneWidget);
});
testWidgets('변경을 누르면 fn-nebula-create 로 만들고 새 성운 ID 를 들고 닫힌다', (tester) async {
final repo = _CreateRepo();
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(repo.calls, ['BTS_RM']);
expect(results, ['new-nebula']);
expect(find.byType(NebulaCreateScreen), findsNothing);
});
testWidgets('서버가 거부하면 사유를 알리고 화면에 남는다', (tester) async {
final repo = _CreateRepo(
error: const BackendException('E-FDM-02', '이미 오늘 성운을 생성하셨습니다.'),
);
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget);
expect(results, isEmpty);
expect(find.byType(NebulaCreateScreen), findsOneWidget);
});
testWidgets('대표 변경 30일 대기 중이면 성운만 생겼다고 알리고 닫힌다(C-15)', (tester) async {
final repo = _CreateRepo(primaryChanged: false);
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(find.textContaining('30일이 지나야'), findsOneWidget);
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(results, ['new-nebula']);
});
testWidgets('보유 POP 은 여는 쪽이 넘긴 지갑 값이다', (tester) async {
await _pump(tester, const NebulaCreateScreen(myPop: 3210));
expect(find.textContaining('3,210'), findsOneWidget);
});
testWidgets('오늘 이미 만들었으면 사유를 알리고 진행하지 않는다', (tester) async {
await _pump(tester, const NebulaCreateScreen(alreadyCreatedToday: true));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget);
// 제한에 걸렸으면 변경 확인으로 넘어가면 안 된다.
expect(find.text('변경'), findsNothing);
});
});
group('성장 완료 (F1 · 344:213778) — HOME 성운 상태', () {
testWidgets('두 조건을 모두 채우면 성운을 눌러 행성 생성으로 간다', (tester) async {
await _pump(tester, const GrowthCompleteScreen());
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('성운'));
await tester.pumpAndSettle();
expect(find.byType(PlanetCreateScreen), findsOneWidget);
});
testWidgets('창립자가 아니면 조건을 채워도 탄생 안내 · 탭이 없다(Q-PN-01 A안)', (tester) async {
await _pump(tester, const GrowthCompleteScreen(isFounder: false));
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsNothing);
await tester.tap(find.bySemanticsLabel('성운'));
await tester.pumpAndSettle();
expect(find.byType(PlanetCreateScreen), findsNothing);
});
testWidgets('한쪽만 채우면 대기다 — 정책 원장 B-10 동시 충족', (tester) async {
await _pump(
tester,
// 인원은 넘었으나 POP 이 모자란 경우.
const GrowthCompleteScreen(members: 12, contributedPop: 400),
);
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsNothing);
await tester.tap(find.bySemanticsLabel('성운'));
await tester.pumpAndSettle();
expect(
find.byType(PlanetCreateScreen),
findsNothing,
reason: '한쪽만 채웠는데 승격이 열리면 안 된다',
);
});
testWidgets('두 조건을 갈라 보여 준다', (tester) async {
await _pump(tester, const GrowthCompleteScreen());
expect(find.text('활동을 통해 얻은 POP을 통해\n성운에 기여해 보세요!'), findsOneWidget);
expect(find.text('POP 조건 충족 · 1,240 / 1,000'), findsOneWidget);
expect(find.text('창립 멤버 조건 충족 · 12 / 10'), findsOneWidget);
});
});
group('행성 생성 (F1 · 344:214421)', () {
testWidgets('형태 → 색 두 단계만 고른다 — C-50', (tester) async {
final repo = _CreateRepo();
final results = await _pushed(
tester,
PlanetCreateScreen(fandomId: 'nebula-1', repository: repo),
);
expect(find.text('행성 타입 선택'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('형태 2'));
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
expect(find.text('행성 색 선택'), findsOneWidget);
expect(find.text('배경색 선택'), findsOneWidget);
// 생성 때 고르는 것은 형태와 컬러뿐 — 오브젝트 색 · 직접 고르기는 없다.
expect(find.text('오브젝트 색 선택'), findsNothing);
await tester.tap(find.bySemanticsLabel('gold'));
await tester.tap(find.text('완료').last);
await tester.pumpAndSettle();
expect(find.text('선택한 옵션으로\n행성을 생성할까요?'), findsOneWidget);
await tester.tap(find.text('생성'));
await tester.pumpAndSettle();
// fn-planet-create 에는 형태 · 컬러 두 값만 간다. 성공하면 닫혀 HOME 으로 돌아간다.
expect(repo.calls, ['nebula-1 · shape2 · gold']);
expect(results, ['nebula-1']);
expect(find.byType(PlanetCreateScreen), findsNothing);
});
testWidgets('전환 조건 미달 등 서버 거부는 사유를 띄우고 화면에 남는다', (tester) async {
final repo = _CreateRepo(
error: const BackendException('E-FDM-03', '성운 전환 조건을 아직 채우지 못했습니다.'),
);
await _pump(tester, PlanetCreateScreen(fandomId: 'n', repository: repo));
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
await tester.tap(find.text('완료').last);
await tester.pumpAndSettle();
await tester.tap(find.text('생성'));
await tester.pumpAndSettle();
expect(find.text('성운 전환 조건을 아직 채우지 못했습니다.'), findsOneWidget);
expect(find.byType(PlanetCreateScreen), findsOneWidget);
});
testWidgets('색 단계에서 이전으로 돌아간다', (tester) async {
await _pump(tester, const PlanetCreateScreen());
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
await tester.tap(find.text('이전'));
await tester.pumpAndSettle();
expect(find.text('행성 타입 선택'), findsOneWidget);
});
});
group('초기 사용자 홈 (F1 · 343:212939) — HOME 무소속 상태', () {
testWidgets('「+」 를 누르면 행성 생성·가입 시트가 뜨고 고른 동작을 부른다', (tester) async {
var created = 0;
await _pump(
tester,
HomeOnboardingScreen(onCreateNebula: () => created++),
);
// 사용자 노출 브랜드는 팬우주다(공통 기준 4항).
expect(find.text('행성을 생성 또는 가입하고\n팬우주를 시작해 보세요!'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('행성 생성 또는 가입'));
await tester.pumpAndSettle();
expect(find.text('행성 가입'), findsOneWidget);
expect(find.text('취소'), findsOneWidget);
await tester.tap(find.text('행성 생성 (성운)'));
await tester.pumpAndSettle();
expect(created, 1);
expect(find.text('행성 가입'), findsNothing);
});
});
testWidgets('헤더 지갑은 「아이템 구입」 시트를 열고 구매는 준비 중이다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const HomeScreen(
repository: SampleHomeRepository(),
questRepository: SampleQuestRepository(),
eclipse: NoEclipseRepository(),
),
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.byTooltip('아이템'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('아이템 구입'), findsOneWidget);
expect(find.text('₩1,200'), findsOneWidget);
await tester.tap(find.text('베이직 드랍'));
await tester.pump();
expect(find.text('아이템 구입은 준비 중입니다.'), findsOneWidget);
});
testWidgets('F1 화면 문구에 내부 경로가 새지 않는다', (tester) async {
for (final screen in <Widget>[
const NebulaCreateScreen(),
const GrowthCompleteScreen(),
const PlanetCreateScreen(),
const HomeOnboardingScreen(),
]) {
await _pump(tester, screen);
final texts = tester
.widgetList<Text>(find.byType(Text))
.map((w) => w.data ?? '')
.join('\n');
for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) {
expect(texts.contains(leak), isFalse, reason: '「$leak」 노출');
}
}
});
}
+395
View File
@@ -0,0 +1,395 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_motion.dart';
import 'package:fancake/core/data/backend.dart' show BackendException;
import 'package:fancake/features/galaxy/galaxy_home_screen.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/galaxy_search_screen.dart';
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'eclipse_stubs.dart';
/// 저장소를 전부 주입한 궤도 맵. 화면이 표본을 기본값으로 만들지 않으므로
/// (공통 규칙 — 저장소 선택은 `repositories.dart` 한 곳) 테스트가 직접 넘긴다.
GalaxyScreen _galaxy({
GalaxyRepository repository = const SampleGalaxyRepository(),
EclipseRepository eclipse = const NoEclipseRepository(),
}) => GalaxyScreen(
repository: repository,
planetRepository: samplePlanetRepository,
eclipse: eclipse,
);
/// 갤럭시 맵은 궤도 회전 애니메이션이 끊이지 않는다. `pumpAndSettle` 은 영원히
/// 기다리므로 이 화면 계열에서는 시간을 정해 [WidgetTester.pump] 한다.
Future<void> _pump(WidgetTester tester, Widget screen) async {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
await tester.pump(); // FutureBuilder
await tester.pump(const Duration(milliseconds: 950)); // 이벤트 띠 슬라이드
}
/// 회전이 도는 동안에도 안전하게 몇 프레임 넘긴다.
Future<void> _settle(WidgetTester tester) async {
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
/// 궤도 꼭대기에 바깥 궤도 행성을 세우는 표본. 목록 index 가 궤도 각도를 정하므로
/// (`index * 2pi / 5.4`) index 4 가 화면 위쪽 끝이다.
class _TopOrbitRepository implements GalaxyRepository {
const _TopOrbitRepository();
@override
Future<void> setPrimary(String fandomId) async {}
@override
Future<ZapResult> zap({
required String fandomId,
required int count,
required String clientSeq,
}) async => const ZapResult(txId: '', balanceAfter: -1);
@override
Future<GalaxySnapshot> load() async => const GalaxySnapshot(
myPop: 0,
joined: false,
planets: [
PlanetNode(id: 'mine', name: '내 행성', rank: 1, fpi: 100, members: 1, online: 1, isMine: true),
PlanetNode(id: 'a', name: 'A', rank: 2, fpi: 90, members: 1, online: 1),
PlanetNode(id: 'b', name: 'B', rank: 3, fpi: 80, members: 1, online: 1),
PlanetNode(id: 'c', name: 'C', rank: 4, fpi: 70, members: 1, online: 1),
PlanetNode(id: 'top', name: 'TOP', rank: 6, fpi: 60, members: 1, online: 1),
PlanetNode(id: 'e', name: 'E', rank: 5, fpi: 50, members: 1, online: 1),
],
);
}
/// 내 행성이 1위가 아닌 표본 — Ranking View 전환이 눈에 보이게 한다.
class _RankedRepository extends _TopOrbitRepository {
const _RankedRepository();
@override
Future<GalaxySnapshot> load() async => const GalaxySnapshot(
myPop: 0,
joined: false,
planets: [
PlanetNode(id: 'top', name: 'TOP', rank: 1, fpi: 900, members: 99, online: 1, stage: 4),
PlanetNode(id: 'a', name: 'A', rank: 2, fpi: 500, members: 5, online: 1),
PlanetNode(id: 'mine', name: '내 행성', rank: 3, fpi: 100, members: 7, online: 1, isMine: true),
],
);
}
/// 대표 팬덤 변경 호출을 기록하는 표본. [error] 가 있으면 서버 거부를 흉내 낸다.
class _PrimaryRepository extends SampleGalaxyRepository {
_PrimaryRepository({this.error});
final BackendException? error;
final changes = <String>[];
@override
Future<void> setPrimary(String fandomId) async {
if (error case final e?) throw e;
changes.add(fandomId);
}
}
void main() {
group('GALAXY 모아보기 (§2.56 · §2.58) — 기본 행성 변경', () {
Future<void> pumpHome(WidgetTester tester, GalaxyRepository repo) async {
await tester.pumpWidget(MaterialApp(
theme: buildAppTheme(),
home: GalaxyHomeScreen(
repository: repo,
planetRepository: samplePlanetRepository,
eclipse: const NoEclipseRepository(),
),
));
await tester.pumpAndSettle();
}
testWidgets('대표가 아닌 가입 행성을 누르면 변경 모달 → fn-primary-fandom-set', (tester) async {
final repo = _PrimaryRepository();
await pumpHome(tester, repo);
await tester.tap(find.bySemanticsLabel('NewJeans'));
await tester.pumpAndSettle();
expect(find.text('선택한 행성을 기본 행성으로 변경할까요?'), findsOneWidget);
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(repo.changes, ['newjeans']);
});
testWidgets('30일 대기 중이면 서버 사유(E-FDM-04)를 알린다', (tester) async {
final repo = _PrimaryRepository(
error: const BackendException('E-FDM-04', '기본 행성은 변경 후 30일이 지나야 다시 바꿀 수 있습니다.'),
);
await pumpHome(tester, repo);
await tester.tap(find.bySemanticsLabel('NewJeans'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(find.textContaining('30일이 지나야'), findsOneWidget);
});
});
group('갤럭시 맵 (F2 · 354:220228)', () {
testWidgets('내 행성 HUD 가 Rank·FPI·접속자수를 띄운다', (tester) async {
await _pump(tester, _galaxy());
// 명세 — HUD 데이터 : Rank, FPI 총점, 접속자수(맴버수)
expect(find.text('RANK'), findsOneWidget);
expect(find.text('FPI'), findsOneWidget);
expect(find.textContaining('접속 1,204명'), findsOneWidget);
expect(find.textContaining('멤버 12,840명'), findsOneWidget);
});
testWidgets('Ranking View 를 켜면 FPI 1위로 중심이 옮겨 가고 끄면 내 행성으로 돌아온다', (tester) async {
await _pump(tester, _galaxy(repository: const _RankedRepository()));
// 진입은 내 Primary 행성 중심(B-21) — HUD 가 내 행성을 가리킨다.
expect(find.textContaining('멤버 7명'), findsOneWidget);
await tester.tap(find.byTooltip('Ranking View'));
await _settle(tester);
expect(find.textContaining('멤버 99명'), findsOneWidget);
expect(find.textContaining('멤버 7명'), findsNothing);
await tester.tap(find.byTooltip('Ranking View'));
await _settle(tester);
expect(find.textContaining('멤버 7명'), findsOneWidget);
});
testWidgets('줌인·줌아웃과 터치 이동이 가능하다', (tester) async {
await _pump(tester, _galaxy());
// 명세 — 3d갤럭시 : 브라우징 줌인 줌아웃 / 터치 이동
expect(find.byType(InteractiveViewer), findsOneWidget);
final v = tester.widget<InteractiveViewer>(find.byType(InteractiveViewer));
expect(v.minScale, lessThan(1));
expect(v.maxScale, greaterThan(1));
});
// 검토회신서 3절 — *「선택 행성 정보가 행성과 연계된 HUD 가 아니라 화면
// 최상단 고정 패널 형태」*. 고정 패널로 되돌아가면 여기서 깨진다.
testWidgets('HUD 는 화면이 아니라 행성에 붙어 움직인다', (tester) async {
await _pump(tester, _galaxy());
final before = tester.getCenter(find.text('RANK'));
await tester.drag(find.byType(InteractiveViewer), const Offset(-90, -70));
await _settle(tester);
final after = tester.getCenter(find.text('RANK'));
expect(
(after - before).distance,
greaterThan(20),
reason: '화면 고정 패널이면 맵을 끌어도 제자리에 있다',
);
});
// 검토회신서 3절 — *「행성 선택 시 중심 전환과 HUD 데이터의 즉시 연동」*.
testWidgets('행성을 고르면 HUD 가 즉시 바뀌고 중심 전환은 감속하며 간다', (tester) async {
// 기본 테스트 화면(800×600)에서는 궤도가 하단 이벤트 띠와 겹쳐 탭이
// 띠에 먹힌다. 세로를 넉넉히 잡아 겹침을 없앤다.
tester.view.physicalSize = const Size(1200, 2000);
tester.view.devicePixelRatio = 2.0;
addTearDown(tester.view.reset);
await _pump(tester, _galaxy());
await tester.tap(find.byKey(const ValueKey('newjeans')));
await tester.pump();
// 즉시 연동 — 고른 행성의 FPI 총점이 바로 HUD 에 올라온다.
expect(find.text('271,905'), findsOneWidget);
// HUD 카드는 행성 위에 가운데를 맞춰 선다. 게이지의 x 가 곧 행성의 x 다.
final gauge = find.byType(LinearProgressIndicator);
final mid = tester.getSize(find.byType(Scaffold)).width / 2;
await tester.pump(const Duration(milliseconds: 16));
final early = (tester.getCenter(gauge).dx - mid).abs();
await tester.pump(GalaxyMotion.recenter);
final settled = (tester.getCenter(gauge).dx - mid).abs();
expect(settled, lessThan(12), reason: '전환이 끝나면 고른 행성이 한가운데다');
expect(early, greaterThan(settled + 20),
reason: '한 프레임에 순간이동하면 「행성 간 이동감」이 없다');
});
// 리뷰 M3 — HUD 위/아래 뒤집힘이 비행 중에 일어나면 카드가 행성을 가로질러
// 순간 이동한다. 선택 시점에 정한 방향을 전환이 끝날 때까지 붙들어야 한다.
//
// 표본 6행성은 어느 것도 화면 위쪽 끝에서 출발하지 않아 뒤집힘이 나지 않는다.
// 궤도 꼭대기(목록 index 4 → 각도 ≈ -pi/2)에 바깥 궤도 행성을 놓아 재현한다.
testWidgets('중심 전환 중에 HUD 가 행성 위아래로 뒤집히지 않는다', (tester) async {
// 세로 800 이 분기점이다 — 출발점에는 카드가 들어갈 자리가 없고(아래),
// 도착점인 화면 한가운데에는 있다(위).
tester.view.physicalSize = const Size(800, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await _pump(tester, _galaxy(repository: const _TopOrbitRepository()));
await tester.tap(find.byKey(const ValueKey('top')));
await tester.pump();
expect(find.byKey(const ValueKey('hud-top')), findsOneWidget);
final planet = find.byKey(const ValueKey('top'));
final card = find.byType(LinearProgressIndicator);
int? side;
// 전환이 끝나기 전까지(620ms) 방향이 한 번도 바뀌면 안 된다.
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16));
final now =
tester.getCenter(card).dy < tester.getCenter(planet).dy ? -1 : 1;
side ??= now;
expect(now, side, reason: '비행 ${i * 16}ms 에서 HUD 가 반대편으로 넘어갔다');
}
});
// 명세 — *HUD 터치시 FPI 상세 페이지 이동*. 목 모드에서 저장소가 누른 행성
// ID 를 버리면 여섯 행성이 전부 내 행성(BTS) 상세로 열린다.
testWidgets('남의 행성 HUD 를 누르면 그 행성 상세가 열린다', (tester) async {
tester.view.physicalSize = const Size(1200, 2400);
tester.view.devicePixelRatio = 2.0;
addTearDown(tester.view.reset);
// 기본 팩토리(samplePlanetRepository)를 그대로 쓴다 — 실제 앱 경로와 같다.
await _pump(tester, _galaxy());
await tester.tap(find.byKey(const ValueKey('newjeans')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('hud-newjeans')));
// 갤럭시는 회전이 멈추지 않으므로 pumpAndSettle 을 쓸 수 없다.
for (var i = 0; i < 20; i++) {
await tester.pump(const Duration(milliseconds: 60));
}
expect(find.textContaining('TOTAL FPI'), findsOneWidget, reason: '상세로 넘어가지 않았다');
expect(find.text('NewJeans'), findsWidgets);
expect(find.textContaining('271,905'), findsWidgets);
// 내 행성 상세가 열리면 이 값들이 보인다. 하나라도 보이면 ID 를 버린 것이다.
expect(find.text('BTS'), findsNothing, reason: '내 행성 상세가 열렸다');
expect(find.textContaining('284,310'), findsNothing, reason: '내 행성 상세가 열렸다');
// 남의 행성에는 내 기여도가 없다 — 대조표 3-5.
expect(find.textContaining(RegExp(r'상위 \d+%')), findsNothing);
});
// 리뷰 M2 — 행성이 뷰포트 밖이면 HUD 를 아예 그리지 않는다. 카드만 가장자리에
// clamp 해 두면 어느 행성을 가리키는지 알 수 없고 연결선은 화면 밖에 그려진다.
testWidgets('행성이 화면 밖으로 나가면 HUD 도 사라진다', (tester) async {
await _pump(tester, _galaxy());
expect(find.text('RANK'), findsOneWidget);
// 맵을 끝까지 밀어 내 행성을 화면 밖으로 보낸다.
for (var i = 0; i < 6; i++) {
await tester.drag(
find.byType(InteractiveViewer),
const Offset(-400, -400),
);
await _settle(tester);
}
expect(find.text('RANK'), findsNothing);
});
testWidgets('GALAXY 는 좌측 하단, 돋보기는 우측 하단', (tester) async {
await _pump(tester, _galaxy());
expect(find.text('GALAXY'), findsOneWidget);
final label = tester.getCenter(find.text('GALAXY'));
final glass = tester.getCenter(find.byIcon(Icons.search));
final size = tester.getSize(find.byType(Scaffold));
expect(label.dx, lessThan(size.width / 2), reason: 'GALAXY 는 좌측');
expect(glass.dx, greaterThan(size.width / 2), reason: '돋보기는 우측');
});
testWidgets('돋보기를 누르면 검색 화면으로 간다', (tester) async {
await _pump(tester, _galaxy());
await tester.tap(find.byIcon(Icons.search));
await _settle(tester);
expect(find.byType(GalaxySearchScreen), findsOneWidget);
});
});
group('Zap (F2 · 명세 「Zap 버튼」)', () {
testWidgets('가입한 사용자에게는 노출된다', (tester) async {
await _pump(tester, _galaxy());
expect(find.byType(ZapButton), findsOneWidget);
});
testWidgets('가입·생성하지 않은 사용자에게는 노출되지 않는다', (tester) async {
await _pump(
tester,
_galaxy(repository: const SampleGalaxyRepository(joined: false)),
);
expect(find.byType(ZapButton), findsNothing);
});
testWidgets('롱프레스로 옵션이 열린다', (tester) async {
await _pump(tester, _galaxy());
// 명세 — 2초간 롱프레싱할 경우 Zap 옵션 버튼으로 전환
final hold = await tester.startGesture(tester.getCenter(find.byType(ZapButton)));
await tester.pump(ZapButton.longPress);
await hold.up();
await _settle(tester);
for (final label in ['기여', '아이템', '닫기']) {
expect(find.text(label), findsOneWidget, reason: '옵션 「$label」 누락');
}
// 「자동(오토 도네이션)」은 두지 않는다 — 별첨 1 ① 시나리오 4 가 적었으나
// 별첨 B 가 상위 기준이고 거기에 없는 기능이다(공통 기준 2항).
expect(find.text('자동'), findsNothing);
});
testWidgets('POP 이 모자라면 누를 수 없다', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: ZapButton(zapPop: 10, myPop: 3, onZap: () {}),
),
),
);
await tester.pumpAndSettle();
expect(find.text('POP이 모자랍니다'), findsOneWidget);
final btn = tester.widget<FilledButton>(find.byType(FilledButton));
expect(btn.onPressed, isNull);
});
});
group('갤럭시 검색 (F2 · 353:217832)', () {
testWidgets('처음에는 최근 검색어가 없다고 알린다', (tester) async {
await _pump(tester, const GalaxySearchScreen());
expect(find.text('최근 검색어'), findsOneWidget);
expect(find.text('최근 검색어가 없습니다.'), findsOneWidget);
});
testWidgets('검색하면 팬덤이 걸리고 최근 검색어에 남는다', (tester) async {
await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'BTS');
await tester.pumpAndSettle();
expect(find.textContaining('멤버 12,840명'), findsOneWidget);
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '');
await tester.pumpAndSettle();
expect(find.text('전체 삭제'), findsOneWidget);
});
testWidgets('없는 이름이면 사유를 알린다', (tester) async {
await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'zzzz');
await tester.pumpAndSettle();
expect(find.textContaining('찾는 팬덤이 없습니다'), findsOneWidget);
});
});
testWidgets('F2 화면 문구에 내부 경로가 새지 않는다', (tester) async {
for (final screen in <Widget>[
_galaxy(),
const GalaxySearchScreen(),
]) {
await _pump(tester, screen);
final texts = tester
.widgetList<Text>(find.byType(Text))
.map((w) => w.data ?? '')
.join('\n');
for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) {
expect(texts.contains(leak), isFalse, reason: '「$leak」 노출');
}
}
});
}
+580
View File
@@ -0,0 +1,580 @@
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/core/ui/relative_time.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/planet/fpi_ranking_screen.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/planet/lounge_post_screens.dart';
import 'package:fancake/features/planet/moderation.dart';
import 'package:fancake/features/planet/planet_detail_screen.dart';
import 'eclipse_stubs.dart';
Future<void> _pump(WidgetTester tester, Widget screen) async {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
await tester.pumpAndSettle();
}
/// 세로가 긴 화면을 한 번에 담는다.
///
/// TabBarView 안에는 스크롤 가능한 것이 여럿이라 `scrollUntilVisible` 이 엉뚱한
/// 쪽을 잡는다. 실기기에 가까운 세로 길이를 주는 편이 확실하다.
Future<void> _pumpTall(
WidgetTester tester,
Widget screen, {
double height = 2400,
}) async {
tester.view.physicalSize = Size(1080, height);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await _pump(tester, screen);
}
/// 서버 산출 「내 기여 상위 %」가 아직 없는 상태의 저장소.
class _NoTopPercentRepository extends SamplePlanetRepository {
@override
Future<void> join() async {}
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) => _sample.history(cursor: cursor);
@override
Future<HeroDropReceipt> claimHeroDrop(String dropId) => _sample.claimHeroDrop(dropId);
const _NoTopPercentRepository();
static const _sample = SamplePlanetRepository();
@override
Future<PlanetDetail> load({required bool nebula}) async {
final detail = await _sample.load(nebula: nebula);
return PlanetDetail(
fandomId: detail.fandomId,
name: detail.name,
isNebula: detail.isNebula,
totalFpi: detail.totalFpi,
members: detail.members,
posts: detail.posts,
scores: [
for (final s in detail.scores)
FpiScore(
metric: s.metric,
value: s.value,
rank: s.rank,
trend: s.trend,
myTopPercent: null,
),
],
mockNote: '「내 기여 상위 %」 집계 함수가 아직 없습니다',
);
}
@override
Future<List<LoungePost>> morePosts({required int offset, int limit = 5}) =>
_sample.morePosts(offset: offset, limit: limit);
@override
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30}) =>
_sample.planetRanking(metric: metric, limit: limit);
@override
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
}) => _sample.userRanking(metric: metric, fandomId: fandomId, limit: limit);
@override
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30}) =>
_sample.countryRanking(metric: metric, limit: limit);
}
/// 가입 전 상태로 시작하고, [join] 이 불리면 가입중으로 바뀌는 표본.
class _Joinable extends SamplePlanetRepository {
_Joinable();
int joins = 0;
@override
Future<void> join() async => joins++;
@override
Future<PlanetDetail> load({required bool nebula}) async {
final d = await super.load(nebula: nebula);
return PlanetDetail(
fandomId: d.fandomId, name: d.name, stage: d.stage, isNebula: d.isNebula,
totalFpi: d.totalFpi, scores: d.scores, members: d.members, posts: d.posts,
joined: joins > 0,
);
}
}
void main() {
_fpiRankingTests();
testWidgets('가입하지 않은 행성은 「가입하기」를 눌러 fn-fandom-join 으로 가입한다', (tester) async {
final repo = _Joinable();
await _pumpTall(tester, PlanetDetailScreen(repository: repo, eclipse: const NoEclipseRepository()));
expect(find.text('가입하기'), findsOneWidget);
await tester.tap(find.text('가입하기'));
await tester.pumpAndSettle();
expect(repo.joins, 1);
expect(find.text('가입중'), findsOneWidget);
// 가입중 알약은 다시 눌러도 호출하지 않는다.
await tester.tap(find.text('가입중'));
await tester.pumpAndSettle();
expect(repo.joins, 1);
});
group('행성 상세 · FPI (F3 · 343:213428)', () {
testWidgets('TOTAL FPI 값과 갱신 주기를 띄운다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
expect(find.text('TOTAL FPI : 284,310'), findsOneWidget);
// 원장 B-15 검증 기준 — 화면에 마지막 갱신 시각 노출(서버 시각이 없으면 --:--).
expect(find.text('마지막 갱신 --:--'), findsOneWidget);
expect(find.textContaining('30초'), findsNothing);
// 개설자 이름은 서버에 없으면 그리지 않는다 — 시안 표본 이름을 박지 않는다.
expect(find.text('BTS_Maker'), findsNothing);
});
testWidgets('5지표를 답변서 확정 표기로 띄운다', (tester) async {
// 시안(373:441117)은 1243 높이의 스크롤 화면이라 지표 다섯을 한 창에 담으려면
// 실기기보다 긴 창이 필요하다. 스크롤하면 위 카드가 해제되어 다섯을 동시에 못 본다.
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
), height: 4200);
// 답변서 v1.1 Q3 이 정본이다. Figma 의 BMP·STEPS·REFERAL 이 아니다.
for (final label in [
'BPM',
'HEALTH',
'REFERRAL',
'COMMUNITY',
'CONTRIBUTION',
]) {
expect(find.text(label), findsWidgets, reason: '지표 「$label」 누락');
}
});
testWidgets('지표마다 전체 순위와 내 기여 상위를 보여 준다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
), height: 4200);
expect(find.text('전체 1위'), findsOneWidget);
expect(find.text('상위 17%'), findsWidgets);
});
testWidgets('내 기여 상위 집계 전이면 숫자 대신 사유를 띄운다 — 대조표 3-5', (tester) async {
await _pumpTall(
tester,
const PlanetDetailScreen(
repository: _NoTopPercentRepository(),
eclipse: NoEclipseRepository(),
),
height: 4200,
);
// 없는 값을 100%(최하위)로 지어내지 않는다.
expect(find.text('상위 100%'), findsNothing);
expect(find.text('집계 준비 중'), findsWidgets);
// 사유는 배지로도 뜬다 — 숨기지 않는다.
expect(find.textContaining('MOCK · '), findsOneWidget);
});
testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async {
// 스펙 B — 「집계 문서를 갤럭시·행성 상세 HUD 에서 실시간으로 표시한다」.
// 침식은 TOTAL FPI 를 깎는 경로이므로(원장 B-55) 그 카드 위에 온다.
await _pumpTall(tester, PlanetDetailScreen(
repository: const SamplePlanetRepository(),
eclipse: SampleEclipseRepository(),
));
expect(find.textContaining('이클립스 발동'), findsOneWidget);
expect(find.textContaining('침식 42%'), findsOneWidget);
// **POP 단위가 아니다** — 방어는 POP 을 소모하지 않는 공동 Shake 참여다
// (Firestore 설계서 v1.1 §9-#5).
expect(find.textContaining('유효 참여 5,800 / 10,000명'), findsOneWidget);
expect(find.textContaining('POP'), findsNothing);
});
testWidgets('이클립스가 없으면 침식 배너 자리가 비지 않는다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
expect(find.textContaining('이클립스 발동'), findsNothing);
// 배너가 없어도 본문은 그대로다 — 빈 자리가 남지 않는다.
expect(find.textContaining('TOTAL FPI'), findsOneWidget);
});
});
group('팬덤 라운지 (F3 · 251:51073)', () {
testWidgets('최초 5개를 최신순으로 띄운다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
await tester.tap(find.text('팬덤 라운지'));
await tester.pumpAndSettle();
// 명세 — 최초 5개, 스크롤 시 5개씩 호출.
expect(find.textContaining('오늘 공방 다녀왔어요'), findsOneWidget);
expect(find.byType(FloatingActionButton), findsOneWidget);
});
testWidgets('스크롤하면 히어로가 접히고 탭은 위에 남는다 — 발주사 지적(글이 보이게)', (tester) async {
tester.view.physicalSize = const Size(750, 1624);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await _pump(
tester,
const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
),
);
await tester.tap(find.text('팬덤 라운지'));
await tester.pumpAndSettle();
final tabTop = tester.getTopLeft(find.text('팬덤 라운지')).dy;
await tester.drag(
find.textContaining('오늘 공방 다녀왔어요'),
const Offset(0, -500),
);
await tester.pumpAndSettle();
// 탭 줄이 앱 바 바로 아래로 올라와 고정되고, 글이 화면 위쪽까지 올라온다.
expect(tester.getTopLeft(find.text('팬덤 라운지')).dy, lessThan(tabTop - 300));
expect(find.text('팬덤 라운지').hitTestable(), findsOneWidget);
expect(find.textContaining('이번 앨범 최애 트랙').hitTestable(), findsOneWidget);
});
testWidgets('이 회원의 글 모두 숨기기 — 확인하면 그 작성자 글이 목록에서 빠진다', (tester) async {
await _pumpTall(
tester,
const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
),
);
await tester.tap(find.text('팬덤 라운지'));
await tester.pumpAndSettle();
expect(find.text('아미보이'), findsOneWidget);
await tester.tap(
find.image(const AssetImage('assets/figma/icon-post-more.png')).first,
);
await tester.pumpAndSettle();
await tester.tap(find.text('이 회원의 글 모두 숨기기'));
await tester.pumpAndSettle();
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(find.text('아미보이'), findsNothing);
});
testWidgets('글 쓰기는 본문이 있어야 완료된다', (tester) async {
// 시안 244:21746 · 설계서 §2.6 posts — 제목 칸 없이 본문 한 칸.
await _pump(tester, const LoungeComposeScreen());
final register = find.widgetWithText(TextButton, '완료');
expect(tester.widget<TextButton>(register).onPressed, isNull);
await tester.enterText(find.byType(TextField), ' ');
await tester.pumpAndSettle();
// 공백만으로는 아직 안 된다.
expect(tester.widget<TextButton>(register).onPressed, isNull);
await tester.enterText(find.byType(TextField), '본문입니다');
await tester.pumpAndSettle();
expect(tester.widget<TextButton>(register).onPressed, isNotNull);
});
});
group('신고 · 차단 (F3 · 252:162674 · 252:163306)', () {
testWidgets('사유를 고르기 전에는 신고할 수 없다', (tester) async {
await _pump(
tester,
const ReportScreen(
target: ModerationTarget(authorName: '아미보이', title: '테스트 글'),
),
);
// 신고 정보 영역 — 작성자 닉네임 · 게시물 제목
expect(find.text('아미보이'), findsOneWidget);
expect(find.text('테스트 글'), findsOneWidget);
final btn = tester.widget<TextButton>(find.widgetWithText(TextButton, '신고'));
expect(btn.onPressed, isNull);
});
testWidgets('사유는 단수 선택이고 고르면 확인을 되묻는다', (tester) async {
await _pump(
tester,
const ReportScreen(
target: ModerationTarget(authorName: '아미보이', title: '테스트 글'),
),
);
await tester.tap(find.text('부적절한 콘텐츠'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '신고'));
await tester.pumpAndSettle();
// 화면 중앙 오버레이 — 취소 / 확인
expect(find.textContaining('신고할까요?'), findsOneWidget);
expect(find.text('취소'), findsOneWidget);
expect(find.text('확인'), findsOneWidget);
});
testWidgets('신고 사유는 어드민 등록 항목을 받는다', (tester) async {
await _pump(
tester,
const ReportScreen(
target: ModerationTarget(authorName: 'x', title: 'y'),
reasons: ['운영자가 정한 사유'],
),
);
expect(find.text('운영자가 정한 사유'), findsOneWidget);
expect(find.text('부적절한 콘텐츠'), findsNothing);
});
});
group('성운 상세 (F3 · 373:442708)', () {
testWidgets('성운도 같은 화면으로 열린다', (tester) async {
await _pump(tester, const PlanetDetailScreen(
nebula: true,
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
expect(find.text('New_BTS'), findsOneWidget);
});
});
testWidgets('상대 시각 표기', (tester) async {
final now = DateTime(2026, 8, 31, 15);
expect(relativeTime(now.subtract(const Duration(seconds: 20)), now: now), '방금 전');
expect(relativeTime(now.subtract(const Duration(minutes: 10)), now: now), '10분 전');
expect(relativeTime(now.subtract(const Duration(hours: 3)), now: now), '3시간 전');
expect(relativeTime(now.subtract(const Duration(days: 2)), now: now), '2일 전');
});
testWidgets('F3 화면 문구에 내부 경로가 새지 않는다', (tester) async {
await _pump(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
final texts = tester
.widgetList<Text>(find.byType(Text))
.map((w) => w.data ?? '')
.join('\n');
for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) {
expect(texts.contains(leak), isFalse, reason: '「$leak」 노출');
}
});
}
/// 지표별 랭킹 2단계 — F3 · Figma `546:14589`(행성) → `546:14646`(사용자).
///
/// 발주사 검토회신서 4절 회귀 가드다. 지적은 두 가지였다 —
/// ① 지표 카드와 Ranking 사이 수치·순위 불일치 ② 지표를 바꿔도 같은 Ranking.
/// 둘 다 여기서 깨지면 바로 보이게 고정한다.
void _fpiRankingTests() {
const repo = SamplePlanetRepository();
group('표본은 행성별로 갈린다 — 어느 HUD 를 눌러도 같은 상세가 열리면 안 된다', () {
// 갤럭시 HUD 는 남의 행성도 연다. 표본이 내 행성 하나로 고정돼 있으면
// 여섯 행성이 전부 BTS 상세로 열려 발주사 확인에서 바로 드러난다.
test('행성마다 이름·TOTAL FPI·멤버 수가 다르다', () async {
final details = <PlanetDetail>[
for (final id in [
'bts',
'newjeans',
'seventeen',
'ive',
'aespa',
'lesserafim',
])
await SamplePlanetRepository(fandomId: id).load(nebula: false),
];
expect(details.map((d) => d.fandomId).toSet(), hasLength(6));
expect(details.map((d) => d.name).toSet(), hasLength(6));
expect(details.map((d) => d.totalFpi).toSet(), hasLength(6));
expect(details.map((d) => d.members).toSet(), hasLength(6));
});
test('상세의 TOTAL FPI 와 멤버 수가 갤럭시 맵 표본과 같다', () async {
// 같은 행성인데 HUD 와 상세가 다른 수치를 띄우면 그것부터 지적받는다.
final snapshot = await const SampleGalaxyRepository().load();
for (final node in snapshot.planets) {
final detail =
await SamplePlanetRepository(fandomId: node.id).load(nebula: false);
expect(detail.totalFpi, node.fpi, reason: '${node.id} TOTAL FPI 불일치');
expect(detail.members, node.members, reason: '${node.id} 멤버 수 불일치');
expect(detail.name, node.name, reason: '${node.id} 이름 불일치');
}
});
test('전체 순위는 그 행성의 순위다 — 내 행성 순위를 돌려주지 않는다', () async {
final detail =
await SamplePlanetRepository(fandomId: 'lesserafim').load(nebula: false);
for (final score in detail.scores) {
final rows = (await const SamplePlanetRepository()
.planetRanking(metric: score.metric))
.rows;
final row = rows.firstWhere((r) => r.id == 'lesserafim');
expect(score.rank, row.rank, reason: '${score.metric.label} 순위 불일치');
expect(score.value, row.value, reason: '${score.metric.label} 수치 불일치');
}
});
test('남의 행성에서는 내 기여 상위 %를 지어내지 않는다 — 대조표 3-5', () async {
final mine = await const SamplePlanetRepository().load(nebula: false);
expect(mine.scores.every((s) => s.myTopPercent != null), isTrue);
final theirs =
await SamplePlanetRepository(fandomId: 'aespa').load(nebula: false);
expect(theirs.scores.every((s) => s.myTopPercent == null), isTrue);
});
test('남의 행성 사용자 랭킹에는 「나」 행이 없다', () async {
const repo = SamplePlanetRepository();
expect(
(await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts')).mine,
isNotNull,
);
expect(
(await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'ive')).mine,
isNull,
);
});
test('ID 를 주지 않으면 내 행성이다 — 기존 호출부가 그대로 돈다', () async {
final byDefault = await const SamplePlanetRepository().load(nebula: false);
final explicit =
await SamplePlanetRepository(fandomId: 'bts').load(nebula: false);
expect(byDefault.fandomId, 'bts');
expect(byDefault.totalFpi, explicit.totalFpi);
});
});
group('지표별 랭킹 — 발주사 검토회신서 4절 회귀 가드', () {
test('지표를 바꾸면 랭킹 순서가 달라진다', () async {
final byMetric = <FpiMetric, List<String>>{
for (final m in FpiMetric.values)
m: (await repo.planetRanking(metric: m)).rows
.map((r) => r.name)
.toList(),
};
// 다섯 축의 1위가 모두 같으면 「같은 Ranking 반복」 상태로 돌아간 것이다.
final tops = byMetric.values.map((rows) => rows.first).toSet();
expect(tops.length, greaterThan(1), reason: '지표마다 1위가 같습니다');
// 순서 자체도 축마다 달라야 한다.
expect(byMetric.values.map((r) => r.join()).toSet(), hasLength(5));
});
test('지표 카드의 값·순위가 행성 랭킹의 내 행성 행과 같다', () async {
final detail = await repo.load(nebula: false);
for (final score in detail.scores) {
final mine = (await repo.planetRanking(metric: score.metric)).mine;
expect(mine, isNotNull, reason: '${score.metric.label} 내 행성 행 없음');
expect(mine!.value, score.value, reason: '${score.metric.label} 수치 불일치');
expect(mine.rank, score.rank, reason: '${score.metric.label} 순위 불일치');
}
});
test('TOTAL FPI 는 5축 단순 합산이다 — 별첨 B · 설계서 §4.1', () async {
final detail = await repo.load(nebula: false);
expect(
detail.totalFpi,
detail.scores.fold<int>(0, (a, s) => a + s.value),
);
});
test('사용자 랭킹도 지표마다 다르다', () async {
final tops = <String>{
for (final m in FpiMetric.values)
(await repo.userRanking(metric: m, fandomId: 'bts')).rows.first.name,
};
expect(tops.length, greaterThan(1));
});
});
group('행성 지표 랭킹 화면 (Figma 546:14589)', () {
Future<void> pump(WidgetTester tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const MaterialApp(
home: FpiRankingScreen(metric: FpiMetric.bpm, repository: repo),
),
);
await tester.pumpAndSettle();
}
testWidgets('제목은 지표명 + RANKING 이다', (tester) async {
await pump(tester);
expect(find.text('BPM RANKING'), findsOneWidget);
});
testWidgets('소속 행성 순위를 맨 위에 고정한다', (tester) async {
await pump(tester);
expect(find.text('내 행성의 BPM 랭킹'), findsOneWidget);
// 표본에서 BPM 1위는 NewJeans, 내 행성 BTS 는 2위다(내 줄 카드).
expect(find.text('BTS'), findsOneWidget);
expect(find.text('NewJeans'), findsOneWidget);
});
testWidgets('행성 행을 누르면 사용자 랭킹으로 간다 (546:14639)', (tester) async {
await pump(tester);
await tester.tap(find.text('BTS (나)'));
await tester.pumpAndSettle();
expect(find.byType(FpiUserRankingScreen), findsOneWidget);
expect(find.text('나의 BPM 랭킹'), findsOneWidget);
});
});
group('사용자 지표 랭킹 화면 (Figma 546:14646)', () {
Future<void> pump(WidgetTester tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const MaterialApp(
home: FpiUserRankingScreen(
metric: FpiMetric.bpm,
fandomId: 'bts',
planetName: 'BTS',
repository: repo,
),
),
);
await tester.pumpAndSettle();
}
testWidgets('내 순위를 맨 위에 고정한다', (tester) async {
await pump(tester);
expect(find.text('나의 BPM 랭킹'), findsOneWidget);
// 내 줄 카드와 시상대(3위) 양쪽에 「나」가 있다.
expect(find.text('나'), findsOneWidget);
expect(find.text('나 (나)'), findsOneWidget);
});
testWidgets('필터는 실시간과 국가별 두 축이다', (tester) async {
await pump(tester);
await tester.tap(find.text('실시간 랭킹'));
await tester.pumpAndSettle();
expect(find.text('국가별'), findsWidgets);
expect(find.text('취소'), findsOneWidget);
});
testWidgets('국가별로 바꾸면 제목과 나의 랭킹 영역이 국가 기준이 된다', (tester) async {
await pump(tester);
await tester.tap(find.text('실시간 랭킹'));
await tester.pumpAndSettle();
await tester.tap(find.text('국가별').last);
await tester.pumpAndSettle();
// 전 국가 합산이므로 행성명이 없다.
expect(find.text('BTS'), findsNothing);
expect(find.text('국가별 랭킹'), findsOneWidget);
// 「나의 랭킹 영역」(546:14713)은 여기서 내 국가 행이다.
expect(find.text('나의 BPM 랭킹'), findsNothing);
expect(find.text('내 국가의 BPM 랭킹'), findsOneWidget);
// 목록에서도 내 국가 행에 표시가 붙는다.
expect(find.text('대한민국 (나)'), findsOneWidget);
});
});
}
+538
View File
@@ -0,0 +1,538 @@
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('일일 한도(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('진행 상태 (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);
}
});
test('완료 탭은 진행 완료와 보상 완료를 함께 받는다', () {
expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isTrue);
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).
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);
// 별첨 B **B-28** 이 배수형(×2/×3)을 철회했다. 기본 보상과 광고 추가 보상은
// 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다(`fnAdReward.rewarded`).
expect(find.text('+10 받았어요!'), findsOneWidget);
expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget);
});
testWidgets('회신 대기 유형은 참여 버튼이 잠기고 MOCK 으로 알린다', (tester) async {
await pumpWide(tester, const QuestDetailScreen(questId: 'q6', repository: _sample));
await tester.pump();
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '발주사 확인 중'),
);
expect(button.onPressed, isNull);
expect(find.textContaining('Q-QC-04'), 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();
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')));
}
}
});
}
/// 광고 경계만 바꾼 표본 — B-03 「광고 실패 시 기본 보상 지급 · 추가 보상 없음」.
class _AdRepo extends SampleQuestRepository {
_AdRepo({this.loadError, this.impression = 'imp-1', this.claimError});
final BackendException? loadError;
final String? impression;
final BackendException? claimError;
final claimed = <String>[];
@override
Future<RewardedAdHandle> loadRewardedAd() 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)),
),
);
}
+581
View File
@@ -0,0 +1,581 @@
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: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('타인 캠페인에는 기여하기 버튼이 있다', (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('Catalog 표본 가격은 앱바 MOCK 알약으로 알린다(G-08)', (tester) async {
await pumpWide(tester, CampaignCreateScreen());
expect(
find.bySemanticsLabel('MOCK · 상품 목록은 Catalog 연결 전 표본입니다'),
findsOneWidget,
);
});
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(AlertDialog),
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});
final CampaignStatus status;
final int supporters;
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: true,
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),
),
);
}
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
import 'dart:async';
import 'package:fancake/core/ui/planet_25d.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
/// 모든 위젯 테스트 앞에서 한 번 실행된다.
/// 2.5D 행성의 무한 반복 애니메이션은 `pumpAndSettle` 을 끝나지 않게 하므로 끈다.
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
Planet25D.animationsEnabled = false;
// 테스트 단말 언어는 기본 en_US 다. 앱은 단말 언어를 따르므로(B-76) 한국어 문구를
// 확인하는 기존 테스트가 한국어 단말을 전제로 돌도록 고정한다. 언어 전환은 i18n_test 가 본다.
TestWidgetsFlutterBinding.ensureInitialized()
.platformDispatcher
.localesTestValue = const [
Locale('ko', 'KR'),
];
await testMain();
}
+95
View File
@@ -0,0 +1,95 @@
import 'dart:async';
import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_motion.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'eclipse_stubs.dart';
void main() {
test('상태별 표류는 점진적으로 변하고 사용자 드래그는 유지된다', () {
final clock = GalaxyClock();
addTearDown(clock.dispose);
for (final scale in [0.25, 0.5, 1.5, 1.0]) {
final before = clock.velocity;
clock.idleSpeedScale = scale;
clock.advance(1 / 60);
final target = GalaxyMotion.idleSpin * scale;
expect(
clock.velocity,
inExclusiveRange(
before < target ? before : target,
before > target ? before : target,
),
);
for (var i = 0; i < 600; i++) {
clock.advance(1 / 60);
}
expect(clock.velocity, closeTo(target, 1e-6));
}
clock.idleSpeedScale = 0.25;
clock.drag(10);
clock.advance(1 / 60);
expect(clock.velocity, closeTo(10 * 60 * GalaxyMotion.dragSpinGain, 1e-9));
});
testWidgets('선택 행성의 서버 상태만 주변 궤도에 적용하고 Reduce Motion은 정지한다', (tester) async {
final streams = <String, StreamController<PlanetVisualState>>{};
Stream<PlanetVisualState> watch(String id) => streams
.putIfAbsent(id, () => StreamController<PlanetVisualState>.broadcast())
.stream;
await tester.pumpWidget(
MaterialApp(
home: GalaxyScreen(
repository: const SampleGalaxyRepository(),
planetRepository: samplePlanetRepository,
eclipse: const NoEclipseRepository(),
watchPlanet: watch,
),
),
);
await tester.pump();
await tester.pump();
final clock = tester
.widgetList<AnimatedBuilder>(find.byType(AnimatedBuilder))
.map((w) => w.listenable)
.whereType<GalaxyClock>()
.first;
GalaxyField field() => tester.widget<GalaxyField>(find.byType(GalaxyField));
final mine = field().planets.firstWhere((p) => p.isMine);
for (final entry in [
(const PlanetVisualState(isHeroTime: true), 1.5),
(const PlanetVisualState(isHeroTime: true, isEclipsed: true), 0.5),
(const PlanetVisualState(isHeroTime: true, isHibernating: true), 0.25),
(const PlanetVisualState(), 1.0),
]) {
streams[mine.id]!.add(entry.$1);
await tester.pump();
expect(clock.idleSpeedScale, entry.$2);
}
final other = field().planets.firstWhere((p) => !p.isMine);
field().onSelect(other);
await tester.pump();
streams[mine.id]!.add(const PlanetVisualState(isHibernating: true));
streams[other.id]!.add(const PlanetVisualState(isHeroTime: true));
await tester.pump();
expect(clock.idleSpeedScale, 1.5);
tester.platformDispatcher.accessibilityFeaturesTestValue =
const FakeAccessibilityFeatures(disableAnimations: true);
addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
await tester.pump();
final spin = clock.spin;
await tester.pump(const Duration(seconds: 1));
expect(clock.spin, spin);
await tester.pumpWidget(const SizedBox());
for (final stream in streams.values) {
expect(stream.hasListener, isFalse);
await stream.close();
}
});
}
+327
View File
@@ -0,0 +1,327 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/planet_25d.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'eclipse_stubs.dart';
import 'perf_paint_test.dart' show medianPaintMicros, paintBudgetMicros;
/// 프로토타입 궤도 간격 450 × Mass Volume 1.5 × Orbit Exp 1.0.
const _spacing = 675.0;
GalaxySnapshot _snap(List<PlanetNode> planets) =>
GalaxySnapshot(planets: planets, myPop: 0);
const _base = [
PlanetNode(
id: 'a',
name: 'Alpha',
rank: 1,
fpi: 5000,
members: 10,
online: 1204,
isMine: true,
),
PlanetNode(
id: 'b',
name: 'Beta',
rank: 2,
fpi: 4000,
members: 10,
online: 30,
),
PlanetNode(
id: 'c',
name: 'Gamma',
rank: 3,
fpi: 1000,
members: 10,
online: 20,
),
];
void main() {
late int opened;
Future<void> pumpMap(
WidgetTester tester,
GalaxySnapshot data, {
double size = 140,
}) => tester.pumpWidget(
MaterialApp(
home: Center(
child: GalaxyMiniMap(data: data, onOpen: () => opened++, size: size),
),
),
);
GalaxyMiniMapPainter painterOf(WidgetTester tester) => tester
.widgetList<CustomPaint>(
find.descendant(
of: find.byType(GalaxyMiniMap),
matching: find.byType(CustomPaint),
),
)
.map((w) => w.painter)
.whereType<GalaxyMiniMapPainter>()
.single;
setUp(() => opened = 0);
testWidgets('실데이터의 내 행성 HUD 숫자를 그리고 궤도는 순위로 정한다', (tester) async {
await pumpMap(tester, _snap(_base));
final label = tester.getSemantics(find.byType(GalaxyMiniMap)).label;
expect(label, contains('ALPHA · RANK #1 · FPI: 5000 · LIVE: 1,204'));
final p = painterOf(tester);
expect(p.orbitOf('a'), 0);
expect(p.orbitOf('b'), _spacing);
expect(p.orbitOf('c'), 2 * _spacing);
expect(tester.takeException(), isNull);
});
testWidgets('행성 색은 팬덤 colorKey 를 따르고, 없거나 모르는 키는 순번 색으로 둔다', (tester) async {
await pumpMap(
tester,
_snap([
for (final (i, p) in _base.indexed)
PlanetNode(
id: p.id,
name: p.name,
rank: p.rank,
fpi: p.fpi,
members: p.members,
online: p.online,
isMine: p.isMine,
colorKey: const ['gold', 'violet', null][i],
),
]),
);
final p = painterOf(tester);
expect(p.colorOf('a'), planetColorOf('gold'));
// 모르는 키(서버 기본값 violet)·미제공은 FPI 순번 프로토타입 색(2번째 · 3번째).
expect(p.colorOf('b'), const Color(0xFF8B5CF6));
expect(p.colorOf('c'), const Color(0xFF10B981));
});
testWidgets('누르면 열리고, 가로로 끌면 장면이 돈다(±180° 한계)', (tester) async {
await pumpMap(tester, _snap(_base));
await tester.tap(find.byType(GalaxyMiniMap));
expect(opened, 1);
await tester.drag(find.byType(GalaxyMiniMap), const Offset(30, 0));
await tester.pump();
final turned = painterOf(tester).rotation;
expect(turned, greaterThan(0));
await tester.drag(find.byType(GalaxyMiniMap), const Offset(-2000, 0));
await tester.pump();
expect(painterOf(tester).rotation, closeTo(-math.pi, 1e-9));
expect(opened, 1, reason: '끌기는 열기가 아니다');
});
testWidgets('순위가 바뀌면 궤도와 HUD 숫자가 따라 바뀐다', (tester) async {
await pumpMap(tester, _snap(_base));
await pumpMap(
tester,
_snap(const [
PlanetNode(
id: 'c',
name: 'Gamma',
rank: 1,
fpi: 6000,
members: 10,
online: 20,
),
PlanetNode(
id: 'a',
name: 'Alpha',
rank: 2,
fpi: 5100,
members: 10,
online: 1300,
isMine: true,
),
PlanetNode(
id: 'b',
name: 'Beta',
rank: 3,
fpi: 4000,
members: 10,
online: 30,
),
]),
);
final p = painterOf(tester);
expect(p.orbitOf('c'), 0);
expect(p.orbitOf('a'), _spacing);
expect(
tester.getSemantics(find.byType(GalaxyMiniMap)).label,
contains('ALPHA · RANK #2 · FPI: 5100 · LIVE: 1,300'),
);
});
testWidgets('움직임이 켜져 있으면 새 궤도로 미끄러져 간다(프로토타입 lerp)', (tester) async {
Planet25D.animationsEnabled = true;
addTearDown(() => Planet25D.animationsEnabled = false);
await pumpMap(tester, _snap(_base));
// 한 프레임 dt 는 1/20 초로 잘리므로 프레임 단위로 흘린다.
for (var i = 0; i < 30; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
final mid = painterOf(tester).orbitOf('c')!;
expect(mid, greaterThan(0));
expect(mid, lessThan(2 * _spacing));
for (var i = 0; i < 600; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(painterOf(tester).orbitOf('c'), closeTo(2 * _spacing, 5));
});
testWidgets('140 에서는 내 행성 간략 카드만 — 두 줄 모두 9pt 이상', (tester) async {
await pumpMap(tester, _snap(_base));
final p = painterOf(tester);
expect(p.compactText, [
('ALPHA', GalaxyMiniMapPainter.compactNamePt),
('#1 · FPI 5,000 · LIVE 1,204', GalaxyMiniMapPainter.compactLinePt),
]);
expect(p.compactText.first.$2, greaterThanOrEqualTo(10));
for (final (_, pt) in p.compactText) {
expect(pt, greaterThanOrEqualTo(9));
}
final w = p.hudWidthOf('a')!;
expect(w, greaterThan(0));
expect(w, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor));
expect(p.hudWidthOf('b'), 0, reason: '다른 행성 카드는 뺀다');
expect(p.hudWidthOf('c'), 0);
});
testWidgets('320 에서는 모든 행성에 프로토타입 카드를 그린다', (tester) async {
await pumpMap(tester, _snap(_base), size: 320);
final p = painterOf(tester);
for (final id in ['a', 'b', 'c']) {
expect(p.hudWidthOf(id), greaterThan(0), reason: id);
}
expect(p.compactText, isEmpty);
});
testWidgets('FPI 상위 10개 + 순위 밖 내 행성만 그린다', (tester) async {
await pumpMap(
tester,
_snap([
for (var i = 0; i < 12; i++)
PlanetNode(
id: 'p$i',
name: 'P$i',
rank: i + 1,
fpi: 1000 - i,
members: 1,
online: 1,
isMine: i == 11,
),
]),
);
final p = painterOf(tester);
expect(p.orbitOf('p9'), isNotNull);
expect(p.orbitOf('p10'), isNull);
expect(p.orbitOf('p11'), isNotNull);
});
testWidgets('보조 기술은 버튼 하나로 읽고 눌러 연다', (tester) async {
final handle = tester.ensureSemantics();
await pumpMap(tester, _snap(_base));
final node = tester.getSemantics(find.byType(GalaxyMiniMap));
expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
node.owner!.performAction(node.id, SemanticsAction.tap);
expect(opened, 1);
handle.dispose();
});
testWidgets('게이지 조절 UI 는 없다(C-51)', (tester) async {
await pumpMap(tester, _snap(_base));
expect(find.byType(Slider), findsNothing);
expect(find.byType(RangeSlider), findsNothing);
for (final gauge in [
'Mass Volume',
'Orbit Exp.',
'HUD Size',
'HUD Dist.',
'Camera Height',
'View Rotation',
'Action Flare',
]) {
expect(find.text(gauge), findsNothing);
}
});
test('행성 12개 한 프레임 그리기가 예산 안에 든다', () {
final painter = GalaxyMiniMapPainter()
..update([
for (var i = 0; i < 12; i++)
PlanetNode(
id: 'p$i',
name: 'PLANET $i',
rank: i + 1,
fpi: 300000 - i * 20000,
members: 100,
online: 10,
isMine: i == 2,
),
])
..settle();
addTearDown(painter.dispose);
final micros = medianPaintMicros(painter, const Size(140, 140));
expect(micros, lessThan(paintBudgetMicros));
});
testWidgets('HOME 미니맵은 같은 저장소의 Galaxy로 진입한다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
const galaxy = SampleGalaxyRepository();
final states = StreamController<PlanetVisualState>.broadcast();
addTearDown(states.close);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: HomeScreen(
repository: const SampleHomeRepository(),
questRepository: const SampleQuestRepository(),
eclipse: const NoEclipseRepository(),
galaxyRepository: galaxy,
planetRepository: samplePlanetRepository,
watchPlanet: (_) => states.stream,
),
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
final mini = find.byType(GalaxyMiniMap);
expect(mini, findsOneWidget);
// Q-DS-06 권고안 A(회신 전 임시) — 행성 영역 우측 상단 140 정사각.
expect(tester.getSize(mini), const Size(140, 140));
expect(
tester.getTopRight(mini).dx,
closeTo(tester.getSize(find.byType(HomeScreen)).width - 16, 1),
);
await tester.tap(mini);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(
tester.widget<GalaxyScreen>(find.byType(GalaxyScreen)).repository,
same(galaxy),
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
expect(states.hasListener, isFalse);
});
}
+225
View File
@@ -0,0 +1,225 @@
import 'dart:math' as math;
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_motion.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
import 'package:flutter_test/flutter_test.dart';
/// 검토회신서 3절 *「드래그/회전 시 자연스러운 관성과 감속」*·*「공간 깊이감」*의
/// 회귀 방지. 눈으로 보는 값이라 화면 없이 물리만 잰다.
void main() {
group('회전 관성 (GalaxyClock)', () {
test('손을 대지 않으면 기본 표류 속도로 돈다', () {
final clock = GalaxyClock();
for (var i = 0; i < 60; i++) {
clock.advance(1 / 60);
}
expect(clock.velocity, closeTo(GalaxyMotion.idleSpin, 1e-9));
expect(clock.spin, closeTo(GalaxyMotion.idleSpin, 1e-6));
});
test('드래그 속도가 각속도를 만든다 — 거리가 아니라 속도다', () {
final fast = GalaxyClock()..drag(30); // 한 프레임에 30px
fast.advance(1 / 60);
final slow = GalaxyClock();
// 같은 30px 를 열 프레임에 나눠 끈다. 거리는 같고 속도는 1/10 이다.
for (var i = 0; i < 10; i++) {
slow.drag(3);
slow.advance(1 / 60);
}
expect(fast.velocity, greaterThan(slow.velocity * 5));
});
test('손을 떼면 감속해서 기본 표류로 돌아온다', () {
final clock = GalaxyClock()..drag(40);
clock.advance(1 / 60);
final kicked = clock.velocity;
expect(kicked, greaterThan(GalaxyMotion.idleSpin));
// 한 e-폴딩(1/friction 초) 뒤에는 초과분이 1/e 만 남는다.
final steps = (1 / GalaxyMotion.spinFriction * 60).round();
for (var i = 0; i < steps; i++) {
clock.advance(1 / 60);
}
final excess = clock.velocity - GalaxyMotion.idleSpin;
expect(excess, closeTo((kicked - GalaxyMotion.idleSpin) / math.e, 0.02));
// 오래 두면 결국 기본 표류다 — 영원히 돌지 않는다.
for (var i = 0; i < 600; i++) {
clock.advance(1 / 60);
}
expect(clock.velocity, closeTo(GalaxyMotion.idleSpin, 1e-6));
});
// 리뷰 H1 — onInteractionUpdate 가 focalPointDelta.dx 를 무조건 넘기면
// 세로 드래그·핀치 줌이 각속도를 덮어써 궤도가 선다.
test('세로 드래그·핀치 줌은 각속도를 바꾸지 않는다', () {
final dragged = GalaxyClock();
final pinched = GalaxyClock();
for (var i = 0; i < 30; i++) {
// 세로로 끄는 동안에도 가로 성분은 조금씩 섞여 들어온다.
dragged.drag(GalaxyMotion.spinDragOf(1, const Offset(3, -18)));
// 핀치는 손가락이 둘이다 — 초점이 옆으로 밀려도 회전이 아니다.
pinched.drag(GalaxyMotion.spinDragOf(2, const Offset(24, 2)));
dragged.advance(1 / 60);
pinched.advance(1 / 60);
}
expect(dragged.velocity, closeTo(GalaxyMotion.idleSpin, 1e-9));
expect(pinched.velocity, closeTo(GalaxyMotion.idleSpin, 1e-9));
// 가로 드래그는 여전히 민다 — 필터가 회전 자체를 죽이면 안 된다.
final swiped = GalaxyClock()
..drag(GalaxyMotion.spinDragOf(1, const Offset(24, -6)));
swiped.advance(1 / 60);
expect(swiped.velocity, greaterThan(GalaxyMotion.idleSpin));
});
test('아무리 세게 던져도 상한을 넘지 않는다', () {
final clock = GalaxyClock()..drag(100000);
clock.advance(1 / 60);
expect(clock.velocity, lessThanOrEqualTo(GalaxyMotion.maxSpin));
});
});
group('깊이감 (GalaxyField)', () {
const front = Offset(GalaxyField.extent / 2, GalaxyField.extent);
const back = Offset(GalaxyField.extent / 2, 0);
test('앞쪽 행성이 크고 뒤쪽이 작다', () {
expect(
GalaxyField.depthScaleOf(front),
greaterThan(GalaxyField.depthScaleOf(back)),
);
});
test('뒤쪽 행성이 옅다 — 대기 원근', () {
expect(GalaxyField.depthOpacityOf(front), closeTo(1, 1e-9));
expect(
GalaxyField.depthOpacityOf(back),
closeTo(1 - GalaxyMotion.depthFade, 1e-9),
);
});
test('바깥 궤도가 더 느리게 돈다', () {
expect(
GalaxyMotion.orbitSpeedOf(6),
lessThan(GalaxyMotion.orbitSpeedOf(2)),
);
});
// 리뷰 H2 — 기본 궤도 속도가 프로토타입의 1/4~1/5 이었다.
// 프로토타입은 `time = Date.now()*0.0005` 에 `speed = 0.8/(rank+1)` 를 곱한다.
test('기본 궤도 각속도가 프로토타입과 같다 — 0.4/(rank+1) rad/s', () {
for (final rank in [2, 3, 6, 12]) {
expect(
GalaxyMotion.idleSpin * GalaxyMotion.orbitSpeedOf(rank),
closeTo(0.4 / (rank + 1), 1e-9),
reason: '$rank위',
);
}
// rank 2 는 1회전 47초다.
expect(
2 * math.pi / (GalaxyMotion.idleSpin * GalaxyMotion.orbitSpeedOf(2)),
closeTo(47, 0.5),
);
});
test('내 행성은 회전과 무관하게 한가운데다', () {
const mine = PlanetNode(
id: 'bts', name: 'BTS', rank: 1, fpi: 1, members: 1, online: 1,
isMine: true,
);
expect(
GalaxyField.positionOf(mine, 0, 9.9, 6),
const Offset(GalaxyField.extent / 2, GalaxyField.extent / 2),
);
});
// 원장 B-21 — 기본은 내 Primary 중심, Ranking View 는 FPI 상위 중심.
test('Ranking View 는 FPI 1위(성운 제외)를 가운데 두고 내 행성은 순위 궤도를 돈다', () {
const snapshot = GalaxySnapshot(
myPop: 0,
planets: [
PlanetNode(id: 'neb', name: '성운', rank: 1, fpi: 900, members: 1, online: 1, stage: 0),
PlanetNode(id: 'top', name: 'TOP', rank: 1, fpi: 900, members: 1, online: 1, stage: 3),
PlanetNode(id: 'tie', name: 'TIE', rank: 1, fpi: 900, members: 1, online: 1, stage: 2),
PlanetNode(id: 'mine', name: '내 행성', rank: 4, fpi: 10, members: 1, online: 1, isMine: true),
],
);
const center = Offset(GalaxyField.extent / 2, GalaxyField.extent / 2);
expect(snapshot.planets.where((p) => p.isCenter).map((p) => p.id), ['mine']);
final ranking = snapshot.rankingView.planets;
expect(ranking.where((p) => p.isCenter).map((p) => p.id), ['top']);
expect(GalaxyField.positionOf(ranking[1], 1, 3.3, 4), center);
final mine = ranking.last;
expect(mine.isMine, isTrue, reason: '내 행성 강조는 보기와 무관하다');
expect(GalaxyField.positionOf(mine, 3, 3.3, 4), isNot(center));
expect(GalaxyField.radiusOf(ranking[1]), greaterThan(GalaxyField.radiusOf(mine)));
});
test('궤도는 눕는다 — 세로 폭이 가로 폭보다 좁다', () {
const other = PlanetNode(
id: 'ive', name: 'IVE', rank: 4, fpi: 1, members: 1, online: 1,
);
var maxDx = 0.0;
var maxDy = 0.0;
for (var i = 0; i < 200; i++) {
final at = GalaxyField.positionOf(other, 1, i * 0.1, 6);
maxDx = math.max(maxDx, (at.dx - GalaxyField.extent / 2).abs());
maxDy = math.max(maxDy, (at.dy - GalaxyField.extent / 2).abs());
}
expect(maxDy, lessThan(maxDx));
});
// 리뷰 M4 — extent=900 에 간격 52 를 그대로 쓰면 8위부터 궤도가 맵 반쪽을
// 넘어 Stack 에 잘리고, 반지름은 19위부터 음수가 된다.
test('행성이 많아도 전부 맵 안에 남는다 — 6·30·100개', () {
for (final n in [6, 30, 100]) {
final planets = [
for (var i = 0; i < n; i++)
PlanetNode(
id: 'p$i',
name: 'p$i',
rank: i + 1,
fpi: 1,
members: 1,
online: 1,
isMine: i == 0,
),
];
final outer = GalaxyField.outerRankOf(planets);
expect(outer, n);
for (final (i, p) in planets.indexed) {
final r = GalaxyField.radiusOf(p);
expect(r, greaterThan(0), reason: '$n개 중 ${p.rank}위의 반지름');
for (var s = 0; s < 40; s++) {
final at = GalaxyField.positionOf(p, i, s * 0.37, outer);
// 눈에 보이는 반지름은 일러스트 1.6배에 깊이 배율까지 얹은 값이다.
final edge = r * GalaxyField.depthScaleOf(at) * 1.6;
expect(at.dx - edge, greaterThanOrEqualTo(0), reason: '$n개 ${p.rank}위 좌');
expect(at.dx + edge, lessThanOrEqualTo(GalaxyField.extent),
reason: '$n개 ${p.rank}위 우');
expect(at.dy - edge, greaterThanOrEqualTo(0), reason: '$n개 ${p.rank}위 상');
expect(at.dy + edge, lessThanOrEqualTo(GalaxyField.extent),
reason: '$n개 ${p.rank}위 하');
}
}
}
});
test('궤도는 순위 순서를 지킨다 — 상위가 안쪽이다', () {
for (final outer in [6, 30, 100]) {
for (var rank = 2; rank < outer; rank++) {
expect(
GalaxyField.ringOf(rank, outer),
lessThan(GalaxyField.ringOf(rank + 1, outer)),
);
}
}
});
});
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/galaxy/widgets/zap_feedback.dart';
import 'dart:async';
import 'package:fancake/core/data/backend.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:fancake/features/galaxy/galaxy_repository_firestore.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'eclipse_stubs.dart';
class _DelayedGalaxy extends SampleGalaxyRepository {
final replies = <Completer<ZapResult>>[];
final keys = <String>[];
final counts = <int>[];
final fandoms = <String>[];
@override
Future<ZapResult> zap({
required String fandomId,
required int count,
required String clientSeq,
}) {
keys.add(clientSeq);
counts.add(count);
fandoms.add(fandomId);
final reply = Completer<ZapResult>();
replies.add(reply);
return reply.future;
}
}
Future<void> _show(WidgetTester tester, GalaxyRepository repository) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: GalaxyScreen(
repository: repository,
planetRepository: samplePlanetRepository,
eclipse: const NoEclipseRepository(),
),
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
}
class _LedgerReply extends Backend {
_LedgerReply(FakeFirebaseFirestore db)
: super(firestore: db, auth: MockFirebaseAuth());
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async => {
'txId': 'receipt',
'balanceAfter': 963,
'lotRefs': [
{'lotId': 'a', 'delta': -12},
{'lotId': 'b', 'delta': -25},
],
};
}
void main() {
testWidgets('HOME ZAP 연타·응답유실·확정 결과가 실제 행성에 연결된다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
final repository = _DelayedGalaxy();
final home = await const SampleHomeRepository().load();
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: HomeScreen(
repository: const SampleHomeRepository(),
questRepository: const SampleQuestRepository(),
eclipse: const NoEclipseRepository(), galaxyRepository: repository,
)));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(ZapButton), findsOneWidget);
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 350));
expect(repository.counts, [2]);
expect(repository.fandoms, [home.fandomId]);
expect(find.textContaining('기여 완료'), findsNothing);
final fx = tester.widget<ZapFeedback>(find.byType(ZapFeedback));
expect(fx.source.dy, greaterThan(fx.target.dy));
expect(fx.target.dx, greaterThan(0));
repository.replies.single.completeError(const BackendException('unavailable', 'private error'));
await tester.pump();
await tester.tap(find.text('같은 기여 다시 확인'));
await tester.pump();
expect(repository.keys.last, repository.keys.first);
expect(repository.counts, [2, 2]);
repository.replies.last.complete(const ZapResult(txId: 'home-confirmed', balanceAfter: 900, contributedPop: 37));
await tester.pump();
expect(find.text('37 POP 기여 완료'), findsOneWidget);
expect(tester.widget<ZapButton>(find.byType(ZapButton)).myPop, 900);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
testWidgets('Reduce Motion은 갤럭시 자전도 정지하고 해제 시 복구한다', (tester) async {
Future<void> show(bool reduced) => tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduced),
child: GalaxyScreen(
repository: const SampleGalaxyRepository(),
planetRepository: samplePlanetRepository,
eclipse: const NoEclipseRepository(),
),
),
),
);
await show(true);
await tester.pump();
await tester.pump(const Duration(seconds: 3));
await tester.pump(const Duration(seconds: 3));
expect(tester.binding.transientCallbackCount, 0);
await show(false);
await tester.pump(const Duration(milliseconds: 50));
expect(tester.binding.transientCallbackCount, greaterThan(0));
await show(true);
await tester.pump(const Duration(seconds: 1));
expect(tester.binding.transientCallbackCount, 0);
});
test('기여 결과는 현재 단가가 아니라 서버가 확정한 차감 lot 합계다', () async {
final db = FakeFirebaseFirestore();
await db.doc('config/policy').set({'zapPop': 10});
final result = await FirestoreGalaxyRepository(
_LedgerReply(db),
).zap(fandomId: 'bts', count: 1, clientSeq: 'retry');
expect(result.contributedPop, 37);
});
testWidgets('실제 기여량을 표시하고 같은 거래 응답의 성공 연출은 반복하지 않는다', (tester) async {
final repository = _DelayedGalaxy();
await _show(tester, repository);
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 500));
const receipt = ZapResult(
txId: 'receipt',
balanceAfter: 963,
contributedPop: 37,
);
repository.replies.last.complete(receipt);
await tester.pump();
expect(find.text('37 POP 기여 완료'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
expect(find.text('37 POP 기여 완료'), findsNothing);
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 500));
repository.replies.last.complete(receipt);
await tester.pump();
expect(find.text('37 POP 기여 완료'), findsNothing);
});
testWidgets('ZAP 옵션은 2초 전에 열리지 않고 길게 누르면 차감하지 않는다', (tester) async {
var taps = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: ZapButton(zapPop: 10, myPop: 100, onZap: () => taps++),
),
),
),
);
final hold = await tester.startGesture(
tester.getCenter(find.byType(ZapButton)),
);
await tester.pump(const Duration(milliseconds: 1999));
expect(find.text('아이템'), findsNothing);
await tester.pump(const Duration(milliseconds: 1));
expect(find.text('아이템'), findsOneWidget);
await hold.up();
expect(taps, 0);
await tester.pumpAndSettle();
});
testWidgets('전송 중 성공을 보여주지 않고 전송 중 연타로 중복 차감하지 않는다', (tester) async {
final repository = _DelayedGalaxy();
await _show(tester, repository);
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 500));
expect(repository.replies, hasLength(1));
expect(find.textContaining('기여 반영 중'), findsWidgets);
expect(find.textContaining('기여 완료'), findsNothing);
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 500));
expect(repository.replies, hasLength(1));
repository.replies.single.complete(
const ZapResult(txId: 'confirmed', balanceAfter: 12530),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.textContaining('기여 완료'), findsOneWidget);
});
testWidgets('응답 유실 재확인은 같은 멱등키를 쓰고 실패에는 성공 FX가 없다', (tester) async {
final repository = _DelayedGalaxy();
await _show(tester, repository);
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 500));
repository.replies.single.completeError(
const BackendException('unavailable', '연결 실패'),
);
await tester.pump();
expect(find.textContaining('기여 완료'), findsNothing);
await tester.tap(find.text('다시 확인'));
await tester.pump();
expect(repository.keys, hasLength(2));
expect(repository.keys[1], repository.keys[0]);
repository.replies.last.complete(
const ZapResult(txId: 'confirmed', balanceAfter: 12530),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.textContaining('기여 완료'), findsOneWidget);
});
}
+98
View File
@@ -0,0 +1,98 @@
import 'package:sensors_plus/sensors_plus.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/planet/planet_repository_firestore.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/features/planet/hero_participation_sheet.dart';
import 'package:fancake/features/planet/planet_data.dart';
class _Claims extends SamplePlanetRepository {
final ids = <String>[];
final replies = <Completer<HeroDropReceipt>>[];
@override
Future<HeroDropReceipt> claimHeroDrop(String id) {
ids.add(id);
final reply = Completer<HeroDropReceipt>();
replies.add(reply);
return reply.future;
}
}
class _HeroBackend extends Backend {
_HeroBackend() : super(firestore: FakeFirebaseFirestore(),
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: 'viewer')));
Map<String, dynamic> response = {'claimedPop': 5, 'txId': 'receipt'};
Map<String, dynamic>? payload;
@override
Future<Map<String, dynamic>> call(String name, [Map<String, dynamic> data = const {}]) async {
expect(name, 'fnHeroDropClaim');
payload = data;
return response;
}
}
void main() {
for (final code in ['E-POP-02', 'E-POP-03', 'E-AUTH-03', 'E-PERM-01']) {
testWidgets('서버 확정 거절 $code는 성공이나 무한 재시도로 표시하지 않는다', (tester) async {
final repo = _Claims();
await tester.pumpWidget(MaterialApp(home: Scaffold(body: HeroParticipationSheet(
fandomId: 'target', repository: repo, accelerometer: const Stream.empty(),
watch: (_) => Stream.value(PlanetVisualState(isHeroTime: true,
heroEventIds: const {'gift'}, heroEndsAt: DateTime.now().add(const Duration(minutes: 1)))),
))));
await tester.pumpAndSettle();
await tester.tap(find.text('흔들기 대신 버튼으로 참여'));
repo.replies.single.completeError(BackendException(code, 'internal details'));
await tester.pumpAndSettle();
expect(find.text('같은 선물 다시 확인'), findsNothing);
expect(find.textContaining('POP 수령 확인'), findsNothing);
expect(find.text('internal details'), findsNothing);
expect(tester.widget<FilledButton>(find.byType(FilledButton)).onPressed, isNull);
expect(repo.ids, ['gift']);
await tester.pumpWidget(const SizedBox());
});
}
test('수령 요청에 서버 판정용 팬덤·이벤트만 보내며 잘못된 응답은 성공으로 읽지 않는다', () async {
final backend = _HeroBackend();
final repo = FirestorePlanetRepository(backend, fandomId: 'target');
expect((await repo.claimHeroDrop('gift')).amount, 5);
expect(backend.payload, {'fandomId': 'target', 'dropId': 'gift'});
backend.response = {'claimedPop': 0, 'txId': 'receipt'};
await expectLater(repo.claimHeroDrop('gift'), throwsStateError);
});
testWidgets('지급 전 성공 없음·연타 중복 없음·종료 후에도 같은 선물 재확인', (tester) async {
final repo = _Claims();
final sensor = StreamController<AccelerometerEvent>();
addTearDown(sensor.close);
final states = StreamController<PlanetVisualState>.broadcast();
addTearDown(states.close);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: HeroParticipationSheet(
fandomId: 'test-fandom', watch: (_) => states.stream, repository: repo,
accelerometer: sensor.stream,
))));
states.add(PlanetVisualState(isHeroTime: true, heroEventIds: const {'gift'},
heroEndsAt: DateTime.now().add(const Duration(minutes: 1))));
await tester.pumpAndSettle();
sensor.add(AccelerometerEvent(25, 0, 0, DateTime.now()));
await tester.pump();
await tester.pump();
await tester.tap(find.text('지급 확인 중…'));
expect(repo.ids, ['gift']);
expect(find.textContaining('POP 수령 확인'), findsNothing);
repo.replies.first.completeError(StateError('response lost'));
await tester.pumpAndSettle();
states.add(const PlanetVisualState(isHeroTime: false));
await tester.pumpAndSettle();
await tester.tap(find.text('같은 선물 다시 확인'));
expect(repo.ids, ['gift', 'gift']);
repo.replies.last.complete(const HeroDropReceipt(txId: 'confirmed', amount: 5));
await tester.pumpAndSettle();
expect(find.text('5 POP 수령 확인'), findsOneWidget);
expect(tester.widget<FilledButton>(find.byType(FilledButton)).onPressed, isNull);
await tester.pumpWidget(const SizedBox());
});
}
+141
View File
@@ -0,0 +1,141 @@
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/planet/hero_participation_sheet.dart';
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/planet/planet_detail_screen.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'eclipse_stubs.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/core/ui/hero_time_status.dart';
class _NoSpendGalaxy extends SampleGalaxyRepository {
int spends = 0;
@override
Future<ZapResult> zap({required String fandomId, required int count, required String clientSeq}) {
spends++;
return super.zap(fandomId: fandomId, count: count, clientSeq: clientSeq);
}
}
void main() {
testWidgets('HOME HERO 참여는 현재 팬덤의 서버 저장소로 진입한다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
String? opened;
final home = await const SampleHomeRepository().load();
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: HomeScreen(
repository: const SampleHomeRepository(),
questRepository: const SampleQuestRepository(),
eclipse: const NoEclipseRepository(),
planetRepository: (id) { opened = id; return SamplePlanetRepository(fandomId: id); },
watchPlanet: (_) => Stream.value(PlanetVisualState(stage: 1, isHeroTime: true,
heroEventIds: const {'gift'}, heroEndsAt: DateTime.now().add(const Duration(minutes: 5)))),
)));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.text('ZAP+ · 선물 참여'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(opened, home.fandomId);
expect(tester.widget<HeroParticipationSheet>(find.byType(HeroParticipationSheet)).fandomId, home.fandomId);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
testWidgets('HERO ZAP+ 진입은 POP을 차감하지 않고 종료시 일반 ZAP으로 돌아간다', (tester) async {
final galaxy = _NoSpendGalaxy();
final states = StreamController<PlanetVisualState>.broadcast();
addTearDown(states.close);
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: GalaxyScreen(
repository: galaxy, planetRepository: samplePlanetRepository,
eclipse: const NoEclipseRepository(), watchPlanet: (_) => states.stream,
)));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<ZapButton>(find.byType(ZapButton)).isHeroTime, isFalse);
states.add(PlanetVisualState(stage: 1, isHeroTime: true, heroEventIds: const {'gift'},
heroEndsAt: DateTime.now().add(const Duration(minutes: 1))));
await tester.pump();
await tester.pump();
await tester.tap(find.text('ZAP+ · HERO TIME'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.byType(HeroParticipationSheet), findsOneWidget);
expect(galaxy.spends, 0);
Navigator.of(tester.element(find.byType(HeroParticipationSheet))).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
states.add(const PlanetVisualState(stage: 1));
await tester.pump();
await tester.pump();
expect(tester.widget<ZapButton>(find.byType(ZapButton)).isHeroTime, isFalse);
await tester.pumpWidget(const SizedBox());
});
testWidgets('HERO 참여는 개인 POP 잔액 0이어도 열 수 있다', (tester) async {
var opened = 0;
await tester.pumpWidget(MaterialApp(home: Scaffold(body: ZapButton(
zapPop: 10, myPop: 0, isHeroTime: true, onZap: () => opened++,
))));
await tester.tap(find.text('ZAP+ · HERO TIME'));
expect(opened, 1);
});
testWidgets('Galaxy 전광판은 해당 팬덤의 진행 중 행성 상세로 진입한다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
String? opened;
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: GalaxyScreen(
repository: const SampleGalaxyRepository(),
eclipse: const NoEclipseRepository(),
planetRepository: (id) { opened = id; return SamplePlanetRepository(fandomId: id); },
watchPlanet: (id) => id != 'bts' ? const Stream.empty() : Stream.value(
PlanetVisualState(stage: 4, isHeroTime: true, heroNicknames: const ['공개팬'],
heroEndsAt: DateTime.now().add(const Duration(minutes: 5))),
),
)));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(find.text('BTS · HERO TIME 진행 중 →'), findsOneWidget);
await tester.tap(find.text('BTS · HERO TIME 진행 중 →'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(opened, 'bts');
expect(find.byType(PlanetDetailScreen), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
await tester.pump();
});
testWidgets('서버 종료시각을 표시하고 만료·종료는 숨기며 구독을 해제한다', (tester) async {
final states = StreamController<PlanetVisualState>.broadcast();
addTearDown(states.close);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: HeroTimeStatus(
fandomId: 'bts', watch: (_) => states.stream,
))));
expect(find.textContaining('HERO TIME'), findsNothing);
states.add(PlanetVisualState(isHeroTime: true, heroNicknames: const ['공개팬'], heroClaimCount: 3,
heroEndsAt: DateTime.now().add(const Duration(minutes: 5))));
await tester.pumpAndSettle();
expect(find.text('HERO TIME · 5:00 남음'), findsOneWidget);
expect(find.text('by 공개팬'), findsOneWidget);
expect(find.text('팬들의 선물 수령 3건'), findsOneWidget);
states.add(PlanetVisualState(isHeroTime: true,
heroEndsAt: DateTime.now().subtract(const Duration(seconds: 1))));
await tester.pumpAndSettle();
expect(find.textContaining('HERO TIME'), findsNothing);
states.add(const PlanetVisualState());
await tester.pumpAndSettle();
expect(find.textContaining('HERO TIME'), findsNothing);
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
expect(states.hasListener, isFalse);
});
}
+284
View File
@@ -0,0 +1,284 @@
import 'dart:async';
import 'package:fancake/core/ui/section_header.dart';
import 'package:fancake/features/quest/quest_list_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/home/widgets/briefing_list.dart';
import 'eclipse_stubs.dart';
class _LiveGrowthRepo extends SampleHomeRepository {
final changes = StreamController<PlanetGrowth>.broadcast();
@override
Stream<PlanetGrowth> watchGrowth(String fandomId) => changes.stream;
}
/// 값을 못 가져오는 경우를 재현한다.
class _FailingRepo extends SampleHomeRepository {
@override
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
@override
Future<HomeSnapshot> load() async => throw StateError('boom');
}
/// 5 요소(B-75)를 갖춘 브리핑 한 줄. 검증마다 제목과 구획만 바꿔 쓴다.
BriefingItem _brief(
String headline, {
int rank = 1,
BriefingSection section = BriefingSection.news,
}) => BriefingItem(
rank: rank,
section: section,
headline: headline,
sourceName: '연합뉴스',
sourceAt: DateTime(2026, 9, 14, 8),
sourceUrl: 'https://example.com/a',
articleUrl: 'https://example.com/a',
aiSummary: '요약입니다.',
);
/// 브리핑만 갈아 끼운 표본. 「0 건이면 블록 전체 숨김」을 그 한 값으로 본다.
class _BriefingRepo extends SampleHomeRepository {
@override
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
const _BriefingRepo(this.briefings);
final List<BriefingItem> briefings;
@override
Future<HomeSnapshot> load() async {
final base = await const SampleHomeRepository().load();
return HomeSnapshot(
popBalance: base.popBalance,
liveTicker: base.liveTicker,
growth: base.growth,
quests: base.quests,
briefings: briefings,
campaign: base.campaign,
insides: base.insides,
);
}
}
Future<void> _pump(WidgetTester tester, {HomeRepository? repo}) async {
// 홈 히어로가 시안대로 첫 화면의 78% 를 차지하므로 기본 800×600 창에서는 아래
// 구획이 지연 빌드되지 않는다. 실기기에 가까운 세로 길이를 준다(F3 테스트와 같다).
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: HomeScreen(
repository: repo ?? const SampleHomeRepository(),
questRepository: const SampleQuestRepository(),
eclipse: const NoEclipseRepository(),
),
),
);
await tester.pumpAndSettle();
}
void main() {
testWidgets('성장 조건을 실시간 갱신하고 화면 종료 시 구독을 해제한다', (tester) async {
final repo = _LiveGrowthRepo();
addTearDown(repo.changes.close);
await _pump(tester, repo: repo);
expect(repo.changes.hasListener, isTrue);
repo.changes.add(const PlanetGrowth(
stage: 1, fandomName: 'BTS', contributorName: '함께한팬',
contributedPop: 10, growthPercent: 100, currentPop: 10000,
requiredPop: 10000, currentSfpi: 750, requiredSfpi: 1000,
));
await tester.pumpAndSettle();
expect(find.text('POP 조건 충족 · 10,000 / 10,000'), findsOneWidget);
expect(find.text('sFPI 조건 미충족 · 750 / 1,000'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump();
expect(repo.changes.hasListener, isFalse);
});
testWidgets('Figma 홈의 구획 제목이 순서대로 나온다', (tester) async {
await _pump(tester);
// 화면 대조 원장 F1 · Figma `252:159401` 이하의 구획명 그대로여야 한다.
// 기본 뷰포트(800×600)보다 화면이 길어 아래쪽은 스크롤해야 잡힌다.
for (final title in [
'TODAY’S QUEST',
'AI Daily Briefing',
'HOT ARCHIVE',
'인기 K-INSIDE',
]) {
await tester.scrollUntilVisible(
find.text(title),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text(title), findsOneWidget, reason: '구획 「$title」 누락');
}
});
testWidgets('POP 진행률과 두 성장 조건을 구분해서 표시한다', (tester) async {
await _pump(tester);
expect(find.text('67%'), findsOneWidget);
expect(find.text('POP 진행률'), findsOneWidget);
expect(find.text('POP 조건 미충족 · 6,270 / 10,000'), findsOneWidget);
expect(find.text('sFPI 조건 확인 중 · — / —'), findsOneWidget);
});
testWidgets('POP 잔액은 천 단위로 끊어 띄운다', (tester) async {
await _pump(tester);
expect(find.text('12,540'), findsOneWidget);
});
testWidgets('퀘스트 카드는 보상과 진행 상태를 보여 준다', (tester) async {
await _pump(tester);
// 시안(가로 카드)은 제목 옆에 「+보상」, 아래에 상태 한 줄.
expect(find.text('+10'), findsOneWidget);
expect(find.text('출석 완료'), findsOneWidget);
expect(find.text('0/1,500 걸음'), findsOneWidget);
});
testWidgets('현상금 표기에 천 단위 구분이 들어간다', (tester) async {
await _pump(tester);
await tester.scrollUntilVisible(
find.text('500 P'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('500 P'), findsOneWidget);
});
testWidgets('아직 없는 화면으로 가려 하면 언제 열리는지 알린다', (tester) async {
await _pump(tester);
// 첫 「더 보기」는 퀘스트라 실제로 이동한다. 아직 없는 구간까지 굴려서 누른다.
// 브리핑 헤더는 「공유」다(`266:90529`). 아직 없는 목록인 인기 K-INSIDE 로 본다.
final briefing = find.byWidgetPredicate(
(w) => w is SectionHeader && w.title == '인기 K-INSIDE',
);
await tester.scrollUntilVisible(briefing, 220,
scrollable: find.byType(Scrollable).first);
await tester.ensureVisible(briefing);
await tester.pumpAndSettle();
await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기')));
await tester.pump();
// 조용히 무시하면 검수 때 고장으로 보인다.
expect(find.textContaining('구현 확인물 2차'), findsOneWidget);
});
testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async {
await _pump(tester);
await tester.tap(find.text('더 보기').first);
await tester.pumpAndSettle();
expect(find.byType(QuestListScreen), findsOneWidget);
});
testWidgets('값을 못 가져오면 다시 시도할 수 있다', (tester) async {
await _pump(tester, repo: _FailingRepo());
expect(find.text('홈을 불러오지 못했습니다'), findsOneWidget);
expect(find.text('다시 시도'), findsOneWidget);
});
testWidgets('화면 문구에 내부 경로가 새지 않는다', (tester) async {
await _pump(tester);
final texts = tester
.widgetList<Text>(find.byType(Text))
.map((w) => w.data ?? '')
.join('\n');
for (final leak in ['lib/', 'app/', '.dart', 'Exception', 'StateError']) {
expect(texts.contains(leak), isFalse, reason: '화면에 「$leak」 노출');
}
});
/// 정책 원장 B-47 — *News·SNS 모두 0건 시 **Briefing 블록 전체 숨김**(빈 상태*
/// *문구도 노출하지 않음)*. 서버가 당일 게시분을 0 건으로 주는 상황이다.
testWidgets('브리핑이 0 건이면 구획 제목까지 숨긴다', (tester) async {
await _pump(tester, repo: const _BriefingRepo([]));
expect(find.text('AI Daily Briefing'), findsNothing);
// 빈 상태 문구도 두지 않는다 — 아래 구획은 그대로 있어야 한다.
await tester.scrollUntilVisible(
find.text('HOT ARCHIVE'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('HOT ARCHIVE'), findsOneWidget);
});
testWidgets('브리핑 헤더의 「공유」는 브리핑 공유 카드를 연다(F8 · 별첨 1 ③-5)', (tester) async {
await _pump(tester, repo: _BriefingRepo([_brief('공유할 한 줄')]));
final header = find.byWidgetPredicate(
(w) => w is SectionHeader && w.title == 'AI Daily Briefing',
);
await tester.scrollUntilVisible(find.text('공유할 한 줄'), 220,
scrollable: find.byType(Scrollable).first);
await tester.tap(find.descendant(of: header, matching: find.text('공유')));
await tester.pumpAndSettle();
expect(find.text('AI DAILY BRIEFING'), findsOneWidget);
expect(find.text('공유할 한 줄'), findsNWidgets(2));
});
testWidgets('한 건이라도 있으면 구획을 그린다', (tester) async {
await _pump(tester, repo: _BriefingRepo([_brief('오늘 게시된 한 줄')]));
// 헤더가 아니라 줄 자체를 기준으로 스크롤한다 — 헤더가 보이는 시점에 줄은
// 아직 화면 아래에 있어 지연 빌드가 끝나지 않는다.
await tester.scrollUntilVisible(
find.text('오늘 게시된 한 줄'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('AI Daily Briefing'), findsOneWidget);
expect(find.text('오늘 게시된 한 줄'), findsOneWidget);
});
/// 정책 원장 B-47 — *News/SNS 각각 0건이면 해당 섹션 숨김*.
testWidgets('SNS 가 0 건이면 SNS 구획만 숨는다', (tester) async {
await _pump(tester, repo: _BriefingRepo([
_brief('뉴스 한 줄', section: BriefingSection.news),
]));
await tester.scrollUntilVisible(
find.text('뉴스 한 줄'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('NEWS'), findsOneWidget);
expect(find.text('SNS'), findsNothing);
});
testWidgets('News 가 0 건이면 News 구획만 숨는다', (tester) async {
await _pump(tester, repo: _BriefingRepo([
_brief('SNS 한 줄', section: BriefingSection.sns),
]));
await tester.scrollUntilVisible(
find.text('SNS 한 줄'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('SNS'), findsOneWidget);
expect(find.text('NEWS'), findsNothing);
});
testWidgets('줄을 누르면 원문보기로 나간다', (tester) async {
final opened = <String>[];
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: BriefingList(
items: [_brief('원문이 있는 줄')],
onOpen: (item) async => opened.add(item.articleUrl),
),
),
));
await tester.tap(find.text('원문이 있는 줄'));
await tester.pumpAndSettle();
expect(opened, ['https://example.com/a']);
});
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:fancake/core/i18n/app_locale.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
import 'package:fancake/features/home/nebula_create_screen.dart';
import 'package:fancake/features/inside/inside_data.dart';
import 'package:fancake/features/inside/inside_detail_screen.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_list_screen.dart';
import 'package:fancake/features/station/station_screen.dart';
import 'package:fancake/l10n/app_localizations.dart'
show lookupAppLocalizations;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
/// 이전한 화면을 ko 가 아닌 언어로 띄워 문구가 바뀌고, 긴 번역에도 레이아웃이
/// 넘치지 않는지(RenderFlex overflow 등) 본다. 폭은 작은 단말(360×800) 기준.
void main() {
tearDown(() => appL10n = koL10n);
final screens = <String, (Widget, String Function(AppLocalizations))>{
'MY': (MyScreen(repository: SampleMyRepository()), (l) => l.myTabActivity),
'QUEST': (
const QuestListScreen(repository: SampleQuestRepository()),
(l) => l.questDailyHelp,
),
'STATION': (const StationScreen(), (l) => l.stationSuggest),
'성운 생성': (const NebulaCreateScreen(), (l) => l.nebulaHeadline),
'캠페인 상세': (
CampaignDetailScreen(campaignId: 'c1', myPop: 1000),
(l) => l.campaignFundingStatus,
),
'INSIDE 상세': (
InsideDetailScreen(
question: InsideQuestion(
id: 'q-0',
title: 'Title',
body: 'Body',
authorName: 'fan',
createdAt: DateTime(2026, 9, 1),
bountyPop: 500,
),
answers: sampleInsideAnswers,
),
(l) => l.insideAnswerHint,
),
};
for (final locale in const [Locale('en'), Locale('ja')]) {
for (final MapEntry(key: name, value: (screen, marker))
in screens.entries) {
testWidgets('$name 화면이 ${locale.languageCode} 로 넘침 없이 그려진다', (
tester,
) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
locale: locale,
supportedLocales: appLocales,
localizationsDelegates: const [
RemoteL10nDelegate({}),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: screen,
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
find.text(marker(lookupAppLocalizations(locale)), findRichText: true),
findsWidgets,
);
});
}
}
}
+229
View File
@@ -0,0 +1,229 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:fancake/core/i18n/app_locale.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/settings_screen.dart';
import 'package:fancake/l10n/app_localizations.dart'
show lookupAppLocalizations;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../tool/gen_l10n_remote.dart';
Map<String, dynamic> _arb(String lang) =>
jsonDecode(File('lib/l10n/app_$lang.arb').readAsStringSync())
as Map<String, dynamic>;
Set<String> _keys(Map<String, dynamic> arb) =>
arb.keys.where((k) => !k.startsWith('@')).toSet();
Widget _app(LocaleController c, Widget home) => ListenableBuilder(
listenable: c,
builder: (_, _) => LocaleScope(
controller: c,
child: MaterialApp(
locale: c.locale,
supportedLocales: appLocales,
localizationsDelegates: [
RemoteL10nDelegate(c.remote),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: home,
),
),
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('지원 언어 (원장 B-76)', () {
test('supportedLocales 가 정확히 ko · en · ja · zh-CN · es 다', () {
expect(appLocales.map(localeTag).toList(), [
'ko',
'en',
'ja',
'zh-CN',
'es',
]);
});
test('단말 언어로 기본값을 고르고 zh-TW·zh-Hant 는 zh-CN · 미지원 언어는 en 으로 폴백한다', () {
expect(resolveAppLocale(const [Locale('ja', 'JP')]), const Locale('ja'));
expect(
resolveAppLocale(const [Locale('zh', 'TW')]),
const Locale('zh', 'CN'),
);
expect(
resolveAppLocale(const [
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
]),
const Locale('zh', 'CN'),
);
expect(resolveAppLocale(const [Locale('fr', 'FR')]), const Locale('en'));
expect(
resolveAppLocale(const [Locale('fr'), Locale('es', 'MX')]),
const Locale('es'),
);
});
test('5개 언어 arb 가 같은 키를 전부 가진다(QA 8-2 문자열 누락 0)', () {
final ko = _keys(_arb('ko'));
for (final lang in const ['en', 'ja', 'zh', 'es']) {
final other = _keys(_arb(lang));
expect(ko.difference(other), isEmpty, reason: '$lang 누락');
expect(other.difference(ko), isEmpty, reason: '$lang 초과');
for (final k in other) {
expect(
(_arb(lang)[k] as String).trim(),
isNotEmpty,
reason: '$lang.$k',
);
}
}
});
test('원격 덮어쓰기 층이 arb 의 모든 키를 덮는다(생성물이 최신)', () {
final generated = File(
'lib/l10n/remote_app_localizations.dart',
).readAsStringSync();
final expected = generateRemoteL10n(_arb('ko'));
for (final key in _keys(_arb('ko'))) {
expect(expected, contains(' $key'));
expect(generated, contains(' $key'), reason: '재생성 필요: $key');
}
});
});
group('언어 전환 · 원격 문자열 (FR-I18N-01)', () {
testWidgets('설정의 언어 시트에서 English 를 고르면 화면 문자열이 바뀌고 기기에 남는다', (
tester,
) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
String? saved;
final c = LocaleController(
initial: fallbackLocale,
onSaved: (tag) async => saved = tag,
);
await tester.pumpWidget(
_app(c, SettingsScreen(repository: SampleMyRepository())),
);
await tester.pumpAndSettle();
expect(find.text('설정'), findsOneWidget);
await tester.scrollUntilVisible(find.text('한국어'), 200);
await tester.tap(find.text('한국어'));
await tester.pumpAndSettle();
await tester.tap(find.text('English'));
await tester.tap(find.text('선택'));
await tester.pumpAndSettle();
expect(c.locale, const Locale('en'));
expect(find.text('Settings'), findsOneWidget);
expect(saved, 'en');
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(LocaleController.prefsKey), 'en');
});
testWidgets('원격 문서의 키는 번들 문자열을 덮고, 없는 키는 번들을 쓴다', (tester) async {
final remote = StreamController<Map<String, Object?>>();
addTearDown(remote.close);
final c = LocaleController(
initial: const Locale('ja'),
remoteSource: (tag) {
expect(tag, 'ja');
return remote.stream;
},
);
await tester.pumpWidget(
_app(
c,
Builder(
builder: (context) => Column(
children: [
Text(context.l10n.settingsTitle),
Text(context.l10n.commonNotYet('X')),
Text(context.l10n.commonCancel),
],
),
),
),
);
expect(find.text('設定'), findsOneWidget);
remote.add({
'settingsTitle': '設定(更新)',
'commonNotYet': '{where} まもなく',
'ignored': 3,
});
await tester.pumpAndSettle();
expect(find.text('設定(更新)'), findsOneWidget);
expect(find.text('X まもなく'), findsOneWidget);
expect(find.text('キャンセル'), findsOneWidget);
});
test('원격 문자열은 인자 값 안의 자리표시자를 다시 치환하지 않는다', () {
final t = RemoteL10nDelegate(const {
'linkTitle': '{name} 연동',
'settingsLinkNotReady': '{name}: {items}',
});
return t.load(const Locale('ko')).then((l10n) {
expect(l10n.linkTitle('{name}'), '{name} 연동');
expect(l10n.settingsLinkNotReady('{items}', 'A'), '{items}: A');
});
});
test('언어를 복원하면 이전 언어의 원격 문자열을 버린다', () async {
final remote = StreamController<Map<String, Object?>>.broadcast();
addTearDown(remote.close);
final c = LocaleController(
initial: const Locale('ja'),
remoteSource: (_) => remote.stream,
);
remote.add({'settingsTitle': '設定(更新)'});
await Future<void>.delayed(Duration.zero);
expect(c.remote, isNotEmpty);
SharedPreferences.setMockInitialValues({LocaleController.prefsKey: 'es'});
await c.restore();
expect(c.locale, const Locale('es'));
expect(c.remote, isEmpty);
});
test('로그인 때 이 기기에서 고른 언어가 없으면 프로필 언어를 따른다', () async {
final c = LocaleController(initial: const Locale('en'));
await c.adoptProfile('ja');
expect(c.locale, const Locale('ja'));
});
test('이 기기에서 고른 언어가 있으면 프로필 언어보다 우선한다', () async {
SharedPreferences.setMockInitialValues({LocaleController.prefsKey: 'es'});
final c = LocaleController(initial: const Locale('es'));
await c.adoptProfile('ja');
expect(c.locale, const Locale('es'));
});
test('저장된 언어를 복원한다', () async {
SharedPreferences.setMockInitialValues({
LocaleController.prefsKey: 'zh-CN',
});
final c = LocaleController(initial: fallbackLocale);
await c.restore();
expect(c.locale, const Locale('zh', 'CN'));
});
test('대리자가 없는 트리는 ko 로 폴백한다', () {
expect(koL10n.settingsTitle, '설정');
expect(
lookupAppLocalizations(const Locale('zh', 'CN')).settingsTitle,
'设置',
);
});
});
}
+499
View File
@@ -0,0 +1,499 @@
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/app_icons.dart';
import 'package:fancake/features/inside/inside_data.dart';
import 'package:fancake/features/inside/inside_detail_screen.dart';
import 'package:fancake/features/inside/inside_screen.dart';
import 'package:fancake/features/inside/inside_write_screen.dart';
import 'package:fancake/features/station/station_data.dart';
import 'package:fancake/features/station/station_screen.dart';
import 'package:fancake/features/station/station_suggest_screen.dart';
import 'package:fancake/features/translation/translation_data.dart';
import 'package:fancake/features/translation/translation_comments.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> _pump(WidgetTester tester, Widget screen) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
await tester.pumpAndSettle();
}
/// 항상 실패하는 저장소. 화면의 오류 경로를 태운다.
class _FailingInsideRepository extends SampleInsideRepository {
@override
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest}) async =>
throw StateError('boom');
@override
Future<List<InsideQuestion>> more({
required InsideSort sort,
int limit = 5,
}) async => throw StateError('boom');
}
/// 현상금 Q&A 경로를 기록하는 저장소. [failWith] 가 있으면 쓰기 호출이 그걸 던진다.
class _QnaRepository extends SampleInsideRepository {
_QnaRepository({this.failWith});
final BackendException? failWith;
final created = <(String, String, int)>[];
final seqs = <String>[];
final accepted = <String>[];
final sent = <String>[];
var list = <InsideAnswer>[
InsideAnswer(
id: 'a1',
authorName: '남',
body: '남의 답',
createdAt: DateTime(2026, 9, 1),
),
InsideAnswer(
id: 'a2',
authorName: '나',
body: '내 답',
createdAt: DateTime(2026, 9, 1),
isMine: true,
),
];
@override
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
}) async {
seqs.add(clientSeq);
if (failWith != null) throw failWith!;
created.add((title, body, bountyPop));
return 'q-new';
}
@override
Future<List<InsideAnswer>> answers(String questionId) async => list;
@override
Future<void> answer(String questionId, String body) async {
sent.add(body);
list = [
InsideAnswer(
id: 'a3',
authorName: '나',
body: body,
createdAt: DateTime(2026, 9, 2),
isMine: true,
),
...list,
];
}
@override
Future<void> accept(String questionId, String answerId) async {
if (failWith != null) throw failWith!;
accepted.add(answerId);
list = [
for (final a in list)
a.id == answerId
? InsideAnswer(
id: a.id,
authorName: a.authorName,
body: a.body,
createdAt: a.createdAt,
isAccepted: true,
)
: a,
];
}
}
class _EmptyStationRepository implements StationRepository {
@override
Future<StationSnapshot> load() async =>
const StationSnapshot(links: [], canSuggest: false);
}
void main() {
group('INSIDE 목록 (F3 · 196:2673 · 199:43083 · 199:43964)', () {
testWidgets('세 정렬 칩을 Figma 화면 이름 그대로 띄운다', (tester) async {
await _pump(tester, const InsideScreen());
// Figma 가 화면을 셋으로 나눠 둔 그 축이다. 라벨이 바뀌면 대조가 끊긴다.
for (final label in ['최신순', '인기순', 'POP순']) {
expect(find.text(label), findsOneWidget);
}
});
testWidgets('명세대로 최초 5건만 띄운다', (tester) async {
await _pump(tester, const InsideScreen());
// 2.70-1 — *최신순 소팅 최초 5개, 스크롤 시 5개씩 호출*
expect(find.byType(InsideQuestionCard), findsNWidgets(5));
});
testWidgets('기본 정렬은 최신순이다', (tester) async {
await _pump(tester, const InsideScreen());
final cards = tester
.widgetList<InsideQuestionCard>(find.byType(InsideQuestionCard))
.toList();
for (var i = 1; i < cards.length; i++) {
expect(
cards[i - 1].question.createdAt.isAfter(cards[i].question.createdAt),
isTrue,
reason: '${cards[i - 1].question.id} 가 ${cards[i].question.id} 보다 뒤다',
);
}
});
testWidgets('POP순을 고르면 현상금 내림차순으로 다시 그린다', (tester) async {
await _pump(tester, const InsideScreen());
await tester.tap(find.text('POP순'));
await tester.pumpAndSettle();
final cards = tester
.widgetList<InsideQuestionCard>(find.byType(InsideQuestionCard))
.toList();
expect(cards.first.question.bountyPop, 800);
for (var i = 1; i < cards.length; i++) {
expect(
cards[i - 1].question.bountyPop >= cards[i].question.bountyPop,
isTrue,
);
}
});
testWidgets('현상금이 0이면 POP 칩을 그리지 않는다', (tester) async {
// 0 P 칩이 붙으면 없는 상금을 광고하는 셈이다.
await _pump(
tester,
Scaffold(
body: InsideQuestionCard(
question: InsideQuestion(
id: 'z',
title: '제목',
body: '본문',
authorName: '누군가',
createdAt: DateTime(2026, 9, 1),
),
onTap: () {},
),
),
);
expect(find.text('0 P'), findsNothing);
});
testWidgets('불러오기가 실패하면 목록 대신 다시 시도를 준다', (tester) async {
await _pump(tester, InsideScreen(repository: _FailingInsideRepository()));
expect(find.text('인사이드를 불러오지 못했습니다'), findsOneWidget);
expect(find.text('다시 시도'), findsOneWidget);
expect(find.byType(InsideQuestionCard), findsNothing);
});
testWidgets('작성 플로팅 버튼이 있다', (tester) async {
// 2.70-3 — *우측 하단 고정 · 해당 팬덤에 가입된 유저에게만 노출*
await _pump(tester, const InsideScreen());
expect(find.text('인사이드 작성'), findsOneWidget);
});
});
group('INSIDE 상세 (F3 · 207:53130 본인 · 201:49678 타인)', () {
InsideQuestion question({bool isMine = false}) => InsideQuestion(
id: 'q',
title: '제목',
body: '본문',
authorName: '별빛수집가',
createdAt: DateTime(2026, 9, 1, 20),
bountyPop: 500,
answerCount: 3,
isMine: isMine,
);
testWidgets('타인 글은 신고·차단을 준다', (tester) async {
await _pump(tester, InsideDetailScreen(question: question()));
await tester.tap(find.byTooltip('더보기'));
await tester.pumpAndSettle();
expect(find.text('신고하기'), findsOneWidget);
expect(find.text('차단하기'), findsOneWidget);
expect(find.text('삭제하기'), findsNothing);
});
testWidgets('번역 저장소가 있으면 별도 영역 없이 댓글 입력에 지구본(번역 댓글)을 붙인다', (tester) async {
await _pump(
tester,
InsideDetailScreen(
question: question(),
translation: SampleTranslationRepository(),
),
);
final bar = tester.widget<CommentInputBar>(find.byType(CommentInputBar));
expect(bar.rootPath, 'questions/q');
expect(bar.translation, isNotNull);
expect(find.bySemanticsLabel('번역 댓글 작성'), findsOneWidget);
});
testWidgets('본인 글은 수정·삭제를 준다', (tester) async {
await _pump(tester, InsideDetailScreen(question: question(isMine: true)));
await tester.tap(find.byTooltip('더보기'));
await tester.pumpAndSettle();
expect(find.text('수정하기'), findsOneWidget);
expect(find.text('삭제하기'), findsOneWidget);
// 본인 글을 본인이 신고하는 경로가 열려 있으면 안 된다.
expect(find.text('신고하기'), findsNothing);
});
});
group('INSIDE 작성 (F3 · 199:44733)', () {
testWidgets('제목과 내용이 비면 등록되지 않는다', (tester) async {
await _pump(tester, const InsideWriteScreen());
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
expect(find.text('제목을 입력해 주세요.'), findsOneWidget);
expect(find.text('내용을 입력해 주세요.'), findsOneWidget);
});
testWidgets('현상금이 보유 POP 을 넘으면 막는다', (tester) async {
await _pump(tester, const InsideWriteScreen(myPop: 1000));
await tester.enterText(find.byType(TextFormField).at(0), '제목');
await tester.enterText(find.byType(TextFormField).at(1), '내용');
await tester.enterText(find.byType(TextField).at(2), '2000');
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
expect(find.text('보유 POP 을 넘을 수 없습니다.'), findsOneWidget);
});
testWidgets('현상금은 사본 안내대로 100 POP 이상이어야 한다', (tester) async {
await _pump(tester, const InsideWriteScreen(myPop: 1000));
await tester.enterText(find.byType(TextFormField).at(0), '제목');
await tester.enterText(find.byType(TextFormField).at(1), '내용');
await tester.enterText(find.byType(TextField).at(2), '99');
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
expect(find.text('현상금은 100 POP 이상이어야 합니다.'), findsOneWidget);
});
});
group('INSIDE 현상금 하한 운영값', () {
testWidgets('하한은 저장소(config/policy.qnaMinBountyPop)를 따른다', (tester) async {
await _pump(
tester,
InsideWriteScreen(repository: _MinBounty(), myPop: 1000),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextFormField).at(0), '제목');
await tester.enterText(find.byType(TextFormField).at(1), '내용');
await tester.enterText(find.byType(TextField).at(2), '150');
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
expect(find.text('현상금은 200 POP 이상이어야 합니다.'), findsOneWidget);
});
});
group('INSIDE 현상금 Q&A (별첨 1 ④-2)', () {
InsideQuestion mine() => InsideQuestion(
id: 'q',
title: '제목',
body: '본문',
authorName: '나',
createdAt: DateTime(2026, 9, 1),
bountyPop: 300,
isMine: true,
);
Future<void> fill(WidgetTester tester, String bounty) async {
await tester.enterText(find.byType(TextFormField).at(0), ' 제목 ');
await tester.enterText(find.byType(TextFormField).at(1), '내용');
await tester.enterText(find.byType(TextField).at(2), bounty);
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
}
testWidgets('현상금을 비우면 등록되지 않는다', (tester) async {
final repo = _QnaRepository();
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
await fill(tester, '');
expect(find.text('현상금을 입력해 주세요.'), findsOneWidget);
expect(repo.created, isEmpty);
});
testWidgets('완료하면 fn-question-create 로 예치 요청을 보낸다', (tester) async {
final repo = _QnaRepository();
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
await fill(tester, '300');
expect(repo.created, [('제목', '내용', 300)]);
});
testWidgets('잔액 부족(E-POP-01)은 한국어 안내로 보인다', (tester) async {
final repo = _QnaRepository(
failWith: const BackendException('E-POP-01', 'POP 잔액이 부족합니다.'),
);
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
await fill(tester, '300');
expect(find.text('POP 이 모자라 현상금을 걸지 못했습니다.'), findsOneWidget);
// 입력을 바꿔 다시 보내도 같은 화면에서는 같은 번호다 — 이중 예치 방지.
await tester.enterText(find.byType(TextField).at(2), '400');
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
expect(repo.seqs, hasLength(2));
expect(repo.seqs.toSet(), hasLength(1));
});
testWidgets('환불 중단 상태는 중립 문구로 보인다', (tester) async {
final q = InsideQuestion(
id: 'q',
title: '제목',
body: '본문',
authorName: '나',
createdAt: DateTime(2026, 9, 1),
isMine: true,
refund: InsideRefund.fromStatus('refund_failed'),
);
await _pump(
tester,
InsideDetailScreen(question: q, repository: _QnaRepository()),
);
expect(find.text('채택 기한이 지나 현상금 환불을 확인하고 있습니다.'), findsOneWidget);
expect(find.text('채택하기'), findsNothing);
});
testWidgets('질문자는 남의 답변만 채택할 수 있고, 채택 뒤에는 버튼이 사라진다', (tester) async {
final repo = _QnaRepository();
await _pump(
tester,
InsideDetailScreen(question: mine(), repository: repo),
);
expect(find.text('남의 답'), findsOneWidget);
// 자기 답변에는 채택하기가 없다(E-QNA-01).
expect(find.text('채택하기'), findsOneWidget);
await tester.tap(find.text('채택하기'));
await tester.pumpAndSettle();
await tester.tap(find.text('채택'));
await tester.pumpAndSettle();
expect(repo.accepted, ['a1']);
expect(find.text('채택 완료'), findsOneWidget);
expect(find.text('채택하기'), findsNothing);
});
testWidgets('타인 질문에는 채택하기가 없다', (tester) async {
final q = InsideQuestion(
id: 'q',
title: '제목',
body: '본문',
authorName: '남',
createdAt: DateTime(2026, 9, 1),
);
await _pump(
tester,
InsideDetailScreen(question: q, repository: _QnaRepository()),
);
expect(find.text('채택하기'), findsNothing);
});
testWidgets('기한이 지난 채택은 E-QNA-02 안내를 띄운다', (tester) async {
final repo = _QnaRepository(
failWith: const BackendException(
'E-QNA-02',
'채택 기한이 지나 현상금이 환불될 예정입니다.',
),
);
await _pump(
tester,
InsideDetailScreen(question: mine(), repository: repo),
);
await tester.tap(find.text('채택하기'));
await tester.pumpAndSettle();
await tester.tap(find.text('채택'));
await tester.pumpAndSettle();
expect(find.text('채택 기한이 지나 현상금이 환불될 예정입니다.'), findsOneWidget);
});
testWidgets('댓글을 보내면 저장 뒤 목록에 보인다', (tester) async {
final repo = _QnaRepository();
await _pump(
tester,
InsideDetailScreen(question: mine(), repository: repo),
);
await tester.enterText(find.byType(TextField), '새 답변');
await tester.tap(
find.byWidgetPredicate((w) => w is Image && w.image == AppIcons.send),
);
await tester.pumpAndSettle();
expect(repo.sent, ['새 답변']);
expect(find.text('새 답변'), findsOneWidget);
});
test('서버 코드를 화면 문구로 옮긴다', () {
expect(
insideErrorMessage(const BackendException('E-QNA-01', 'x')),
'자기 답변은 채택할 수 없습니다.',
);
expect(
insideErrorMessage(const BackendException('E-QNA-03', 'x')),
'이미 채택한 질문입니다. 채택은 바꿀 수 없습니다.',
);
expect(
insideErrorMessage(const BackendException('E-SYS-01', '서버 문구')),
'서버 문구',
);
expect(insideErrorMessage(StateError('x')), '연결을 확인하고 다시 시도해 주세요.');
});
});
group('STATION 허브 (S-S-HUB · Figma 시안 없음)', () {
testWidgets('분류 칩과 링크를 띄운다', (tester) async {
await _pump(tester, const StationScreen());
expect(find.text('전체'), findsOneWidget);
for (final c in StationCategory.values) {
expect(find.text(c.label), findsWidgets);
}
expect(find.byType(StationLinkTile), findsNWidgets(4));
});
testWidgets('분류를 고르면 그 분류만 남는다', (tester) async {
await _pump(tester, const StationScreen());
await tester.tap(find.widgetWithText(ChoiceChip, '예매'));
await tester.pumpAndSettle();
expect(find.byType(StationLinkTile), findsOneWidget);
expect(find.text('팬미팅 예매'), findsOneWidget);
});
testWidgets('링크가 없으면 가입 안내를 띄우고 제안 버튼을 감춘다', (tester) async {
await _pump(tester, StationScreen(repository: _EmptyStationRepository()));
expect(find.textContaining('아직 등록된 링크가 없습니다'), findsOneWidget);
expect(find.text('판매처 제안'), findsNothing);
});
});
group('STATION 제안 (S-S-SUGGEST)', () {
testWidgets('https 가 아닌 주소를 막는다', (tester) async {
await _pump(tester, const StationSuggestScreen());
await tester.enterText(find.byType(TextFormField).at(0), '어떤 판매처');
await tester.enterText(
find.byType(TextFormField).at(1),
'http://shop.example',
);
await tester.tap(find.text('제출'));
await tester.pumpAndSettle();
expect(find.text('https 로 시작하는 주소를 입력해 주세요.'), findsOneWidget);
});
testWidgets('https 주소는 통과한다', (tester) async {
await _pump(tester, const StationSuggestScreen());
await tester.enterText(find.byType(TextFormField).at(0), '어떤 판매처');
await tester.enterText(
find.byType(TextFormField).at(1),
'https://shop.example',
);
await tester.tap(find.text('제출'));
await tester.pumpAndSettle();
expect(find.text('https 로 시작하는 주소를 입력해 주세요.'), findsNothing);
expect(find.textContaining('서버 연동 후 열립니다'), findsOneWidget);
});
});
}
class _MinBounty extends SampleInsideRepository {
@override
Future<int> minBounty() async => 200;
}
+256
View File
@@ -0,0 +1,256 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:cloud_firestore/cloud_firestore.dart' show FieldValue;
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/photo_picker.dart';
import 'package:fancake/features/planet/lounge_post_screens.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/planet/planet_repository_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// 팬덤 라운지 쓰기 경로 — 글 · 좋아요 · 댓글 · 신고 · 차단이 서버 문서로 남는가.
///
/// 규칙 판정(본인 · 팬덤 회원 · 집계 필드 위조)은 `firebase/test` 의 규칙
/// 테스트가 본다. 여기서는 앱이 **규칙이 받는 모양으로** 쓰는지를 본다.
const _me = 'u-me';
Future<(FakeFirebaseFirestore, FirestorePlanetRepository)> _repo() async {
final db = FakeFirebaseFirestore();
// displayName 은 공급자 실명 자리 — 라운지 이름은 닉네임에서만 나온다(M-7).
await db.doc('users/$_me').set({
'displayName': '김실명',
'nickname': '나',
'fandomId': 'bts',
});
final backend = Backend(
firestore: db,
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _me)),
);
return (db, FirestorePlanetRepository(backend, fandomId: 'bts'));
}
/// 1×1 PNG. 화면이 미리보기를 그려야 해서 실제로 디코딩되는 바이트를 쓴다.
final _png = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
);
PickedPhoto _photo([int? size]) => PickedPhoto(
bytes: size == null ? _png : Uint8List(size),
contentType: 'image/png',
name: 'a.png',
);
void main() {
group('라운지 쓰기 — Firestore', () {
test('글은 규칙이 받는 필드로만 남고 목록에 바로 뜬다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('첫 글');
final data = (await db.doc('posts/${post.id}').get()).data()!;
expect(data.keys.toSet(), {
'fandomId', 'authorUid', 'authorName', 'body', 'imageUrls',
'likeCount', 'commentCount', 'visibility', 'createdAt', 'updatedAt',
});
expect(data['authorName'], '나');
expect(data['visibility'], 'visible');
expect(data['likeCount'], 0);
expect(post.isMine, isTrue);
final list = await repo.morePosts(offset: 0);
expect(list.map((p) => p.id), [post.id]);
});
test('닉네임이 없으면 작성자 이름은 실명이 아니라 「팬」이다', () async {
final (db, repo) = await _repo();
await db.doc('users/$_me').update({'nickname': FieldValue.delete()});
final post = await repo.createPost('글');
expect(post.authorName, '팬');
final c = await repo.addComment(post.id, '댓글');
expect(c.authorName, '팬');
final data = (await db.doc('posts/${post.id}').get()).data()!;
expect(data['authorName'], '팬');
});
test('사진 6장 · 10MB 초과는 업로드 전에 거른다(B-44)', () async {
final (db, repo) = await _repo();
await expectLater(
repo.createPost('x', photos: List.filled(6, _photo())),
throwsA(isA<PhotoRejected>()),
);
await expectLater(
repo.createPost('x', photos: [_photo(10 * 1024 * 1024 + 1)]),
throwsA(isA<PhotoRejected>()),
);
expect((await db.collection('posts').get()).docs, isEmpty);
});
test('수정 · 삭제는 본문과 문서에 반영된다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('before');
final edited = await repo.updatePost(post, 'after');
expect(edited.body, 'after');
expect((await db.doc('posts/${post.id}').get()).data()!['body'], 'after');
await repo.deletePost(edited);
expect((await db.doc('posts/${post.id}').get()).exists, isFalse);
});
test('좋아요는 likes/{uid} 한 건을 켜고 끄며 수는 쓰지 않는다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('글');
final liked = await repo.toggleLike(post);
expect(liked.liked, isTrue);
expect(liked.likes, 1);
expect((await db.doc('posts/${post.id}/likes/$_me').get()).exists, isTrue);
// 집계는 서버 트리거 몫 — 앱은 likeCount 를 건드리지 않는다.
expect((await db.doc('posts/${post.id}').get()).data()!['likeCount'], 0);
expect((await repo.morePosts(offset: 0)).single.liked, isTrue);
final unliked = await repo.toggleLike(liked);
expect(unliked.liked, isFalse);
expect((await db.doc('posts/${post.id}/likes/$_me').get()).exists, isFalse);
});
test('댓글은 쓰고 읽고 내 것만 지운다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('글');
final c = await repo.addComment(post.id, '댓글');
expect(c.isMine, isTrue);
await db.collection('posts/${post.id}/comments').add({
'authorUid': 'other', 'authorName': '남', 'body': '숨김',
'visibility': 'hidden', 'createdAt': DateTime(2026),
});
final list = await repo.comments(post.id);
expect(list.map((c) => c.body), ['댓글']);
await repo.deleteComment(post.id, c.id);
expect(await repo.comments(post.id), isEmpty);
});
test('신고는 reports 에 대상 + 신고자 ID 로 한 건 쌓인다', () async {
final (db, repo) = await _repo();
final post = await repo.createPost('글');
await repo.reportPost(post, '부적절한 콘텐츠');
final report =
(await db.doc('reports/post-${post.id}-$_me').get()).data()!;
expect(report['reporterUid'], _me);
expect(report['targetType'], 'post');
expect(report['targetPath'], 'posts/${post.id}');
// 화면 문구가 아니라 규칙이 받는 코드로 적는다.
expect(report['reasonCode'], 'inappropriate');
await expectLater(
repo.reportPost(post, '목록에 없는 사유'),
throwsA(isA<BackendException>()),
);
expect(report['status'], 'pending');
});
test('차단한 작성자의 글 · 댓글은 목록에서 빠진다', () async {
final (db, repo) = await _repo();
for (var i = 0; i < 6; i++) {
await db.collection('posts').add({
'fandomId': 'bts', 'visibility': 'visible',
'authorUid': 'troll', 'authorName': '트롤',
'body': '$i', 'createdAt': DateTime(2026, 9, 2, i),
});
}
final good = await db.collection('posts').add({
'fandomId': 'bts', 'visibility': 'visible',
'authorUid': 'fan', 'authorName': '팬', 'body': '정상',
'createdAt': DateTime(2026, 9, 1),
});
await db.collection('posts/${good.id}/comments').add({
'authorUid': 'troll', 'authorName': '트롤', 'body': '악플',
'visibility': 'visible', 'createdAt': DateTime(2026),
});
final first = await repo.morePosts(offset: 0);
await repo.blockAuthor(first.first);
expect((await db.doc('users/$_me/blocks/troll').get()).exists, isTrue);
// 한 장(5개)이 통째로 차단 글이어도 다음 장까지 읽어 정상 글을 낸다.
final after = await repo.morePosts(offset: 0);
expect(after.map((p) => p.body), ['정상']);
expect(await repo.comments(good.id), isEmpty);
});
});
test('운영 사칭 닉네임은 대소문자와 무관하게 막는다(규칙과 같은 목록)', () {
for (final n in ['FANUZU팬', 'Admin99', 'xOfficialx', '운영팀장', '관리자']) {
expect(nicknameReserved(n), isTrue, reason: n);
}
expect(nicknameReserved('평범한팬'), isFalse);
for (final n in [' 팬 ', '\u3000팬', '팬\u200B', 'Fan', ' FAN', 'ファン', '粉丝', 'ad\u200Dmin', 'FANU\uFEFFZU']) {
expect(nicknameReserved(n), isTrue, reason: n);
}
expect(nicknameReserved('팬팬'), isFalse);
expect(nicknameReserved('Fans'), isFalse);
});
group('라운지 쓰기 — 화면', () {
Future<void> pump(WidgetTester tester, Widget screen) async {
await tester.pumpWidget(MaterialApp(home: screen));
await tester.pumpAndSettle();
}
testWidgets('6번째 사진은 붙지 않고 사유를 띄운다', (tester) async {
await pump(
tester,
LoungeComposeScreen(picker: (PhotoSource _) async => _photo()),
);
for (var i = 0; i < 6; i++) {
await tester.tap(find.bySemanticsLabel('앨범'));
await tester.pumpAndSettle();
}
expect(find.bySemanticsLabel('사진 5 빼기'), findsOneWidget);
expect(find.bySemanticsLabel('사진 6 빼기'), findsNothing);
expect(find.text('사진은 5장까지 올릴 수 있습니다.'), findsOneWidget);
});
testWidgets('저장 실패면 화면에 남아 사유를 띄운다', (tester) async {
await pump(
tester,
LoungeComposeScreen(
onSubmit: (_) async => throw const PhotoRejected('용량 초과'),
),
);
await tester.enterText(find.byType(TextField), '본문');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '완료'));
await tester.pumpAndSettle();
expect(find.text('용량 초과'), findsOneWidget);
expect(find.byType(LoungeComposeScreen), findsOneWidget);
});
testWidgets('댓글을 보내면 목록에 붙고 수가 늘어 알린다', (tester) async {
LoungePost? changed;
final post = LoungePost(
id: 'widget-${DateTime.now().microsecondsSinceEpoch}',
authorName: '팬',
createdAt: DateTime(2026, 9, 1),
title: '',
body: '본문',
likes: 0,
comments: 0,
);
await pump(
tester,
LoungePostScreen(post: post, onChanged: (p) => changed = p),
);
expect(find.text('첫 댓글을 남겨주세요.'), findsOneWidget);
await tester.enterText(find.byType(TextField), '반가워요');
await tester.tap(find.byTooltip('보내기'));
await tester.pumpAndSettle();
expect(find.text('반가워요'), findsOneWidget);
expect(changed?.comments, 1);
await tester.tap(find.bySemanticsLabel('좋아요'));
await tester.pumpAndSettle();
expect(changed?.liked, isTrue);
expect(changed?.likes, 1);
});
});
}
+252
View File
@@ -0,0 +1,252 @@
import 'dart:async';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/monetization/store.dart';
import 'package:fancake/features/home/home_data.dart' show ItemProduct;
import 'package:fancake/features/home/home_repository_firestore.dart' show kstDayKey;
import 'package:fancake/features/home/widgets/item_sheet.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_screen.dart';
import 'package:fancake/features/my/my_repository_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
/// 결제 경계 — 별첨 1 ⑥ 「영수증 서버검증 통과」 · 원장 B-02 · B-72.
///
/// 스토어와 서버는 대역이다. 보는 것은 두 가지다 — **서버가 받아들인 뒤에만 스토어에
/// 구매 완료를 알리는가**, 그리고 MY 결제 내역이 서버 문서를 화면 상태로 옳게 옮기는가.
const _uid = 'u-pay';
class _Backend extends Backend {
_Backend(FakeFirebaseFirestore db, {this.failWith})
: super(
firestore: db,
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
);
final BackendException? failWith;
final calls = <Map<String, dynamic>>[];
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async {
calls.add({'name': name, ...payload});
if (failWith case final error?) throw error;
return const {'granted': 100};
}
}
class _Focused extends SampleMyRepository {
bool claimed = false;
@override
Future<FocusStatus?> focus() async =>
FocusStatus(expiresAt: DateTime(2026, 10, 28), claimedToday: claimed);
@override
Future<int> claimFocusDaily() async {
claimed = true;
return 40;
}
}
class _FakeIap extends Fake implements InAppPurchase {
final updates = StreamController<List<PurchaseDetails>>();
final completed = <String>[];
@override
Stream<List<PurchaseDetails>> get purchaseStream => updates.stream;
@override
Future<void> completePurchase(PurchaseDetails purchase) async =>
completed.add(purchase.productID);
}
PurchaseDetails _purchase(PurchaseStatus status) => PurchaseDetails(
purchaseID: '2000000123',
productID: 'focus.monthly',
verificationData: PurchaseVerificationData(
localVerificationData: 'local',
serverVerificationData: 'receipt-1',
source: 'app_store',
),
transactionDate: '0',
status: status,
)..pendingCompletePurchase = true;
Future<(_FakeIap, _Backend, List<String>)> _run(
PurchaseStatus status, {
BackendException? failWith,
}) async {
final iap = _FakeIap();
final backend = _Backend(FakeFirebaseFirestore(), failWith: failWith);
final store = Store(backend, iap: iap);
final messages = <String>[];
store.messages.listen(messages.add);
store.start();
iap.updates.add([_purchase(status)]);
await pumpEventQueue();
await store.dispose();
return (iap, backend, messages);
}
void main() {
group('Store — 검증 뒤에만 구매 완료', () {
test('서버가 받아들이면 영수증을 보낸 뒤 구매를 완료한다', () async {
final (iap, backend, messages) = await _run(PurchaseStatus.purchased);
expect(backend.calls.single['name'], 'fnIapVerify');
expect(backend.calls.single['receipt'], 'receipt-1');
expect(backend.calls.single['productId'], 'focus.monthly');
// iOS 영수증에서 이 거래를 고르라고 거래 ID 를 함께 보낸다.
expect(backend.calls.single['transactionId'], '2000000123');
expect(iap.completed, ['focus.monthly']);
expect(messages, ['구매가 반영되었습니다.']);
});
test('서버가 거부하면 완료하지 않고 사유를 알린다', () async {
final (iap, _, messages) = await _run(
PurchaseStatus.purchased,
failWith: const BackendException('E-PAY-02', '결제 검증 설정이 아직 준비되지 않았습니다.'),
);
// 완료하지 않은 구매는 다음 실행에서 다시 검증된다(Android 는 끝내 미확인이면 자동 환불).
expect(iap.completed, isEmpty);
expect(messages, ['결제 검증 설정이 아직 준비되지 않았습니다.']);
});
test('재시도해도 같은 거부(E-PAY-01)는 거래를 닫고 한 번만 알린다(Android 외)', () async {
final (iap, _, messages) = await _run(
PurchaseStatus.purchased,
failWith: const BackendException('E-PAY-01', '판매하지 않는 상품입니다.'),
);
// 테스트 호스트는 Android 가 아니다. Android 는 확인하지 않고 두어 스토어 자동 환불을 받는다.
expect(iap.completed, ['focus.monthly']);
expect(messages, ['판매하지 않는 상품입니다.']);
});
test('승인 대기(pending)는 서버에 보내지 않고 기다린다', () async {
final (iap, backend, messages) = await _run(PurchaseStatus.pending);
expect(backend.calls, isEmpty);
expect(iap.completed, isEmpty);
expect(messages.single, contains('승인'));
});
test('복원 재전송은 조용히 검증만 한다', () async {
final (iap, backend, messages) = await _run(PurchaseStatus.restored);
expect(backend.calls, hasLength(1));
expect(iap.completed, ['focus.monthly']);
expect(messages, isEmpty);
});
});
group('Focus 일일 보상 — B-04', () {
test('만료 전 구독만 상태를 돌려주고 오늘 받았는지 서버 기록으로 판단한다', () async {
final db = FakeFirebaseFirestore();
final repo = FirestoreMyRepository(_Backend(db));
expect(await repo.focus(), isNull);
await db.doc('users/$_uid/subscription/current').set({
'state': 'active', 'expiresAt': DateTime.now().add(const Duration(days: 3)),
'focusClaimedDay': kstDayKey(),
});
expect((await repo.focus())!.claimedToday, isTrue);
await db.doc('users/$_uid/subscription/current').update({
'expiresAt': DateTime.now().subtract(const Duration(minutes: 1)),
});
expect(await repo.focus(), isNull);
});
testWidgets('MY 에 구독 카드가 뜨고 받으면 「오늘 받음」으로 잠긴다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: MyScreen(repository: _Focused())));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '오늘 보상 받기'));
await tester.pumpAndSettle();
expect(find.textContaining('+40 기여 POP'), findsOneWidget);
expect(find.widgetWithText(FilledButton, '오늘 받음'), findsOneWidget);
});
testWidgets('구독하지 않으면 카드를 그리지 않는다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: MyScreen(repository: SampleMyRepository())));
await tester.pumpAndSettle();
expect(find.text('광고 없는 집중모드 이용 중'), findsNothing);
});
});
testWidgets('소속 행성이 없으면 Hero Gift 결제 창을 열기 전에 막는다', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showItemSheet(
context,
items: const [
ItemProduct(
productId: 'herogift.basic', name: '베이직 드랍', description: '',
dropPop: 500, buyerPop: 500, priceWon: 1200, owned: 0,
),
],
purchase: true,
),
child: const Text('open'),
),
),
),
));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.text('베이직 드랍'));
await tester.pump();
expect(find.text('행성에 가입한 뒤 영웅의 선물을 보낼 수 있어요.'), findsOneWidget);
});
group('MY 결제 내역 — users/{uid}/purchases', () {
test('최신순으로 읽고 서버 상태를 화면 다섯 상태로 옮긴다', () async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_uid/purchases/android_a').set({
'itemName': '베이직 드랍', 'priceAmount': 1200, 'method': 'Google Play',
'state': 'granted', 'purchasedAt': DateTime(2026, 9, 1),
});
await db.doc('users/$_uid/purchases/android_b').set({
'productId': 'focus.monthly', 'state': 'clawback-pending',
'purchasedAt': DateTime(2026, 9, 20),
});
final repo = FirestoreMyRepository(_Backend(db));
final rows = await repo.payments();
expect(rows.map((p) => p.id), ['android_b', 'android_a']);
expect(rows.last.itemName, '베이직 드랍');
expect(rows.last.price, 1200);
expect(rows.last.state, PaymentState.paid);
// 이름이 없으면 상품 ID, 회수 대기는 스토어 환불이 끝난 건이다.
expect(rows.first.itemName, 'focus.monthly');
expect(rows.first.state, PaymentState.refunded);
});
test('환불 요청은 상태 · 사유 · 시각만 쓴다', () async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_uid/purchases/android_a').set({
'state': 'granted', 'amountPop': 100, 'purchasedAt': DateTime(2026, 9, 1),
});
final repo = FirestoreMyRepository(_Backend(db));
await repo.requestRefund('android_a', '실수로 결제했습니다');
final doc = (await db.doc('users/$_uid/purchases/android_a').get()).data()!;
expect(doc['state'], 'refund-requested');
expect(doc['refundReason'], '실수로 결제했습니다');
expect(doc['amountPop'], 100);
expect((await repo.payment('android_a')).state, PaymentState.refundRequested);
});
});
}
+133
View File
@@ -0,0 +1,133 @@
import 'dart:ui' as ui;
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/planet/widgets/fpi_hexagon.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// 그리기 비용 측정 — 09-03 GALAXY 2.5D 시연의 프레임 예산을 지키는지 본다.
///
/// **왜 이걸 재는가.** 갤럭시 맵은 매 프레임 별밭 220개와 궤도·행성을 다시 그린다.
/// 한 프레임의 예산은 60fps 기준 16.7ms 이고, 그 안에 레이아웃·합성·래스터화가 전부
/// 들어가야 한다. 그리기 하나가 예산을 다 먹으면 맵이 끊긴다.
///
/// 여기서 재는 것은 **[CustomPainter.paint] 가 표시 목록을 만드는 데 드는 CPU 시간**이다.
/// GPU 래스터화 시간은 기기에서만 잴 수 있으므로 이 수치가 곧 프레임 시간은 아니다.
/// 다만 이 값이 예산을 넘으면 어떤 기기에서도 60fps 가 나오지 않는다 — 하한 검증이다.
/// 표시 목록 생성 시간의 중앙값(마이크로초).
int medianPaintMicros(
CustomPainter painter,
Size size, {
int warmup = 20,
int samples = 60,
}) {
// 첫 몇 회는 JIT 예열이라 버린다. 넣으면 중앙값이 실제보다 나쁘게 나온다.
for (var i = 0; i < warmup; i++) {
_paintOnce(painter, size);
}
final measured = <int>[
for (var i = 0; i < samples; i++) _paintOnce(painter, size),
]..sort();
return measured[measured.length ~/ 2];
}
int _paintOnce(CustomPainter painter, Size size) {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final watch = Stopwatch()..start();
painter.paint(canvas, size);
watch.stop();
recorder.endRecording().dispose();
return watch.elapsedMicroseconds;
}
/// 60fps 프레임 예산. 그리기 하나가 이 절반을 넘으면 여유가 없다고 본다.
const int frameBudgetMicros = 16700;
const int paintBudgetMicros = frameBudgetMicros ~/ 2;
void main() {
test('갤럭시 맵 한 프레임 그리기가 예산 안에 든다', () {
const planets = [
PlanetNode(id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 421, isMine: true),
PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 231000, members: 9800, online: 388),
PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 198400, members: 8600, online: 254),
PlanetNode(id: 'ive', name: 'IVE', rank: 4, fpi: 176200, members: 7400, online: 199),
PlanetNode(id: 'aespa', name: 'aespa', rank: 5, fpi: 155900, members: 6900, online: 174),
PlanetNode(id: 'lesserafim', name: 'LE SSERAFIM', rank: 6, fpi: 141300, members: 6100, online: 143),
PlanetNode(id: 'txt', name: 'TXT', rank: 7, fpi: 128700, members: 5500, online: 121),
PlanetNode(id: 'straykids', name: 'Stray Kids', rank: 8, fpi: 119400, members: 5200, online: 108),
PlanetNode(id: 'enhypen', name: 'ENHYPEN', rank: 9, fpi: 104800, members: 4700, online: 96),
PlanetNode(id: 'zerobaseone', name: 'ZEROBASEONE', rank: 10, fpi: 98200, members: 4100, online: 87),
PlanetNode(id: 'riize', name: 'RIIZE', rank: 11, fpi: 90500, members: 3800, online: 74),
PlanetNode(id: 'boynextdoor', name: 'BOYNEXTDOOR', rank: 12, fpi: 82100, members: 3400, online: 61),
];
final micros = medianPaintMicros(
galaxyBackdropPainterForTest(planets: planets),
const Size(GalaxyField.extent, GalaxyField.extent),
);
// ignore: avoid_print — 성능 리포트가 이 값을 그대로 인용한다.
print('[perf] galaxy-backdrop median paint: ${micros}us');
expect(micros, lessThan(paintBudgetMicros),
reason: '갤럭시 맵 그리기가 프레임 예산의 절반을 넘었습니다');
});
test('행성 수를 배로 늘려도 예산 안에 있다', () {
final planets = [
for (var i = 0; i < 24; i++)
PlanetNode(
id: 'f$i', name: 'F$i', rank: i + 1, fpi: 300000 - i * 9000,
members: 12000 - i * 300, online: 400 - i * 12, isMine: i == 0,
),
];
final micros = medianPaintMicros(
galaxyBackdropPainterForTest(planets: planets),
const Size(GalaxyField.extent, GalaxyField.extent),
);
print('[perf] galaxy-backdrop(24) median paint: ${micros}us');
expect(micros, lessThan(paintBudgetMicros));
});
test('FPI 육각형 그래프 그리기 비용', () {
const scores = [
FpiScore(metric: FpiMetric.bpm, value: 82400, rank: 1, trend: 1240, myTopPercent: 3),
FpiScore(metric: FpiMetric.health, value: 61200, rank: 2, trend: -320, myTopPercent: 12),
FpiScore(metric: FpiMetric.referral, value: 38900, rank: 4, trend: 640, myTopPercent: 21),
FpiScore(metric: FpiMetric.community, value: 55100, rank: 2, trend: 210, myTopPercent: 8),
FpiScore(metric: FpiMetric.contribution, value: 46710, rank: 3, trend: 980, myTopPercent: 5),
];
final micros = medianPaintMicros(
fpiHexagonPainterForTest(scores: scores, max: 82400),
const Size(320, 320),
);
print('[perf] fpi-hexagon median paint: ${micros}us');
expect(micros, lessThan(paintBudgetMicros));
});
testWidgets('갤럭시 맵이 두 프레임 안에 선다', (tester) async {
// 앱 시작 시간은 여기서 재지 않는다. 테스트 하네스가 바인딩을 세우는 비용이
// 섞여 기기 실측과 자릿수가 다르다 — 시작 시간은 기기에서만 잰다.
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: GalaxyScreen(
repository: SampleGalaxyRepository(),
planetRepository: samplePlanetRepository,
eclipse: SampleEclipseRepository(),
),
),
);
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 16));
expect(find.byType(GalaxyField), findsOneWidget);
});
}
+302
View File
@@ -0,0 +1,302 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'dart:async';
import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/core/ui/live_planet.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/data/repositories.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:fancake/core/ui/planet_25d.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
for (final reduced in [false, true]) {
testWidgets('진화 완료 기록 버튼: 첫 조회/Level 제외, Reduce Motion=$reduced', (tester) async {
var opened = 0;
Future<void> show(int stage, int level) => tester.pumpWidget(MaterialApp(
home: MediaQuery(data: MediaQueryData(disableAnimations: reduced),
child: Scaffold(body: Center(child: Planet25D(
image: 'assets/figma/planet-home.png', size: 240,
stage: stage, level: level, onOpenHistory: () => opened++,
)))),
));
await show(1, 1);
await tester.pump(const Duration(seconds: 5));
expect(find.text('PASSPORT 기록 보기'), findsNothing);
await show(1, 2);
await tester.pump(const Duration(seconds: 2));
expect(find.text('PASSPORT 기록 보기'), findsNothing);
await show(2, 3);
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('PASSPORT 기록 보기'), findsNothing);
await tester.pump(const Duration(seconds: 4));
expect(find.text('PASSPORT 기록 보기'), findsOneWidget);
await tester.tap(find.text('PASSPORT 기록 보기'));
await tester.pump();
expect(opened, 1);
expect(find.text('PASSPORT 기록 보기'), findsNothing);
await show(2, 3);
await tester.pump(const Duration(seconds: 4));
expect(find.text('PASSPORT 기록 보기'), findsNothing);
});
}
test('팬덤 서버 스트림은 유효 Stage/Level 변화만 전달한다', () async {
final db = FakeFirebaseFirestore();
await db.doc('fandoms/bts').set({'stage': 1, 'level': 2});
final repos = Repositories(
live: true,
backend: Backend(firestore: db, auth: MockFirebaseAuth()),
);
final states = <PlanetVisualState>[];
final sub = repos.watchPlanet('bts').listen(states.add);
await Future<void>.delayed(Duration.zero);
expect(states.last.stage, 1);
expect(states.last.level, 2);
await db.doc('fandoms/bts').update({'stage': 2, 'level': 10});
await Future<void>.delayed(Duration.zero);
expect(states.last.stage, 2);
expect(states.last.level, 10);
await db.doc('fandoms/bts').update({'stage': 9, 'level': -1});
await Future<void>.delayed(Duration.zero);
expect(states.last.stage, isNull);
expect(states.last.level, isNull);
await sub.cancel();
});
test('Eclipse 성공은 행성 조명만 회복하고 Stage/Level은 유지한다', () async {
final db = FakeFirebaseFirestore();
await db.doc('fandoms/bts').set({'stage': 3, 'level': 20});
final event = db.doc('fandoms/bts/eclipseRounds/one');
await event.set({
'status': 'active',
'startAt': Timestamp.now(),
'defended': false,
});
final repos = Repositories(
live: true,
backend: Backend(firestore: db, auth: MockFirebaseAuth()),
);
final states = <PlanetVisualState>[];
final sub = repos.watchPlanet('bts').listen(states.add);
addTearDown(sub.cancel);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last.isEclipsed, true);
expect(states.last.stage, 3);
await event.update({'defended': true});
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last.isEclipsed, false);
expect(states.last.stage, 3);
expect(states.last.level, 20);
});
testWidgets('Eclipse 밝기는 점진 복귀하며 행성 표면과 단계는 유지한다', (tester) async {
Future<void> show(bool danger) => tester.pumpWidget(
MaterialApp(
home: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
stage: 3,
isEclipsed: danger,
),
),
),
);
final opacity = find.byKey(const ValueKey('planet-surface-light'));
await show(true);
expect(tester.widget<Opacity>(opacity).opacity, 0.5);
await show(false);
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<Opacity>(opacity).opacity, greaterThan(0.5));
expect(tester.widget<Opacity>(opacity).opacity, lessThan(1));
await tester.pump(const Duration(seconds: 1));
expect(tester.widget<Opacity>(opacity).opacity, 1);
expect(tester.widget<Planet25D>(find.byType(Planet25D)).stage, 3);
expect(find.text('이상 반응'), findsNothing);
expect(tester.takeException(), isNull);
});
test('HERO TIME은 실제 남은 Pool과 서버 종료시각을 따르고 Stage는 유지한다', () async {
final db = FakeFirebaseFirestore();
await db.doc('fandoms/bts').set({'stage': 3, 'level': 20});
final drop = db.doc('fandoms/bts/heroDrops/one');
await drop.set({
'heroNickname': '공개팬',
'claimedCount': 3,
'triggeredBy': 'private-uid',
'email': 'private@example.com',
'remainingPop': 100,
'endsAt': Timestamp.fromDate(
DateTime.now().add(const Duration(hours: 1)),
),
});
final repos = Repositories(
live: true,
backend: Backend(firestore: db, auth: MockFirebaseAuth()),
);
final states = <PlanetVisualState>[];
final sub = repos.watchPlanet('bts').listen(states.add);
addTearDown(sub.cancel);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last.isHeroTime, true);
expect(states.last.heroEndsAt, isNotNull);
expect(states.last.heroNicknames, ['공개팬']);
expect(states.last.heroClaimCount, 3);
// 닉네임 없는 영웅은 서버가 「팬」으로 싣는다 — 이름 줄에는 띄우지 않는다(M-7 이전과 같음).
await db.doc('fandoms/bts/heroDrops/nameless').set({
'heroNickname': '팬',
'claimedCount': 0,
'remainingPop': 50,
'endsAt': Timestamp.fromDate(
DateTime.now().add(const Duration(hours: 1)),
),
});
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last.heroNicknames, ['공개팬']);
await db.doc('fandoms/bts/heroDrops/nameless').delete();
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last.stage, 3);
await drop.update({'remainingPop': 0});
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last.isHeroTime, false);
expect(states.last.heroEndsAt, isNull);
await drop.update({
'remainingPop': 100,
'endsAt': Timestamp.fromDate(
DateTime.now().subtract(const Duration(seconds: 1)),
),
});
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last.isHeroTime, false);
expect(states.last.level, 20);
});
test('마지막 HERO TIME 종료시각에 추가 스냅샷 없이 축제를 해제한다', () async {
final db = FakeFirebaseFirestore();
await db.doc('fandoms/bts').set({'stage': 3});
await db.doc('fandoms/bts/heroDrops/one').set({
'remainingPop': 100,
'endsAt': Timestamp.fromDate(
DateTime.now().add(const Duration(seconds: 1)),
),
});
final repos = Repositories(
live: true,
backend: Backend(firestore: db, auth: MockFirebaseAuth()),
);
final states = <PlanetVisualState>[];
final sub = repos.watchPlanet('bts').listen(states.add);
addTearDown(sub.cancel);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(states.last.isHeroTime, true);
await Future<void>.delayed(const Duration(milliseconds: 1100));
expect(states.last.isHeroTime, false);
expect(states.last.stage, 3);
});
testWidgets('첫 실시간 값은 캐시와 달라도 진화로 연출하지 않는다', (tester) async {
final states = StreamController<PlanetVisualState>();
await tester.pumpWidget(
MaterialApp(
home: Center(
child: LivePlanet25D(
fandomId: 'bts',
watch: (_) => states.stream,
stage: 0,
image: 'assets/figma/planet-home.png',
size: 240,
),
),
),
);
states.add(const PlanetVisualState(stage: 2, level: 10));
await tester.pump();
await tester.pump();
expect(tester.widget<Planet25D>(find.byType(Planet25D)).stage, 2);
expect(find.text('이상 반응'), findsNothing);
states.add(const PlanetVisualState(stage: 3, level: 20));
await tester.pump();
await tester.pump();
expect(find.text('이상 반응'), findsOneWidget);
await tester.pump(const Duration(seconds: 4));
await tester.pumpWidget(const SizedBox());
await tester.runAsync(states.close);
});
testWidgets('서버 Stage 상승은 5구간 진화로 공개하고 재수신에는 반복하지 않는다', (tester) async {
Future<void> show(int stage) => tester.pumpWidget(
MaterialApp(
home: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 240,
stage: stage,
),
),
),
);
await show(1);
expect(find.text('에너지 집중'), findsNothing);
await show(2);
expect(find.text('이상 반응'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 600));
expect(find.text('에너지 집중'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 650));
expect(find.text('전환'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('Stage 2 · 성장'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 800));
expect(find.text('행성 진화 완료'), findsOneWidget);
await tester.pumpAndSettle();
await show(2);
expect(find.text('이상 반응'), findsNothing);
await show(1); // 정정된 낮은 값 이후 같은 최고 Stage도 재축하하지 않는다.
await show(2);
expect(find.text('이상 반응'), findsNothing);
});
testWidgets('Level 상승은 Stage Evolution과 구분된 짧은 결과를 표시한다', (tester) async {
Future<void> show(int level) => tester.pumpWidget(
MaterialApp(
home: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 240,
stage: 2,
level: level,
),
),
),
);
await show(5);
await show(6);
expect(find.text('Lv.6'), findsOneWidget);
expect(find.text('이상 반응'), findsNothing);
await tester.pumpAndSettle();
expect(find.text('Lv.6'), findsNothing);
});
testWidgets('Reduce Motion에서도 확정된 새 Stage를 즉시 알린다', (tester) async {
Future<void> show(int stage) => tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 240,
stage: stage,
),
),
),
),
);
await show(0);
await show(1);
expect(find.text('이상 반응'), findsNothing);
expect(find.text('Stage 1 · 시작'), findsOneWidget);
await tester.pumpAndSettle();
});
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/ui/planet_25d.dart';
import 'package:fancake/core/ui/planet_fx_scope.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/widgets/zap_feedback.dart';
void main() {
testWidgets('진화 중 ZAP 효과만 억제하고 확정 기여는 표시하며 종료 후 복귀한다', (tester) async {
Future<void> show(int stage, String? tx, {bool planet = true}) =>
tester.pumpWidget(
MaterialApp(
home: PlanetFxScope(
child: Stack(
children: [
if (planet)
Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
stage: stage,
),
),
Positioned.fill(
child: ZapFeedback(
touchSerial: 0,
pending: false,
result: tx == null
? null
: ZapResult(
txId: tx,
balanceAfter: 900,
contributedPop: 37,
),
source: const Offset(100, 300),
target: const Offset(100, 100),
radius: 60,
),
),
],
),
),
),
);
final paints = find.descendant(
of: find.byType(ZapFeedback),
matching: find.byType(CustomPaint),
);
await show(1, null);
await show(2, 'one');
await tester.pump();
expect(find.text('이상 반응'), findsOneWidget);
expect(find.text('37 POP 기여 완료'), findsOneWidget);
expect(paints, findsNothing);
await tester.pump(const Duration(seconds: 4));
await tester.pump();
await show(2, 'two');
expect(paints, findsOneWidget);
expect(find.text('37 POP 기여 완료'), findsOneWidget);
await show(3, 'three');
await tester.pump();
expect(paints, findsNothing);
await show(3, 'three', planet: false);
await tester.pump();
expect(paints, findsOneWidget, reason: '연출 소유자가 제거되면 억제가 남지 않아야 한다');
expect(tester.takeException(), isNull);
});
}
+187
View File
@@ -0,0 +1,187 @@
import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/core/ui/planet_25d.dart';
import 'package:fancake/features/home/planet_create_screen.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// C-50 — 생성 화면이 고른 colorKey 를 같은 팔레트로 본체에 입힌다.
testWidgets('행성 본체는 서버 colorKey 의 생성 팔레트 색을 입는다', (tester) async {
expect(identical(PlanetCreateScreen.colors, planetColors), isTrue);
expect(PlanetVisualState.fromDocument({'colorKey': 'gold'}).colorKey, 'gold');
expect(planetColorOf('gold'), const Color(0xFFFFC500));
expect(planetColorOf('violet'), isNull, reason: '서버 기본값은 원래 일러스트 그대로');
Future<Iterable<ColorFiltered>> filters(Color? tint) async {
await tester.pumpWidget(MaterialApp(home: Center(child: Planet25D(
image: 'assets/figma/planet-home.png', size: 200, stage: 1, tint: tint, animate: false,
))));
return tester.widgetList<ColorFiltered>(find.byType(ColorFiltered));
}
expect(await filters(null), isEmpty);
final tinted = await filters(planetColorOf('gold'));
expect(tinted.single.colorFilter,
const ColorFilter.mode(Color(0xFFFFC500), BlendMode.modulate));
});
testWidgets('행성 본체와 로고는 시간이 지나도 평면 회전하지 않는다', (tester) async {
final previous = Planet25D.animationsEnabled;
Planet25D.animationsEnabled = true;
addTearDown(() => Planet25D.animationsEnabled = previous);
await tester.pumpWidget(const MaterialApp(home: Center(child: Planet25D(
image: 'assets/figma/planet-home.png', size: 200, stage: 1,
))));
final body = find.byWidgetPredicate((w) => w is Image &&
w.image is AssetImage && (w.image as AssetImage).assetName == 'assets/figma/planet-home.png');
await tester.pump(const Duration(milliseconds: 1));
for (var quarter = 0; quarter < 4; quarter++) {
await tester.pump(const Duration(seconds: 12));
final box = tester.renderObject<RenderBox>(body);
final left = box.localToGlobal(Offset(0, box.size.height / 2));
final right = box.localToGlobal(Offset(box.size.width, box.size.height / 2));
expect(right.dx - left.dx, greaterThan(0));
expect(right.dy, closeTo(left.dy, 0.01));
}
await tester.pumpWidget(const SizedBox());
});
testWidgets('뒤 고리와 잔해는 본체 안쪽에서 차폐하고 앞 고리만 통과한다', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
stage: 4,
animate: false,
),
),
),
);
final clips = tester
.widgetList<ClipPath>(
find.descendant(
of: find.byType(Planet25D),
matching: find.byType(ClipPath),
),
)
.where((w) => w.clipper != null)
.toList();
expect(clips.length, 2);
const size = Size(340, 340);
final rear = clips.first.clipper!.getClip(size);
final front = clips.last.clipper!.getClip(size);
expect(rear.contains(const Offset(170, 145)), false, reason: '본체 뒤는 투과 금지');
expect(
rear.contains(const Offset(55, 150)),
true,
reason: '본체 밖의 뒤 고리는 표시',
);
expect(
front.contains(const Offset(170, 195)),
true,
reason: '앞 고리는 본체 위에 합성',
);
expect(tester.takeException(), isNull);
});
testWidgets('갤럭시의 Stage 0도 고체 행성 대신 원본 성운을 렌더한다', (tester) async {
const nebula = PlanetNode(
id: 'nebula',
name: '성운',
stage: 0,
rank: 1,
fpi: 0,
members: 1,
online: 1,
isMine: true,
);
await tester.pumpWidget(
MaterialApp(
home: GalaxyField(
planets: const [nebula],
selected: nebula,
onSelect: (_) {},
spin: 0,
pulse: 0,
),
),
);
expect(find.byType(Planet25D), findsOneWidget);
final images = tester
.widgetList<Image>(find.byType(Image))
.map((image) => image.image)
.whereType<AssetImage>()
.map((asset) => asset.assetName);
expect(images.contains('assets/fx/nebula-figma.png'), true);
expect(images.contains('assets/figma/planet-home.png'), false);
});
testWidgets(
'Stage 0 uses the Figma nebula instead of a solid planet; stages expose their names',
(tester) async {
final semantics = tester.ensureSemantics();
const names = ['성운', '시작', '성장', '함께', '동행', '빛', '영원'];
for (var stage = 0; stage <= 6; stage++) {
await tester.pumpWidget(
MaterialApp(
home: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
stage: stage,
animate: false,
),
),
),
);
final images = tester
.widgetList<Image>(find.byType(Image))
.map((image) => image.image)
.whereType<AssetImage>()
.map((asset) => asset.assetName);
expect(images.contains('assets/figma/planet-home.png'), stage != 0);
expect(images.contains('assets/fx/nebula-figma.png'), stage == 0);
expect(
find.bySemanticsLabel('Stage $stage · ${names[stage]}'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
}
semantics.dispose();
},
);
testWidgets(
'Reduce Motion stops the ambient ticker, including runtime changes',
(tester) async {
Planet25D.animationsEnabled = true;
addTearDown(() => Planet25D.animationsEnabled = false);
Future<void> show(bool reduced) => tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduced),
child: const Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
),
),
),
),
);
await show(true);
await tester.pump(const Duration(seconds: 1));
expect(tester.binding.transientCallbackCount, 0);
await show(false);
await tester.pump(const Duration(milliseconds: 100));
expect(tester.binding.transientCallbackCount, greaterThan(0));
await show(true);
await tester.pump(const Duration(seconds: 1));
expect(tester.binding.transientCallbackCount, 0);
},
);
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/home/widgets/home_hero.dart';
import 'package:fancake/features/home/widgets/growth_conditions.dart';
import 'package:fancake/features/home/widgets/planet_growth_card.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('기여 기록이 없으면 두 성장 영역에 가짜 0 POP 기여를 표시하지 않는다', (tester) async {
const growth = PlanetGrowth(fandomName: 'BTS', contributorName: '팬',
contributedPop: 0, growthPercent: null, currentPop: null, requiredPop: null);
for (final widget in [const HomeHero(growth: growth), const PlanetGrowthCard(growth: growth)]) {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: Scaffold(body: widget)));
await tester.pumpAndSettle();
expect(find.textContaining('0 POP', findRichText: true), findsNothing);
expect(find.textContaining('님의', findRichText: true), findsNothing);
expect(find.text('함께 키워가는 팬덤 행성'), findsOneWidget);
}
});
test('POP 진행만으로 진화 임박/완료를 단정하지 않고 문턱 누락을 구분한다', () {
PlanetGrowth growth(int? current, int? required) => PlanetGrowth(
fandomName: 'BTS',
contributorName: '팬',
contributedPop: 0,
growthPercent: 100,
currentPop: current,
requiredPop: required,
);
expect(growth(null, null).popConditionLabel, 'POP 조건 확인 중');
expect(growth(999, 1000).popConditionLabel, 'POP 조건 미충족');
expect(growth(1000, 1000).popConditionLabel, 'POP 조건 충족');
expect(growth(1200, 1000).popConditionLabel, 'POP 조건 충족');
});
testWidgets('목표 기간이 지난 성운은 기간 경과 문구를 보인다(Q-PN-02)', (tester) async {
PlanetGrowth nebula({required bool passed}) => PlanetGrowth(stage: 0, nebulaGoalPassed: passed,
fandomName: 'BTS', contributorName: '팬', contributedPop: 0, growthPercent: null,
currentPop: 500, requiredPop: null);
const text = '목표 기간이 지났어요 · 조건을 채우면 행성으로 탄생해요';
for (final (passed, expected) in [(true, findsOneWidget), (false, findsNothing)]) {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(),
home: Scaffold(body: GrowthConditions(growth: nebula(passed: passed)))));
expect(find.text(text), expected);
}
});
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/ui/planet_25d.dart';
void main() {
testWidgets('HERO 종료는 잔여 입자 회수 후 사라지고 재조회에 반복하지 않는다', (tester) async {
Future<void> show(Set<String> ids, {int stage = 2, bool reduced = false}) =>
tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: MediaQueryData(disableAnimations: reduced),
child: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
stage: stage,
heroEventIds: ids,
isHeroTime: ids.isNotEmpty,
),
),
),
),
);
await show({'one'});
await show({});
expect(find.text('HERO TIME 종료'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
expect(find.text('HERO TIME 종료'), findsNothing);
await show({});
expect(find.text('HERO TIME 종료'), findsNothing);
await show({'two'});
await show({}, stage: 3);
expect(find.text('이상 반응'), findsOneWidget);
expect(find.text('HERO TIME 종료'), findsNothing);
await tester.pump(const Duration(milliseconds: 3300));
expect(find.text('HERO TIME 종료'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
await show({'three'}, stage: 3, reduced: true);
await show({}, stage: 3, reduced: true);
expect(find.text('HERO TIME 종료'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
expect(tester.takeException(), isNull);
});
testWidgets('HERO 최초 상태는 기준점이며 새 ID만 발동하고 진화 뒤에 이어진다', (tester) async {
Future<void> show(int stage, Set<String>? ids) => tester.pumpWidget(
MaterialApp(
home: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
stage: stage,
isHeroTime: ids?.isNotEmpty ?? false,
heroEventIds: ids,
),
),
),
);
await show(1, null);
await show(1, {'old'});
expect(find.text('행성이 깨어납니다'), findsNothing);
await show(1, {'old', 'new'});
expect(find.text('행성이 깨어납니다'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1500));
expect(find.text('HERO TIME'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
await show(1, {'old', 'new'});
expect(find.text('HERO TIME'), findsNothing);
await show(2, {'old', 'new', 'next'});
expect(find.text('이상 반응'), findsOneWidget);
expect(find.text('행성이 깨어납니다'), findsNothing);
await tester.pump(const Duration(milliseconds: 3300));
expect(find.text('행성이 깨어납니다'), findsOneWidget);
await show(2, {});
expect(find.text('행성이 깨어납니다'), findsNothing);
await show(2, {'next'});
expect(find.text('행성이 깨어납니다'), findsNothing, reason: '재수신은 재발동하지 않는다');
await show(3, {'next', 'expires'});
await show(3, {'next'});
await tester.pump(const Duration(milliseconds: 3300));
expect(
find.text('행성이 깨어납니다'),
findsNothing,
reason: '대기하던 신규 이벤트가 끝났으면 다른 진행 중 이벤트가 있어도 재생하지 않는다',
);
});
}
+38
View File
@@ -0,0 +1,38 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/core/ui/live_planet.dart';
void main() {
test('휴면은 FPI를 단말에서 추측하지 않고 명시적인 서버 상태만 읽는다', () {
expect(PlanetVisualState.fromDocument({'fpi': 0}).isHibernating, isFalse);
expect(PlanetVisualState.fromDocument({'isHibernating': 'true'}).isHibernating, isFalse);
expect(PlanetVisualState.fromDocument({'isHibernating': true}).isHibernating, isTrue);
});
testWidgets('서버 휴면에서 어두워지고 복귀 시 점진적으로 광량을 회복한다', (tester) async {
final states = StreamController<PlanetVisualState>.broadcast();
addTearDown(states.close);
await tester.pumpWidget(MaterialApp(home: Scaffold(body: LivePlanet25D(
fandomId: 'one', watch: (_) => states.stream,
image: 'assets/figma/planet-home.png', size: 200,
))));
states.add(const PlanetVisualState(stage: 1, isHibernating: true));
await tester.pump();
await tester.pump(const Duration(seconds: 2));
double light() => tester.widget<Opacity>(find.byKey(const ValueKey('planet-surface-light'))).opacity;
expect(light(), 0.35);
expect(find.bySemanticsLabel('Stage 1 · 시작 · 휴면 중'), findsOneWidget);
states.add(const PlanetVisualState(stage: 1));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(light(), greaterThan(0.35));
expect(light(), lessThan(1));
await tester.pump(const Duration(seconds: 1));
expect(light(), 1);
await tester.pumpWidget(const SizedBox());
expect(states.hasListener, isFalse);
});
}
+201
View File
@@ -0,0 +1,201 @@
import 'dart:async';
import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/planet/planet_repository_firestore.dart';
import 'package:fancake/features/planet/planet_detail_screen.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'eclipse_stubs.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/planet/planet_history_screen.dart';
class _Backend extends Backend {
_Backend()
: super(
firestore: FakeFirebaseFirestore(),
auth: MockFirebaseAuth(
mockUser: MockUser(uid: 'reader'),
signedIn: true,
),
);
Map<String, dynamic>? payload;
Map<String, dynamic> response = {
'items': [
{
'id': 'one',
'kind': 'evolution',
'occurredAt': '2026-09-22T00:00:00Z',
'fromStage': 1,
'toStage': 2,
'participationConfirmed': 'true',
},
],
'nextCursor': null,
};
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> data = const {},
]) async {
expect(name, 'fnPlanetHistory');
payload = data;
return response;
}
}
class _PersonalHistory extends SamplePlanetRepository {
final scopes = <bool>[];
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
scopes.add(personal);
return PlanetHistoryPage(items: [PlanetHistoryEntry(
id: 'one', kind: personal ? PlanetHistoryKind.zap : PlanetHistoryKind.evolution,
occurredAt: DateTime.utc(2026, 9, 22), fromStage: personal ? null : 1,
toStage: personal ? null : 2, amount: personal ? 37 : null,
)], nextCursor: personal ? null : 'older');
}
}
class _History extends SamplePlanetRepository {
final cursors = <String?>[];
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
cursors.add(cursor);
if (cursors.length == 2) throw StateError('private details');
return PlanetHistoryPage(
items: [
PlanetHistoryEntry(
id: cursor ?? 'first',
kind: PlanetHistoryKind.evolution,
occurredAt: DateTime.utc(2026, 9, 22),
fromStage: 1,
toStage: 2,
participationConfirmed: cursor == null,
),
],
nextCursor: cursor == null ? 'next' : null,
);
}
}
void main() {
testWidgets('내 활동 전환은 이전 목록과 커서를 지우고 실제 성장량을 표시한다', (tester) async {
final repository = _PersonalHistory();
await tester.pumpWidget(MaterialApp(home: PlanetHistoryScreen(
repository: repository, fandomName: '팬덤',
)));
await tester.pumpAndSettle();
await tester.tap(find.text('내 활동'));
await tester.pumpAndSettle();
expect(repository.scopes, [false, true]);
expect(find.text('Stage 1 → 2 진화'), findsNothing);
expect(find.text('ZAP 기여'), findsOneWidget);
expect(find.textContaining('성장에 37 POP 반영'), findsOneWidget);
expect(find.textContaining('이 진화에 함께했습니다'), findsNothing);
expect(find.text('이전 기록 더 보기'), findsNothing);
});
testWidgets('서버 Stage 상승 완료에서 현재 행성의 역사 화면을 연다', (tester) async {
const repository = SamplePlanetRepository();
final states = StreamController<PlanetVisualState>.broadcast();
addTearDown(states.close);
await tester.pumpWidget(MaterialApp(home: PlanetDetailScreen(
repository: repository, eclipse: const NoEclipseRepository(),
watchPlanet: (_) => states.stream,
)));
await tester.pumpAndSettle();
states.add(const PlanetVisualState(stage: 1));
await tester.pumpAndSettle();
states.add(const PlanetVisualState(stage: 2));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 4));
await tester.tap(find.text('PASSPORT 기록 보기'));
await tester.pumpAndSettle();
expect(tester.widget<PlanetHistoryScreen>(find.byType(PlanetHistoryScreen)).repository, same(repository));
await tester.pumpWidget(const SizedBox());
});
test('실제 저장소는 대상 팬덤·커서만 요청하고 불완전한 응답을 빈 기록으로 숨기지 않는다', () async {
final backend = _Backend();
final repository = FirestorePlanetRepository(backend, fandomId: 'target');
final page = await repository.history(cursor: 'previous');
expect(backend.payload, {'fandomId': 'target', 'cursor': 'previous'});
expect(page.items.single.participationConfirmed, isFalse);
backend.response = {
'items': [
{'id': 'bad'},
],
'nextCursor': null,
};
await expectLater(repository.history(), throwsStateError);
backend.response = {'items': [{'id': 'zap', 'kind': 'zap',
'occurredAt': '2026-09-22T00:00:00Z', 'amount': 37, 'participationConfirmed': true}], 'nextCursor': null};
final personal = await repository.history(personal: true);
expect(backend.payload, {'fandomId': 'target', 'scope': 'personal'});
expect(personal.items.single.amount, 37);
expect(personal.items.single.participationConfirmed, isFalse);
});
testWidgets('행성 상세에서 같은 저장소의 팬덤 역사를 연다', (tester) async {
const repository = SamplePlanetRepository();
await tester.pumpWidget(
const MaterialApp(
home: PlanetDetailScreen(
repository: repository,
eclipse: NoEclipseRepository(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('더 보기'));
await tester.pumpAndSettle();
await tester.tap(find.text('PASSPORT · 팬덤 역사'));
await tester.pumpAndSettle();
expect(
tester
.widget<PlanetHistoryScreen>(find.byType(PlanetHistoryScreen))
.repository,
same(repository),
);
});
testWidgets('확정된 개인 참여만 표시하고 다음 페이지 실패는 같은 커서로 재시도한다', (tester) async {
final repository = _History();
await tester.pumpWidget(
MaterialApp(
home: PlanetHistoryScreen(repository: repository, fandomName: '팬덤'),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('이 진화에 함께했습니다'), findsOneWidget);
await tester.tap(find.text('이전 기록 더 보기'));
await tester.pumpAndSettle();
expect(find.text('기록을 불러오지 못했습니다.'), findsOneWidget);
expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsNothing);
expect(find.textContaining('private details'), findsNothing);
await tester.tap(find.text('다시 시도'));
await tester.pumpAndSettle();
expect(repository.cursors, [null, 'next', 'next']);
expect(find.text('Stage 1 → 2 진화'), findsNWidgets(2));
expect(find.textContaining('이 진화에 함께했습니다'), findsOneWidget);
expect(find.text('이전 기록 더 보기'), findsNothing);
});
testWidgets('조회 성공의 빈 기록을 오류와 구분한다', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: PlanetHistoryScreen(
repository: SamplePlanetRepository(),
fandomName: '팬덤',
),
),
);
await tester.pumpAndSettle();
expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsOneWidget);
expect(find.text('다시 시도'), findsNothing);
});
}
+494
View File
@@ -0,0 +1,494 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_evidence.dart';
import 'package:fancake/features/quest/quest_repository_firestore.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 퀘스트 서버 연동 계층 — 화면 대조 원장 F4 의 데이터 경로.
///
/// **무엇을 읽어 무엇으로 옮기는지**와 **어느 함수를 부르는지**를 본다. 규칙이 틀리면
/// 화면은 조용히 0 을 그리거나 엉뚱한 함수를 불러 지급이 일어나므로, 눈으로는 잡히지
/// 않는다. 지급 자체의 판정은 서버 쪽 `firebase/functions/test/quest_e2e.test.ts` 가 본다.
const _uid = 'u-quest';
/// Cloud Functions 호출을 기록하는 경계. 실제 함수는 에뮬레이터에서 따로 검증한다.
class _RecordingBackend extends Backend {
_RecordingBackend(FakeFirebaseFirestore db, {Map<String, dynamic>? reply})
: _reply =
reply ?? const {'txId': 'tx-1', 'balanceAfter': 60, 'rewarded': 10},
super(
firestore: db,
auth: MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: _uid, displayName: '테스터'),
),
);
final Map<String, dynamic> _reply;
final List<({String name, Map<String, dynamic> payload})> calls = [];
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async {
calls.add((name: name, payload: payload));
return _reply;
}
}
String _today() => DateTime.now()
.toUtc()
.add(const Duration(hours: 9))
.toIso8601String()
.substring(0, 10);
Future<FakeFirebaseFirestore> _seed() async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'});
// 걸음 목표는 운영값이다 — 앱에 하드코딩하지 않는다(별첨 B 「운영값 관리 원칙」).
await db.doc('config/policy').set({
'stepsGoal': 10000,
'stepsPop': 50,
'attendPop': 10,
});
await db.doc('quests/attend').set({
'type': 'attend',
'title': '오늘의 출석',
'rewardPop': 50,
'dailyLimit': 1,
});
await db.doc('quests/steps').set({
'type': 'walk',
'title': '만보기',
'rewardPop': 50,
'dailyLimit': 1,
});
await db.doc('quests/place').set({
'type': 'location',
'title': '팝업 방문',
'rewardPop': 100,
'dailyLimit': 1,
'placeId': 'hongdae',
});
await db.doc('quests/stream').set({
'type': 'streaming',
'title': '신곡 완청',
'rewardPop': 10,
'dailyLimit': 1,
});
return db;
}
QuestCard _card(List<QuestCard> cards, String id) =>
cards.firstWhere((c) => c.id == id);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
// 걸음 기준선은 단말에 남는다. 회차마다 빈 저장소에서 시작해야 값이 겹치지 않는다.
SharedPreferences.setMockInitialValues({});
});
group('퀘스트 목록 — Firestore 구현', () {
test('정의 문서를 카드로 옮기고 유형을 별칭까지 읽는다', () async {
final db = await _seed();
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(cards.map((c) => c.id).toSet(), {
'attend',
'steps',
'place',
'stream',
});
expect(_card(cards, 'steps').type, QuestType.steps);
expect(_card(cards, 'place').type, QuestType.place);
// 출석은 문서의 50 이 아니라 서버가 지급하는 config/policy.attendPop 이다.
expect(_card(cards, 'attend').rewardPop, 10);
// 걸음 목표는 `config/policy` 에서 온다. 나머지 유형은 일일 한도가 목표다.
expect(_card(cards, 'steps').goal, 10000);
expect(_card(cards, 'attend').goal, 1);
expect(_card(cards, 'attend').stage, QuestStage.notStarted);
});
test('당일 한도를 채우면 보상완료로 본다', () async {
final db = await _seed();
await db.doc('users/$_uid/questProgress/attend').set({
'count': 1,
'dayKey': _today(),
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(_card(cards, 'attend').stage, QuestStage.rewarded);
});
test('어제 진행분은 오늘로 세지 않는다', () async {
final db = await _seed();
await db.doc('users/$_uid/questProgress/attend').set({
'count': 1,
'dayKey': '2026-01-01',
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(_card(cards, 'attend').stage, QuestStage.notStarted);
});
test('닫힌 퀘스트는 감추고 기간 필드가 없는 문서는 그대로 보여 준다', () async {
final db = await _seed();
await db.doc('quests/closed').set({
'type': 'attend',
'title': '지난 이벤트',
'rewardPop': 10,
'active': false,
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(cards.map((c) => c.id), isNot(contains('closed')));
// 시연 데이터에도 Admin 문서에도 없는 필드를 근거로 감추지 않는다.
expect(cards.map((c) => c.id), contains('attend'));
});
test('내가 만든 퀘스트는 내 퀘스트 상태로 온다', () async {
final db = await _seed();
await db.doc('quests/mine').set({
'type': 'attend',
'title': '내가 만든 것',
'rewardPop': 10,
'authorUid': _uid,
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load();
expect(_card(cards, 'mine').stage, QuestStage.mine);
expect(_card(cards, 'mine').author, QuestAuthor.user);
});
});
group('퀘스트 참여 — 지급은 전부 Cloud Functions 를 지난다', () {
test('출석은 fnQuestAttend 를 부르고 서버 값을 그대로 옮긴다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
final reward = await repo.verify(_card(cards, 'attend'));
expect(backend.calls.single.name, 'fnQuestAttend');
expect(reward.rewardedPop, 10);
expect(reward.balanceAfter, 60);
});
test('걸음은 하루 기준선 대비 오늘치를 보낸다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(
backend,
// 부팅 이후 누적값이 들어온다. 그대로 보내면 며칠 켜 둔 단말이 공짜로 통과한다.
stepCount: () => Stream.fromIterable([40_000, 45_000, 50_400]),
);
final cards = await repo.load();
final card = _card(cards, 'steps');
expect(await repo.progressStream(card).toList(), [0, 5_000, 10_400]);
await repo.verify(card);
expect(backend.calls.single.name, 'fnQuestVerifySteps');
expect(backend.calls.single.payload['steps'], 10_400);
// 진행 문서와 일일 한도를 퀘스트 단위로 세도록 문서 ID 를 함께 보낸다.
expect(backend.calls.single.payload['questId'], 'steps');
// 집계 축은 소속 팬덤이다 — `activityLogs.fandomId`(설계서 §2.1).
expect(backend.calls.single.payload['fandomId'], 'bts');
expect(backend.calls.single.payload['occurredAt'], isA<String>());
});
test('앱을 껐다 켜도 오늘치가 0 으로 돌아가지 않는다', () async {
final db = await _seed();
// 첫 실행 — 기준선 40,000 을 잡고 5,000 걷는다.
final first = FirestoreQuestRepository(
_RecordingBackend(db),
stepCount: () => Stream.fromIterable([40_000, 45_000]),
);
final card = _card(await first.load(), 'steps');
expect(await first.progressStream(card).toList(), [0, 5_000]);
// 앱을 다시 열었다. 저장소도 기준선도 새로 만들지만 단말에 남은 값을 읽는다.
final backend = _RecordingBackend(db);
final second = FirestoreQuestRepository(
backend,
stepCount: () => Stream.fromIterable([46_200]),
);
expect(await second.progressStream(card).toList(), [6_200]);
await second.verify(card);
expect(backend.calls.single.payload['steps'], 6_200);
});
test('출석도 퀘스트 문서 ID 를 함께 보낸다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
await repo.verify(_card(cards, 'attend'));
expect(backend.calls.single.payload['questId'], 'attend');
});
test('걸음 수를 아직 세지 못했으면 서버를 부르지 않는다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
await expectLater(
repo.verify(_card(cards, 'steps')),
throwsA(
isA<BackendException>().having((e) => e.code, 'code', 'E-QST-01'),
),
);
expect(backend.calls, isEmpty);
});
test('위치는 좌표와 모의 위치 여부를 함께 보낸다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(
backend,
placeFix: () async => const PlaceFix(
lat: 37.5563,
lng: 126.9236,
accuracyM: 12,
mocked: true,
),
);
final cards = await repo.load();
await repo.verify(_card(cards, 'place'));
final call = backend.calls.single;
expect(call.name, 'fnQuestVerifyPlace');
expect(call.payload['placeId'], 'hongdae');
expect(call.payload['questId'], 'place');
expect(call.payload['lat'], 37.5563);
// 판정은 서버가 한다. 앱은 모의 위치 여부를 감추지도, 스스로 막지도 않는다.
expect(call.payload['mocked'], isTrue);
});
test('장소가 등록되지 않은 위치 퀘스트는 서버를 부르지 않는다', () async {
final db = await _seed();
await db.doc('quests/place').set({
'type': 'location',
'title': '장소 미등록',
'rewardPop': 100,
'dailyLimit': 1,
});
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
await expectLater(
repo.verify(_card(cards, 'place')),
throwsA(
isA<BackendException>().having((e) => e.code, 'code', 'E-QST-03'),
),
);
expect(backend.calls, isEmpty);
});
test('회신 대기 유형은 어느 함수도 부르지 않는다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
final cards = await repo.load();
final card = _card(cards, 'stream');
expect(card.type.verifiable, isFalse);
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
expect(backend.calls, isEmpty);
});
test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async {
final db = await _seed();
final backend = _RecordingBackend(db, reply: const {'questId': 'q-new'});
final repo = FirestoreQuestRepository(backend);
final id = await repo.create(
QuestDraft(
clientSeq: 's1',
activityKey: 'vote',
title: '투표',
rewardPop: 5,
participants: 10,
endDate: DateTime(2026, 10, 3),
url: 'https://x.com/a',
manualTitle: '제목',
),
);
expect(id, 'q-new');
final call = backend.calls.single;
expect(call.name, 'fnQuestCreate');
expect(call.payload, containsPair('endDate', '2026-10-03'));
expect(call.payload, containsPair('capacity', 10));
expect(call.payload, containsPair('manualTitle', '제목'));
expect(
call.payload['utcOffsetMinutes'],
DateTime(2026, 10, 3, 23, 59, 59).timeZoneOffset.inMinutes,
);
// 앱은 quests 에 직접 쓰지 않는다.
expect((await db.collection('quests').get()).docs, hasLength(4));
});
test(
'분류 마스터 — 노출 · ugcAllowed · 순서를 따르고 만들 활동이 없는 분류는 감춘다(C-01)',
() async {
final db = await _seed();
await db.doc('config/policy').update({
'ugcFeeMin': 20,
'ugcMaxDays': 14,
});
await db.doc('config/questCatalog').set({
'categories': [
{'key': 'location', 'label': '특정 위치', 'order': 2, 'visible': true},
{'key': 'planet', 'label': '행성 활동', 'order': 1, 'visible': true},
{'key': 'health', 'label': '건강', 'order': 3, 'visible': true},
{'key': 'community', 'label': '커뮤니티', 'order': 4, 'visible': false},
],
'activities': [
{
'key': 'sns',
'categoryKey': 'planet',
'order': 2,
'visible': true,
'ugcAllowed': true,
'inputSchema': [
{'field': 'link', 'required': true},
],
},
{
'key': 'vote',
'categoryKey': 'planet',
'order': 1,
'visible': true,
'ugcAllowed': true,
'inputSchema': [
{'field': 'link', 'required': true},
],
},
{
'key': 'youtube',
'categoryKey': 'planet',
'order': 3,
'visible': true,
'ugcAllowed': false,
},
{
'key': 'walk',
'categoryKey': 'health',
'order': 1,
'visible': true,
'ugcAllowed': false,
},
{
'key': 'location',
'categoryKey': 'location',
'order': 1,
'visible': true,
'ugcAllowed': true,
'inputSchema': [
{'field': 'place', 'required': true},
],
},
{
'key': 'qr',
'categoryKey': 'location',
'order': 2,
'visible': true,
'ugcAllowed': false,
},
{
'key': 'community',
'categoryKey': 'community',
'order': 1,
'visible': true,
'ugcAllowed': true,
},
],
});
final catalog = await FirestoreQuestRepository(
_RecordingBackend(db),
).catalog();
expect(catalog.categories.map((c) => c.id), ['planet', 'location']);
expect(catalog.categories.first.activities.map((a) => a.key), [
'vote',
'sns',
]);
expect(catalog.categories.first.activities.first.fields, {'link'});
expect(catalog.categories.last.activities.single.fields, {'place'});
expect(catalog.feeOf(50), 20);
// Q-UQ-02 권고 A안 — 보상 없는 Quest 는 수수료 0.
expect(catalog.feeOf(0), 0);
expect(catalog.maxDays, 14);
},
);
test('제출 · 검토 · 수정은 각 함수로만 간다', () async {
final db = await _seed();
final backend = _RecordingBackend(db);
final repo = FirestoreQuestRepository(backend);
await repo.submit('q1', url: 'https://x.com/p', text: '');
await repo.review(
'q1_u',
approve: false,
reason: '불명확',
resubmitAllowed: true,
);
await repo.edit('q1', title: 't', description: 'd');
expect(backend.calls.map((c) => c.name), [
'fnQuestSubmit',
'fnQuestReview',
'fnQuestEdit',
]);
expect(backend.calls.first.payload, {
'questId': 'q1',
'url': 'https://x.com/p',
});
expect(backend.calls[1].payload, {
'submissionId': 'q1_u',
'action': 'reject',
'reason': '불명확',
'resubmitAllowed': true,
});
});
test('마감이 지난 남의 사용자 생성 Quest 는 목록에서 빠진다(G-04)', () async {
final db = await _seed();
await db.doc('quests/ugc-old').set({
'type': 'vote',
'title': '지난 투표',
'authorUid': 'someone',
'status': 'published',
'endsAt': DateTime.now().subtract(const Duration(minutes: 1)),
});
await db.doc('quests/ugc-live').set({
'type': 'vote',
'title': '진행 투표',
'authorUid': 'someone',
'status': 'published',
'endsAt': DateTime.now().add(const Duration(hours: 3)),
'capacity': 5,
});
final cards = await FirestoreQuestRepository(
_RecordingBackend(db),
).load(limit: 20);
expect(cards.map((c) => c.id), isNot(contains('ugc-old')));
final live = _card(cards, 'ugc-live');
expect(live.type, QuestType.vote);
expect(live.author, QuestAuthor.user);
expect(live.capacity, 5);
});
});
}
-76
View File
@@ -1,76 +0,0 @@
import 'dart:async';
import 'package:fancake/core/data/shared_stream.dart';
import 'package:flutter_test/flutter_test.dart';
/// 실시간 구독 공유 — 같은 문서를 여러 화면이 볼 때 원본 구독은 하나만 연다.
void main() {
group('구독 공유 — 여러 화면이 원본 하나를 나눠 본다', () {
test('여러 화면이 같은 원본 하나를 나눠 쓴다', () async {
// 화면마다 snapshots() 를 열면 한 팬덤에 리스너가 화면 수만큼 붙는다.
var opened = 0;
final controller = StreamController<int>.broadcast();
addTearDown(controller.close);
final shared = SharedStream<int>(() {
opened += 1;
return controller.stream;
});
final a = <int>[];
final b = <int>[];
final subA = shared.stream.listen(a.add);
final subB = shared.stream.listen(b.add);
await Future<void>.delayed(Duration.zero);
controller.add(7);
await Future<void>.delayed(Duration.zero);
// 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다.
expect(opened, 1);
expect(a, [7]);
expect(b, [7]);
await subA.cancel();
await subB.cancel();
});
test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async {
final controller = StreamController<int>.broadcast();
addTearDown(controller.close);
final shared = SharedStream<int>(() => controller.stream);
final first = <int>[];
final subA = shared.stream.listen(first.add);
await Future<void>.delayed(Duration.zero);
controller.add(42);
await Future<void>.delayed(Duration.zero);
// 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다.
final late = <int>[];
final subB = shared.stream.listen(late.add);
await Future<void>.delayed(Duration.zero);
expect(late, [42]);
await subA.cancel();
await subB.cancel();
});
test('마지막 화면이 떠나면 원본을 닫는다', () async {
var cancelled = false;
final controller = StreamController<int>(
onCancel: () => cancelled = true,
);
addTearDown(controller.close);
final shared = SharedStream<int>(() => controller.stream);
final sub = shared.stream.listen((_) {});
await Future<void>.delayed(Duration.zero);
expect(cancelled, isFalse);
await sub.cancel();
await Future<void>.delayed(Duration.zero);
// 남겨 두면 화면이 없는 동안에도 읽기 비용이 계속 든다.
expect(cancelled, isTrue);
});
});
}
+123 -49
View File
@@ -2,13 +2,14 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
/// iOS 스토어 제출 구성 검사. /// 스토어 제출 준비물 검사.
/// ///
/// 여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다. 문제는 업로드·심사·로그인 /// **여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다.** 문제는 심사에 넣거나
/// 시점에야 드러나고, 그때는 원인이 코드처럼 보인다. 파일을 문자열로 읽어 /// 사용자가 권한을 누르는 순간에야 드러나고, 그때는 원인이 코드처럼 보인다.
/// 키가 있는지만 본다 — 값의 해석은 빌드가 한다. /// 실제로 이 프로젝트에서 최소 지원 OS 가 계약값과 다른 채로 나가고 있었다.
///
const _teamId = 'V28G543VK5'; // Fancake Inc. /// 파일을 문자열로 읽는 이유는 plist·gradle 파서를 붙이지 않기 위해서다.
/// 키가 있는지만 보면 되고, 값의 해석은 빌드가 한다.
String _read(String path) { String _read(String path) {
final file = File(path); final file = File(path);
@@ -17,47 +18,62 @@ String _read(String path) {
} }
void main() { void main() {
group('iOS — 서명', () { group('iOS — 권한 사용 문구', () {
test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로 서명한다', () {
final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
.allMatches(pbx)
.map((m) => m.group(1))
.toSet();
expect(teams, {_teamId});
expect(_read('ios/ExportOptions.plist'), contains(_teamId));
});
test('Sign in with Apple Capability 가 서명에 연결되어 있다', () {
expect(
_read('ios/Runner/Runner.entitlements'),
contains('com.apple.developer.applesignin'),
);
expect(
'CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;'
.allMatches(_read('ios/Runner.xcodeproj/project.pbxproj'))
.length,
3, // Debug · Release · Profile
);
});
});
group('iOS — 제출 선언과 Firebase 설정', () {
late final String plist = _read('ios/Runner/Info.plist'); late final String plist = _read('ios/Runner/Info.plist');
test('권한을 요청하는 기능마다 문구가 있다', () {
// 문구 없이 요청하면 iOS 가 앱을 즉시 종료한다. 크래시로 보인다.
for (final key in const [
'NSPhotoLibraryUsageDescription', // 프로필 사진
'NSCameraUsageDescription', // 프로필 · 방문 인증 사진
'NSLocationWhenInUseUsageDescription', // 위치 퀘스트
'NSMotionUsageDescription', // 만보기
'NSHealthShareUsageDescription', // 걸음 수
'NSUserTrackingUsageDescription', // 광고
]) {
expect(plist, contains(key), reason: key);
}
});
test('문구가 용도를 말한다', () {
// 「접근이 필요합니다」처럼 용도 없는 문장은 심사 반려 사유다.
expect(plist, contains('프로필 사진을 고르기 위해'));
expect(plist, contains('퀘스트 보상을 드리기 위해'));
expect(plist, contains('다른 건강 정보는 읽지 않습니다'));
});
});
group('iOS — 제출 선언', () {
late final String plist = _read('ios/Runner/Info.plist');
late final String entitlements = _read('ios/Runner/Runner.entitlements');
test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () { test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () {
expect(plist, contains('ITSAppUsesNonExemptEncryption')); expect(plist, contains('ITSAppUsesNonExemptEncryption'));
}); });
test('Google 로그인 콜백 스킴이 설정 파일 값으로 채워진다', () { test('Google 로그인 콜백 스킴 자리가 있다', () {
expect(plist, contains('CFBundleURLTypes'));
// 값은 Firebase 설정 파일에서 뽑아 넣는다. 손으로 적으면 오타로 조용히 실패한다.
expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)')); expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)'));
for (final c in const ['Debug', 'Release']) { });
expect(
_read('ios/Flutter/$c.xcconfig'), test('Sign in with Apple Capability 가 있다', () {
contains('#include? "../config/Google.xcconfig"'), // 정책 §P-01 — 소셜 로그인을 제공하면 Apple 심사 필수 조건이다.
reason: c, expect(entitlements, contains('com.apple.developer.applesignin'));
); });
}
test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로만 서명한다', () {
// 다른 팀으로 서명하면 업로드가 그 팀의 App Store Connect 로 간다(10/2 실제 발생).
const teamId = 'V28G543VK5'; // Fancake Inc.
final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
.allMatches(pbx)
.map((m) => m.group(1))
.toSet();
expect(teams, {teamId});
final options = _read('ios/ExportOptions.plist');
expect(options, contains('app-store-connect'));
expect(options, contains(teamId));
}); });
test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () { test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () {
@@ -69,18 +85,76 @@ void main() {
}); });
}); });
test('설정·서명 자산을 git 이 추적하지 않는다', () { group('Android — 계약값과 배포 경계', () {
final result = Process.runSync('git', [ late final String gradle = _read('android/app/build.gradle.kts');
'ls-files',
'--', test('최소 지원 OS 가 계약값 API 29 다', () {
'ios/Runner/GoogleService-Info.plist', // 별첨 1 v1.0 이 Android 10 으로 정했다. Flutter 기본값 24 로 두면
'ios/config/Google.xcconfig', // 검증하지 않은 기기까지 설치가 열린다.
'android/app/google-services.json', expect(gradle, contains('minSdk = 29'));
});
test('평문 예외가 개발 소스 세트에만 있다', () {
for (final variant in const ['debug', 'profile']) {
expect(
File('android/app/src/$variant/res/xml/network_security_config.xml')
.existsSync(),
isTrue,
reason: variant,
);
}
// main 에 있으면 릴리스 빌드로 새어 중간자 공격 표면이 열린다.
expect(
File('android/app/src/main/res/xml/network_security_config.xml')
.existsSync(),
isFalse,
);
expect(
_read('android/app/src/main/AndroidManifest.xml'),
isNot(contains('networkSecurityConfig')),
);
});
});
group('서명 자산이 저장소에 올라가지 않는다', () {
late final String ignore = _read('.gitignore');
test('키스토어·설정 파일 패턴이 걸려 있다', () {
for (final pattern in const [
'key.properties',
'*.jks',
'*.keystore',
'*.p12', '*.p12',
'*.p8', '*.p8',
'*.mobileprovision', '*.mobileprovision',
]); 'google-services.json',
'GoogleService-Info.plist',
]) {
expect(ignore, contains(pattern), reason: pattern);
}
});
test('git 이 실제로 추적하고 있지 않다', () {
// .gitignore 에 적혀 있어도 이미 추적 중이면 계속 올라간다.
// 패턴 검사만으로는 그 상태를 잡지 못하므로 git 에 직접 묻는다.
final result = Process.runSync('git', [
'ls-files',
'--',
'android/app/google-services.json',
'ios/Runner/GoogleService-Info.plist',
'android/key.properties',
'*.jks',
'*.keystore',
'*.p8',
], workingDirectory: '.');
expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다'); expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다');
expect((result.stdout as String).trim(), isEmpty); final tracked = (result.stdout as String).trim();
expect(
tracked,
isEmpty,
reason: '서명·설정 자산이 저장소에 올라가 있습니다:\n$tracked',
);
});
}); });
} }
+259
View File
@@ -0,0 +1,259 @@
import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp;
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/data/invite_code.dart';
import 'package:fancake/core/data/server_clock.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_repository_firestore.dart';
import 'package:fancake/features/home/widgets/briefing_share.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_detail_screen.dart';
import 'package:fancake/features/quest/quest_repository_firestore.dart';
import 'package:fancake/features/quest/widgets/time_attack_countdown.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:share_plus/share_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 긴급 돌발 타임어택(별첨 1 ③-4 · FR-QST-06) · 브리핑 공유(③-5 · 「홈_브리핑 공유」).
///
/// 지급 판정은 서버 `firebase/functions/test/timeattack.test.ts` 가 본다. 여기서는
/// 앱이 **무엇을 읽어 무엇을 부르는지**와 마감 · 공유 문구를 본다.
const _uid = 'u-ta';
class _Backend extends Backend {
_Backend(FakeFirebaseFirestore db, {this.serverAhead = Duration.zero, this.duplicate = false})
: super(
firestore: db,
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
);
final Duration serverAhead;
final bool duplicate;
final calls = <String>[];
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async {
if (name == 'fnServerTime') {
calls.add(name);
return {'now': DateTime.now().add(serverAhead).millisecondsSinceEpoch};
}
calls.add('$name:${payload['questId']}');
return {'txId': 'tx', 'balanceAfter': 30, 'rewarded': 30, 'duplicate': duplicate};
}
}
/// `fnInviteCodeGet` 만 흉내 — 부른 횟수를 센다. [fail] 이면 서버 오류.
class _InviteBackend extends Backend {
_InviteBackend({this.fail = false})
: super(
firestore: FakeFirebaseFirestore(),
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
);
final bool fail;
int calls = 0;
@override
Future<Map<String, dynamic>> call(String name, [Map<String, dynamic> payload = const {}]) async {
calls++;
if (fail) throw const BackendException('E-SYS-01', 'down');
return {'code': 'ABCDEFGH23'};
}
}
Future<FakeFirebaseFirestore> _seed({Duration left = const Duration(minutes: 5)}) async {
final db = FakeFirebaseFirestore();
await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'});
await db.doc('quests/ta').set({
'type': 'timeattack', 'title': '돌발', 'fandomId': 'bts', 'rewardPop': 30,
'dailyLimit': 1, 'active': true,
'activeFrom': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))),
'activeTo': Timestamp.fromDate(DateTime.now().add(left)),
});
return db;
}
BriefingItem _brief(int rank, String headline) => BriefingItem(
rank: rank,
headline: headline,
section: BriefingSection.news,
sourceName: '출처',
sourceAt: DateTime(2026, 9, 28),
sourceUrl: 'https://example.com',
articleUrl: 'https://example.com/a',
aiSummary: '요약',
);
Widget _app(Widget child) => MaterialApp(home: child);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ServerClock.reset();
});
group('타임어택 — 앱 경로', () {
test('카드에 마감을 싣고 참여는 fnQuestTimeAttack 으로 보낸다', () async {
final db = await _seed();
final backend = _Backend(db);
final repo = FirestoreQuestRepository(backend);
final card = (await repo.load()).single;
expect(card.type, QuestType.timeAttack);
expect(card.endsAt, isNotNull);
expect(card.rewardPop, 30);
final reward = await repo.verify(card);
expect(reward.duplicate, isFalse);
// 서버 시계는 타임어택이 있을 때 세션당 한 번만 잰다.
expect(backend.calls, ['fnServerTime', 'fnQuestTimeAttack:ta']);
});
test('재응답(duplicate)은 새 지급이 아니라고 표시한다', () async {
final repo = FirestoreQuestRepository(_Backend(await _seed(), duplicate: true));
final card = (await repo.load()).single;
expect((await repo.verify(card)).duplicate, isTrue);
});
test('노출 · 마감은 서버 시각으로 판정한다(단말 시계가 늦어도)', () async {
// 서버가 10분 앞서 있다 → 단말 기준 5분 남은 창은 이미 닫혔다.
final db = await _seed();
final backend = _Backend(db, serverAhead: const Duration(minutes: 10));
expect(await FirestoreQuestRepository(backend).load(), isEmpty);
expect(ServerClock.offset.inMinutes, inInclusiveRange(9, 10));
final home = await FirestoreHomeRepository(backend).load();
expect(home.quests.where((q) => q.id == 'ta'), isEmpty);
});
test('어제 받은 타임어택도 보상 완료로 남는다(1인 1회)', () async {
final db = await _seed();
await db.doc('users/$_uid/questProgress/ta').set({'dayKey': '2000-01-01', 'count': 1});
final card = (await FirestoreQuestRepository(_Backend(db)).load()).single;
expect(card.stage, QuestStage.rewarded);
});
test('홈은 열린 타임어택을 맨 앞에 두고 마감된 것은 뺀다', () async {
final db = await _seed();
await db.doc('quests/ta-old').set({
'type': 'timeattack', 'title': '지난 돌발', 'fandomId': 'bts', 'active': true,
'activeTo': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))),
});
await db.doc('quests/ta-off').set({
'type': 'timeattack', 'title': '내려진 돌발', 'fandomId': 'bts', 'active': false,
'activeTo': Timestamp.fromDate(DateTime.now().add(const Duration(minutes: 5))),
});
await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'active': true});
final home = await FirestoreHomeRepository(_Backend(db)).load();
expect(home.quests.map((q) => q.id), ['ta', 'attend']);
expect(home.quests.first.endsAt, isNotNull);
});
test('성운 목표는 config/policy 운영값 · 탄생 권한은 창립자만(Q-PN-01)', () async {
final db = await _seed();
await db.doc('config/policy').set({'nebulaMinMembers': 12, 'nebulaMinPop': 1500});
await db.doc('fandoms/bts').set({
'name': 'BTS', 'stage': 0, 'memberCount': 12, 'growthPop': 1500, 'founderUid': _uid,
});
var growth = (await FirestoreHomeRepository(_Backend(db)).load()).growth;
expect((growth.requiredMembers, growth.requiredPop), (12, 1500));
expect(growth.nebulaReady && growth.isFounder, isTrue);
await db.doc('fandoms/bts').update({'founderUid': 'someone-else'});
growth = (await FirestoreHomeRepository(_Backend(db)).load()).growth;
expect(growth.nebulaReady, isTrue);
expect(growth.isFounder, isFalse);
expect(await FirestoreHomeRepository(_Backend(db)).nebulaTargets(),
(pop: 1500, members: 12, days: 7));
});
test('남은 시간 표기', () {
expect(formatLeft(const Duration(minutes: 9, seconds: 5)), '09:05');
expect(formatLeft(const Duration(hours: 1, minutes: 2, seconds: 3)), '1:02:03');
});
testWidgets('마감되면 상세 버튼이 잠긴다', (tester) async {
final db = await tester.runAsync(() => _seed(left: const Duration(seconds: 2)));
final repo = FirestoreQuestRepository(_Backend(db!));
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(_app(QuestDetailScreen(questId: 'ta', repository: repo)));
for (var i = 0; i < 50 && find.text('남은 시간').evaluate().isEmpty; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
}
expect(find.text('남은 시간'), findsOneWidget);
await tester.runAsync(() => Future<void>.delayed(const Duration(seconds: 3)));
await tester.pump(const Duration(seconds: 1));
expect(find.text('마감'), findsNWidgets(2));
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNull);
});
});
group('브리핑 공유', () {
test('초대 코드는 세션당 한 번 받고, 실패하면 ref 없이 나간다', () async {
addTearDown(InviteCode.reset);
InviteCode.reset();
final down = _InviteBackend(fail: true);
await InviteCode.ensure(down);
expect(InviteCode.current, isNull);
expect(briefingShareLink('bts', '2026-09-28', base: 'https://s.example'),
'https://s.example/b/bts/2026-09-28');
final up = _InviteBackend();
await Future.wait([InviteCode.ensure(up), InviteCode.ensure(up)]);
await InviteCode.ensure(up);
expect(up.calls, 1);
expect(briefingShareLink('bts', '2026-09-28', base: 'https://s.example'),
'https://s.example/b/bts/2026-09-28?ref=ABCDEFGH23');
});
test('링크는 설정된 웹 브릿지 주소가 있을 때만 만든다', () {
expect(briefingShareLink('bts', '2026-09-28', base: ''), isNull);
expect(
briefingShareLink('bts', '2026-09-28', base: 'https://share.example/'),
'https://share.example/b/bts/2026-09-28',
);
});
test('문구는 헤드라인 최대 3건 · 링크 · 동적 해시태그다', () {
final text = briefingShareText(
items: [for (var i = 1; i <= 4; i++) _brief(i, '뉴스 $i')],
fandomName: 'BTS',
link: 'https://share.example/b/bts/2026-09-28',
);
expect(text, contains('3. 뉴스 3'));
expect(text, isNot(contains('뉴스 4')));
expect(text, contains('https://share.example/b/bts/2026-09-28'));
expect(text, contains('#FANUZU #BTS #AIBriefing'));
expect(briefingShareText(items: [_brief(1, 'a')]), isNot(contains('http')));
});
testWidgets('SNS 버튼은 카드 이미지와 문구를 공유 시트로 보낸다', (tester) async {
ShareParams? sent;
await tester.pumpWidget(_app(Scaffold(
body: BriefingShareDialog(
items: [_brief(1, '빌보드 수상')],
fandomName: 'BTS',
share: (params) async => sent = params,
),
)));
expect(find.text('AI DAILY BRIEFING'), findsOneWidget);
expect(find.text('빌보드 수상'), findsOneWidget);
await tester.runAsync(() async {
await tester.tap(find.byTooltip('X'));
for (var i = 0; i < 50 && sent == null; i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
});
expect(sent?.text, contains('1. 빌보드 수상'));
expect(sent?.files, hasLength(1));
// 웹 브릿지 주소가 없으면 링크가 없으니 「문구 복사」로 부른다.
expect(find.byTooltip('문구 복사'), findsOneWidget);
expect(find.byTooltip('링크 복사'), findsNothing);
});
});
}
+443
View File
@@ -0,0 +1,443 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/i18n/app_locale.dart';
import 'package:fancake/core/ui/app_icons.dart';
import 'package:fancake/features/translation/translation_data.dart';
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
import 'package:fancake/features/inside/inside_data.dart';
import 'package:fancake/features/inside/inside_detail_screen.dart';
import 'package:fancake/features/planet/lounge_post_screens.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/translation/translation_comments.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
/// 참여형 번역 — FR-I18N-02 · 원장 B-63 · E-TRN-01.
/// 화면에서는 댓글 영역의 「번역 댓글」이다(Figma `201:50554` · `540:19136`).
void main() {
const target = 'questions/q1';
final question = InsideQuestion(
id: 'q1',
authorName: '다이브짱',
title: '공방 신청 꿀팁',
body: '본문',
createdAt: DateTime(2026, 9, 1),
);
final answers = [
InsideAnswer(
id: 'a1',
authorName: 'pro_따봉',
body: '세상이 이런 꿀팁이',
createdAt: DateTime(2026, 9, 1),
),
];
Future<void> pumpScreen(WidgetTester tester, Widget screen) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ko'),
supportedLocales: appLocales,
localizationsDelegates: const [
RemoteL10nDelegate({}),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: screen,
),
);
await tester.pumpAndSettle();
}
Future<void> pump(WidgetTester tester, TranslationRepository repo) =>
pumpScreen(
tester,
InsideDetailScreen(
question: question,
answers: answers,
translation: repo,
),
);
Future<void> send(WidgetTester tester, String body) async {
await tester.enterText(find.byType(TextField), body);
await tester.tap(find.byTooltip('보내기'));
await tester.pumpAndSettle();
}
testWidgets('지구본으로 쓴 번역 댓글은 질문 본문의 번역으로 댓글 목록에 뜨고, 본인 번역은 마킹이 거절된다', (
tester,
) async {
final repo = SampleTranslationRepository(uid: 'me');
await pump(tester, repo);
expect(find.byType(TranslationCommentCard), findsNothing);
await tester.tap(
find.descendant(
of: find.byType(CommentInputBar),
matching: find.bySemanticsLabel('번역 댓글 작성'),
),
);
await tester.pumpAndSettle();
expect(find.text('번역을 입력하세요'), findsOneWidget);
await send(tester, ' Great tip ');
final card = tester.widget<TranslationCommentCard>(
find.byType(TranslationCommentCard),
);
expect(card.entry.targetPath, target);
expect(card.reply, isFalse);
expect(find.text('번역함'), findsOneWidget);
expect(find.text('한국어'), findsOneWidget);
expect(find.text('Great tip'), findsOneWidget);
expect(find.textContaining('24시간'), findsOneWidget);
// 등록 뒤 입력은 일반 댓글로 돌아간다.
expect(find.text('댓글을 입력해 보세요.'), findsOneWidget);
await tester.tap(find.text('유효 0'));
await tester.pumpAndSettle();
expect(find.text('본인 번역은 마킹할 수 없습니다.'), findsOneWidget);
expect(find.text('유효 0'), findsOneWidget);
});
testWidgets('댓글의 대댓글 버튼으로 고르면 그 댓글의 번역이 대댓글 자리에 붙는다', (tester) async {
final repo = SampleTranslationRepository(uid: 'me');
await pump(tester, repo);
// 대댓글 버튼(시안의 말풍선)은 이 댓글을 번역 대상으로 고른다.
await tester.tap(
find.byWidgetPredicate(
(w) =>
w is ImageIcon && w.image == AppIcons.messageCircle && w.size == 16,
),
);
await tester.pumpAndSettle();
expect(find.text('pro_따봉 님 댓글 번역'), findsOneWidget);
await send(tester, 'Is there really such a great tip');
final card = tester.widget<TranslationCommentCard>(
find.byType(TranslationCommentCard),
);
expect(card.entry.targetPath, '$target/answers/a1');
expect(card.reply, isTrue);
expect(find.text('pro_따봉 님 댓글 번역'), findsNothing);
});
testWidgets('다른 멤버의 번역은 한 번만 마킹할 수 있다', (tester) async {
final repo = SampleTranslationRepository(
uid: 'me',
seed: [
SampleTranslation(
id: 't1',
targetPath: target,
locale: 'en',
authorUid: 'alice',
authorName: '호떡이',
body: '번역문',
),
],
);
await pump(tester, repo);
expect(find.text('호떡이'), findsOneWidget);
expect(find.text('English'), findsOneWidget);
await tester.tap(find.text('유효 0'));
await tester.pumpAndSettle();
expect(find.text('유효 1'), findsOneWidget);
expect(find.text('마킹했습니다.'), findsOneWidget);
// 마킹 뒤에는 두 버튼이 잠긴다.
final invalid = tester.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, '무효 0'),
);
expect(invalid.onPressed, isNull);
});
testWidgets('라운지 글 상세 — 댓글의 지구본으로 그 댓글을 번역한다(Q-TR-06 B안)', (tester) async {
final repo = SampleTranslationRepository(uid: 'me');
final post = LoungePost(
id: 'tr-${DateTime.now().microsecondsSinceEpoch}',
authorName: '팬',
createdAt: DateTime(2026, 9, 1),
title: '',
body: '본문',
likes: 0,
comments: 0,
);
await pumpScreen(tester, LoungePostScreen(post: post, translation: repo));
await send(tester, '반가워요');
expect(find.text('반가워요'), findsOneWidget);
await tester.tap(find.byTooltip('번역 댓글 작성'));
await tester.pumpAndSettle();
expect(find.textContaining('님 댓글 번역'), findsOneWidget);
await send(tester, 'Nice to meet you');
final card = tester.widget<TranslationCommentCard>(
find.byType(TranslationCommentCard),
);
expect(card.entry.targetPath, startsWith('posts/${post.id}/comments/'));
expect(card.reply, isTrue);
});
testWidgets('캠페인 응원 게시판 — 기본은 일반 댓글, 지구본을 켜면 캠페인 본문의 번역 댓글', (tester) async {
final repo = SampleTranslationRepository(uid: 'me');
await pumpScreen(
tester,
CampaignDetailScreen(campaignId: 'c1', myPop: 0, translation: repo),
);
expect(find.byType(CommentInputBar), findsNothing);
await tester.tap(find.textContaining('응원 게시판'));
await tester.pumpAndSettle();
expect(find.text('댓글을 입력해 보세요.'), findsOneWidget);
await send(tester, '화이팅');
expect(find.text('화이팅'), findsOneWidget);
expect(find.byType(TranslationCommentCard), findsNothing);
await tester.tap(
find.descendant(
of: find.byType(CommentInputBar),
matching: find.bySemanticsLabel('번역 댓글 작성'),
),
);
await tester.pumpAndSettle();
expect(find.text('번역을 입력하세요'), findsOneWidget);
await send(tester, 'Go go');
final card = tester.widget<TranslationCommentCard>(
find.byType(TranslationCommentCard),
);
expect(card.entry.targetPath, 'campaigns/c1');
});
testWidgets('마킹이 성공하면 스트림을 기다리지 않고 수와 잠금이 바로 바뀐다', (tester) async {
final repo = _SilentMarkRepository([
SampleTranslation(
id: 't1',
targetPath: target,
locale: 'en',
authorUid: 'alice',
body: '번역문',
),
]);
await pump(tester, repo);
// 작성자 이름이 비어 있으면 「팬」.
expect(find.text('팬'), findsOneWidget);
await tester.tap(find.text('무효 0'));
await tester.pumpAndSettle();
expect(repo.marks, 1);
expect(find.text('무효 1'), findsOneWidget);
final valid = tester.widget<OutlinedButton>(
find.widgetWithText(OutlinedButton, '유효 0'),
);
expect(valid.onPressed, isNull);
});
testWidgets('정산이 끝난 번역은 마킹 버튼을 숨긴다', (tester) async {
final repo = _FixedRepository([
const TranslationEntry(
id: 'done',
authorUid: 'alice',
authorName: '호떡이',
body: 'Settled',
locale: 'en',
targetPath: target,
status: 'confirmed',
),
]);
await pump(tester, repo);
expect(find.text('Settled'), findsOneWidget);
expect(find.byType(OutlinedButton), findsNothing);
});
testWidgets('한 페이지(50건)가 차면 「더 보기」로 다음 페이지를 붙인다', (tester) async {
final repo = SampleTranslationRepository(
uid: 'me',
seed: [
for (var i = 0; i < translationPageSize + 1; i++)
SampleTranslation(
id: 'p$i',
targetPath: target,
locale: 'en',
authorUid: 'u$i',
body: 'row $i',
),
],
);
await pump(tester, repo);
expect(find.byType(TranslationCommentCard), findsNWidgets(50));
await tester.ensureVisible(find.text('더 보기'));
await tester.pumpAndSettle();
await tester.tap(find.text('더 보기'));
await tester.pumpAndSettle();
expect(find.byType(TranslationCommentCard), findsNWidgets(51));
expect(find.text('더 보기'), findsNothing);
});
test('글 경로로 본문과 댓글의 번역을 모두 읽는다', () async {
final repo = SampleTranslationRepository(uid: 'me');
await repo.submit(target, 'en', 'Hi');
await repo.submit('$target/answers/a1', 'en', 'Yo');
await repo.submit('questions/q2', 'en', 'Other');
final rows = await repo.watch(target).first;
// 최신순.
expect(rows.map((e) => e.targetPath), ['$target/answers/a1', target]);
expect(translationRootOf('posts/p1/comments/c1'), 'posts/p1');
});
test('같은 콘텐츠·언어 재등록은 duplicate 로 거절한다', () async {
final repo = SampleTranslationRepository(uid: 'me');
await repo.submit(target, 'en', 'Hi');
await expectLater(
repo.submit(target, 'en', 'Hi again'),
throwsA(
isA<TranslationException>().having(
(e) => e.kind,
'kind',
TranslationFailure.duplicate,
),
),
);
});
group('Firestore 저장소', () {
late FakeFirebaseFirestore db;
late FirestoreTranslationRepository repo;
setUp(() async {
db = FakeFirebaseFirestore();
final auth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: 'bob'),
);
repo = FirestoreTranslationRepository(Backend(firestore: db, auth: auth));
await db.doc('translationEntries/e1').set({
// 수는 fn-translation-mark 가 번역 문서에 올린 값이다.
'validCount': 0,
'invalidCount': 1,
'authorUid': 'alice',
'targetPath': target,
'rootPath': target,
'authorName': '호떡이',
'locale': 'en',
'body': 'Hello',
'status': 'pending',
'createdAt': DateTime(2026, 9, 28),
});
// 작성자 자기 마킹은 규칙이 막지만, 들어와 있어도 세지 않는다(fn-translation-settle 과 같음).
await db.doc('translationEntries/e1/markings/alice').set({'valid': true});
await db.doc('translationEntries/e1/markings/carol').set({
'valid': false,
});
});
test('수는 번역 문서에서 읽고 내 마킹만 한 건 읽는다', () async {
final rows = await repo.watch(target).first;
expect(rows.single.authorName, '호떡이');
expect(rows.single.targetPath, target);
expect(rows.single.validCount, 0);
expect(rows.single.invalidCount, 1);
expect(rows.single.myMark, isNull);
});
test('이미 마킹한 번역은 서버에 닿기 전에 거절한다', () async {
await db.doc('translationEntries/e1/markings/bob').set({'valid': true});
final entry = (await repo.watch(target).first).single;
expect(entry.myMark, isTrue);
await expectLater(
repo.mark(entry, valid: false),
throwsA(
isA<TranslationException>().having(
(e) => e.kind,
'kind',
TranslationFailure.alreadyMarked,
),
),
);
});
test('서버 오류 코드 E-TRN-01~05 를 화면 실패 종류로 옮긴다', () {
expect(
translationFailureOf('E-TRN-01', forMark: true),
TranslationFailure.selfMark,
);
expect(
translationFailureOf('E-TRN-02', forMark: false),
TranslationFailure.dailyCap,
);
expect(
translationFailureOf('E-TRN-03', forMark: false),
TranslationFailure.invalidTarget,
);
expect(
translationFailureOf('E-TRN-04', forMark: true),
TranslationFailure.notEligible,
);
expect(
translationFailureOf('E-TRN-05', forMark: true),
TranslationFailure.alreadyMarked,
);
expect(
translationFailureOf('E-TRN-05', forMark: false),
TranslationFailure.duplicate,
);
expect(
translationFailureOf('E-SYS-01', forMark: false),
TranslationFailure.other,
);
});
test('본인 번역 마킹은 서버에 닿기 전에 거절한다(E-TRN-01)', () async {
const own = TranslationEntry(
id: 'e1',
authorUid: 'bob',
body: 'x',
locale: 'en',
);
await expectLater(
repo.mark(own, valid: true),
throwsA(
isA<TranslationException>().having(
(e) => e.kind,
'kind',
TranslationFailure.selfMark,
),
),
);
expect(
(await db.doc('translationEntries/e1/markings/bob').get()).exists,
isFalse,
);
});
});
}
/// 마킹은 성공하지만 스트림은 다시 오지 않는 저장소 — 낙관적 갱신을 본다.
class _SilentMarkRepository extends SampleTranslationRepository {
_SilentMarkRepository(List<SampleTranslation> seed)
: super(uid: 'me', seed: seed);
int marks = 0;
@override
Future<void> mark(TranslationEntry entry, {required bool valid}) async =>
marks++;
}
/// 고정 목록을 돌려주는 저장소.
class _FixedRepository extends SampleTranslationRepository {
_FixedRepository(this.rows) : super(uid: 'me');
final List<TranslationEntry> rows;
@override
Stream<List<TranslationEntry>> watch(
String rootPath, {
int limit = translationPageSize,
}) => Stream.value(rows);
}
+29
View File
@@ -0,0 +1,29 @@
import 'dart:io';
import 'package:integration_test/integration_test_driver_extended.dart';
/// 구현 확인물 캡처 드라이버 — 화면마다 PNG 하나를 `build/captures/` 에 떨어뜨린다.
///
/// 드라이버는 호스트 VM 에서 도는 별개 프로그램이라 `--dart-define` 이 닿지 않는다.
/// 그래서 경로를 인자로 받지 않고 빌드 산출물 폴더에 모은 뒤 밖에서 옮긴다.
///
/// ```
/// export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
/// adb shell input keyevent KEYCODE_WAKEUP # ← 이거 빼먹으면 앱이 죽는다
/// fvm flutter drive --driver=test_driver/capture_driver.dart \
/// --target=integration_test/capture_f2_test.dart -d emulator-5554
/// ```
///
/// **화면을 먼저 깨워야 한다.** 잠긴 에뮬레이터에서는 창에 surface 가 없어
/// `takeScreenshot` 이 `PixelCopy … Window doesn't have a backing surface!` 로
/// **앱을 죽인다.** 드라이버는 그때 `DriverError: Service has disappeared` 만
/// 남기므로 원인이 화면 잠금이라는 게 로그에 드러나지 않는다 — logcat 을 봐야 나온다.
Future<void> main() => integrationDriver(
onScreenshot: (String name, List<int> bytes, [Map<String, Object?>? _]) async {
final file = File('build/captures/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes);
stdout.writeln('[capture] ${file.path}');
return true;
},
);
+22
View File
@@ -0,0 +1,22 @@
import 'dart:convert';
import 'dart:io';
import 'package:integration_test/integration_test_driver.dart';
/// 프레임 타이밍 결과를 파일로 떨어뜨리는 드라이버.
///
/// 기본 드라이버는 요약을 콘솔에만 남긴다. 성능 리포트가 수치를 인용해야 하므로
/// 원본 JSON 을 남겨 근거를 되짚을 수 있게 한다.
Future<void> main() => integrationDriver(
responseDataCallback: (data) async {
if (data == null) return;
final dir = Directory('build/perf')..createSync(recursive: true);
for (final entry in data.entries) {
final file = File('${dir.path}/${entry.key}.json');
file.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(entry.value),
);
stdout.writeln('[perf] saved ${file.path}');
}
},
);