From 4f6e1eac613c016ff88b61c3237154d801dfc68f Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sun, 4 Oct 2026 01:43:10 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=B1=20=EB=8B=A8=EC=9C=84=C2=B7=ED=99=94?= =?UTF-8?q?=EB=A9=B4=C2=B7=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integration_test/auth_emulator_test.dart | 5 +- app/integration_test/capture_common.dart | 61 + app/integration_test/capture_f0_test.dart | 55 + app/integration_test/capture_f2_test.dart | 68 + app/integration_test/capture_f4_test.dart | 90 ++ app/integration_test/capture_f5_test.dart | 58 + app/integration_test/capture_f6a_test.dart | 89 + app/integration_test/capture_f6b_test.dart | 65 + app/integration_test/galaxy_perf_test.dart | 46 + .../pop_loop_emulator_test.dart | 133 ++ .../quest_callable_emulator_test.dart | 107 ++ app/test/adaptive_planet_effects_test.dart | 65 + app/test/app_smoke_test.dart | 133 +- app/test/auth_repository_test.dart | 21 + app/test/backend_repository_test.dart | 1426 +++++++++++++++++ app/test/backend_test.dart | 68 - app/test/campaign_comments_test.dart | 416 +++++ app/test/campaign_share_test.dart | 97 ++ app/test/design_match/a_test.dart | 270 ++++ app/test/design_match/b_test.dart | 308 ++++ app/test/design_match/c_test.dart | 499 ++++++ app/test/design_match/d_test.dart | 415 +++++ app/test/design_match/design_match_test.dart | 205 +++ app/test/design_match/e_test.dart | 328 ++++ app/test/design_match/home_fixtures.dart | 59 + app/test/eclipse_live_test.dart | 589 +++++++ app/test/eclipse_state_stream_test.dart | 99 ++ app/test/eclipse_stubs.dart | 211 +++ app/test/f0_screens_test.dart | 202 +++ app/test/f1_screens_test.dart | 349 ++++ app/test/f2_screens_test.dart | 395 +++++ app/test/f3_screens_test.dart | 580 +++++++ app/test/f4_screens_test.dart | 538 +++++++ app/test/f5_screens_test.dart | 581 +++++++ app/test/f6_screens_test.dart | 1002 ++++++++++++ app/test/flutter_test_config.dart | 19 + app/test/galaxy_environment_test.dart | 95 ++ app/test/galaxy_mini_map_test.dart | 327 ++++ app/test/galaxy_motion_test.dart | 225 +++ app/test/galaxy_zap_fx_test.dart | 232 +++ app/test/hero_participation_test.dart | 98 ++ app/test/hero_time_status_test.dart | 141 ++ app/test/home_screen_test.dart | 284 ++++ app/test/i18n_smoke_test.dart | 84 + app/test/i18n_test.dart | 229 +++ app/test/inside_station_test.dart | 499 ++++++ app/test/lounge_write_test.dart | 256 +++ app/test/monetization_test.dart | 252 +++ app/test/perf_paint_test.dart | 133 ++ app/test/planet_evolution_test.dart | 302 ++++ app/test/planet_fx_overlap_test.dart | 68 + app/test/planet_fx_test.dart | 187 +++ app/test/planet_growth_status_test.dart | 48 + app/test/planet_hero_fx_test.dart | 86 + app/test/planet_hibernation_test.dart | 38 + app/test/planet_history_test.dart | 201 +++ app/test/quest_repository_test.dart | 494 ++++++ app/test/shared_stream_test.dart | 76 - app/test/store_readiness_test.dart | 170 +- app/test/timeattack_briefing_test.dart | 259 +++ app/test/translation_test.dart | 443 +++++ app/test_driver/capture_driver.dart | 29 + app/test_driver/perf_driver.dart | 22 + 63 files changed, 14700 insertions(+), 230 deletions(-) create mode 100644 app/integration_test/capture_common.dart create mode 100644 app/integration_test/capture_f0_test.dart create mode 100644 app/integration_test/capture_f2_test.dart create mode 100644 app/integration_test/capture_f4_test.dart create mode 100644 app/integration_test/capture_f5_test.dart create mode 100644 app/integration_test/capture_f6a_test.dart create mode 100644 app/integration_test/capture_f6b_test.dart create mode 100644 app/integration_test/galaxy_perf_test.dart create mode 100644 app/integration_test/pop_loop_emulator_test.dart create mode 100644 app/integration_test/quest_callable_emulator_test.dart create mode 100644 app/test/adaptive_planet_effects_test.dart create mode 100644 app/test/backend_repository_test.dart delete mode 100644 app/test/backend_test.dart create mode 100644 app/test/campaign_comments_test.dart create mode 100644 app/test/campaign_share_test.dart create mode 100644 app/test/design_match/a_test.dart create mode 100644 app/test/design_match/b_test.dart create mode 100644 app/test/design_match/c_test.dart create mode 100644 app/test/design_match/d_test.dart create mode 100644 app/test/design_match/design_match_test.dart create mode 100644 app/test/design_match/e_test.dart create mode 100644 app/test/design_match/home_fixtures.dart create mode 100644 app/test/eclipse_live_test.dart create mode 100644 app/test/eclipse_state_stream_test.dart create mode 100644 app/test/eclipse_stubs.dart create mode 100644 app/test/f0_screens_test.dart create mode 100644 app/test/f1_screens_test.dart create mode 100644 app/test/f2_screens_test.dart create mode 100644 app/test/f3_screens_test.dart create mode 100644 app/test/f4_screens_test.dart create mode 100644 app/test/f5_screens_test.dart create mode 100644 app/test/f6_screens_test.dart create mode 100644 app/test/flutter_test_config.dart create mode 100644 app/test/galaxy_environment_test.dart create mode 100644 app/test/galaxy_mini_map_test.dart create mode 100644 app/test/galaxy_motion_test.dart create mode 100644 app/test/galaxy_zap_fx_test.dart create mode 100644 app/test/hero_participation_test.dart create mode 100644 app/test/hero_time_status_test.dart create mode 100644 app/test/home_screen_test.dart create mode 100644 app/test/i18n_smoke_test.dart create mode 100644 app/test/i18n_test.dart create mode 100644 app/test/inside_station_test.dart create mode 100644 app/test/lounge_write_test.dart create mode 100644 app/test/monetization_test.dart create mode 100644 app/test/perf_paint_test.dart create mode 100644 app/test/planet_evolution_test.dart create mode 100644 app/test/planet_fx_overlap_test.dart create mode 100644 app/test/planet_fx_test.dart create mode 100644 app/test/planet_growth_status_test.dart create mode 100644 app/test/planet_hero_fx_test.dart create mode 100644 app/test/planet_hibernation_test.dart create mode 100644 app/test/planet_history_test.dart create mode 100644 app/test/quest_repository_test.dart delete mode 100644 app/test/shared_stream_test.dart create mode 100644 app/test/timeattack_briefing_test.dart create mode 100644 app/test/translation_test.dart create mode 100644 app/test_driver/capture_driver.dart create mode 100644 app/test_driver/perf_driver.dart diff --git a/app/integration_test/auth_emulator_test.dart b/app/integration_test/auth_emulator_test.dart index b435d23..7be1a80 100644 --- a/app/integration_test/auth_emulator_test.dart +++ b/app/integration_test/auth_emulator_test.dart @@ -17,8 +17,9 @@ import 'package:integration_test/integration_test.dart'; /// /// 실행: /// firebase emulators:start --only auth,firestore,functions --project demo-fanuzu -/// flutter test integration_test/auth_emulator_test.dart \ -/// --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 -d +/// flutter drive --driver=test_driver/perf_driver.dart \ +/// --target=integration_test/auth_emulator_test.dart \ +/// --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 --no-dds -d void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); diff --git a/app/integration_test/capture_common.dart b/app/integration_test/capture_common.dart new file mode 100644 index 0000000..05efa49 --- /dev/null +++ b/app/integration_test/capture_common.dart @@ -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 prepare() => binding.convertFlutterSurfaceToImage(); + + /// 정해진 시간만큼 프레임을 돌린다. + /// + /// `pumpAndSettle` 은 로딩 스피너처럼 끝나지 않는 애니메이션이 하나라도 있으면 + /// 던진다. 캡처는 그런 화면에서도 나와야 하고, 저장소를 읽고 다시 그리는 + /// 시간도 필요하므로 고정 프레임 수로 돌린다. + Future 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 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 shot(String name) => binding.takeScreenshot(name); + + /// 띄우고 찍기. + Future capture(WidgetTester tester, String name, Widget screen) async { + await open(tester, name, screen); + await shot(name); + } +} diff --git a/app/integration_test/capture_f0_test.dart b/app/integration_test/capture_f0_test.dart new file mode 100644 index 0000000..2156cdb --- /dev/null +++ b/app/integration_test/capture_f0_test.dart @@ -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 get accountChanges => const Stream.empty(); + + @override + Future signOut() async {} +} diff --git a/app/integration_test/capture_f2_test.dart b/app/integration_test/capture_f2_test.dart new file mode 100644 index 0000000..2ed5c57 --- /dev/null +++ b/app/integration_test/capture_f2_test.dart @@ -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_드래그_관성'); + }); +} diff --git a/app/integration_test/capture_f4_test.dart b/app/integration_test/capture_f4_test.dart new file mode 100644 index 0000000..e37e602 --- /dev/null +++ b/app/integration_test/capture_f4_test.dart @@ -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(), + ); + }); +} diff --git a/app/integration_test/capture_f5_test.dart b/app/integration_test/capture_f5_test.dart new file mode 100644 index 0000000..514c235 --- /dev/null +++ b/app/integration_test/capture_f5_test.dart @@ -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()); + }); +} diff --git a/app/integration_test/capture_f6a_test.dart b/app/integration_test/capture_f6a_test.dart new file mode 100644 index 0000000..469daa2 --- /dev/null +++ b/app/integration_test/capture_f6a_test.dart @@ -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), + ); + + }); +} diff --git a/app/integration_test/capture_f6b_test.dart b/app/integration_test/capture_f6b_test.dart new file mode 100644 index 0000000..a485b8d --- /dev/null +++ b/app/integration_test/capture_f6b_test.dart @@ -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 _noPhoto(PhotoSource source) async => null; diff --git a/app/integration_test/galaxy_perf_test.dart b/app/integration_test/galaxy_perf_test.dart new file mode 100644 index 0000000..84a0ac9 --- /dev/null +++ b/app/integration_test/galaxy_perf_test.dart @@ -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'); + }); +} diff --git a/app/integration_test/pop_loop_emulator_test.dart b/app/integration_test/pop_loop_emulator_test.dart new file mode 100644 index 0000000..3a56504 --- /dev/null +++ b/app/integration_test/pop_loop_emulator_test.dart @@ -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); + }); +} diff --git a/app/integration_test/quest_callable_emulator_test.dart b/app/integration_test/quest_callable_emulator_test.dart new file mode 100644 index 0000000..80e59a1 --- /dev/null +++ b/app/integration_test/quest_callable_emulator_test.dart @@ -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(), 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().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: '거부된 호출이 원장을 늘리면 안 된다'); + }); +} diff --git a/app/test/adaptive_planet_effects_test.dart b/app/test/adaptive_planet_effects_test.dart new file mode 100644 index 0000000..74c8148 --- /dev/null +++ b/app/test/adaptive_planet_effects_test.dart @@ -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 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(find.byType(PlanetEffectsQuality)) + .level; + Future 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(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); + }); +} diff --git a/app/test/app_smoke_test.dart b/app/test/app_smoke_test.dart index 34f3f9b..a305621 100644 --- a/app/test/app_smoke_test.dart +++ b/app/test/app_smoke_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:fancake/app.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/router/app_router.dart'; import 'package:fancake/features/auth/auth.dart'; @@ -96,6 +97,25 @@ void main() { 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 { await _enterShell(tester); @@ -157,9 +177,10 @@ void main() { await source.close(); }); - test('STATION 라우트는 남아 있다 — 비노출이지 삭제가 아니다', () { + test('STATION 라우트와 저장소는 남아 있다 — 비노출이지 삭제가 아니다', () { final station = appTabs.firstWhere((t) => t.label == 'STATION'); expect(station.path, '/station'); + expect(Repositories(live: false).station, isNotNull); }); }); @@ -249,44 +270,51 @@ void main() { testWidgets('3. 탭을 전환하면 각 탭 루트 화면이 바뀐다', (tester) async { await _enterShell(tester); - const roots = { - 'HOME': ('S-H-HOME', '오늘의 퀘스트'), - 'INSIDE': ('S-I-LOUNGE', '팬덤 라운지'), - 'GALAXY': ('S-G-PLANET', '내 행성'), - // STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다. - 'MY': ('S-M-HOME', '마이 페이지'), + // 5탭 모두 실제 화면이 들어갔다. 자리표시가 남아 있으면 발주사가 설치본에서 + // 「2주차 범위」 문구를 보게 되므로, 탭마다 그 화면의 고정 구획명으로 확인한다. + // STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다. + 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( find.descendant( 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( - find.text(screenId), - findsOneWidget, - reason: '${entry.key} → $screenId', - ); - // AppBar 제목과 본문 제목 두 군데에 나온다. - expect( - find.text(screenName), + find.text(implemented[tab]!), findsWidgets, - reason: '${entry.key} → $screenName', + reason: '$tab → ${implemented[tab]}', + ); + // 자리표시가 되살아나면 여기서 잡힌다. + expect( + find.textContaining('2주차 범위'), + findsNothing, + reason: '$tab 이 자리표시로 돌아갔다', ); expect( find.textContaining('wireframe/'), findsNothing, - reason: '${entry.key} 화면에 내부 저장소 경로가 노출되면 안 된다', + reason: '$tab 화면에 내부 저장소 경로가 노출되면 안 된다', ); expect( find.textContaining('screen-spec'), findsNothing, - reason: '${entry.key} 화면에 내부 명세 경로가 노출되면 안 된다', + reason: '\$tab 화면에 내부 명세 경로가 노출되면 안 된다', ); } }); @@ -297,6 +325,13 @@ void main() { /// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로, /// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable). Future _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.pumpAndSettle(); await tester.longPress(find.byKey(loginLogoKey)); @@ -319,6 +354,10 @@ Future _enterShell(WidgetTester tester) async { /// /// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면 /// 계약 대조가 조용히 사라진다. +/// +/// **사본 표류도 실패로 끝낸다.** 후보가 둘 이상 보이면(환경 변수로 가리킨 정본과 +/// 저장소 내 사본이 함께 있는 경우) `APP_TABS` 줄을 서로 비교한다. 사본만 고치거나 +/// 정본만 고치면 저장소마다 다른 답이 나오고, 그건 대조가 아니라 우연이다. List _contractTabs() { final candidates = [ if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?) @@ -326,24 +365,31 @@ List _contractTabs() { 'design/craft-allowlist.yaml', '../design/craft-allowlist.yaml', ]; - final file = candidates - .map(File.new) - .firstWhere( - (f) => f.existsSync(), - orElse: () => throw StateError( - 'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. ' - '찾아본 곳: ${candidates.join(' · ')}', - ), - ); - final line = file.readAsLinesSync().firstWhere( - (l) => l.trimLeft().startsWith('APP_TABS:'), - orElse: () => - throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'), - ); - final inner = RegExp(r'\[(.*)\]').firstMatch(line)!.group(1)!; + final found = candidates.map(File.new).where((f) => f.existsSync()).toList(); + if (found.isEmpty) { + throw StateError( + 'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. ' + '찾아본 곳: ${candidates.join(' · ')}', + ); + } + 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:'), + orElse: () => + throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'), +); + final class _SmokeSession implements AuthSession { const _SmokeSession(this.currentAccount); @@ -356,3 +402,18 @@ final class _SmokeSession implements AuthSession { @override Future signOut() async {} } + +final class _SignupProvider implements SocialAuthProvider { + @override + AuthProviderKind get provider => SocialProvider.google; + @override + bool get isLive => true; + @override + List get missingConfig => const []; + @override + Future signIn() async => const AuthSucceeded( + AuthAccount(uid: 'new-user', provider: SocialProvider.google), + ); + @override + Future signOut() async {} +} diff --git a/app/test/auth_repository_test.dart b/app/test/auth_repository_test.dart index 82f2524..ab760d9 100644 --- a/app/test/auth_repository_test.dart +++ b/app/test/auth_repository_test.dart @@ -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 { for (final provider in SocialProvider.values) { final auth = AuthRepository( diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart new file mode 100644 index 0000000..602516e --- /dev/null +++ b/app/test/backend_repository_test.dart @@ -0,0 +1,1426 @@ +import 'dart:async'; +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:fancake/core/data/repositories.dart'; +import 'package:fancake/features/campaign/campaign_repository_firestore.dart'; +import 'package:fancake/features/galaxy/galaxy_data.dart'; +import 'package:fancake/features/galaxy/galaxy_repository_firestore.dart'; +import 'package:fancake/features/home/home_data.dart'; +import 'package:fancake/features/home/home_repository_firestore.dart'; +import 'package:fancake/core/push/push_messaging.dart'; +import 'package:fancake/core/push/push_transport.dart'; +import 'package:fancake/features/my/my_data.dart'; +import 'package:fancake/features/my/my_repository_firestore.dart'; +import 'package:fancake/features/notification/notification_data.dart'; +import 'package:fancake/features/planet/planet_data.dart'; +import 'package:fancake/features/planet/planet_repository_firestore.dart'; +import 'package:fancake/features/quest/quest_data.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'; + +/// 서버 연동 계층 검증 — 화면 대조 원장 F1·F2·F3 의 데이터 경로. +/// +/// 「서버 문서를 화면 값으로 옮기는 규칙」이 맞는지를 본다. 규칙이 틀리면 화면은 +/// 조용히 0 을 그리므로 눈으로는 잡히지 않는다. + +const _uid = 'u-test'; + +Backend _backend(FakeFirebaseFirestore db) => Backend( + firestore: db, + auth: MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: _uid, displayName: '테스터'), + ), +); + +/// 첫 호출은 응답 유실처럼 던지고 이후는 성공하는 경계 — 후원 재시도 번호 검증용. +class _FlakyBackend extends Backend { + _FlakyBackend(FakeFirebaseFirestore db) + : super( + firestore: db, + auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)), + ); + + final List> calls = []; + + @override + Future> call( + String name, [ + Map payload = const {}, + ]) async { + calls.add(payload); + if (calls.length == 1) throw const BackendException('E-SYS-01', '응답 유실'); + return const {}; + } +} + +Future _seed() async { + final db = FakeFirebaseFirestore(); + await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'}); + await db.doc('users/$_uid/wallet/main').set({ + 'activityBalance': 12540, + 'supportBalance': 300, + 'pendingActivity': 0, + }); + await db.doc('fandoms/bts').set({ + 'name': 'BTS', + 'stage': 1, + 'growthPop': 6270, + 'sfpi': 750, + 'lastContributorName': 'BTS_Hong', + 'lastContributedPop': 1230, + 'fpiScore': 284310, + 'memberCount': 12840, + 'onlineCount': 421, + 'liveTicker': '[LIVE] 버프가 발동되었습니다!', + }); + await db.doc('fandoms/bts/planets/$_uid').set({ + 'stage': 1, + 'lastContributorName': 'BTS_Hong', + 'lastContributedPop': 1230, + }); + // 누적 기여 POP 은 멤버 문서에 쌓인다 — `fn-pop-spend` 가 기여 차감과 같은 + // 트랜잭션에서 올리는 유일한 필드다(설계서 §3.6). + await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 6270}); + // 다음 단계 문턱은 마스터 데이터다(설계서 §2.3 `planetStages`). + await db.doc('planetStages/2').set({'stageNo': 2, 'requiredPop': 10000, 'requiredSfpi': 1000}); + return db; +} + +void main() { + group('홈 — Firestore 구현', () { + test('공동 성장 변경과 Stage 승급을 다시 load하지 않고 받는다', () async { + final db = await _seed(); + final values = StreamIterator(FirestoreHomeRepository(_backend(db)).watchGrowth('bts')); + addTearDown(values.cancel); + expect(await values.moveNext(), isTrue); + expect(values.current.currentPop, 6270); + expect(values.current.sfpiConditionLabel, 'sFPI 조건 미충족'); + await db.doc('fandoms/bts').update({'growthPop': 10000, 'sfpi': 1000}); + expect(await values.moveNext(), isTrue); + expect(values.current.growthStatus, '승급 확정 대기'); + await db.doc('planetStages/3').set({'requiredPop': 20000, 'requiredSfpi': 2000}); + await db.doc('fandoms/bts').update({'stage': 2}); + expect(await values.moveNext(), isTrue); + expect(values.current.stage, 2); + expect(values.current.requiredPop, 20000); + expect(values.current.popConditionLabel, 'POP 조건 미충족'); + }); + + test('홈과 상세는 같은 팬덤의 서버 Stage를 표시하고 개인 Stage를 섞지 않는다', () async { + final db = await _seed(); + await db.doc('fandoms/bts').update({'stage': 4, 'colorKey': 'gold'}); + final home = await FirestoreHomeRepository(_backend(db)).load(); + final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') + .load(nebula: true); + final galaxy = await FirestoreGalaxyRepository(_backend(db)).load(); + expect(galaxy.mine!.stage, 4); + expect(galaxy.mine!.colorKey, 'gold'); + expect(home.growth.stage, 4); + expect(detail.stage, 4); + expect(detail.isNebula, false); + await db.doc('fandoms/bts').update({'stage': 0}); + final nebula = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') + .load(nebula: false); + expect(nebula.stage, 0); + expect(nebula.isNebula, true); + }); + + test('서버에 단계가 없으면 시작 단계로 지어내지 않는다', () async { + final db = await _seed(); + await db.doc('fandoms/bts').update({'stage': FieldValue.delete()}); + final home = await FirestoreHomeRepository(_backend(db)).load(); + expect(home.growth.stage, isNull); + }); + + test('목표 기간이 지난 성운은 기간 경과 표시 · 전환 차단 설정이 켜지면 표시하지 않는다(Q-PN-02)', () async { + final db = await _seed(); + await db.doc('fandoms/bts').update({ + 'stage': 0, + 'nebulaDeadline': Timestamp.fromDate( + DateTime.now().subtract(const Duration(days: 1)), + ), + }); + final repo = FirestoreHomeRepository(_backend(db)); + expect((await repo.load()).growth.nebulaGoalPassed, isTrue); + await db.doc('config/policy').set({ + 'nebulaConversionDeadlineEnforced': true, + }); + expect((await repo.load()).growth.nebulaGoalPassed, isFalse); + await db.doc('config/policy').delete(); + await db.doc('fandoms/bts').update({ + 'nebulaDeadline': Timestamp.fromDate( + DateTime.now().add(const Duration(days: 1)), + ), + }); + expect((await repo.load()).growth.nebulaGoalPassed, isFalse); + }); + + test('지갑·행성·팬덤을 화면 값으로 옮긴다', () async { + final db = await _seed(); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + + expect(snapshot.popBalance, 12540); + expect(snapshot.liveTicker, contains('버프')); + expect(snapshot.growth.fandomName, 'BTS'); + expect(snapshot.growth.contributorName, 'BTS_Hong'); + expect(snapshot.growth.currentPop, 6270); + // 서버가 growthPercent 를 주지 않으면 진행도로 낸다. + expect(snapshot.growth.growthPercent, 63); + }); + + test('개인 행성의 성장률을 공동 성장률로 사용하지 않는다', () async { + final db = await _seed(); + await db.doc('fandoms/bts/planets/$_uid') + .set({'growthPercent': 67}, SetOptions(merge: true)); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.growth.growthPercent, 63); + }); + + test('공동 성장 값 누락은 개인 값으로 채우지 않고 sFPI 조건을 따로 읽는다', () async { + final db = await _seed(); + await db.doc('fandoms/bts').update({'growthPop': FieldValue.delete()}); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.growth.currentPop, isNull); + expect(snapshot.growth.growthPercent, isNull); + expect(snapshot.growth.currentSfpi, 750); + expect(snapshot.growth.requiredSfpi, 1000); + expect(snapshot.growth.sfpiConditionLabel, 'sFPI 조건 미충족'); + }); + + test('다음 Stage 조건은 공동 Stage 기준이며 개인 Stage와 무관하다', () async { + final db = await _seed(); + await db.doc('fandoms/bts').update({'stage': 4, 'growthPop': 30000, 'sfpi': 500}); + await db.doc('planetStages/5').set({'requiredPop': 25000, 'requiredSfpi': 1000}); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.growth.requiredPop, 25000); + expect(snapshot.growth.popConditionLabel, 'POP 조건 충족'); + expect(snapshot.growth.sfpiConditionLabel, 'sFPI 조건 미충족'); + expect(snapshot.growth.growthStatus, isNot('승급 확정 대기')); + }); + + test('진행 횟수가 한도를 채우면 완료로 본다', () async { + final db = await _seed(); + await db.doc('quests/attend').set({ + 'active': true, 'title': '오늘의 출석체크', 'rewardPop': 50, 'dailyLimit': 1, + }); + await db.doc('users/$_uid/questProgress/attend') + .set({'count': 1, 'dayKey': '2026-09-01'}); + + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.quests.single.state, QuestState.done); + expect(snapshot.quests.single.rewardPop, 50); + }); + + test('출석 보상은 서버 지급과 같은 config/policy.attendPop 을 쓴다', () async { + final db = await _seed(); + await db.doc('quests/attend').set({ + 'active': true, 'type': 'attend', 'title': '오늘의 출석체크', + 'rewardPop': 50, 'dailyLimit': 1, + }); + await db.doc('config/policy').set({'attendPop': 10}); + var snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.quests.single.rewardPop, 10); + + // 운영값이 없으면 숫자를 지어내지 않는다. + await db.doc('config/policy').delete(); + snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.quests.single.rewardPop, isNull); + }); + + test('캠페인 달성률은 목표 대비로 낸다', () async { + final db = await _seed(); + await db.doc('campaigns/c1').set({ + 'status': 'funding', 'authorName': '다이브짱', 'title': '강남역 LED', + 'description': '도심을 물들여 주세요', 'goalPop': 10000, 'raisedPop': 9200, + 'donorCount': 48, 'commentCount': 24, 'likeCount': 82, + }); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.campaign!.achievedPercent, 92); + expect(snapshot.campaign!.supporters, 48); + }); + + test('차단한 개설자의 캠페인은 홈 대표 캠페인에서 건너뛴다', () async { + final db = await _seed(); + await db.doc('campaigns/top').set({ + 'status': 'funding', 'creatorUid': 'troll', 'title': '차단됨', + 'targetPop': 100, 'raisedPop': 90, + }); + await db.doc('campaigns/next').set({ + 'status': 'funding', 'creatorUid': 'fan', 'title': '다음', + 'targetPop': 100, 'raisedPop': 50, + }); + await db.doc('users/$_uid/blocks/troll').set({'nickname': '트롤'}); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.campaign!.title, '다음'); + }); + + test('모금 중이 아닌 캠페인은 홈에 올리지 않는다', () async { + final db = await _seed(); + await db.doc('campaigns/c2').set({ + 'status': 'review', 'title': '심사 중', 'goalPop': 100, 'raisedPop': 0, + }); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.campaign, isNull); + }); + }); + + group('갤럭시 — Firestore 구현', () { + test('FPI 내림차순이 곧 순위이고 내 팬덤이 한가운데 선다', () async { + final db = await _seed(); + await db.doc('fandoms/nj').set({ + 'name': 'NewJeans', 'fpiScore': 310000, 'memberCount': 9000, + }); + await db.doc('fandoms/svt').set({ + 'name': 'SEVENTEEN', 'fpiScore': 120000, 'memberCount': 7000, + }); + + final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); + expect(snapshot.planets.map((p) => p.name).toList(), + ['NewJeans', 'BTS', 'SEVENTEEN']); + expect(snapshot.planets.map((p) => p.rank).toList(), [1, 2, 3]); + expect(snapshot.mine!.name, 'BTS'); + expect(snapshot.myPop, 12540); + expect(snapshot.joined, isTrue); + }); + + // 원장 B-08 · B-17 — 성운은 랭킹 모집단이 아니다. 끼면 정식 행성 번호가 밀린다. + test('Stage0 성운은 순위를 매기기 전에 빠지고 맨 바깥 궤도 자리에 선다', () async { + final db = await _seed(); + await db.doc('fandoms/neb').set({ + 'name': '성운', 'stage': 0, 'fpiScore': 999999, 'memberCount': 3, + }); + await db.doc('fandoms/svt').set({ + 'name': 'SEVENTEEN', 'stage': 1, 'fpiScore': 120000, 'memberCount': 7000, + }); + final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); + final rank = {for (final p in snapshot.planets) p.id: p.rank}; + expect(rank, {'bts': 1, 'svt': 2, 'neb': 3}); + }); + + test('대표 팬덤은 primaryFandomId 가 정본이고 가입 목록은 최신 가입순이다(C-15 · §2.58)', () async { + final db = await _seed(); + await db.doc('fandoms/nj').set({'name': 'NewJeans', 'stage': 1, 'fpiScore': 1}); + await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts', 'primaryFandomId': 'nj'}); + await db.doc('users/$_uid/memberships/bts').set({'fandomId': 'bts', 'joinedAt': DateTime(2026, 9, 1)}); + await db.doc('users/$_uid/memberships/nj').set({'fandomId': 'nj', 'joinedAt': DateTime(2026, 9, 20)}); + final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); + expect(snapshot.mine!.id, 'nj'); + expect(snapshot.memberships.map((p) => (p.id, p.isMine)).toList(), + [('nj', true), ('bts', false)]); + }); + + test('팬덤에 속하지 않으면 Zap 을 내주지 않는다', () async { + final db = await _seed(); + await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': ''}); + final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); + expect(snapshot.joined, isFalse); + expect(snapshot.mine, isNull); + }); + + test('이클립스 이벤트를 띠 문구로 옮긴다', () async { + final db = await _seed(); + await db.collection('galaxyEvents').add({ + 'kind': 'eclipse', + 'message': 'BTS 행성에 이클립스가 발생했습니다', + 'createdAt': DateTime(2026, 9, 1), + }); + final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); + expect(snapshot.event!.kind, GalaxyEventKind.eclipse); + }); + }); + + test('행성 상세는 멤버 문서가 있을 때만 가입중이다', () async { + final db = await _seed(); + await db.doc('fandoms/nj').set({'name': 'NewJeans', 'stage': 1}); + final mine = await FirestorePlanetRepository(_backend(db), fandomId: 'bts').load(nebula: false); + final other = await FirestorePlanetRepository(_backend(db), fandomId: 'nj').load(nebula: false); + expect(mine.joined, isTrue); + expect(other.joined, isFalse); + }); + + group('행성 상세 — Firestore 구현', () { + Future seedPlanet() async { + final db = await _seed(); + // 현재값(1분 집계)은 팬덤 문서에 있고, Daily FPI Snapshot 은 일 1회다 + // (별첨 B). 최근 동향 = 현재값 − 오늘의 스냅샷. + // `fpiRanks`·`fpiScore` 는 서버(fn-fpi-aggregate)가 적는 값이다. 픽스처도 + // 서버가 냈을 값과 같아야 한다 — 축 값과 순위가 어긋난 픽스처는 화면이 + // 이미 어긋나 있어도 통과한다. 모집단은 eligible 인 bts·nj 둘뿐이다. + await db.doc('fandoms/bts').set({ + 'name': 'BTS', + 'memberCount': 12840, + 'rankingEligible': true, + // 가중치 초기 1.0 이라 5축 합과 같다(별첨 B). + 'fpiScore': 284310, + 'fpiAxes': { + 'bpm': 82400, 'health': 61200, 'referral': 38900, + 'community': 55100, 'contribution': 46710, + }, + // bpm·referral 은 NewJeans 가 위, 나머지 세 축은 BTS 가 1위다. + 'fpiRanks': { + 'bpm': 2, 'health': 1, 'referral': 2, + 'community': 1, 'contribution': 1, + }, + 'fpiRanksPrev': {'bpm': 3}, + }); + await db.doc('fandoms/nj').set({ + 'name': 'NewJeans', + 'rankingEligible': true, + 'fpiScore': 272405, + 'fpiAxes': { + 'bpm': 91200, 'health': 44300, 'referral': 52800, + 'community': 41600, 'contribution': 42005, + }, + 'fpiRanks': { + 'bpm': 1, 'health': 2, 'referral': 1, + 'community': 2, 'contribution': 2, + }, + }); + // 최근 7일 유효 활동이 없어 모집단에서 빠진 행성(별첨 B). + await db.doc('fandoms/quiet').set({ + 'name': '조용한행성', + 'rankingEligible': false, + 'fpiAxes': {'bpm': 999999}, + }); + await db.doc('fandoms/bts/fpiSnapshots/2026-09-01-00').set({ + 'fpiAxes': { + 'bpm': 81160, 'health': 61520, 'referral': 38260, + 'community': 54890, 'contribution': 45730, + }, + 'createdAt': DateTime(2026, 9, 1, 0), + }); + await db.doc('fandoms/bts/members/$_uid').set({ + 'nickname': '나', + // 국가 랭킹의 「나의 랭킹 영역」이 읽는 비정규화 국가 코드. + 'countryCode': 'KR', + 'topPercentByAxis': {'bpm': 3, 'health': 12}, + 'fpiAxes': {'bpm': 2740, 'referral': 3180}, + }); + await db.doc('fandoms/bts/members/other').set({ + 'nickname': '버니즈', + 'fpiAxes': {'bpm': 4820, 'referral': 1240}, + }); + for (var i = 0; i < 8; i++) { + await db.collection('posts').add({ + 'fandomId': 'bts', + 'visibility': 'visible', + 'authorName': '아미보이', + 'title': '글 $i', + 'body': '본문', + 'likeCount': 40 - i, + 'commentCount': 3, + 'createdAt': DateTime(2026, 9, 1).subtract(Duration(hours: i)), + }); + } + return db; + } + + test('현재값에서 Daily Snapshot 을 뺀 것이 최근 동향이다', () async { + final db = await seedPlanet(); + final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') + .load(nebula: false); + + final bpm = detail.scores.firstWhere((s) => s.metric == FpiMetric.bpm); + expect(bpm.value, 82400); + expect(bpm.trend, 1240); + expect(bpm.rank, 2); + expect(bpm.myTopPercent, 3); + + // 서버 산출값이 없는 축은 null 이다 — 100%(최하위)로 채우면 화면이 없는 + // 값을 사실로 띄운다(대조표 3-5). + final referral = detail.scores.firstWhere((s) => s.metric == FpiMetric.referral); + expect(referral.myTopPercent, isNull); + + // 내려간 지표는 음수로 나와야 화면이 아래 화살표를 그린다. + final health = detail.scores.firstWhere((s) => s.metric == FpiMetric.health); + expect(health.trend, -320); + }); + + test('TOTAL FPI 는 서버 fpiScore 하나를 읽는다 — 갤럭시 HUD 와 같은 원천', () async { + final db = await seedPlanet(); + final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); + final detail = await repo.load(nebula: false); + // 가중치 초기 1.0 이므로 지금은 5축 합과 같다(별첨 B · 설계서 §4.1). + expect(detail.totalFpi, 284310); + expect(detail.totalFpi, detail.scores.fold(0, (a, s) => a + s.value)); + + // 가중치가 1.0 을 벗어나 서버 값이 축 합과 갈리면 **서버 값을 따른다**. + // 갤럭시 HUD 가 읽는 값과 같아야 두 화면이 어긋나지 않는다. + await db.doc('fandoms/bts') + .set({'fpiScore': 300000}, SetOptions(merge: true)); + expect((await repo.load(nebula: false)).totalFpi, 300000); + }); + + test('내 기여 상위 집계가 없으면 Mock 사유를 화면에 넘긴다 — 대조표 3-5', () async { + final db = await seedPlanet(); + await db.doc('fandoms/bts/members/$_uid') + .set({'nickname': '나'}); + final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') + .load(nebula: false); + expect(detail.mockNote, isNotNull); + expect(detail.scores.every((s) => s.myTopPercent == null), isTrue); + }); + + test('스냅샷이 없으면 동향은 0 이다', () async { + final db = await seedPlanet(); + await db.doc('fandoms/bts/fpiSnapshots/2026-09-01-00').delete(); + final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') + .load(nebula: false); + expect(detail.scores.every((s) => s.trend == 0), isTrue); + }); + + test('행성 랭킹은 지표별로 다른 쿼리를 쓴다 — 검토회신서 4절', () async { + final db = await seedPlanet(); + final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); + + final bpm = await repo.planetRanking(metric: FpiMetric.bpm); + final contribution = await repo.planetRanking(metric: FpiMetric.contribution); + expect(bpm.rows.first.name, 'NewJeans'); + expect(contribution.rows.first.name, 'BTS'); + + // 지표 카드가 읽는 fpiAxes 와 같은 필드라 값이 갈릴 수 없다. + final detail = await repo.load(nebula: false); + final card = detail.scores.firstWhere((s) => s.metric == FpiMetric.bpm); + expect(bpm.mine!.value, card.value); + // **순위도 같은 원천이어야 한다.** 목록의 순번으로 다시 매기면 동점에서 + // 서버 정렬과 문서 ID 정렬이 갈려 카드의 「전체 n위」와 어긋난다 — + // 발주사 검토회신서 4절이 지적한 자리다. + expect(bpm.mine!.rank, card.rank); + // 순위 동향 = 직전 순위 − 현재 순위. BTS 는 3위에서 2위로 올랐다. + expect(bpm.mine!.change, 1); + }); + + test('내 행성이 상위 목록 밖이어도 소속 행성 영역은 뜬다 — Figma 546:14628', () async { + final db = await seedPlanet(); + final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); + // 목록을 1칸으로 줄이면 BPM 2위인 BTS 는 목록 밖이다. + final ranking = await repo.planetRanking(metric: FpiMetric.bpm, limit: 1); + expect(ranking.rows.map((r) => r.name), ['NewJeans']); + expect(ranking.mine, isNotNull); + expect(ranking.mine!.rank, 2); + expect(ranking.mine!.value, 82400); + expect(ranking.mine!.change, 1); + }); + + test('랭킹 모집단은 최근 7일 유효 활동 행성뿐이다 — 별첨 B', () async { + final db = await seedPlanet(); + final ranking = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') + .planetRanking(metric: FpiMetric.bpm); + expect(ranking.rows.map((r) => r.name), isNot(contains('조용한행성'))); + }); + + test('사용자 랭킹도 지표별로 순서가 다르다', () async { + final db = await seedPlanet(); + final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); + final bpm = await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts'); + final referral = + await repo.userRanking(metric: FpiMetric.referral, fandomId: 'bts'); + expect(bpm.rows.first.name, '버니즈'); + expect(referral.rows.first.name, '나'); + expect(bpm.mine!.rank, 2); + expect(referral.mine!.rank, 1); + }); + + test('국가 랭킹은 사전 집계 1건을 읽고, 없으면 Mock 이라고 알린다', () async { + final db = await seedPlanet(); + final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); + expect((await repo.countryRanking(metric: FpiMetric.bpm)).isMock, isTrue); + + // 서버는 국가 **코드**만 적는다. 표시명은 화면이 붙인다(countryName). + await db.doc('rankings/country-bpm').set({ + 'rows': [ + {'code': 'JP', 'value': 1284000, 'change': 0}, + {'code': 'KR', 'value': 921000, 'change': 1}, + {'code': 'XX', 'value': 100, 'change': 0}, + ], + }); + final ranking = await repo.countryRanking(metric: FpiMetric.bpm); + expect(ranking.isMock, isFalse); + expect(ranking.rows.first.name, '일본'); + expect(ranking.rows.first.rank, 1); + // 표에 없는 코드는 코드를 그대로 띄운다 — 「미지정」으로 뭉개면 다른 나라가 된다. + expect(ranking.rows.last.name, 'XX'); + // 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가(KR) 행이다. + expect(ranking.mine!.id, 'KR'); + expect(ranking.mine!.rank, 2); + }); + + test('라운지는 최신순 5개를 먼저 주고 이어서 다음 5개를 준다', () async { + final db = await seedPlanet(); + final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); + final detail = await repo.load(nebula: false); + expect(detail.posts, hasLength(5)); + expect(detail.posts.first.title, '글 0'); + + final next = await repo.morePosts(offset: 5); + expect(next, hasLength(3)); + expect(next.first.title, '글 5'); + // 커서로 넘기므로 앞 장과 겹치지 않는다. + final titles = [...detail.posts, ...next].map((p) => p.title).toSet(); + expect(titles, hasLength(8)); + }); + + test('offset 0 은 처음부터 다시 읽는다', () async { + final db = await seedPlanet(); + final repo = FirestorePlanetRepository(_backend(db), fandomId: 'bts'); + await repo.load(nebula: false); + final again = await repo.morePosts(offset: 0); + expect(again.first.title, '글 0'); + }); + + test('다른 팬덤 글은 섞이지 않는다', () async { + final db = await seedPlanet(); + await db.collection('posts').add({ + 'fandomId': 'nj', 'visibility': 'visible', 'title': '남의 팬덤 글', + 'createdAt': DateTime(2026, 9, 2), + }); + final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts') + .load(nebula: false); + expect(detail.posts.map((p) => p.title), isNot(contains('남의 팬덤 글'))); + }); + }); + + group('POP → 행성 반영 — 화면이 서버 값으로 움직인다', () { + /// 여기 쓰는 숫자는 **서버 E2E 가 실제로 만든 값**이다 — + /// `firebase/functions/test/quest_e2e.test.ts` 의 「출석으로 받은 POP 을 캠페인에 + /// 쓰면 화면이 읽는 필드가 함께 움직인다」가 에뮬레이터에서 남긴 상태를 그대로 넣고, + /// 화면 값으로 옮겨지는지만 본다. + test('개인 기여 증가는 공동 Growth POP으로 오인하지 않는다', () async { + final db = await _seed(); + // 출석 보상 10 POP 이 들어온 지갑(`grantPop` 이 쓴 활동 잔액). + await db.doc('users/$_uid/wallet/main').set({'activityBalance': 10}); + // 기여 3,000 POP 을 캠페인에 쓴 뒤의 멤버 문서(`fn-pop-spend` 가 쓴 값). + await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 3000}); + + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.popBalance, 10); + expect(snapshot.growth.currentPop, 6270); + expect(snapshot.growth.requiredPop, 10000); + // 3,000 / 10,000 — 다음 단계까지의 진행도. + expect(snapshot.growth.growthPercent, 63); + }); + + test('다음 단계 문턱이 적재되지 않았으면 임의값을 만들지 않는다', () async { + final db = await _seed(); + await db.doc('planetStages/2').delete(); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.growth.requiredPop, isNull); + expect(snapshot.growth.growthPercent, isNull); + }); + }); + + group('MY — Firestore 구현', () { + test('누적 기여·등급·행성 기여를 서버가 쓰는 필드에서 읽는다', () async { + final db = await _seed(); + // `fn-pop-spend` 가 기여 차감과 같은 트랜잭션에서 쓰는 세 필드다(설계서 §3.6). + await db.doc('users/$_uid').set({ + 'uid': _uid, 'fandomId': 'bts', 'nickname': '아미보이', + 'totalContributedPop': 24800, 'userExp': 500000, 'userLevel': 31, + 'passportTier': 'GOLD', + }); + await db.doc('fandoms/bts/members/$_uid').set({'contributedPop': 24800}); + + final profile = await FirestoreMyRepository(_backend(db)).profile(); + expect(profile.contributedPop, 24800); + // 레벨은 서버 값을 읽는다. 앱이 기여 POP 으로 다시 매기지 않는다(C-18). + expect(profile.levelLabel, 'Lv.31 BEAM'); + expect(profile.userExp, 500000); + expect(profile.passportTier, PassportTier.gold); + expect(profile.planetTiers.single.contributedPop, 24800); + expect(profile.balancePop, 12540); + }); + + test('레벨 필드가 없으면 Lv.1 · SILVER 로 둔다', () async { + final db = await _seed(); + final profile = await FirestoreMyRepository(_backend(db)).profile(); + expect(profile.contributedPop, 0); + expect(profile.levelLabel, 'Lv.1 GLOW'); + expect(profile.passportTier, PassportTier.silver); + }); + }); + + group('FPI 공동 순위 (C-19)', () { + test('실값 동률만 같은 순위이고 다음 순위는 건너뛴다', () { + final ranked = rankWithTies<(String, num)>( + const [('c', 5), ('b', 10), ('a', 10), ('d', 10.4)], + (r) => r.$2, + (r) => r.$1, + ); + // 10.4 는 표시가 10 이어도 실값이 커서 단독 1위, 동점 10 두 개는 2·2, 다음은 4. + expect([for (final (r, rank) in ranked) '${r.$1}$rank'], ['d1', 'a2', 'b2', 'c4']); + }); + + test('갤럭시 순위도 공동 순위이고 동점 내부는 fandomId 오름차순이다', () async { + final db = await _seed(); + await db.doc('fandoms/zz').set({'name': 'Z', 'fpiScore': 100}); + await db.doc('fandoms/aa').set({'name': 'A', 'fpiScore': 100}); + await db.doc('fandoms/mm').set({'name': 'M', 'fpiScore': 50}); + final snapshot = await FirestoreGalaxyRepository(_backend(db)).load(); + // 픽스처의 bts(284,310)가 1위다. + final ranks = {for (final p in snapshot.planets) p.id: p.rank}; + expect(ranks['aa'], 2); + expect(ranks['zz'], 2); + expect(ranks['mm'], 4); + final ids = snapshot.planets.map((p) => p.id).toList(); + expect(ids.indexOf('aa'), lessThan(ids.indexOf('zz'))); + }); + }); + + group('이메일·UID 비노출 (B-52 · G-07)', () { + test('이메일 형태·빈 값은 중립 이름으로 바꾼다', () { + expect(publicName('test123@naver.com'), '팬'); + expect(publicName(' '), '팬'); + expect(publicName(null), '팬'); + expect(publicName('아미보이'), '아미보이'); + }); + + test('문서에 싣는 이름은 언어와 무관한 「팬」 · 읽을 때는 지금 언어의 「팬」(M-7)', () { + // 규칙 publicNameOf 가 「팬」과 대조하므로 번역된 대체값을 실으면 안 된다. + expect(storedPublicName(null), storedFanName); + expect(storedPublicName('me@x.com'), storedFanName); + expect(storedPublicName(' 아미보이 '), '아미보이'); + expect(publicName(storedFanName, 'Fan'), 'Fan'); + }); + + test('닉네임이 없어도 이메일 앞부분을 이름으로 쓰지 않는다', () async { + final db = await _seed(); + final backend = Backend( + firestore: db, + auth: MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: _uid, email: 'test123@naver.com'), + ), + ); + final profile = await FirestoreMyRepository(backend).profile(); + expect(profile.nickname, '팬'); + }); + + test('홈 기여자명에 이메일이 남아 있어도 가린다', () async { + final db = await _seed(); + await db.doc('fandoms/bts').set({ + 'name': 'BTS', 'lastContributorName': 'test123@naver.com', + 'lastContributedPop': 10, + }, SetOptions(merge: true)); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.growth.contributorName, '팬'); + }); + }); + + group('캠페인 — Firestore 구현', () { + test('공개 캠페인만 규칙 스키마 필드로 읽는다', () async { + final db = await _seed(); + await db.doc('campaigns/c1').set({ + 'status': 'funding', 'creatorUid': 'u-other', 'creatorName': '다이브짱', + 'title': '강남역 LED', 'targetPop': 10000, 'raisedPop': 9200, 'donorCount': 3, + }); + await db.doc('campaigns/c2').set({'status': 'draft', 'title': '초안'}); + final cards = await FirestoreCampaignRepository(_backend(db)).load(); + expect(cards.map((c) => c.id), ['c1']); + expect(cards.single.achievedPercent, 92); + expect(cards.single.authorName, '다이브짱'); + }); + + test('좋아요는 likes/{uid} 에 저장되고 상세가 서버 개수와 내 상태를 읽는다', () async { + final db = await _seed(); + final repo = FirestoreCampaignRepository(_backend(db)); + await db.doc('campaigns/c1').set({'status': 'funding', 'title': '강남역 LED'}); + await db.doc('campaigns/c1/likes/u-other').set({'createdAt': DateTime(2026)}); + await repo.like('c1', true); + var detail = await repo.detail('c1'); + expect(detail.card.likes, 2); + expect(detail.liked, isTrue); + await repo.like('c1', false); + detail = await repo.detail('c1'); + expect(detail.card.likes, 1); + expect(detail.liked, isFalse); + }); + + test('응답 유실 뒤 재시도는 같은 clientSeq · 성공 뒤 새 후원은 새 번호', () async { + final backend = _FlakyBackend(await _seed()); + final repo = FirestoreCampaignRepository(backend); + await expectLater(repo.contribute('c1', 100), throwsA(isA())); + await repo.contribute('c1', 100); + await repo.contribute('c1', 100); + final seqs = backend.calls.map((c) => c['clientSeq']).toList(); + expect(seqs[0], seqs[1]); + expect(seqs[2], isNot(seqs[1])); + // 기여 POP 고정은 서버가 한다 — 앱은 재화 종류를 보내지 않는다. + expect(backend.calls.every((c) => !c.containsKey('popType')), isTrue); + }); + + test('기여 랭킹에 UID 를 이름으로 내보내지 않는다', () async { + final db = await _seed(); + await db.doc('campaigns/c1/donations/d1').set({'uid': 'u-other', 'amount': 300}); + await db.doc('campaigns/c1/donations/d2').set({'uid': _uid, 'amount': 100}); + final rows = await FirestoreCampaignRepository(_backend(db)).ranking('c1'); + expect(rows.map((r) => r.name), ['팬', '나']); + expect(rows.first.pop, 300); + }); + }); + + group('저장소 선택', () { + test('서버가 없으면 표본을 쓴다', () { + final repos = Repositories(live: false); + expect(repos.home, isA()); + expect(repos.galaxy, isA()); + expect(repos.planet('bts'), isA()); + expect(repos.quest, isA()); + }); + + test('서버가 있으면 Firestore 구현을 쓴다', () async { + final repos = Repositories(live: true, backend: _backend(await _seed())); + expect(repos.home, isA()); + expect(repos.galaxy, isA()); + expect(repos.planet('bts'), isA()); + expect(repos.quest, isA()); + }); + + test('팬덤이 정해지지 않았으면 행성 상세는 표본으로 내려간다', () async { + final repos = Repositories(live: true, backend: _backend(await _seed())); + expect(repos.planet(null), isA()); + expect(repos.planet(''), isA()); + }); + + test('MY 와 알림함도 같은 갈림길을 지난다', () async { + final live = Repositories(live: true, backend: _backend(await _seed())); + expect(live.my, isA()); + expect(live.notifications, isA()); + + final mock = Repositories(live: false); + expect(mock.my, isA()); + expect(mock.notifications, isA()); + }); + }); + + group('MY — Firestore 구현', () { + /// 원장 시드. **차감은 음수다** — `spendPop` 이 `amount: -input.amount` 로 + /// 쓰고 설계서 §3.4 대조식이 `balance == Σ amount` 이므로, 양수로 시드하면 + /// 화면이 부호를 뒤집는 잘못이 테스트를 통과해 버린다. + Future seedLedger() async { + final db = await _seed(); + // 같은 날 Zap 두 건 + 다른 거래 한 건 + 전날 Zap 한 건 + 지급 한 건. + await db.doc('users/$_uid/popTransactions/z2').set({ + 'type': 'spend', 'popType': 'general', 'amount': -10, + 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12540, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 21)), + }); + await db.doc('users/$_uid/popTransactions/z1').set({ + 'type': 'spend', 'popType': 'general', 'amount': -10, + 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12550, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 20)), + }); + await db.doc('users/$_uid/popTransactions/fund').set({ + 'type': 'spend', 'popType': 'contribution', 'amount': -500, + 'reasonCode': 'CAMPAIGN_FUND', 'balanceAfter': 12560, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 10)), + }); + await db.doc('users/$_uid/popTransactions/z0').set({ + 'type': 'spend', 'popType': 'general', 'amount': -10, + 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 13060, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 9, 22)), + }); + await db.doc('users/$_uid/popTransactions/quest').set({ + 'type': 'grant', 'popType': 'general', 'amount': 50, + 'reasonCode': 'QUEST_STEPS', 'balanceAfter': 13070, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 8, 9)), + }); + return db; + } + + test('원장의 부호를 그대로 옮긴다 — 차감은 음수', () async { + final db = await seedLedger(); + final rows = await FirestoreMyRepository(_backend(db)).popHistory(); + + // 최신순이고 Zap 은 같은 날 한 줄로 접힌다. + expect(rows.map((e) => e.title).toList(), [ + 'Zap 기여', '캠페인 후원', 'Zap 기여', '만보기 퀘스트', + ]); + expect(rows.first.repeatCount, 2); + expect(rows.first.delta, -20); + // 접힌 줄의 누적 값은 그룹에서 가장 최신 행의 값이다. + expect(rows.first.balanceAfter, 12540); + expect(rows.first.kind, PopKind.general); + expect(rows.first.txType, PopTxType.spend); + + final fund = rows[1]; + expect(fund.kind, PopKind.contribution); + expect(fund.delta, -500); + expect(fund.displaySubtitle, '기여 · 차감 · 기여 POP'); + + // 날이 다른 Zap 은 접히지 않는다. + expect(rows[2].repeatCount, 1); + expect(rows[2].delta, -10); + expect(rows.last.delta, 50); + expect(rows.last.txType, PopTxType.grant); + + // 차감이 양수로 보이면 화면의 증감 부호가 뒤집힌다. 음수 하나라도 없으면 실패다. + expect(rows.where((e) => e.delta < 0).length, 3); + }); + + test('원장 합산이 서버 잔액을 재현한다 — 설계서 §3.4 대조식', () async { + final db = await seedLedger(); + final rows = await FirestoreMyRepository(_backend(db)).popHistory(); + // 접기는 합계를 바꾸지 않는다. Zap 두 건을 한 줄로 접어도 총합은 같다. + final sum = rows.fold(0, (a, e) => a + e.delta); + expect(sum, -10 - 10 - 500 - 10 + 50); + }); + + test('Zap 횟수는 원장의 count 를 합산한다', () async { + final db = await _seed(); + // 한 번에 여러 번 보낸 Zap. `fn-eclipse-defend` 가 `count` 를 묶어 받는다. + await db.doc('users/$_uid/popTransactions/zc2').set({ + 'type': 'spend', 'popType': 'general', 'amount': -50, 'count': 5, + 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12500, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 21)), + }); + await db.doc('users/$_uid/popTransactions/zc1').set({ + 'type': 'spend', 'popType': 'general', 'amount': -30, 'count': 3, + 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12550, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 20)), + }); + // 예전 행에는 `count` 가 없다. 한 번으로 센다. + await db.doc('users/$_uid/popTransactions/zc0').set({ + 'type': 'spend', 'popType': 'general', 'amount': -10, + 'reasonCode': 'ECLIPSE_DEFENSE', 'balanceAfter': 12580, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 19)), + }); + + final rows = await FirestoreMyRepository(_backend(db)).popHistory(); + expect(rows.single.repeatCount, 9); + expect(rows.single.displayTitle, 'Zap 기여 9회'); + // 합산은 금액도 그대로 더한다. + expect(rows.single.delta, -90); + }); + + test('배포된 Functions 의 activity·support 도 같은 두 종류로 읽는다', () async { + final db = await _seed(); + await db.doc('users/$_uid/popTransactions/iap').set({ + 'type': 'grant', 'popType': 'support', 'amount': 5500, + 'reasonCode': 'IAP_PURCHASE', 'balanceAfter': 18040, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 7)), + }); + final rows = await FirestoreMyRepository(_backend(db)).popHistory(); + expect(rows.single.kind, PopKind.contribution); + expect(rows.single.title, '상품 구매'); + }); + + test('커서는 마지막 줄의 위치이고 그 다음만 온다', () async { + final db = await seedLedger(); + final repo = FirestoreMyRepository(_backend(db)); + + // 원장 두 건을 읽었는데 둘 다 같은 날 Zap 이라 한 줄로 접힌다. + // 장의 길이가 읽은 건수와 다를 수 있는 이유이고, 화면이 길이로 끝을 + // 판정하지 않는 근거다. + final first = await repo.popHistory(limit: 2); + expect(first.length, 1); + expect(first.single.repeatCount, 2); + + // 커서는 표시 시각이 아니라 접힌 줄이 품은 가장 오래된 행의 위치다. + expect(first.single.at, DateTime(2026, 9, 10, 21)); + expect(first.single.cursor?.at, DateTime(2026, 9, 10, 20)); + expect(first.single.cursor?.id, 'z1'); + + final next = await repo.popHistory(limit: 2, after: first.last.cursor); + expect(next.map((e) => e.title).toList(), ['캠페인 후원', 'Zap 기여']); + + final last = await repo.popHistory(limit: 2, after: next.last.cursor); + expect(last.map((e) => e.title).toList(), ['만보기 퀘스트']); + + // 빈 장이 오면 끝이다. + expect(await repo.popHistory(limit: 2, after: last.last.cursor), isEmpty); + }); + + test('createdAt 이 같은 두 행도 경계에서 빠지지 않는다', () async { + final db = await _seed(); + // 한 트랜잭션이 일반·기여를 각각 쓰면 서버 타임스탬프가 같아진다. + final same = Timestamp.fromDate(DateTime(2026, 9, 10, 12)); + await db.doc('users/$_uid/popTransactions/pair-a').set({ + 'type': 'spend', 'popType': 'general', 'amount': -100, + 'reasonCode': 'QNA_BOUNTY', 'balanceAfter': 900, 'createdAt': same, + }); + await db.doc('users/$_uid/popTransactions/pair-b').set({ + 'type': 'spend', 'popType': 'contribution', 'amount': -50, + 'reasonCode': 'QNA_BOUNTY', 'balanceAfter': 850, 'createdAt': same, + }); + final repo = FirestoreMyRepository(_backend(db)); + + final first = await repo.popHistory(limit: 1); + expect(first.length, 1); + final second = await repo.popHistory(limit: 1, after: first.last.cursor); + // 시각만 비교했다면 같은 시각의 남은 한 행이 통째로 사라진다. + expect(second.length, 1); + expect(second.single.delta, isNot(first.single.delta)); + expect(await repo.popHistory(limit: 1, after: second.last.cursor), isEmpty); + }); + + test('공지는 게시된 것만 최신순으로 읽는다', () async { + final db = await _seed(); + await db.doc('notices/n1').set({ + 'active': true, 'title': '서비스 점검 안내', 'body': '점검이 있습니다.', + 'publishedAt': Timestamp.fromDate(DateTime(2026, 9, 1)), + }); + await db.doc('notices/n2').set({ + 'active': true, 'title': '이클립스 방어 이벤트', 'body': '추가 POP 을 드립니다.', + 'publishedAt': Timestamp.fromDate(DateTime(2026, 9, 5)), + }); + // 내려 둔 공지는 목록에 오르지 않는다. + await db.doc('notices/n3').set({ + 'active': false, 'title': '지난 이벤트', 'body': '끝났습니다.', + 'publishedAt': Timestamp.fromDate(DateTime(2026, 9, 6)), + }); + + final notices = await FirestoreMyRepository(_backend(db)).notices(); + expect(notices.map((n) => n.title).toList(), [ + '이클립스 방어 이벤트', '서비스 점검 안내', + ]); + expect(notices.first.at, DateTime(2026, 9, 5)); + }); + + test('차단을 추가하고 목록으로 읽고 해제한다', () async { + final db = await _seed(); + final repo = FirestoreMyRepository(_backend(db)); + + await repo.block('u-spam', nickname: '스팸계정'); + var blocked = await repo.blockedUsers(); + expect(blocked.single.uid, 'u-spam'); + expect(blocked.single.nickname, '스팸계정'); + + await repo.unblock('u-spam'); + blocked = await repo.blockedUsers(); + expect(blocked, isEmpty); + }); + + test('자기 자신은 차단하지 않는다', () async { + final repo = FirestoreMyRepository(_backend(await _seed())); + expect(() => repo.block(_uid), throwsA(isA())); + }); + + test('문의를 접수 상태로 넣고 내 것만 읽는다', () async { + final db = await _seed(); + final repo = FirestoreMyRepository(_backend(db)); + + await repo.submitInquiry('결제 문의', '지급되지 않았습니다.'); + await db.collection('inquiries').add({ + 'authorUid': 'someone-else', 'title': '남의 문의', + 'status': 'pending', 'createdAt': Timestamp.now(), + }); + + final mine = await repo.myInquiries(); + expect(mine.single.title, '결제 문의'); + expect(mine.single.status, InquiryStatus.pending); + }); + + test('운영이 답변하면 상태와 답변이 함께 올라온다', () async { + final db = await _seed(); + await db.collection('inquiries').add({ + 'authorUid': _uid, 'title': '환불 문의', 'body': '환불이 되지 않습니다.', + 'status': 'answered', 'answerBody': '처리했습니다.', + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 1)), + 'answeredAt': Timestamp.fromDate(DateTime(2026, 9, 2)), + }); + final mine = await FirestoreMyRepository(_backend(db)).myInquiries(); + expect(mine.single.status, InquiryStatus.answered); + expect(mine.single.answerBody, '처리했습니다.'); + expect(mine.single.answeredAt, DateTime(2026, 9, 2)); + }); + + test('프로필은 사진 URL 을 함께 싣는다', () async { + final db = await _seed(); + await db.doc('users/$_uid').set({ + 'nickname': '아미보이', + 'photoUrl': 'https://example.invalid/photo.png', + }, SetOptions(merge: true)); + final profile = await FirestoreMyRepository(_backend(db)).profile(); + expect(profile.nickname, '아미보이'); + expect(profile.photoUrl, 'https://example.invalid/photo.png'); + }); + }); + + group('알림함 — Firestore 구현', () { + test('최신순으로 읽고 읽음 상태를 옮긴다', () async { + final db = await _seed(); + await db.doc('users/$_uid/notifications/n1').set({ + 'type': 'fandomEvent', 'eventType': 'quest', + 'title': '만보기 퀘스트를 완료했습니다', 'read': false, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 10, 9)), + }); + await db.doc('users/$_uid/notifications/n2').set({ + 'type': 'fandomEvent', 'eventType': 'eclipse', + 'title': '이클립스가 발생했습니다', 'read': true, + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 11, 9)), + }); + + final repo = FirestoreNotificationRepository(_backend(db)); + final items = await repo.load(); + expect(items.map((i) => i.id).toList(), ['n2', 'n1']); + expect(items.first.kind, NotificationKind.eclipse); + expect(items.first.read, isTrue); + expect(items.last.kind, NotificationKind.quest); + + await repo.markRead('n1'); + final after = (await db.doc('users/$_uid/notifications/n1').get()).data(); + expect(after?['read'], isTrue); + expect(after?['readAt'], isNotNull); + }); + + test('같은 카테고리 안에서도 이동 규칙이 갈린다 — 두 축이 필요한 이유', () async { + final db = await _seed(); + // 정책 원장 B-77 의 「팬덤 이벤트」 하나가 이클립스·캠페인·퀘스트를 모두 + // 담는데, Figma `518:88353` 은 이클립스만 이동하지 말라고 갈라 두었다. + for (final (id, eventType) in [ + ('a', 'eclipse'), + ('b', 'campaign'), + ('c', 'quest'), + ]) { + await db.doc('users/$_uid/notifications/$id').set({ + 'type': 'fandomEvent', 'eventType': eventType, + 'title': '알림 $id', + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 12)), + }); + } + final items = await FirestoreNotificationRepository(_backend(db)).load(); + final byId = {for (final i in items) i.id: i}; + expect(byId['a']!.kind.navigable, isFalse); + expect(byId['b']!.kind.target, '캠페인'); + expect(byId['c']!.kind.target, '퀘스트 상세'); + }); + + test('eventType 이 없으면 type 으로 물러나고 이동하지 않는다', () async { + final db = await _seed(); + // 수신 설정 축의 값만 실려 온 문서. 어디로 보낼지 알 수 없으므로 보내지 않는다. + await db.doc('users/$_uid/notifications/x').set({ + 'type': 'marketing', 'title': '이벤트 안내', + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 12)), + }); + final items = await FirestoreNotificationRepository(_backend(db)).load(); + expect(items.single.kind.navigable, isFalse); + expect(items.single.title, '이벤트 안내'); + }); + + test('모르는 종류도 이동하지 않는 쪽으로 접는다', () async { + final db = await _seed(); + await db.doc('users/$_uid/notifications/y').set({ + 'type': 'essential', 'eventType': '미래에_생길_종류', 'title': '무언가 일어났습니다', + 'createdAt': Timestamp.fromDate(DateTime(2026, 9, 12)), + }); + final items = await FirestoreNotificationRepository(_backend(db)).load(); + expect(items.single.kind.navigable, isFalse); + expect(items.single.title, '무언가 일어났습니다'); + }); + }); + + + group('AI Daily Briefing — 당일 게시분만 (정책 원장 B-47)', () { + /// 브리핑 한 줄을 심는다. `fn-briefing-ingest` 가 쓰는 필드를 그대로 쓴다. + /// B-75 5 요소를 기본으로 채우고, 누락 검증만 [drop] 으로 하나를 뺀다. + Future brief( + FakeFirebaseFirestore db, { + required String dayKey, + required int rank, + required String status, + required String headline, + String section = 'News', + String? drop, + }) { + final data = { + 'fandomId': 'bts', + 'dayKey': dayKey, + 'section': section, + 'rank': rank, + 'status': status, + 'headline': headline, + 'sourceName': '연합뉴스', + 'sourceAt': Timestamp.fromDate(DateTime(2026, 9, 14, 8)), + 'sourceUrl': 'https://example.com/$headline', + 'articleUrl': 'https://example.com/$headline', + 'aiSummary': '요약입니다.', + 'aiGenerated': true, + }; + if (drop != null) data.remove(drop); + return db.doc('briefings/bts_${dayKey}_${section}_$rank').set(data); + } + + String today() => kstDayKey(); + String yesterday() => + kstDayKey(DateTime.now().toUtc().subtract(const Duration(days: 1))); + + test('게시된 당일분을 rank 순으로 읽고 5 요소를 담아 온다', () async { + final db = await _seed(); + await brief(db, dayKey: today(), rank: 2, status: 'published', headline: '둘째 줄'); + await brief(db, dayKey: today(), rank: 1, status: 'published', headline: '첫째 줄'); + + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.briefings.map((b) => b.headline), ['첫째 줄', '둘째 줄']); + expect(snapshot.briefings.map((b) => b.rank), [1, 2]); + // B-75 — 노출 항목 전건에 5 요소가 있어야 한다. + final first = snapshot.briefings.first; + expect(first.section, BriefingSection.news); + expect(first.sourceName, '연합뉴스'); + expect(first.sourceUrl, isNotEmpty); + expect(first.articleUrl, isNotEmpty); + expect(first.aiSummary, isNotEmpty); + expect(first.sourceAt.year, 2026); + }); + + test('5 요소가 빠진 문서는 버린다 — 출처 없는 카드를 그리지 않는다', () async { + for (final missing in [ + 'sourceName', 'sourceAt', 'sourceUrl', 'articleUrl', 'aiSummary', 'section', + ]) { + final db = await _seed(); + await brief(db, dayKey: today(), rank: 1, status: 'published', + headline: '메타가 빈 줄', drop: missing); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.briefings, isEmpty, reason: missing); + } + }); + + test('정책이 정한 News·SNS 밖의 섹션은 버린다', () async { + final db = await _seed(); + await brief(db, dayKey: today(), rank: 1, status: 'published', + headline: '근거 없는 구획', section: 'activity'); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.briefings, isEmpty); + }); + + test('News 와 SNS 를 구획 그대로 읽어 온다', () async { + final db = await _seed(); + await brief(db, dayKey: today(), rank: 1, status: 'published', + headline: '뉴스 줄'); + await brief(db, dayKey: today(), rank: 1, status: 'published', + headline: 'SNS 줄', section: 'SNS'); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect( + snapshot.briefings.map((b) => b.section).toSet(), + {BriefingSection.news, BriefingSection.sns}, + ); + }); + + test('미승인 Draft 는 노출 0 건이다', () async { + final db = await _seed(); + await brief(db, dayKey: today(), rank: 1, status: 'draft', headline: '초안'); + + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.briefings, isEmpty); + }); + + test('전일 게시분은 재사용하지 않는다', () async { + final db = await _seed(); + await brief(db, dayKey: yesterday(), rank: 1, status: 'published', headline: '어제 줄'); + + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.briefings, isEmpty); + }); + + test('0 건이면 빈 목록이라 홈이 블록을 숨긴다', () async { + final db = await _seed(); + final snapshot = await FirestoreHomeRepository(_backend(db)).load(); + expect(snapshot.briefings, isEmpty); + }); + + test('KST 로 날짜를 끊는다 — UTC 로 끊으면 오전 9 시에 하루가 바뀐다', () { + expect(kstDayKey(DateTime.utc(2026, 9, 13, 16)), '2026-09-14'); + expect(kstDayKey(DateTime.utc(2026, 9, 13, 14, 59)), '2026-09-13'); + }); + }); + + group('푸시 설정 — Firestore (정책 원장 B-77)', () { + test('문서가 비면 정책 기본값을 쓴다 — Marketing 만 OFF', () async { + final db = await _seed(); + final prefs = await FirestoreMyRepository(_backend(db)).pushSettings(); + expect(prefs[PushTopic.marketing], isFalse); + expect(prefs[PushTopic.briefing], isTrue); + expect(prefs.length, 5); + }); + + test('끈 구분만 notificationPrefs 에 남는다', () async { + final db = await _seed(); + final repo = FirestoreMyRepository(_backend(db)); + await repo.setPush(PushTopic.briefing, false); + + final saved = (await db.doc('users/$_uid').get()).data()!; + expect((saved['notificationPrefs'] as Map)['briefing'], isFalse); + expect(await repo.pushSettings(), containsPair(PushTopic.briefing, false)); + }); + + test('서비스 필수는 저장하지 않는다 — 끌 수 없는 구분이다', () async { + final db = await _seed(); + final repo = FirestoreMyRepository(_backend(db)); + await repo.setPush(PushTopic.essential, false); + + final saved = (await db.doc('users/$_uid').get()).data()!; + expect(saved['notificationPrefs'], isNull); + expect(await repo.pushSettings(), containsPair(PushTopic.essential, true)); + }); + }); + + group('푸시 토픽 계획', () { + test('초기값에서는 Marketing 만 해지 대상이다', () { + final plan = topicPlan('bts', PushTopic.defaults); + expect(plan.subscribe, contains('fandom_bts_briefing')); + expect(plan.subscribe, isNot(contains('fandom_bts_marketing'))); + expect(plan.unsubscribe, {'fandom_bts_marketing'}); + }); + + test('모든 구분이 구독 아니면 해지 한쪽에 들어간다', () { + final plan = topicPlan('bts', PushTopic.defaults); + expect( + plan.subscribe.length + plan.unsubscribe.length, + PushTopic.values.length, + ); + expect(plan.subscribe.intersection(plan.unsubscribe), isEmpty); + }); + + test('구분을 끄면 해지 대상이 된다', () { + final plan = topicPlan( + 'bts', + {...PushTopic.defaults, PushTopic.briefing: false}, + ); + expect(plan.unsubscribe, contains('fandom_bts_briefing')); + expect(plan.subscribe, isNot(contains('fandom_bts_briefing'))); + }); + + test('서비스 필수는 설정을 꺼도 구독을 유지한다', () { + final plan = topicPlan( + 'bts', + {...PushTopic.defaults, PushTopic.essential: false}, + ); + expect(plan.subscribe, contains('fandom_bts_essential')); + expect(plan.unsubscribe, isNot(contains('fandom_bts_essential'))); + }); + + test('FCM 이 받지 않는 문자를 서버와 같게 치환한다', () { + // 서버 `firebase/functions/src/notify/push.ts` 의 `fandomTopic` 과 같은 규약. + // 한쪽만 치환하면 보내는 토픽과 구독한 토픽이 갈린다. + expect( + PushTopic.briefing.topicFor('b t/s'), + 'fandom_b-t-s_briefing', + ); + expect(PushTopic.briefing.topicFor('bts'), 'fandom_bts_briefing'); + }); + }); + + group('푸시 구독 갱신 — 설정이 실제 구독을 바꾼다', () { + /// 팬덤 소속이 있는 사용자로 [PushMessaging] 을 세운다. + Future<({PushMessaging push, _FakeTransport transport})> boot() async { + final db = await _seed(); + final transport = _FakeTransport(); + return ( + push: PushMessaging(backend: _backend(db), transport: transport), + transport: transport, + ); + } + + test('AI Briefing 을 끄면 그 토픽을 해지한다 — OFF 발송 0 건', () async { + final (push: push, transport: transport) = await boot(); + await push.syncTopics({ + ...PushTopic.defaults, + PushTopic.briefing: false, + }); + expect(transport.unsubscribed, contains('fandom_bts_briefing')); + expect(transport.subscribed, isNot(contains('fandom_bts_briefing'))); + }); + + test('Marketing 을 켜면 그 토픽을 구독한다', () async { + final (push: push, transport: transport) = await boot(); + await push.syncTopics({ + ...PushTopic.defaults, + PushTopic.marketing: true, + }); + expect(transport.subscribed, contains('fandom_bts_marketing')); + }); + + test('앱이 기억한 상태를 믿지 않고 매번 전건을 맞춘다', () async { + final (push: push, transport: transport) = await boot(); + await push.syncTopics(PushTopic.defaults); + await push.syncTopics(PushTopic.defaults); + // FCM 두 호출은 멱등이다. 두 번 불러야 재설치·토큰 재발급으로 실제 구독이 + // 달라진 경우를 되돌릴 수 있다. + expect( + transport.subscribed.where((t) => t == 'fandom_bts_briefing').length, + 2, + ); + }); + + test('로그아웃 정리는 전 구분을 해지하고 토큰을 지운다', () async { + final db = await _seed(); + final transport = _FakeTransport(); + final push = PushMessaging(backend: _backend(db), transport: transport); + await push.syncToken(); + expect( + (await db.doc('users/$_uid').get()).data()!['fcmTokens'], + contains('fake-token'), + ); + + await push.clearOnSignOut(); + for (final topic in PushTopic.values) { + expect(transport.unsubscribed, contains(topic.topicFor('bts'))); + } + expect( + (await db.doc('users/$_uid').get()).data()!['fcmTokens'], + isEmpty, + ); + }); + + test('팬덤 소속이 없으면 아무 토픽도 건드리지 않는다', () async { + final db = await _seed(); + await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': ''}); + final transport = _FakeTransport(); + final push = PushMessaging(backend: _backend(db), transport: transport); + await push.syncTopics(PushTopic.defaults); + expect(transport.subscribed, isEmpty); + expect(transport.unsubscribed, isEmpty); + }); + }); + + group('푸시 수신 분기', () { + test('이클립스는 갤럭시로, 브리핑은 홈으로 보낸다', () { + const eclipse = PushNotice(title: 't', body: 'b', kind: 'eclipse'); + const briefing = PushNotice(title: 't', body: 'b', kind: 'briefing'); + const unknown = PushNotice(title: 't', body: 'b', kind: ''); + expect(eclipse.route, '/galaxy'); + expect(briefing.route, '/home'); + expect(unknown.route, '/home'); + }); + + test('문구가 없는 데이터 전용 메시지는 화면에 띄우지 않는다', () { + const empty = PushNotice(title: '', body: '', kind: 'briefing'); + expect(empty.hasText, isFalse); + }); + }); +} + +/// FCM 플러그인 대역. 구독·해지 호출을 순서대로 기록한다. +/// +/// 실제 `FirebaseMessaging` 은 비공개 생성자라 테스트에서 세울 수 없다. 그래서 +/// [PushTransport] 경계를 두었고, 이 클래스가 그 경계의 유일한 대역이다. +final class _FakeTransport implements PushTransport { + final List subscribed = []; + final List unsubscribed = []; + + @override + Future requestPermission() async => + AuthorizationStatus.authorized; + + @override + Future currentStatus() async => + AuthorizationStatus.authorized; + + @override + Future getToken() async => 'fake-token'; + + @override + Stream get onTokenRefresh => const Stream.empty(); + + @override + Future subscribeToTopic(String topic) async => subscribed.add(topic); + + @override + Future unsubscribeFromTopic(String topic) async => + unsubscribed.add(topic); + + @override + Future getInitialMessage() async => null; +} diff --git a/app/test/backend_test.dart b/app/test/backend_test.dart deleted file mode 100644 index 9fe51e3..0000000 --- a/app/test/backend_test.dart +++ /dev/null @@ -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().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 = {}; - expect(doc.intOr('pop'), 0); - expect(doc.strOr('name', '이름 없음'), '이름 없음'); - expect(doc.boolOr('active'), isFalse); - expect(doc.mapsOr('items'), isEmpty); - }); - - test('실수는 반올림해 정수로 받는다', () { - expect({'pop': 12.6}.intOr('pop'), 13); - }); - - test('빈 문자열은 없는 값으로 본다', () { - expect({'name': ''}.strOr('name', '팬'), '팬'); - }); - - test('서버 시각은 DateTime 으로 옮긴다', () { - final at = DateTime(2026, 9, 1, 9); - final doc = {'createdAt': Timestamp.fromDate(at)}; - expect(doc.dateOr('createdAt', DateTime(2000)), at); - expect(doc.dateOr('missing', DateTime(2000)), DateTime(2000)); - }); - - test('목록 안의 맵만 골라 받는다', () { - final doc = { - 'items': [ - {'id': 1}, - 'x', - {'id': 2}, - ], - }; - expect(doc.mapsOr('items').map((m) => m['id']), [1, 2]); - }); - }); -} diff --git a/app/test/campaign_comments_test.dart b/app/test/campaign_comments_test.dart new file mode 100644 index 0000000..12a7a7c --- /dev/null +++ b/app/test/campaign_comments_test.dart @@ -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 _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 _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( + 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 _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), + ), + ); + 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()), + ); + }); + }); + + 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( + 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 appMenu(String item) async { + await tester.tap( + find.descendant( + of: find.byType(AppBar), + matching: find.byType(PopupMenuButton), + ), + ); + 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 = []; + final liked = <(String, bool)>[]; + final blockedCreators = []; + + @override + Future likeComment( + String campaignId, + String commentId, + bool on, + ) async => liked.add((commentId, on)); + + @override + Future reportCampaign(String campaignId, String reason) async => + reported.add('campaign:$campaignId'); + + @override + Future blockCreator(CampaignCardData card) async => + blockedCreators.add(card.id); + + @override + Future reportComment( + String campaignId, + CampaignComment comment, + String reason, + ) async => reported.add(comment.id); +} + +class _Paged extends SampleCampaignRepository { + @override + Future> 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 hasPublicNickname() async => named; +} diff --git a/app/test/campaign_share_test.dart b/app/test/campaign_share_test.dart new file mode 100644 index 0000000..39f3b3f --- /dev/null +++ b/app/test/campaign_share_test.dart @@ -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); + }); + }); +} diff --git a/app/test/design_match/a_test.dart b/app/test/design_match/a_test.dart new file mode 100644 index 0000000..4cd50ed --- /dev/null +++ b/app/test/design_match/a_test.dart @@ -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 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 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 = [ + // 사본 주 프레임(`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 _settle(WidgetTester t) async { + for (var i = 0; i < 6; i++) { + await t.pump(const Duration(milliseconds: 300)); + } +} + +Future _loadFonts() async { + final manifest = + json.decode(await rootBundle.loadString('FontManifest.json')) as List; + for (final font in manifest.cast>()) { + final loader = FontLoader(font['family'] as String); + for (final f in (font['fonts'] as List).cast>()) { + 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(); + 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(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); + }); + } +} diff --git a/app/test/design_match/b_test.dart b/app/test/design_match/b_test.dart new file mode 100644 index 0000000..9845d92 --- /dev/null +++ b/app/test/design_match/b_test.dart @@ -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 _settle(WidgetTester t) async { + for (var i = 0; i < 6; i++) { + await t.pump(const Duration(milliseconds: 300)); + } +} + +Future _tap(WidgetTester t, Finder f) async { + await t.tap(f.first, warnIfMissed: false); + await _settle(t); +} + +Future _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 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 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> morePosts({ + required int offset, + int limit = 5, + }) async => const []; + + @override + Future 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 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 _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 = [ + // 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)), +]; diff --git a/app/test/design_match/c_test.dart b/app/test/design_match/c_test.dart new file mode 100644 index 0000000..14e0ab1 --- /dev/null +++ b/app/test/design_match/c_test.dart @@ -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/.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 Function(Repositories r); +typedef After = Future 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 _tap(WidgetTester t, String text) async { + await t.tap(find.text(text).first, warnIfMissed: false); + await _settle(t); +} + +/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다. +Future _settle(WidgetTester t) async { + for (var i = 0; i < 6; i++) { + await t.pump(const Duration(milliseconds: 300)); + } +} + +Future _period(WidgetTester t, List 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 = [ + // 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 cards; + + @override + Future> load({int limit = 10}) async => cards; + + @override + Future> more({required int offset, int limit = 10}) async => + const []; + + /// 시안의 카테고리 네 개(C-01 분류 키). + @override + Future 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 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> load({ + CampaignSort sort = CampaignSort.latest, + int limit = 10, + }) async => [_card('c1'), _card('c2')]; + + @override + Future> more({required int offset, int limit = 10}) async => const []; + + @override + Future 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> ranking( + String campaignId, { + RankWindow window = RankWindow.live, + }) async => [ + ..._ranks, + const ContributorRank(rank: 82, name: 'BTS_Hong', pop: 3, change: 1, isMe: true), + ]; + + @override + Future> recentQueries() async => [..._queries]; + + @override + Future> search(String query) async => [_card('c1'), _card('c2')]; + + @override + Future balance() async => 12500; +} + +Future _loadFonts() async { + final manifest = + json.decode(await rootBundle.loadString('FontManifest.json')) as List; + for (final font in manifest.cast>()) { + final loader = FontLoader(font['family'] as String); + for (final f in (font['fonts'] as List).cast>()) { + 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 _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(); + 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(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); + }); + } +} diff --git a/app/test/design_match/d_test.dart b/app/test/design_match/d_test.dart new file mode 100644 index 0000000..255b3d5 --- /dev/null +++ b/app/test/design_match/d_test.dart @@ -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 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> 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> 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> 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> 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 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> 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 providers) => + SampleAccountLinker(); +} + +typedef Build = Future 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 Function(WidgetTester t)? after; +} + +Future _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('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 _loadFonts() async { + final manifest = + json.decode(await rootBundle.loadString('FontManifest.json')) as List; + for (final font in manifest.cast>()) { + final loader = FontLoader(font['family'] as String); + for (final f in (font['fonts'] as List).cast>()) { + 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 _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(); + 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(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); + }); + } +} diff --git a/app/test/design_match/design_match_test.dart b/app/test/design_match/design_match_test.dart new file mode 100644 index 0000000..0a45111 --- /dev/null +++ b/app/test/design_match/design_match_test.dart @@ -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/.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 Function(Repositories r); +typedef After = Future 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 _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( + 'F6-21', + build: (r) async => + ProfileEditScreen(nickname: 'BTS_Hong', repository: r.my), + ), +]; + +Future _loadFonts() async { + final manifest = + json.decode(await rootBundle.loadString('FontManifest.json')) as List; + for (final font in manifest.cast>()) { + final loader = FontLoader(font['family'] as String); + for (final f in (font['fonts'] as List).cast>()) { + 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 _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(); + 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(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); + }); + } +} diff --git a/app/test/design_match/e_test.dart b/app/test/design_match/e_test.dart new file mode 100644 index 0000000..fc3d1d5 --- /dev/null +++ b/app/test/design_match/e_test.dart @@ -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 Function(Repositories r); +typedef After = Future 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 _tap(WidgetTester t, String text) async { + await t.tap(find.text(text).first, warnIfMissed: false); + await _settle(t); +} + +/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다. +Future _settle(WidgetTester t) async { + for (var i = 0; i < 6; i++) { + await t.pump(const Duration(milliseconds: 300)); + } +} + +Future _overlay( + WidgetTester t, + void Function(BuildContext c) open, +) async { + open(t.element(find.byType(Scaffold).first)); + await _settle(t); +} + +/// 시안 알림(229:11265) 표본. 날짜 머리글이 「오늘 · 어제 · 과거」로 갈리도록 지금 기준으로 잡는다. +List _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 _question(Repositories r, String id) async => + (await r.inside.load()).questions.firstWhere((q) => q.id == id); + +final shots = [ + 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 _loadFonts() async { + final manifest = + json.decode(await rootBundle.loadString('FontManifest.json')) as List; + for (final font in manifest.cast>()) { + final loader = FontLoader(font['family'] as String); + for (final f in (font['fonts'] as List).cast>()) { + 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 _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(); + 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(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); + }); + } +} diff --git a/app/test/design_match/home_fixtures.dart b/app/test/design_match/home_fixtures.dart new file mode 100644 index 0000000..f0c68ba --- /dev/null +++ b/app/test/design_match/home_fixtures.dart @@ -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 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, + ); + } +} diff --git a/app/test/eclipse_live_test.dart b/app/test/eclipse_live_test.dart new file mode 100644 index 0000000..b179b3e --- /dev/null +++ b/app/test/eclipse_live_test.dart @@ -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 _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 _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 _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(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(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.broadcast(); + addTearDown(controller.close); + final shared = SharedStream(() { + opened += 1; + return controller.stream; + }); + + final a = []; + final b = []; + final subA = shared.stream.listen(a.add); + final subB = shared.stream.listen(b.add); + await Future.delayed(Duration.zero); + + controller.add(7); + await Future.delayed(Duration.zero); + + // 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다. + expect(opened, 1); + expect(a, [7]); + expect(b, [7]); + + await subA.cancel(); + await subB.cancel(); + }); + + test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async { + final controller = StreamController.broadcast(); + addTearDown(controller.close); + final shared = SharedStream(() => controller.stream); + + final first = []; + final subA = shared.stream.listen(first.add); + await Future.delayed(Duration.zero); + controller.add(42); + await Future.delayed(Duration.zero); + + // 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다. + final late = []; + final subB = shared.stream.listen(late.add); + await Future.delayed(Duration.zero); + expect(late, [42]); + + await subA.cancel(); + await subB.cancel(); + }); + + test('마지막 화면이 떠나면 원본을 닫는다', () async { + var cancelled = false; + final controller = StreamController(onCancel: () => cancelled = true); + addTearDown(controller.close); + final shared = SharedStream(() => controller.stream); + + final sub = shared.stream.listen((_) {}); + await Future.delayed(Duration.zero); + expect(cancelled, isFalse); + + await sub.cancel(); + await Future.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, + ); + }); + }); +} diff --git a/app/test/eclipse_state_stream_test.dart b/app/test/eclipse_state_stream_test.dart new file mode 100644 index 0000000..a2addab --- /dev/null +++ b/app/test/eclipse_state_stream_test.dart @@ -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 = []; + final sub = repo.watch(fandomId: 'bts').listen(states.add); + addTearDown(sub.cancel); + await Future.delayed(const Duration(milliseconds: 20)); + expect(states.last?.eclipseId, 'one'); + await Future.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 = []; + final sub = repo.watch(fandomId: 'bts').listen(states.add); + addTearDown(sub.cancel); + Future flush() => + Future.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 = []; + final banner = []; + 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.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.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.delayed(const Duration(milliseconds: 20)); + expect(results.last?.result, EclipseResult.cancelled); + }); + +} diff --git a/app/test/eclipse_stubs.dart b/app/test/eclipse_stubs.dart new file mode 100644 index 0000000..b55ba35 --- /dev/null +++ b/app/test/eclipse_stubs.dart @@ -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 watch({String? fandomId, bool includeResults = false}) => Stream.value(null); + + @override + Stream> liveEvents({String? fandomId, int limit = 20}) => + Stream.value(const []); + + @override + Future 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.broadcast(); + final _events = StreamController>.broadcast(); + + /// 마지막으로 밀어 넣은 값. 새 구독자가 곧바로 받는다. + EclipseState? _last; + List _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 events) { + _lastEvents = events; + if (_events.hasListener) _events.add(events); + } + + Future dispose() async { + await _eclipse.close(); + await _events.close(); + } + + @override + Stream watch({String? fandomId, bool includeResults = false}) async* { + yield _last; + yield* _eclipse.stream; + } + + @override + Stream> liveEvents({String? fandomId, int limit = 20}) async* { + yield _lastEvents; + yield* _events.stream; + } + + @override + Future 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 watch({String? fandomId, bool includeResults = false}) { + late StreamController controller; + controller = StreamController( + onListen: () { + subscriptions += 1; + active += 1; + controller.add(state); + }, + onCancel: () { + active -= 1; + }, + ); + return controller.stream; + } + + @override + Stream> liveEvents({String? fandomId, int limit = 20}) => + Stream.value(const []); + + @override + Future 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 setPrimary(String fandomId) async {} + + @override + Future load() async => + snapshot ?? await const SampleGalaxyRepository().load(); + + @override + Future 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); + } +} diff --git a/app/test/f0_screens_test.dart b/app/test/f0_screens_test.dart new file mode 100644 index 0000000..8a4deec --- /dev/null +++ b/app/test/f0_screens_test.dart @@ -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 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? returned; + await pumpWide( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () async { + returned = await Navigator.of(context).push>( + 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(find.byType(Text))) { + final data = text.data ?? ''; + expect(data, isNot(contains('lib/'))); + expect(data, isNot(contains('.dart'))); + } + }); + }); +} diff --git a/app/test/f1_screens_test.dart b/app/test/f1_screens_test.dart new file mode 100644 index 0000000..9f90be5 --- /dev/null +++ b/app/test/f1_screens_test.dart @@ -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 = []; + + @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 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() async => + nebulaTargetsFrom({'nebulaMinPop': 2000, 'nebulaMinMembers': 20, 'nebulaDays': 5}); +} + +/// 생성 화면을 위에 얹고, 닫힐 때 돌려준 값을 받는다. +Future> _pushed(WidgetTester tester, Widget screen) async { + final results = []; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Builder( + builder: (context) => TextButton( + onPressed: () async => results.add( + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => screen), + ), + ), + child: const Text('열기'), + ), + ), + ), + ); + await tester.tap(find.text('열기')); + await tester.pumpAndSettle(); + return results; +} + +Future _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( + 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 [ + const NebulaCreateScreen(), + const GrowthCompleteScreen(), + const PlanetCreateScreen(), + const HomeOnboardingScreen(), + ]) { + await _pump(tester, screen); + final texts = tester + .widgetList(find.byType(Text)) + .map((w) => w.data ?? '') + .join('\n'); + for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) { + expect(texts.contains(leak), isFalse, reason: '「$leak」 노출'); + } + } + }); +} diff --git a/app/test/f2_screens_test.dart b/app/test/f2_screens_test.dart new file mode 100644 index 0000000..9381069 --- /dev/null +++ b/app/test/f2_screens_test.dart @@ -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 _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 _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 setPrimary(String fandomId) async {} + + @override + Future zap({ + required String fandomId, + required int count, + required String clientSeq, + }) async => const ZapResult(txId: '', balanceAfter: -1); + + @override + Future 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 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 = []; + + @override + Future setPrimary(String fandomId) async { + if (error case final e?) throw e; + changes.add(fandomId); + } +} + +void main() { + group('GALAXY 모아보기 (§2.56 · §2.58) — 기본 행성 변경', () { + Future 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(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(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 [ + _galaxy(), + const GalaxySearchScreen(), + ]) { + await _pump(tester, screen); + final texts = tester + .widgetList(find.byType(Text)) + .map((w) => w.data ?? '') + .join('\n'); + for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) { + expect(texts.contains(leak), isFalse, reason: '「$leak」 노출'); + } + } + }); +} diff --git a/app/test/f3_screens_test.dart b/app/test/f3_screens_test.dart new file mode 100644 index 0000000..c07fafb --- /dev/null +++ b/app/test/f3_screens_test.dart @@ -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 _pump(WidgetTester tester, Widget screen) async { + await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen)); + await tester.pumpAndSettle(); +} + +/// 세로가 긴 화면을 한 번에 담는다. +/// +/// TabBarView 안에는 스크롤 가능한 것이 여럿이라 `scrollUntilVisible` 이 엉뚱한 +/// 쪽을 잡는다. 실기기에 가까운 세로 길이를 주는 편이 확실하다. +Future _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 join() async {} + + @override + Future history({String? cursor, bool personal = false}) => _sample.history(cursor: cursor); + @override + Future claimHeroDrop(String dropId) => _sample.claimHeroDrop(dropId); + const _NoTopPercentRepository(); + + static const _sample = SamplePlanetRepository(); + + @override + Future 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> morePosts({required int offset, int limit = 5}) => + _sample.morePosts(offset: offset, limit: limit); + + @override + Future planetRanking({required FpiMetric metric, int limit = 30}) => + _sample.planetRanking(metric: metric, limit: limit); + + @override + Future userRanking({ + required FpiMetric metric, + required String fandomId, + int limit = 30, + }) => _sample.userRanking(metric: metric, fandomId: fandomId, limit: limit); + + @override + Future countryRanking({required FpiMetric metric, int limit = 30}) => + _sample.countryRanking(metric: metric, limit: limit); +} + +/// 가입 전 상태로 시작하고, [join] 이 불리면 가입중으로 바뀌는 표본. +class _Joinable extends SamplePlanetRepository { + _Joinable(); + int joins = 0; + + @override + Future join() async => joins++; + + @override + Future 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(register).onPressed, isNull); + + await tester.enterText(find.byType(TextField), ' '); + await tester.pumpAndSettle(); + // 공백만으로는 아직 안 된다. + expect(tester.widget(register).onPressed, isNull); + + await tester.enterText(find.byType(TextField), '본문입니다'); + await tester.pumpAndSettle(); + expect(tester.widget(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(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(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 = [ + 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 = >{ + 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(0, (a, s) => a + s.value), + ); + }); + + test('사용자 랭킹도 지표마다 다르다', () async { + final tops = { + 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 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 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); + }); + }); +} diff --git a/app/test/f4_screens_test.dart b/app/test/f4_screens_test.dart new file mode 100644 index 0000000..279d9ef --- /dev/null +++ b/app/test/f4_screens_test.dart @@ -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 pumpWide(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: child)); + await tester.pump(); +} + +void main() { + // 표본 저장소는 목 모드에서 「보상완료」를 기억한다(서버의 questProgress 자리). + // 회차마다 지워 두지 않으면 앞 테스트가 받은 보상이 다음 테스트에 남는다. + setUp(SampleQuestRepository.resetClaims); + + group('보상형 광고 (B-03 · 별첨 1 ⑥)', () { + Future watchAd(WidgetTester tester, QuestRepository repo) async { + await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo)); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '보상받기')); + await tester.pumpAndSettle(); + await tester.tap(find.text('광고 보고 추가 보상 받기')); + await tester.pumpAndSettle(); + } + + test('안내값 — 운영 Quest 는 기본 × (배수 − 1), 사용자 생성은 고정값 (B-03 · C-02)', () { + const card = QuestCard( + id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin, + stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 30, participants: 0, + ); + expect(card.adBonusPop, 30); + expect( + QuestCard( + id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin, + stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 30, + participants: 0, adMultiplier: 3, + ).adBonusPop, + 60, + ); + expect( + const QuestCard( + id: 'u', title: 't', categoryName: 'c', author: QuestAuthor.user, + stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 500, participants: 0, + ).adBonusPop, + 10, + ); + }); + + testWidgets('끝까지 본 광고만 서버에 청구하고 지급액을 띄운다', (tester) async { + final repo = _AdRepo(); + await watchAd(tester, repo); + expect(repo.claimed, ['imp-1']); + expect(find.text('광고 추가 보상 +20 POP'), findsOneWidget); + }); + + testWidgets('불러오지 못하면 기본 보상만 받는다', (tester) async { + final repo = _AdRepo( + loadError: const BackendException('E-AD-01', '광고를 불러오지 못했어요.'), + ); + await watchAd(tester, repo); + expect(repo.claimed, isEmpty); + expect(find.text('+10 받았어요!'), findsOneWidget); + expect(find.textContaining('광고 추가 보상'), findsNothing); + }); + + testWidgets('중간에 닫으면 청구하지 않는다', (tester) async { + final repo = _AdRepo(impression: null); + await watchAd(tester, repo); + expect(repo.claimed, isEmpty); + expect(find.text('+10 받았어요!'), findsOneWidget); + expect(find.textContaining('광고 추가 보상'), findsNothing); + }); + + testWidgets('일일 한도(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(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( + 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( + find.widgetWithText(FilledButton, '보상 완료'), + ); + expect(button.onPressed, isNull); + }); + }); + + group('생성 화면 (F4 · 254:164081)', () { + testWidgets('완료 버튼은 값이 다 차기 전까지 비활성이다', (tester) async { + await pumpWide(tester, const QuestCreateScreen(repository: _sample)); + await tester.pump(); + final done = tester.widget( + find.widgetWithText(TextButton, '완료'), + ); + expect(done.onPressed, isNull); + }); + + testWidgets('미리보기가 입력을 실시간으로 따라간다', (tester) async { + await pumpWide(tester, const QuestCreateScreen(repository: _sample)); + await tester.pump(); + expect(find.text('옵션을 선택, 미리 보기를 확인하세요'), findsOneWidget); + + // 명세 523:18173 — 입력 필드는 카테고리를 고른 뒤에 나타난다. + expect(find.widgetWithText(TextField, '퀘스트명'), findsNothing); + await tester.tap(find.text('커뮤니티')); + await tester.pump(); + + await tester.enterText( + find.widgetWithText(TextField, '퀘스트명'), '아침 산책', + ); + await tester.pump(); + expect(find.text('아침 산책'), findsWidgets); + }); + + testWidgets('위치 분류를 고르면 장소 선택이 나타난다', (tester) async { + await pumpWide(tester, const QuestCreateScreen(repository: _sample)); + await tester.pump(); + expect(find.textContaining('목표 위치'), findsNothing); + + await tester.tap(find.text('특정 위치')); + await tester.pump(); + expect(find.textContaining('목표 위치'), findsOneWidget); + }); + + testWidgets('위치 분류에서는 장소를 고르기 전까지 완료가 열리지 않는다', (tester) async { + await pumpWide(tester, const QuestCreateScreen(repository: _sample)); + await tester.pump(); + await tester.tap(find.text('특정 위치')); + await tester.pump(); + await _fill(tester); + + var done = tester.widget( + find.widgetWithText(TextButton, '완료'), + ); + expect(done.onPressed, isNull); + + // 시안 서체·입력 테마를 적용하면 폼이 길어져 기본 창에서는 이 행이 접힌다. + await tester.ensureVisible(find.textContaining('목표 위치')); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('목표 위치')); + await tester.pumpAndSettle(); + expect(find.byType(PlacePickerScreen), findsOneWidget); + await tester.tap(find.text('홍대 팝업스토어')); + await tester.pumpAndSettle(); + await tester.tap(find.text('선택 완료')); + await tester.pumpAndSettle(); + + done = tester.widget(find.widgetWithText(TextButton, '완료')); + expect(done.onPressed, isNotNull); + }); + + testWidgets('완료를 누르면 만들기 전에 되묻는다', (tester) async { + await pumpWide(tester, const QuestCreateScreen(repository: _sample)); + await tester.pump(); + await tester.tap(find.text('커뮤니티')); + await tester.pump(); + await _fill(tester); + + await tester.tap(find.widgetWithText(TextButton, '완료')); + await tester.pumpAndSettle(); + expect(find.text('이 내용으로 만들까요?'), findsOneWidget); + }); + }); + + group('사용자 생성 — 활동별 입력 · 링크 · 검토 상태 (C-01 · C-05 · C-08)', () { + testWidgets('활동을 고르면 그 활동에 필요한 입력만 나타난다', (tester) async { + await pumpWide(tester, const QuestCreateScreen(repository: _sample)); + await tester.pump(); + await tester.tap(find.text('행성 활동')); + await tester.pump(); + expect(find.widgetWithText(TextField, '퀘스트명'), findsNothing); + await tester.tap(find.text('투표 참여')); + await tester.pump(); + expect(find.widgetWithText(TextField, '링크 주소'), findsOneWidget); + expect(find.textContaining('목표 위치'), findsNothing); + }); + + testWidgets('링크 조회가 실패하면 제목을 받아야 완료가 열린다', (tester) async { + await pumpWide(tester, const QuestCreateScreen(repository: _sample)); + await tester.pump(); + await tester.tap(find.text('행성 활동')); + await tester.pump(); + await tester.tap(find.text('투표 참여')); + await tester.pump(); + await _fill(tester); + await tester.enterText(find.widgetWithText(TextField, '링크 주소'), 'https://vote.example.com/1'); + await tester.tap(find.byTooltip('링크 확인')); + await tester.pumpAndSettle(); + + var done = tester.widget(find.widgetWithText(TextButton, '완료')); + expect(done.onPressed, isNull); + await tester.enterText(find.widgetWithText(TextField, '링크 제목'), '연말 시상식 투표'); + await tester.pump(); + done = tester.widget(find.widgetWithText(TextButton, '완료')); + expect(done.onPressed, isNotNull); + }); + + testWidgets('검토 중 제출 건은 안내를 띄우고 버튼을 잠근다 · 24h 지연 안내', (tester) async { + await pumpWide(tester, const QuestDetailScreen(questId: 'v1', repository: _ReviewQuests())); + await tester.pumpAndSettle(); + expect(find.text('작성자 설명'), findsOneWidget); + final delayed = find.text('검토가 늦어지고 있어요. 운영팀이 확인하고 있습니다.'); + await tester.scrollUntilVisible(delayed, 120, scrollable: find.byType(Scrollable).first); + expect(delayed, findsOneWidget); + expect(find.text('검토 중이에요. 승인되면 보상이 지급돼요.'), findsOneWidget); + final button = tester.widget(find.widgetWithText(FilledButton, '검토 중')); + expect(button.onPressed, isNull); + }); + }); + + group('위치 선택 (F4 · 371:356705)', () { + testWidgets('고르기 전에는 확정 버튼이 비활성이다', (tester) async { + await pumpWide(tester, const PlacePickerScreen()); + final button = tester.widget( + find.widgetWithText(FilledButton, '선택 완료'), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('검색으로 목록이 줄어든다', (tester) async { + await pumpWide(tester, const PlacePickerScreen()); + await tester.enterText(find.byType(TextField), '홍대'); + await tester.pump(); + expect(find.text('홍대 팝업스토어'), findsOneWidget); + expect(find.text('고척 스카이돔'), findsNothing); + }); + + testWidgets('없는 장소를 찾으면 빈 상태를 알린다', (tester) async { + await pumpWide(tester, const PlacePickerScreen()); + await tester.enterText(find.byType(TextField), '없는장소'); + await tester.pump(); + expect(find.text('검색 결과가 없습니다.'), findsOneWidget); + }); + }); + + testWidgets('F4 화면 문구에 내부 경로가 새지 않는다', (tester) async { + for (final screen in [ + const QuestListScreen(repository: _sample), + const QuestDetailScreen(questId: 'q1', repository: _sample), + const QuestCreateScreen(repository: _sample), + const PlacePickerScreen(), + ]) { + await pumpWide(tester, screen); + await tester.pump(); + for (final text in tester.widgetList(find.byType(Text))) { + final data = text.data ?? ''; + expect(data, isNot(contains('lib/'))); + expect(data, isNot(contains('app/'))); + expect(data, isNot(contains('.dart'))); + } + } + }); +} + +/// 광고 경계만 바꾼 표본 — 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 = []; + + @override + Future loadRewardedAd() async { + if (loadError case final error?) throw error; + return _Ad(impression); + } + + @override + Future claimAdReward(String impressionId, QuestReward base) async { + claimed.add(impressionId); + if (claimError case final error?) throw error; + return 20; + } +} + +class _Ad implements RewardedAdHandle { + _Ad(this.impression); + + final String? impression; + + @override + Future show() async => impression; + + @override + void dispose() {} +} + +QuestCard _stage(QuestCard card, QuestStage stage) => QuestCard( + id: card.id, title: card.title, categoryName: card.categoryName, + author: card.author, stage: stage, goal: card.goal, progress: card.progress, + rewardPop: card.rewardPop, participants: card.participants, +); + +Future _fill(WidgetTester tester) async { + // 활동별 입력이 붙어 폼이 길다 — 목록 아래 칸은 스크롤해야 만들어진다. + for (final (label, value) in [('퀘스트명', '아침 산책'), ('참여 인원', '10'), ('완료 보상 POP', '30')]) { + final field = find.widgetWithText(TextField, label); + await tester.scrollUntilVisible(field, 120, scrollable: find.byType(Scrollable).first); + await tester.enterText(field, value); + } + await tester.pump(); +} + +/// 검토 대기 제출 건이 있는 사용자 생성 Vote Quest. +class _ReviewQuests extends SampleQuestRepository { + const _ReviewQuests(); + + @override + Future detail(String questId) async => QuestDetail( + card: const QuestCard( + id: 'v1', title: '연말 시상식 투표', categoryName: '투표 참여', type: QuestType.vote, + author: QuestAuthor.user, stage: QuestStage.inProgress, goal: 1, progress: 0, + rewardPop: 5, participants: 1, capacity: 10, + ), + imageLabel: '', + description: '인증샷을 올려 주세요', + link: const QuestLink(url: 'https://vote.example.com', status: 'preview_failed', title: '시상식 투표'), + endsAt: DateTime.now().add(const Duration(hours: 5)), + submission: QuestSubmission( + id: 'v1_me', status: 'submitted', + submittedAt: DateTime.now().subtract(const Duration(hours: 25)), + ), + ); +} diff --git a/app/test/f5_screens_test.dart b/app/test/f5_screens_test.dart new file mode 100644 index 0000000..9a0305a --- /dev/null +++ b/app/test/f5_screens_test.dart @@ -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 pumpWide(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(1080, 2600); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: child)); + await tester.pump(); +} + +CampaignCardData _card({ + int likes = 0, int comments = 0, int views = 0, int shares = 0, + int goal = 100, int raised = 50, +}) => CampaignCardData( + id: 'x', title: 't', body: 'b', authorName: 'a', + createdAt: DateTime(2026, 9, 1), status: CampaignStatus.funding, + goalPop: goal, raisedPop: raised, supporters: 1, + likes: likes, comments: comments, views: views, shares: shares, +); + +void main() { + group('내 캠페인 철회 (B-58)', () { + testWidgets('검수 중이면 등록비 반환을 알리고 철회하면 철회 상태로 바뀐다', (tester) async { + final repo = _Mine(CampaignStatus.review); + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1', repository: repo, myPop: 0)); + await tester.pumpAndSettle(); + expect(find.text('운영 검수 중인 캠페인입니다.'), findsOneWidget); + await tester.tap(find.text('캠페인 철회')); + await tester.pumpAndSettle(); + expect(find.textContaining('돌려드립니다'), findsOneWidget); + await tester.tap(find.text('철회하기')); + await tester.pumpAndSettle(); + expect(repo.withdrawn, ['c1']); + expect(find.text('개설자가 철회한 캠페인입니다.'), findsOneWidget); + expect(find.text('캠페인 철회'), findsNothing); + }); + + testWidgets('공개 후 첫 후원 전이면 비환불을 알린다', (tester) async { + await pumpWide( + tester, + CampaignDetailScreen(campaignId: 'c1', repository: _Mine(CampaignStatus.funding), myPop: 0), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('캠페인 철회')); + await tester.pumpAndSettle(); + expect(find.textContaining('돌려드리지 않습니다'), findsOneWidget); + }); + + testWidgets('첫 후원 뒤에는 철회 버튼이 없다', (tester) async { + await pumpWide( + tester, + CampaignDetailScreen( + campaignId: 'c1', + repository: _Mine(CampaignStatus.funding, supporters: 1), + myPop: 0, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('캠페인 철회'), findsNothing); + }); + + test('운영 취소는 반환 안내를 보인다', () { + expect(CampaignStatus.cancelled.notice, contains('돌려드렸습니다')); + expect(CampaignStatus.funding.notice, isNull); + }); + }); + + group('상대 시각 · 반응 수치 (F5 · 523:18399)', () { + final now = DateTime(2026, 9, 1, 12); + + test('일주일이 넘으면 날짜로 바뀐다', () { + expect(relativeTime(now.subtract(const Duration(days: 6)), now: now), '6일 전'); + expect( + relativeTime(DateTime(2026, 1, 1), now: now), + '2026.01.01', + ); + }); + + test('반응 수치는 K 단위로 줄인다', () { + expect(formatReaction(999), '999'); + expect(formatReaction(1200), '1.2K'); + expect(formatReaction(3000), '3K'); + expect(formatReaction(9999), '10K'); + expect(formatReaction(12400), '12K'); + }); + }); + + group('달성률과 인기 정렬', () { + test('목표가 0이면 달성률도 0이다', () { + expect(_card(goal: 0, raised: 100).achievedPercent, 0); + }); + + test('달성률을 반올림한다', () { + expect(_card(goal: 3, raised: 1).achievedPercent, 33); + }); + + test('인기순은 좋아요 → 댓글 → 조회수 → 공유수 순으로 본다', () { + final a = _card(likes: 10, comments: 1); + final b = _card(likes: 10, comments: 5); + // 좋아요가 같으면 댓글이 많은 쪽이 앞선다. + expect(a.comparePopularity(b) > 0, isTrue); + + final c = _card(likes: 10, comments: 5, views: 1); + final d = _card(likes: 10, comments: 5, views: 9); + expect(c.comparePopularity(d) > 0, isTrue); + + final e = _card(likes: 20); + expect(e.comparePopularity(a) < 0, isTrue); + }); + }); + + group('목록 (F5 · 272:241184)', () { + testWidgets('필터 세 개가 있고 디폴트는 최신순이다', (tester) async { + await pumpWide(tester, CampaignListScreen()); + await tester.pump(); + for (final sort in CampaignSort.values) { + expect(find.widgetWithText(CampaignSortChip, sort.label), findsOneWidget); + } + final chip = tester.widget( + find.widgetWithText(CampaignSortChip, '최신순'), + ); + expect(chip.selected, isTrue); + }); + + testWidgets('최신순은 최근 캠페인이 위에 온다', (tester) async { + await pumpWide(tester, CampaignListScreen()); + await tester.pump(); + final titles = tester + .widgetList(find.byType(Text)) + .map((w) => w.data ?? '') + .where((s) => s.contains('광고') || s.contains('커피차')) + .toList(); + expect(titles.first, contains('LED')); + }); + + testWidgets('기여중 필터는 내가 기여한 것만 남긴다', (tester) async { + await pumpWide(tester, CampaignListScreen()); + await tester.pump(); + await tester.tap(find.widgetWithText(CampaignSortChip, '기여중')); + await tester.pump(); + await tester.pump(); + expect(find.textContaining('강남역'), findsOneWidget); + expect(find.textContaining('커피차'), findsNothing); + }); + + testWidgets('본문은 두 줄에서 잘린다', (tester) async { + await pumpWide(tester, CampaignListScreen()); + await tester.pump(); + final body = tester.widget( + find.textContaining('도심을 BTS의 색으로'), + ); + expect(body.maxLines, 2); + expect(body.overflow, TextOverflow.ellipsis); + }); + + testWidgets('새 캠페인 버튼이 생성 화면을 연다', (tester) async { + await pumpWide(tester, CampaignListScreen()); + await tester.pump(); + await tester.tap(find.text('새 캠페인')); + await tester.pumpAndSettle(); + expect(find.byType(CampaignCreateScreen), findsOneWidget); + }); + }); + + group('검색 (F5 · 272:241681 · 272:241724)', () { + testWidgets('처음에는 최근 검색어가 보인다', (tester) async { + await pumpWide(tester, CampaignSearchScreen()); + await tester.pump(); + expect(find.text('최근 검색어'), findsOneWidget); + expect(find.text('강남역'), findsOneWidget); + }); + + testWidgets('검색어 칩의 ✕ 는 그 검색어만 지운다', (tester) async { + await pumpWide(tester, CampaignSearchScreen()); + await tester.pump(); + await tester.tap(find.byTooltip('강남역 삭제')); + await tester.pumpAndSettle(); + expect(find.text('강남역'), findsNothing); + expect(find.text('LED'), findsOneWidget); + }); + + testWidgets('전체 삭제하면 빈 안내가 뜬다', (tester) async { + await pumpWide(tester, CampaignSearchScreen()); + await tester.pump(); + await tester.tap(find.text('전체 삭제')); + await tester.pumpAndSettle(); + expect(find.text('최신 검색어가 없습니다'), findsOneWidget); + }); + + testWidgets('검색하면 결과 화면으로 바뀐다', (tester) async { + await pumpWide(tester, CampaignSearchScreen()); + await tester.pump(); + await tester.enterText(find.byType(TextField), '커피차'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(find.textContaining('커피차'), findsWidgets); + expect(find.text('최근 검색어'), findsNothing); + }); + + testWidgets('없는 말을 찾으면 빈 결과를 알린다', (tester) async { + await pumpWide(tester, CampaignSearchScreen()); + await tester.pump(); + await tester.enterText(find.byType(TextField), '없는캠페인'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(find.text('검색 결과가 없습니다'), findsOneWidget); + }); + + testWidgets('검색어가 최근 목록 맨 앞에 쌓인다', (tester) async { + final repo = SampleCampaignRepository(); + await pumpWide(tester, CampaignSearchScreen(repository: repo)); + await tester.pump(); + await repo.search('커피차'); + final recent = await repo.recentQueries(); + expect(recent.first, '커피차'); + // 중복 제거 — 이미 있던 「강남역」을 다시 찾아도 하나다. + await repo.search('강남역'); + final again = await repo.recentQueries(); + expect(again.where((q) => q == '강남역').length, 1); + expect(again.first, '강남역'); + }); + }); + + group('상세 (F5 · 272:245738)', () { + testWidgets('디폴트 탭은 캠페인 정보다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + expect(find.text('캠페인 정보'), findsOneWidget); + expect(find.textContaining('응원 게시판'), findsOneWidget); + expect(find.text('92%'), findsOneWidget); + }); + + testWidgets('사진 개수만큼 인디케이터가 생긴다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('대표 이미지'), findsOneWidget); + expect(find.byKey(const ValueKey('campaign-media')), findsOneWidget); + }); + + testWidgets('좋아요는 누르면 늘고 다시 누르면 줄어든다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + expect(find.text('820'), findsOneWidget); + await tester.tap(find.byTooltip('좋아요')); + await tester.pump(); + expect(find.text('821'), findsOneWidget); + await tester.tap(find.byTooltip('좋아요')); + await tester.pump(); + expect(find.text('820'), findsOneWidget); + }); + + testWidgets('내 캠페인에는 기여하기 버튼이 없다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c3')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, '기여하기'), findsNothing); + }); + + testWidgets('타인 캠페인에는 기여하기 버튼이 있다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, '기여하기'), findsOneWidget); + }); + + testWidgets('기여 시트는 값을 정하기 전까지 완료가 잠겨 있다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '기여하기')); + await tester.pumpAndSettle(); + + expect(find.text('기여 POP 설정'), findsOneWidget); + final done = tester.widget( + find.widgetWithText(FilledButton, '완료'), + ); + expect(done.onPressed, isNull); + }); + + testWidgets('보유 POP 을 넘겨 끌 수 없다', (tester) async { + await pumpWide( + tester, + CampaignDetailScreen(campaignId: 'c1', myPop: 300), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '기여하기')); + await tester.pumpAndSettle(); + final slider = tester.widget(find.byType(Slider)); + expect(slider.max, 300); + expect(find.text('나의 보유 POP : 300'), findsOneWidget); + }); + + testWidgets('응원 게시판 탭으로 넘어가면 댓글이 보인다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('응원 게시판')); + await tester.pumpAndSettle(); + expect(find.text('꼭 성공했으면 좋겠어요!'), findsOneWidget); + }); + + testWidgets('공유 시트에 네 경로가 뜬다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('공유')); + await tester.pumpAndSettle(); + for (final target in const ['인스타그램', '틱톡', 'X', '링크 복사']) { + expect(find.byTooltip(target), findsOneWidget); + } + }); + + testWidgets('「실시간 랭킹」에서 랭킹 화면으로 간다', (tester) async { + await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + final list = find.byKey(const ValueKey('campaign-info')); + // 끝까지 굴린다. 하단 고정 버튼이 마지막 줄을 덮지 않는지도 함께 본다. + await tester.drag(list, const Offset(0, -1200)); + await tester.pumpAndSettle(); + await tester.tap(find.text('실시간 랭킹')); + await tester.pumpAndSettle(); + expect(find.byType(CampaignRankingScreen), findsOneWidget); + }); + }); + + group('랭킹 (F5 · 333:70971)', () { + testWidgets('내 순위를 맨 위에 고정한다', (tester) async { + await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + expect(find.text('나의 기여 랭킹'), findsOneWidget); + expect(find.text('나'), findsWidgets); + }); + + testWidgets('필터 바텀시트에 세 구간이 있다', (tester) async { + await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + await tester.tap(find.text('실시간 랭킹')); + await tester.pumpAndSettle(); + for (final w in RankWindow.values) { + expect(find.text(w.label), findsWidgets); + } + expect(find.text('취소'), findsOneWidget); + }); + + testWidgets('구간을 바꾸면 라벨이 따라 바뀐다', (tester) async { + await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1')); + await tester.pumpAndSettle(); + await tester.tap(find.text('실시간 랭킹')); + await tester.pumpAndSettle(); + await tester.tap(find.text('주간').last); + await tester.pumpAndSettle(); + expect(find.text('주간 랭킹'), findsOneWidget); + }); + }); + + group('새 캠페인 (F5 · 287:16991)', () { + testWidgets('검수를 거친다는 사실을 먼저 알린다', (tester) async { + await pumpWide(tester, CampaignCreateScreen()); + expect(find.textContaining('운영 검수를 지난 뒤 모금이 열립니다'), findsOneWidget); + }); + + testWidgets('진행 기간은 사본 캘린더 시트로 고른다(333:72497 · 333:72719)', (tester) async { + DateTimeRange? picked; + await pumpWide( + tester, + Builder( + builder: (context) => TextButton( + onPressed: () async => picked = await showCampaignPeriodSheet( + context, + today: DateTime(2026, 4, 1), + ), + child: const Text('열기'), + ), + ), + ); + await tester.tap(find.text('열기')); + await tester.pumpAndSettle(); + expect(find.text('2026년 4월'), findsOneWidget); + await tester.tap(find.text('9')); + await tester.pump(); + // 끝을 고르기 전에는 「기간 선택」이 잠겨 있다. + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + await tester.tap(find.text('23')); + await tester.pump(); + await tester.tap(find.text('4월 9일 ~ 4월 23일')); + await tester.pumpAndSettle(); + expect( + picked, + DateTimeRange(start: DateTime(2026, 4, 9), end: DateTime(2026, 4, 23)), + ); + }); + + testWidgets('오늘 이전 날짜와 1년 뒤 달은 고를 수 없다', (tester) async { + await pumpWide( + tester, + Scaffold(body: CampaignPeriodSheet(today: DateTime(2026, 4, 5))), + ); + await tester.tap(find.text('3').first); + await tester.pump(); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + // 12번 넘기면 2027년 4월(오늘+365일이 든 달)에서 멈춘다. + for (var i = 0; i < 14; i++) { + await tester.tap(find.bySemanticsLabel('다음 달')); + await tester.pump(); + } + expect(find.text('2027년 4월'), findsOneWidget); + }); + + testWidgets('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( + find.widgetWithText(TextButton, '완료'), + ); + expect(done.onPressed, isNull); + + await tester.enterText( + find.widgetWithText(TextField, '캠페인 제목을 입력해주세요.'), '커피차', + ); + await tester.enterText( + find.widgetWithText(TextField, '캠페인 설명을 작성해주세요.'), '스태프 응원', + ); + // 원장 B-32 · B-59 — 목표는 입력이 아니라 상품 선택. C-14 — 권장 기간 자동 입력. + expect(find.widgetWithText(TextField, '목표 POP'), findsNothing); + await tester.tap(find.text('커피차 서포트')); + await tester.pump(); + expect(find.text('500,000 POP'), findsOneWidget); + expect(find.text('날짜 선택'), findsNothing); + + done = tester.widget(find.widgetWithText(TextButton, '완료')); + expect(done.onPressed, isNotNull); + }); + + testWidgets('올리기 전에 되묻는다', (tester) async { + await pumpWide(tester, CampaignCreateScreen()); + await tester.enterText( + find.widgetWithText(TextField, '캠페인 제목을 입력해주세요.'), '커피차', + ); + await tester.enterText( + find.widgetWithText(TextField, '캠페인 설명을 작성해주세요.'), '스태프 응원', + ); + await tester.tap(find.text('커피차 서포트')); + await tester.pump(); + await tester.tap(find.widgetWithText(TextButton, '완료')); + await tester.pumpAndSettle(); + expect(find.text('이 내용으로 올릴까요?'), findsOneWidget); + expect( + find.descendant( + of: find.byType(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 [ + CampaignListScreen(), + CampaignSearchScreen(), + CampaignDetailScreen(campaignId: 'c1'), + CampaignRankingScreen(campaignId: 'c1'), + CampaignCreateScreen(), + ]) { + await pumpWide(tester, screen); + await tester.pumpAndSettle(); + for (final text in tester.widgetList(find.byType(Text))) { + final data = text.data ?? ''; + expect(data, isNot(contains('lib/'))); + expect(data, isNot(contains('.dart'))); + } + } + }); +} + +/// 내 캠페인 상세 — 상태 · 후원 수를 바꿔 철회 버튼을 본다(B-58). +class _Mine extends SampleCampaignRepository { + _Mine(this.status, {this.supporters = 0}); + + final CampaignStatus status; + final int supporters; + final withdrawn = []; + + @override + Future detail(String campaignId) async { + final base = await super.detail(campaignId); + final c = base.card; + return CampaignDetail( + card: CampaignCardData( + id: c.id, title: c.title, body: c.body, authorName: c.authorName, + createdAt: c.createdAt, + status: withdrawn.isEmpty ? status : CampaignStatus.withdrawn, + goalPop: c.goalPop, raisedPop: supporters == 0 ? 0 : 10, + supporters: supporters, mine: true, + likes: 0, comments: 0, views: 0, shares: 0, + ), + mediaLabels: base.mediaLabels, + endsAt: base.endsAt, + topContributors: const [], + ); + } + + @override + Future withdraw(String campaignId) async => withdrawn.add(campaignId); +} + +/// 결과보고가 게시된 상세. 사진 URL 은 비워 둔다 — 위젯 테스트는 네트워크를 타지 않는다. +class _Reported extends SampleCampaignRepository { + @override + Future detail(String campaignId) async { + final base = await super.detail(campaignId); + return CampaignDetail( + card: base.card, + mediaLabels: base.mediaLabels, + endsAt: base.endsAt, + topContributors: base.topContributors, + report: CampaignReport( + body: '광고 집행 완료', + photoUrls: const [], + receiptUrls: const [], + budget: const [(label: '광고 집행', amount: 450), (label: '제작비', amount: 50)], + createdAt: DateTime(2026, 9, 20), + ), + ); + } +} diff --git a/app/test/f6_screens_test.dart b/app/test/f6_screens_test.dart new file mode 100644 index 0000000..11d1209 --- /dev/null +++ b/app/test/f6_screens_test.dart @@ -0,0 +1,1002 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/auth/auth_repository.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/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_test/flutter_test.dart'; + +/// F6 MY — 화면 대조 원장 21화면. + +/// 1×1 투명 PNG. 화면이 고른 사진을 실제로 디코딩해 그리므로 진짜 이미지여야 한다. +final Uint8List _pngPixel = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8' + '/x8AAwMCAO+ip1sAAAAASUVORK5CYII=', +); + +PickedPhoto _photo() => + PickedPhoto(bytes: _pngPixel, contentType: 'image/png', name: 'photo.png'); + +/// 상한을 1바이트 넘는 사진. 형식 이전에 용량에서 걸리므로 내용은 보지 않는다. +PickedPhoto _oversizePhoto() => PickedPhoto( + bytes: Uint8List(profilePhotoMaxBytes + 1), + contentType: 'image/png', + name: 'huge.png', +); + +PickedPhoto _wrongTypePhoto() => PickedPhoto( + bytes: _pngPixel, + contentType: 'application/pdf', + name: 'doc.pdf', +); + +Future pumpWide(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(1080, 2600); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: child)); + await tester.pump(); +} + +/// 머리의 「완료」가 눌리는가. 시안 글자 버튼이라 [Semantics.enabled] 로 본다. +bool _done(WidgetTester tester) => tester + .getSemantics(find.text('완료')) + .flagsCollection + .isEnabled + .toBoolOrNull()!; + +/// 글자 없는 버튼(격자 칸 · 카메라 배지)을 [Semantics] 라벨로 찾는다. +Finder _labeled(String label) => find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == label, +); + +/// 시안 글자 버튼(환불 「요청」)의 눌림 처리. +GestureTapCallback? _tapOf(WidgetTester tester, String text) => tester + .widget( + find + .ancestor(of: find.text(text), matching: find.byType(GestureDetector)) + .first, + ) + .onTap; + +/// 푸시 설정 한 줄의 스위치. +Finder _switch(String label) => find.descendant( + of: find.widgetWithText(Row, label), + matching: find.byType(Switch), +); + +/// 올린 사진만 기록한다. 표본 URL 은 테스트에서 내려받을 수 없어 화면에 걸지 않는다. +class _UploadRecorder extends SampleMyRepository { + PickedPhoto? uploaded; + + @override + Future uploadProfilePhoto(PickedPhoto photo) async { + checkProfilePhoto(photo); + uploaded = photo; + return 'uploaded'; + } +} + +void main() { + group('레벨 · 칭호 (F6 · 정책 원장 C-18 · B-65)', () { + test('칭호는 레벨 구간으로 정해진다', () { + final cases = { + 1: LevelTitle.glow, + 9: LevelTitle.glow, + 10: LevelTitle.spark, + 29: LevelTitle.spark, + 30: LevelTitle.beam, + 59: LevelTitle.beam, + 60: LevelTitle.starlight, + 99: LevelTitle.starlight, + 100: LevelTitle.nova, + 150: LevelTitle.nova, + }; + cases.forEach( + (level, title) => + expect(LevelTitle.of(level), title, reason: 'Lv.$level'), + ); + }); + + test('Lv.100 이후에도 숫자 레벨을 그대로 쓴다', () { + const profile = MyProfile( + nickname: 'a', + email: 'a@b.c', + contributedPop: 0, + userExp: 0, + userLevel: 150, + balancePop: 0, + steps: 0, + completedQuests: 0, + planetTiers: [], + ); + expect(profile.levelLabel, 'Lv.150 NOVA'); + }); + + test('필드가 없으면 Lv.1 · SILVER 로 둔다', () { + const profile = MyProfile( + nickname: 'a', + email: 'a@b.c', + contributedPop: 0, + balancePop: 0, + steps: 0, + completedQuests: 0, + planetTiers: [], + ); + expect(profile.levelLabel, 'Lv.1 GLOW'); + expect(profile.passportTier, PassportTier.silver); + expect(PassportTier.parse(''), PassportTier.silver); + expect(PassportTier.parse('diamond'), PassportTier.diamond); + }); + + test('다음 레벨까지 남은 EXP 와 진행도를 낸다', () { + final from = expForLevel(19); + final to = expForLevel(20); + final profile = MyProfile( + nickname: 'a', + email: 'a@b.c', + contributedPop: 0, + userExp: from + 100, + userLevel: 19, + balancePop: 0, + steps: 0, + completedQuests: 0, + planetTiers: const [], + ); + expect(profile.toNextLevel, to - from - 100); + expect(profile.levelProgress, closeTo(100 / (to - from), 0.0001)); + }); + }); + + group('MY 첫 화면 (F6 · 373:632962)', () { + testWidgets('닉네임·등급·통계를 보여 준다', (tester) async { + await pumpWide(tester, MyScreen(repository: SampleMyRepository())); + await tester.pump(); + expect(find.text('아미보이'), findsOneWidget); + expect(find.text('Lv.18 SPARK'), findsOneWidget); + expect(find.text(' · SILVER'), findsOneWidget); + // 시안 티어 카드 아래 줄 — 레벨 구간 진행도의 EXP(C-18). + expect(find.text('EXP'), findsOneWidget); + expect(find.text('128,450'), findsOneWidget); + expect(find.text('37'), findsOneWidget); + }); + + testWidgets('프로필을 누르면 카드 오버레이가 뜬다', (tester) async { + await pumpWide(tester, MyScreen(repository: SampleMyRepository())); + await tester.pump(); + await tester.tap(find.byType(CircleAvatar).first); + await tester.pumpAndSettle(); + for (final target in const ['인스타그램', '틱톡', 'X', '링크 복사']) { + expect(find.byTooltip(target), findsOneWidget); + } + }); + + testWidgets('티어 카드를 누르면 행성별 티어로 넘어간다', (tester) async { + await pumpWide(tester, MyScreen(repository: SampleMyRepository())); + await tester.pump(); + await tester.tap(find.text('Lv.18 SPARK')); + await tester.pumpAndSettle(); + expect(find.byType(TierDetailScreen), findsOneWidget); + }); + }); + + group('티어 (F6 · 373:633256 · 494:16177)', () { + testWidgets('티어 정보는 칭호를 낮은 순으로 늘어놓는다', (tester) async { + await pumpWide(tester, const TierInfoScreen()); + final labels = tester + .widgetList(find.byType(Text)) + .map((w) => w.data ?? '') + .where(LevelTitle.values.map((t) => '${t.label} TIER').contains) + .toList(); + expect(labels, [ + 'GLOW TIER', + 'SPARK TIER', + 'BEAM TIER', + 'STARLIGHT TIER', + 'NOVA TIER', + ]); + }); + + testWidgets('기준치는 레벨 구간이고 POP 문턱은 없다 (C-18)', (tester) async { + await pumpWide(tester, const TierInfoScreen()); + for (final range in const [ + 'Lv.1~9', + 'Lv.10~29', + 'Lv.30~59', + 'Lv.60~99', + 'Lv.100~', + ]) { + expect(find.text(range), findsOneWidget); + } + expect(find.textContaining('POP'), findsNothing); + for (final tier in const ['SILVER', 'GOLD', 'DIAMOND']) { + await tester.scrollUntilVisible(find.text(tier), 200); + expect(find.text(tier), findsOneWidget); + } + }); + + testWidgets('행성별 티어는 기본 행성이 맨 앞이다', (tester) async { + const tiers = [ + PlanetTier(fandomName: '작은팬덤', contributedPop: 100), + PlanetTier(fandomName: '큰팬덤', contributedPop: 90000), + PlanetTier(fandomName: '내행성', contributedPop: 500, isPrimary: true), + ]; + const screen = TierDetailScreen(tiers: tiers); + expect(screen.ordered.map((p) => p.fandomName).toList(), [ + '내행성', + '큰팬덤', + '작은팬덤', + ]); + }); + }); + + group('프로필 수정 (F6 · 374:16164 · 374:15479)', () { + testWidgets('바꾸기 전에는 완료가 잠겨 있다', (tester) async { + await pumpWide( + tester, + ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), + ); + final done = _done(tester); + expect(done, isFalse); + }); + + testWidgets('중복 닉네임은 필드 아래 안내로 알린다', (tester) async { + await pumpWide( + tester, + ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), + ); + await tester.enterText(find.byType(TextField), '아미보이2'); + await tester.pump(); + await tester.tap(find.text('완료')); + await tester.pumpAndSettle(); + expect(find.text('이미 쓰고 있는 닉네임입니다.'), findsOneWidget); + }); + + testWidgets('닉네임 「팬」은 예약어 안내를 띄운다', (tester) async { + await pumpWide( + tester, + ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), + ); + await tester.enterText(find.byType(TextField), ' 팬'); + await tester.pump(); + expect(find.text('운영자로 오해될 수 있는 이름은 쓸 수 없습니다.'), findsOneWidget); + expect(_done(tester), isFalse); + await tester.enterText(find.byType(TextField), '팬팬'); + await tester.pump(); + expect(find.text('운영자로 오해될 수 있는 이름은 쓸 수 없습니다.'), findsNothing); + }); + + testWidgets('닉네임은 두 자 미만이면 완료가 열리지 않는다 (B-52)', (tester) async { + await pumpWide( + tester, + ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), + ); + await tester.enterText(find.byType(TextField), '가'); + await tester.pump(); + var done = _done(tester); + expect(done, isFalse); + + await tester.enterText(find.byType(TextField), '가나'); + await tester.pump(); + done = _done(tester); + expect(done, isTrue); + }); + + testWidgets('닉네임은 20자를 넘겨 입력할 수 없다 (B-52)', (tester) async { + await pumpWide( + tester, + ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), + ); + final field = tester.widget(find.byType(TextField)); + expect(field.maxLength, nicknameMaxLength); + + await tester.enterText(find.byType(TextField), '가' * 21); + await tester.pump(); + // maxLength 가 21자째를 잘라내므로 저장 값이 20자를 넘지 않는다. + final done = _done(tester); + expect(done, isTrue); + }); + + testWidgets('사진을 고르기 전에는 완료가 잠겨 있다', (tester) async { + await pumpWide( + tester, + ProfilePhotoScreen(pickPhoto: (_) async => _photo()), + ); + var done = _done(tester); + expect(done, isFalse); + + await tester.tap(_labeled('앨범에서 고르기')); + await tester.pumpAndSettle(); + done = _done(tester); + expect(done, isTrue); + }); + + testWidgets('상한을 넘는 사진은 고른 자리에서 사유를 알린다', (tester) async { + await pumpWide( + tester, + ProfilePhotoScreen(pickPhoto: (_) async => _oversizePhoto()), + ); + await tester.tap(_labeled('앨범에서 고르기')); + await tester.pumpAndSettle(); + expect(find.textContaining('10MB 를 넘습니다'), findsOneWidget); + final done = _done(tester); + expect(done, isFalse); + }); + + testWidgets('허용하지 않는 형식은 거부한다', (tester) async { + await pumpWide( + tester, + ProfilePhotoScreen(pickPhoto: (_) async => _wrongTypePhoto()), + ); + await tester.tap(_labeled('앨범에서 고르기')); + await tester.pumpAndSettle(); + expect(find.textContaining('이미지만 올릴 수 있습니다'), findsOneWidget); + }); + + testWidgets('MY 카메라 배지에서 사진을 고르면 바로 올린다', (tester) async { + final repo = _UploadRecorder(); + await pumpWide(tester, MyScreen(repository: repo)); + await tester.pump(); + await tester.tap(_labeled('프로필 사진 수정')); + await tester.pumpAndSettle(); + expect(find.byType(ProfilePhotoScreen), findsOneWidget); + // 선택기는 플러그인이라 화면 결과만 흉내 낸다 — 고른 사진을 돌려준다. + Navigator.of( + tester.element(find.byType(ProfilePhotoScreen)), + ).pop(_photo()); + await tester.pumpAndSettle(); + expect(repo.uploaded, isNotNull); + }); + }); + + group('설정 (F6 · 354:222008)', () { + testWidgets('연동 상태를 그대로 보여 준다', (tester) async { + await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); + await tester.pump(); + expect(find.text('fan@gmail.com'), findsOneWidget); + // 시안 `374:14705` — 연결된 줄은 「연결해제」, 아닌 줄은 「연결」. + expect(find.text('연결해제'), findsNWidgets(2)); + expect(find.text('연결'), findsNWidgets(2)); + }); + + testWidgets('최신 버전이면 번호 대신 안내를 쓴다', (tester) async { + await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); + await tester.pump(); + // 앱 버전은 목록 아래쪽이라 굴려서 확인한다. + await tester.scrollUntilVisible(find.text('앱 버전'), 200); + expect(find.text('최신 버전'), findsOneWidget); + }); + + testWidgets('탈퇴는 되묻고 POP 소멸을 알린다 (시안 374:14364 · §P-13)', (tester) async { + await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); + await tester.pump(); + await tester.scrollUntilVisible(find.text('서비스 탈퇴'), 200); + await tester.ensureVisible(find.text('서비스 탈퇴')); + await tester.pumpAndSettle(); + await tester.tap(find.text('서비스 탈퇴')); + await tester.pumpAndSettle(); + expect(find.text('서비스 탈퇴를 하시겠습니까?'), findsOneWidget); + expect(find.textContaining('POP 이 사라지고'), findsOneWidget); + expect(find.text('취소'), findsOneWidget); + }); + + testWidgets('SNS 연동 라벨은 제공자 이름만 쓴다 — 「LINE으」 잘림 없음', (tester) async { + await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); + await tester.pump(); + await tester.scrollUntilVisible(find.text('LINE'), 200); + expect(find.text('LINE'), findsOneWidget); + expect(find.textContaining('LINE으'), findsNothing); + }); + + testWidgets('언어 목록은 확정 5종이다', (tester) async { + await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); + await tester.pump(); + // 시안 — 현재 언어 줄을 누르면 언어 설정 시트. + await tester.scrollUntilVisible(find.text('한국어'), 200); + await tester.ensureVisible(find.text('한국어')); + await tester.pumpAndSettle(); + await tester.tap(find.text('한국어')); + await tester.pumpAndSettle(); + expect(find.text('언어 설정'), findsOneWidget); + for (final lang in const ['English', '日本語', '中文', 'Español']) { + expect(find.text(lang), findsOneWidget); + } + }); + }); + + group('SNS 연동 (F6 · 357:222740 외 3)', () { + test('이메일 전체가 같을 때만 같은 계정으로 본다', () { + // 앞부분만 같고 도메인이 다르면 다른 계정이다. + expect( + sameAccountIdentity('test123@naver.com', 'test123@gmail.com'), + isFalse, + ); + expect( + sameAccountIdentity('test123@naver.com', 'other123@gmail.com'), + isFalse, + ); + // 대소문자·앞뒤 공백은 정규화한다. + expect( + sameAccountIdentity(' Test123@Naver.com ', 'test123@naver.com'), + isTrue, + ); + // 빈 값 · `@` 없는 값은 이메일이 아니다. + expect(sameAccountIdentity('', ''), isFalse); + expect(sameAccountIdentity('test123', 'test123'), isFalse); + }); + + // 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.* + test('연동 대상은 확정 4종이고 X 는 없다', () { + expect(linkableProviders, const [ + AuthProviderKind.google, + AuthProviderKind.apple, + AuthProviderKind.kakao, + AuthProviderKind.line, + ]); + expect(linkableProviders, isNot(contains(AuthProviderKind.email))); + }); + + testWidgets('설정 행의 「연결」로 바로 연결하고 「연결해제」가 뜬다', (tester) async { + final linker = SampleAccountLinker( + seed: const {AuthProviderKind.google: 'fan@gmail.com'}, + ); + await pumpWide( + tester, + SettingsScreen(repository: SampleMyRepository(), linker: linker), + ); + await tester.pump(); + await tester.tap( + find.descendant( + of: find.widgetWithText(Row, 'Apple'), + matching: find.text('연결'), + ), + ); + await tester.pumpAndSettle(); + expect(linker.linkedAccounts.keys, contains(AuthProviderKind.apple)); + expect( + find.descendant( + of: find.widgetWithText(Row, 'Apple'), + matching: find.text('연결해제'), + ), + findsOneWidget, + ); + }); + + testWidgets('마지막 하나 남은 수단은 해제되지 않는다', (tester) async { + final linker = SampleAccountLinker( + seed: const {AuthProviderKind.google: 'fan@gmail.com'}, + ); + await pumpWide( + tester, + SettingsScreen(repository: SampleMyRepository(), linker: linker), + ); + await tester.pump(); + await tester.tap( + find.descendant( + of: find.widgetWithText(Row, 'Google'), + matching: find.text('연결해제'), + ), + ); + await tester.pumpAndSettle(); + // 시안 `554:23257` 확인 팝업을 거친다. + expect(find.text('연결된 계정을 끊으시겠습니까?'), findsOneWidget); + await tester.tap(find.text('연결 해제')); + await tester.pumpAndSettle(); + + // 떼였으면 계정에 다시 들어올 방법이 사라진다. + expect(linker.linkedAccounts.keys, contains(AuthProviderKind.google)); + expect(find.textContaining('마지막 로그인 수단'), findsOneWidget); + }); + }); + + group('POP 내역 — 장 경계 (F6 · 554:23732)', () { + testWidgets('당일 Zap 이 장을 넘어가도 한 줄로 합쳐진다', (tester) async { + // 저장소는 한 장 안에서만 접는다. 당일 Zap 이 한 장 크기를 넘으면 장마다 + // 「Zap 기여」 줄이 하나씩 생기므로 화면이 경계를 이어 붙여야 한다. + await pumpWide( + tester, + PopHistoryScreen(repository: _PagedZapRepository()), + ); + await tester.pump(); + expect(find.text('Zap 기여 1회'), findsOneWidget); + + await tester.tap(find.text('더 보기')); + await tester.pumpAndSettle(); + expect(find.text('Zap 기여 2회'), findsOneWidget); + expect(find.textContaining('Zap 기여'), findsOneWidget); + + await tester.tap(find.text('더 보기')); + await tester.pumpAndSettle(); + // 날이 바뀐 Zap 은 별도 줄이다. + expect(find.text('Zap 기여 2회'), findsOneWidget); + expect(find.text('Zap 기여 1회'), findsOneWidget); + }); + }); + + group('POP 내역 (F6 · 374:15090)', () { + testWidgets('보유 POP 과 증감을 함께 보여 준다', (tester) async { + await pumpWide( + tester, + PopHistoryScreen(repository: SampleMyRepository()), + ); + await tester.pump(); + expect(find.text('POP'), findsOneWidget); + // 보유 값과 첫 줄 누적 값이 같다. + expect(find.text('12,540'), findsWidgets); + expect(find.text('+50'), findsOneWidget); + expect(find.text('-500'), findsOneWidget); + }); + + testWidgets('Zap 은 건별이 아니라 당일 합산 한 줄이다', (tester) async { + await pumpWide( + tester, + PopHistoryScreen(repository: SampleMyRepository()), + ); + await tester.pump(); + expect(find.text('Zap 기여 128회'), findsOneWidget); + }); + }); + + group('결제 (F6 · 374:15185 · 506:14308 · 506:14437)', () { + testWidgets('결제 목록에서 상세로 간다', (tester) async { + await pumpWide( + tester, + PaymentHistoryScreen(repository: SampleMyRepository()), + ); + await tester.pump(); + await tester.tap(find.text('서포터 팩')); + await tester.pumpAndSettle(); + expect(find.text('결제 내역 상세'), findsOneWidget); + expect(find.text('₩ 7,900'), findsWidgets); + }); + + testWidgets('결제 완료 상태에서만 환불을 신청할 수 있다', (tester) async { + for (final state in PaymentState.values) { + expect(state.canRequestRefund, state == PaymentState.paid); + } + }); + + testWidgets('사유를 고르기 전에는 요청이 잠겨 있다', (tester) async { + await pumpWide( + tester, + RefundRequestScreen(paymentId: 'p1', repository: SampleMyRepository()), + ); + expect(_tapOf(tester, '요청'), isNull); + + await tester.tap(find.text('중복 결제했습니다')); + await tester.pump(); + expect(_tapOf(tester, '요청'), isNotNull); + }); + + testWidgets('환불을 신청하면 상태가 환불 요청으로 바뀐다', (tester) async { + final repo = SampleMyRepository(); + await pumpWide( + tester, + PaymentDetailScreen(paymentId: 'p1', repository: repo), + ); + await tester.pump(); + expect(find.text('결제 완료'), findsOneWidget); + + await tester.tap(find.text('환불 신청')); + await tester.pumpAndSettle(); + await tester.tap(find.text('중복 결제했습니다')); + await tester.pump(); + await tester.tap(find.text('요청')); + await tester.pumpAndSettle(); + // 시안 `506:14615` iOS 경고창. + expect(find.text('정말 환불 신청을 할까요?'), findsOneWidget); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + + // 시안 `571:18672` — 환불할 수 없으면 회색 「환불 불가」. + expect(find.text('환불 요청'), findsOneWidget); + expect(find.text('환불 불가'), findsOneWidget); + }); + }); + + group('푸시 알림 (F6 · 357:223444 · 정책 원장 B-77)', () { + testWidgets('기기 알림이 꺼져 있으면 토글을 그리지 않는다', (tester) async { + await pumpWide( + tester, + PushSettingsScreen( + repository: SampleMyRepository(), + deviceAllowed: false, + ), + ); + await tester.pump(); + expect(find.text('기기의 푸시 알림 설정이 꺼진 상태입니다.'), findsOneWidget); + expect(find.byType(Switch), findsNothing); + }); + + testWidgets('B-77 확정 5 개 구분이 노출된다', (tester) async { + await pumpWide( + tester, + PushSettingsScreen(repository: SampleMyRepository()), + ); + await tester.pump(); + expect(find.byType(Switch), findsNWidgets(5)); + for (final label in const [ + '서비스 필수', + '팬덤 이벤트', + 'AI Briefing', + '커뮤니티', + '마케팅·홍보', + ]) { + expect(find.text(label), findsOneWidget, reason: label); + } + }); + + testWidgets('Marketing 초기값은 OFF, 나머지는 ON 이다', (tester) async { + await pumpWide( + tester, + PushSettingsScreen(repository: SampleMyRepository()), + ); + await tester.pump(); + for (final topic in PushTopic.values) { + final tile = tester.widget(_switch(topic.label)); + expect( + tile.value, + topic == PushTopic.marketing ? isFalse : isTrue, + reason: topic.label, + ); + } + }); + + testWidgets('서비스 필수는 끌 수 없고 나머지 4 종은 끌 수 있다', (tester) async { + await pumpWide( + tester, + PushSettingsScreen(repository: SampleMyRepository()), + ); + await tester.pump(); + for (final topic in PushTopic.values) { + final tile = tester.widget(_switch(topic.label)); + expect( + tile.onChanged, + topic.locked ? isNull : isNotNull, + reason: topic.label, + ); + } + }); + + testWidgets('토글을 켜면 저장소에 반영된다', (tester) async { + final repo = SampleMyRepository(); + await pumpWide(tester, PushSettingsScreen(repository: repo)); + await tester.pump(); + await tester.tap(_switch('마케팅·홍보')); + await tester.pumpAndSettle(); + expect((await repo.pushSettings())[PushTopic.marketing], isTrue); + + await tester.tap(_switch('AI Briefing')); + await tester.pumpAndSettle(); + expect((await repo.pushSettings())[PushTopic.briefing], isFalse); + }); + + testWidgets('권한이 거부된 상태에서 재요청이 성공하면 토글이 열린다', (tester) async { + var asked = 0; + await pumpWide( + tester, + PushSettingsScreen( + repository: SampleMyRepository(), + deviceAllowed: false, + onRequestPermission: () async { + asked += 1; + return true; + }, + ), + ); + await tester.pump(); + await tester.tap(find.text('설정하러 가기')); + await tester.pumpAndSettle(); + expect(asked, 1); + expect(find.byType(Switch), findsNWidgets(5)); + }); + + testWidgets('토글을 바꾸면 저장과 함께 구독 갱신을 부른다', (tester) async { + // 저장만으로는 발송이 멈추지 않는다. 서버가 토픽으로만 보내므로 문서에 + // false 를 적어도 구독이 남아 있으면 알림이 계속 온다(B-77 「OFF 0건」). + final repo = SampleMyRepository(); + final synced = >[]; + await pumpWide( + tester, + PushSettingsScreen( + repository: repo, + onSettingsChanged: (prefs) async => synced.add({...prefs}), + ), + ); + await tester.pump(); + + await tester.tap(_switch('AI Briefing')); + await tester.pumpAndSettle(); + + expect(synced, hasLength(1)); + expect(synced.single[PushTopic.briefing], isFalse); + expect((await repo.pushSettings())[PushTopic.briefing], isFalse); + }); + + testWidgets('재요청이 거부되면 기기 설정 안내를 띄운다', (tester) async { + await pumpWide( + tester, + PushSettingsScreen( + repository: SampleMyRepository(), + deviceAllowed: false, + onRequestPermission: () async => false, + ), + ); + await tester.pump(); + await tester.tap(find.text('설정하러 가기')); + await tester.pumpAndSettle(); + expect(find.textContaining('기기 설정에서 FANUZU 알림을'), findsOneWidget); + expect(find.byType(Switch), findsNothing); + }); + }); + + group('공지·문의·차단 (F6 · 357:224574 · 373:634845)', () { + testWidgets('공지 목록에서 상세로 간다', (tester) async { + await pumpWide( + tester, + NoticeListScreen(repository: SampleMyRepository()), + ); + await tester.pump(); + await tester.tap(find.text('서비스 점검 안내')); + await tester.pumpAndSettle(); + expect(find.textContaining('9월 3일 새벽'), findsOneWidget); + }); + + testWidgets('내 문의 목록에서 상태와 함께 보여 주고 상세로 간다', (tester) async { + await pumpWide(tester, InquiryScreen(repository: SampleMyRepository())); + await tester.pump(); + expect(find.text('구매 상품 환불 관련 문의 접수'), findsOneWidget); + expect(find.textContaining('답변 완료'), findsOneWidget); + + await tester.tap(find.text('구매 상품 환불 관련 문의 접수')); + await tester.pumpAndSettle(); + expect(find.text('문의 상세'), findsOneWidget); + expect(find.text('답변'), findsOneWidget); + expect(find.textContaining('지급 처리했습니다'), findsOneWidget); + }); + + testWidgets('문의는 제목과 열 글자 본문이 있어야 보낼 수 있다', (tester) async { + final repo = SampleMyRepository(); + await pumpWide(tester, InquiryComposeScreen(repository: repo)); + var done = tester + .widget(find.widgetWithText(TextButton, '완료')) + .onPressed; + expect(done, isNull); + + await tester.enterText(find.byType(TextField).first, '결제 문의'); + await tester.enterText(find.byType(TextField).last, '짧음'); + await tester.pump(); + done = tester + .widget(find.widgetWithText(TextButton, '완료')) + .onPressed; + expect(done, isNull); + + await tester.enterText(find.byType(TextField).last, '열 글자가 넘는 문의 내용입니다'); + await tester.pump(); + done = tester + .widget(find.widgetWithText(TextButton, '완료')) + .onPressed; + expect(done, isNotNull); + + await tester.tap(find.widgetWithText(TextButton, '완료')); + await tester.pumpAndSettle(); + final mine = await repo.myInquiries(); + expect(mine.first.title, '결제 문의'); + expect(mine.first.status, InquiryStatus.pending); + }); + + testWidgets('접수 상태의 문의는 수정할 수 있다', (tester) async { + final repo = SampleMyRepository(); + await repo.submitInquiry('결제 문의', '지급되지 않았습니다.'); + final mine = await repo.myInquiries(); + + await pumpWide( + tester, + InquiryDetailScreen(inquiry: mine.first, repository: repo), + ); + await tester.tap(find.widgetWithText(TextButton, '수정')); + await tester.pumpAndSettle(); + expect(find.text('문의 수정'), findsOneWidget); + + await tester.enterText(find.byType(TextField).last, '내용을 고쳐 다시 적습니다'); + await tester.pump(); + await tester.tap(find.widgetWithText(TextButton, '완료')); + await tester.pumpAndSettle(); + + final after = await repo.myInquiries(); + expect(after.first.body, '내용을 고쳐 다시 적습니다'); + expect(after.first.status, InquiryStatus.pending); + }); + + testWidgets('답변이 끝난 문의에는 수정 입구가 없다', (tester) async { + final repo = SampleMyRepository(); + final answered = (await repo.myInquiries()).firstWhere( + (i) => i.status == InquiryStatus.answered, + ); + + await pumpWide( + tester, + InquiryDetailScreen(inquiry: answered, repository: repo), + ); + expect(find.widgetWithText(TextButton, '수정'), findsNothing); + }); + + testWidgets('차단 해제하면 목록에서 빠진다', (tester) async { + await pumpWide(tester, BlockedScreen(repository: SampleMyRepository())); + await tester.pump(); + expect(find.text('스팸계정'), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, '차단 해제').first); + await tester.pumpAndSettle(); + expect(find.text('스팸계정'), findsNothing); + }); + }); + + group('내 활동 (F6 · 370:228289 · 370:229665)', () { + testWidgets('내 인사이드에 내가 올린 글이 나온다', (tester) async { + await pumpWide(tester, const MyListsScreen(kind: MyListKind.inside)); + await tester.pump(); + expect(find.textContaining('화양연화'), findsOneWidget); + expect(find.text('500 P'), findsOneWidget); + }); + + testWidgets('내 캠페인에는 내가 만든 것만 나온다', (tester) async { + await pumpWide(tester, const MyListsScreen(kind: MyListKind.campaign)); + await tester.pumpAndSettle(); + expect(find.textContaining('지하철 생일 광고'), findsOneWidget); + expect(find.textContaining('강남역'), findsNothing); + }); + }); + + testWidgets('F6 화면 문구에 내부 경로가 새지 않는다', (tester) async { + for (final screen in [ + MyScreen(repository: SampleMyRepository()), + SettingsScreen(repository: SampleMyRepository()), + PopHistoryScreen(repository: SampleMyRepository()), + PaymentHistoryScreen(repository: SampleMyRepository()), + PushSettingsScreen(repository: SampleMyRepository()), + NoticeListScreen(repository: SampleMyRepository()), + InquiryScreen(repository: SampleMyRepository()), + BlockedScreen(repository: SampleMyRepository()), + const TierInfoScreen(), + ]) { + await pumpWide(tester, screen); + await tester.pump(); + for (final text in tester.widgetList(find.byType(Text))) { + final data = text.data ?? ''; + expect(data, isNot(contains('lib/'))); + expect(data, isNot(contains('.dart'))); + } + } + }); + + /// 로그아웃 확인 모달이 실제로 로그아웃을 부르는지 본다. + /// + /// 이 배선이 없던 동안 모달은 「09-10 회차에 연결합니다」 안내만 띄우고 세션을 + /// 그대로 두었다. 화면만 보면 로그아웃된 것처럼 보이지 않아 결함이 드러나지 않는다. + testWidgets('로그아웃을 확인하면 세션이 끊긴다 (F6 · 554:22903)', (tester) async { + final session = _LogoutSession( + const AuthAccount(uid: 'u1', provider: SocialProvider.google), + ); + final auth = AuthRepository( + providers: const [], + session: session, + releaseMode: true, + ); + expect(auth.isSignedIn, isTrue); + + await pumpWide( + tester, + SettingsScreen(repository: SampleMyRepository(), auth: auth), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('로그아웃'), + 220, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.text('로그아웃')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '로그아웃')); + await tester.pumpAndSettle(); + + expect(session.signOutCalls, 1); + expect(auth.isSignedIn, isFalse); + + auth.dispose(); + await session.close(); + }); +} + +/// 로그아웃 호출을 세는 최소 세션. 실제 Firebase 를 붙이지 않는다. +final class _LogoutSession implements AuthSession { + _LogoutSession(this._current); + + AuthAccount? _current; + final StreamController _changes = + StreamController.broadcast(); + int signOutCalls = 0; + + @override + AuthAccount? get currentAccount => _current; + + @override + Stream get accountChanges => _changes.stream; + + @override + Future signOut() async { + signOutCalls += 1; + _current = null; + _changes.add(null); + } + + Future close() => _changes.close(); +} + +/// 한 장에 한 줄만 주는 원장. 장 경계 접기를 보이기 위한 것이다. +/// +/// 실제 저장소는 한 장을 20건으로 읽지만, 당일 Zap 이 20건을 넘는 상황을 +/// 위젯 테스트에서 만들려면 장을 작게 쪼개는 쪽이 싸다. +class _PagedZapRepository extends SampleMyRepository { + static final _base = DateTime(2026, 9, 10, 21); + + /// 같은 날 Zap 둘 + 전날 Zap 하나. + static final _rows = [ + PopLedgerEntry( + at: _base, + title: 'Zap 기여', + type: '기여', + kind: PopKind.general, + txType: PopTxType.spend, + delta: -10, + balanceAfter: 12540, + repeatCount: 1, + cursor: PopCursor(at: _base, id: 'z2'), + ), + PopLedgerEntry( + at: _base.subtract(const Duration(hours: 1)), + title: 'Zap 기여', + type: '기여', + kind: PopKind.general, + txType: PopTxType.spend, + delta: -10, + balanceAfter: 12550, + repeatCount: 1, + cursor: PopCursor(at: _base.subtract(const Duration(hours: 1)), id: 'z1'), + ), + PopLedgerEntry( + at: _base.subtract(const Duration(days: 1)), + title: 'Zap 기여', + type: '기여', + kind: PopKind.general, + txType: PopTxType.spend, + delta: -10, + balanceAfter: 12560, + repeatCount: 1, + cursor: PopCursor(at: _base.subtract(const Duration(days: 1)), id: 'z0'), + ), + ]; + + @override + Future> popHistory({ + int limit = 20, + PopCursor? after, + }) async { + if (after == null) return [_rows.first]; + final i = _rows.indexWhere((e) => e.cursor?.id == after.id); + if (i < 0 || i + 1 >= _rows.length) return const []; + return [_rows[i + 1]]; + } +} diff --git a/app/test/flutter_test_config.dart b/app/test/flutter_test_config.dart new file mode 100644 index 0000000..3714157 --- /dev/null +++ b/app/test/flutter_test_config.dart @@ -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 testExecutable(FutureOr Function() testMain) async { + Planet25D.animationsEnabled = false; + // 테스트 단말 언어는 기본 en_US 다. 앱은 단말 언어를 따르므로(B-76) 한국어 문구를 + // 확인하는 기존 테스트가 한국어 단말을 전제로 돌도록 고정한다. 언어 전환은 i18n_test 가 본다. + TestWidgetsFlutterBinding.ensureInitialized() + .platformDispatcher + .localesTestValue = const [ + Locale('ko', 'KR'), + ]; + await testMain(); +} diff --git a/app/test/galaxy_environment_test.dart b/app/test/galaxy_environment_test.dart new file mode 100644 index 0000000..e98dac8 --- /dev/null +++ b/app/test/galaxy_environment_test.dart @@ -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 = >{}; + Stream watch(String id) => streams + .putIfAbsent(id, () => StreamController.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(find.byType(AnimatedBuilder)) + .map((w) => w.listenable) + .whereType() + .first; + GalaxyField field() => tester.widget(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(); + } + }); +} diff --git a/app/test/galaxy_mini_map_test.dart b/app/test/galaxy_mini_map_test.dart new file mode 100644 index 0000000..5453ff3 --- /dev/null +++ b/app/test/galaxy_mini_map_test.dart @@ -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 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 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( + find.descendant( + of: find.byType(GalaxyMiniMap), + matching: find.byType(CustomPaint), + ), + ) + .map((w) => w.painter) + .whereType() + .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.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(find.byType(GalaxyScreen)).repository, + same(galaxy), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + expect(states.hasListener, isFalse); + }); +} diff --git a/app/test/galaxy_motion_test.dart b/app/test/galaxy_motion_test.dart new file mode 100644 index 0000000..4d765c1 --- /dev/null +++ b/app/test/galaxy_motion_test.dart @@ -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)), + ); + } + } + }); + }); +} diff --git a/app/test/galaxy_zap_fx_test.dart b/app/test/galaxy_zap_fx_test.dart new file mode 100644 index 0000000..89640d7 --- /dev/null +++ b/app/test/galaxy_zap_fx_test.dart @@ -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 = >[]; + final keys = []; + final counts = []; + final fandoms = []; + + @override + Future zap({ + required String fandomId, + required int count, + required String clientSeq, + }) { + keys.add(clientSeq); + counts.add(count); + fandoms.add(fandomId); + final reply = Completer(); + replies.add(reply); + return reply.future; + } +} + +Future _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> call( + String name, [ + Map 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(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(find.byType(ZapButton)).myPop, 900); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('Reduce Motion은 갤럭시 자전도 정지하고 해제 시 복구한다', (tester) async { + Future 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); + }); +} diff --git a/app/test/hero_participation_test.dart b/app/test/hero_participation_test.dart new file mode 100644 index 0000000..408e498 --- /dev/null +++ b/app/test/hero_participation_test.dart @@ -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 = []; + final replies = >[]; + @override + Future claimHeroDrop(String id) { + ids.add(id); + final reply = Completer(); + replies.add(reply); + return reply.future; + } +} +class _HeroBackend extends Backend { + _HeroBackend() : super(firestore: FakeFirebaseFirestore(), + auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: 'viewer'))); + Map response = {'claimedPop': 5, 'txId': 'receipt'}; + Map? payload; + @override + Future> call(String name, [Map 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(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(); + addTearDown(sensor.close); + final states = StreamController.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(find.byType(FilledButton)).onPressed, isNull); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/app/test/hero_time_status_test.dart b/app/test/hero_time_status_test.dart new file mode 100644 index 0000000..841dbf8 --- /dev/null +++ b/app/test/hero_time_status_test.dart @@ -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 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(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.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(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(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.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); + }); +} diff --git a/app/test/home_screen_test.dart b/app/test/home_screen_test.dart new file mode 100644 index 0000000..783eeb3 --- /dev/null +++ b/app/test/home_screen_test.dart @@ -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.broadcast(); + @override + Stream watchGrowth(String fandomId) => changes.stream; +} + +/// 값을 못 가져오는 경우를 재현한다. +class _FailingRepo extends SampleHomeRepository { + @override + Stream watchGrowth(String fandomId) => const Stream.empty(); + + @override + Future 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 watchGrowth(String fandomId) => const Stream.empty(); + + const _BriefingRepo(this.briefings); + + final List briefings; + + @override + Future 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 _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(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 = []; + 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']); + }); +} diff --git a/app/test/i18n_smoke_test.dart b/app/test/i18n_smoke_test.dart new file mode 100644 index 0000000..d95d5b0 --- /dev/null +++ b/app/test/i18n_smoke_test.dart @@ -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 = { + '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, + ); + }); + } + } +} diff --git a/app/test/i18n_test.dart b/app/test/i18n_test.dart new file mode 100644 index 0000000..14f3e9e --- /dev/null +++ b/app/test/i18n_test.dart @@ -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 _arb(String lang) => + jsonDecode(File('lib/l10n/app_$lang.arb').readAsStringSync()) + as Map; + +Set _keys(Map 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>(); + 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>.broadcast(); + addTearDown(remote.close); + final c = LocaleController( + initial: const Locale('ja'), + remoteSource: (_) => remote.stream, + ); + remote.add({'settingsTitle': '設定(更新)'}); + await Future.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, + '设置', + ); + }); + }); +} diff --git a/app/test/inside_station_test.dart b/app/test/inside_station_test.dart new file mode 100644 index 0000000..dc6a7ea --- /dev/null +++ b/app/test/inside_station_test.dart @@ -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 _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 load({InsideSort sort = InsideSort.latest}) async => + throw StateError('boom'); + + @override + Future> 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 = []; + final accepted = []; + final sent = []; + var list = [ + InsideAnswer( + id: 'a1', + authorName: '남', + body: '남의 답', + createdAt: DateTime(2026, 9, 1), + ), + InsideAnswer( + id: 'a2', + authorName: '나', + body: '내 답', + createdAt: DateTime(2026, 9, 1), + isMine: true, + ), + ]; + + @override + Future 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> answers(String questionId) async => list; + + @override + Future 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 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 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(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(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(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 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 minBounty() async => 200; +} diff --git a/app/test/lounge_write_test.dart b/app/test/lounge_write_test.dart new file mode 100644 index 0000000..b445a7a --- /dev/null +++ b/app/test/lounge_write_test.dart @@ -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()), + ); + await expectLater( + repo.createPost('x', photos: [_photo(10 * 1024 * 1024 + 1)]), + throwsA(isA()), + ); + 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()), + ); + 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 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); + }); + }); +} diff --git a/app/test/monetization_test.dart b/app/test/monetization_test.dart new file mode 100644 index 0000000..64f504b --- /dev/null +++ b/app/test/monetization_test.dart @@ -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 = >[]; + + @override + Future> call( + String name, [ + Map 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 focus() async => + FocusStatus(expiresAt: DateTime(2026, 10, 28), claimedToday: claimed); + + @override + Future claimFocusDaily() async { + claimed = true; + return 40; + } +} + +class _FakeIap extends Fake implements InAppPurchase { + final updates = StreamController>(); + final completed = []; + + @override + Stream> get purchaseStream => updates.stream; + + @override + Future 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)> _run( + PurchaseStatus status, { + BackendException? failWith, +}) async { + final iap = _FakeIap(); + final backend = _Backend(FakeFirebaseFirestore(), failWith: failWith); + final store = Store(backend, iap: iap); + final messages = []; + 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); + }); + }); +} diff --git a/app/test/perf_paint_test.dart b/app/test/perf_paint_test.dart new file mode 100644 index 0000000..ff0a8eb --- /dev/null +++ b/app/test/perf_paint_test.dart @@ -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 = [ + 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); + }); +} diff --git a/app/test/planet_evolution_test.dart b/app/test/planet_evolution_test.dart new file mode 100644 index 0000000..6ab480d --- /dev/null +++ b/app/test/planet_evolution_test.dart @@ -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 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 = []; + final sub = repos.watchPlanet('bts').listen(states.add); + await Future.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.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.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 = []; + final sub = repos.watchPlanet('bts').listen(states.add); + addTearDown(sub.cancel); + await Future.delayed(const Duration(milliseconds: 20)); + expect(states.last.isEclipsed, true); + expect(states.last.stage, 3); + await event.update({'defended': true}); + await Future.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 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, 0.5); + await show(false); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget(opacity).opacity, greaterThan(0.5)); + expect(tester.widget(opacity).opacity, lessThan(1)); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget(opacity).opacity, 1); + expect(tester.widget(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 = []; + final sub = repos.watchPlanet('bts').listen(states.add); + addTearDown(sub.cancel); + await Future.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.delayed(const Duration(milliseconds: 20)); + expect(states.last.heroNicknames, ['공개팬']); + await db.doc('fandoms/bts/heroDrops/nameless').delete(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(states.last.stage, 3); + await drop.update({'remainingPop': 0}); + await Future.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.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 = []; + final sub = repos.watchPlanet('bts').listen(states.add); + addTearDown(sub.cancel); + await Future.delayed(const Duration(milliseconds: 20)); + expect(states.last.isHeroTime, true); + await Future.delayed(const Duration(milliseconds: 1100)); + expect(states.last.isHeroTime, false); + expect(states.last.stage, 3); + }); + + testWidgets('첫 실시간 값은 캐시와 달라도 진화로 연출하지 않는다', (tester) async { + final states = StreamController(); + 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(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 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 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 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(); + }); +} diff --git a/app/test/planet_fx_overlap_test.dart b/app/test/planet_fx_overlap_test.dart new file mode 100644 index 0000000..21d383e --- /dev/null +++ b/app/test/planet_fx_overlap_test.dart @@ -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 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); + }); +} diff --git a/app/test/planet_fx_test.dart b/app/test/planet_fx_test.dart new file mode 100644 index 0000000..9f6dde0 --- /dev/null +++ b/app/test/planet_fx_test.dart @@ -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> 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(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(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( + 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(find.byType(Image)) + .map((image) => image.image) + .whereType() + .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(find.byType(Image)) + .map((image) => image.image) + .whereType() + .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 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); + }, + ); +} diff --git a/app/test/planet_growth_status_test.dart b/app/test/planet_growth_status_test.dart new file mode 100644 index 0000000..db124d8 --- /dev/null +++ b/app/test/planet_growth_status_test.dart @@ -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); + } + }); +} diff --git a/app/test/planet_hero_fx_test.dart b/app/test/planet_hero_fx_test.dart new file mode 100644 index 0000000..bf47f55 --- /dev/null +++ b/app/test/planet_hero_fx_test.dart @@ -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 show(Set 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 show(int stage, Set? 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: '대기하던 신규 이벤트가 끝났으면 다른 진행 중 이벤트가 있어도 재생하지 않는다', + ); + }); +} diff --git a/app/test/planet_hibernation_test.dart b/app/test/planet_hibernation_test.dart new file mode 100644 index 0000000..0c74e3f --- /dev/null +++ b/app/test/planet_hibernation_test.dart @@ -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.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(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); + }); +} diff --git a/app/test/planet_history_test.dart b/app/test/planet_history_test.dart new file mode 100644 index 0000000..9d339f9 --- /dev/null +++ b/app/test/planet_history_test.dart @@ -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? payload; + Map response = { + 'items': [ + { + 'id': 'one', + 'kind': 'evolution', + 'occurredAt': '2026-09-22T00:00:00Z', + 'fromStage': 1, + 'toStage': 2, + 'participationConfirmed': 'true', + }, + ], + 'nextCursor': null, + }; + @override + Future> call( + String name, [ + Map data = const {}, + ]) async { + expect(name, 'fnPlanetHistory'); + payload = data; + return response; + } +} + +class _PersonalHistory extends SamplePlanetRepository { + final scopes = []; + @override + Future 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 = []; + @override + Future 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.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(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(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); + }); +} diff --git a/app/test/quest_repository_test.dart b/app/test/quest_repository_test.dart new file mode 100644 index 0000000..0fbcea6 --- /dev/null +++ b/app/test/quest_repository_test.dart @@ -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? 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 _reply; + final List<({String name, Map payload})> calls = []; + + @override + Future> call( + String name, [ + Map 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 _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 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()); + }); + + 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().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().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())); + 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); + }); + }); +} diff --git a/app/test/shared_stream_test.dart b/app/test/shared_stream_test.dart deleted file mode 100644 index 7819adc..0000000 --- a/app/test/shared_stream_test.dart +++ /dev/null @@ -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.broadcast(); - addTearDown(controller.close); - final shared = SharedStream(() { - opened += 1; - return controller.stream; - }); - - final a = []; - final b = []; - final subA = shared.stream.listen(a.add); - final subB = shared.stream.listen(b.add); - await Future.delayed(Duration.zero); - - controller.add(7); - await Future.delayed(Duration.zero); - - // 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다. - expect(opened, 1); - expect(a, [7]); - expect(b, [7]); - - await subA.cancel(); - await subB.cancel(); - }); - - test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async { - final controller = StreamController.broadcast(); - addTearDown(controller.close); - final shared = SharedStream(() => controller.stream); - - final first = []; - final subA = shared.stream.listen(first.add); - await Future.delayed(Duration.zero); - controller.add(42); - await Future.delayed(Duration.zero); - - // 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다. - final late = []; - final subB = shared.stream.listen(late.add); - await Future.delayed(Duration.zero); - expect(late, [42]); - - await subA.cancel(); - await subB.cancel(); - }); - - test('마지막 화면이 떠나면 원본을 닫는다', () async { - var cancelled = false; - final controller = StreamController( - onCancel: () => cancelled = true, - ); - addTearDown(controller.close); - final shared = SharedStream(() => controller.stream); - - final sub = shared.stream.listen((_) {}); - await Future.delayed(Duration.zero); - expect(cancelled, isFalse); - - await sub.cancel(); - await Future.delayed(Duration.zero); - // 남겨 두면 화면이 없는 동안에도 읽기 비용이 계속 든다. - expect(cancelled, isTrue); - }); - }); -} diff --git a/app/test/store_readiness_test.dart b/app/test/store_readiness_test.dart index bd59af9..b82afc7 100644 --- a/app/test/store_readiness_test.dart +++ b/app/test/store_readiness_test.dart @@ -2,13 +2,14 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -/// iOS 스토어 제출 구성 검사. +/// 스토어 제출 준비물 검사. /// -/// 여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다. 문제는 업로드·심사·로그인 -/// 시점에야 드러나고, 그때는 원인이 코드처럼 보인다. 파일을 문자열로 읽어 -/// 키가 있는지만 본다 — 값의 해석은 빌드가 한다. - -const _teamId = 'V28G543VK5'; // Fancake Inc. +/// **여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다.** 문제는 심사에 넣거나 +/// 사용자가 권한을 누르는 순간에야 드러나고, 그때는 원인이 코드처럼 보인다. +/// 실제로 이 프로젝트에서 최소 지원 OS 가 계약값과 다른 채로 나가고 있었다. +/// +/// 파일을 문자열로 읽는 이유는 plist·gradle 파서를 붙이지 않기 위해서다. +/// 키가 있는지만 보면 되고, 값의 해석은 빌드가 한다. String _read(String path) { final file = File(path); @@ -17,47 +18,62 @@ String _read(String path) { } void main() { - 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)); + group('iOS — 권한 사용 문구', () { + 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('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 - ); + test('문구가 용도를 말한다', () { + // 「접근이 필요합니다」처럼 용도 없는 문장은 심사 반려 사유다. + expect(plist, contains('프로필 사진을 고르기 위해')); + expect(plist, contains('퀘스트 보상을 드리기 위해')); + expect(plist, contains('다른 건강 정보는 읽지 않습니다')); }); }); - group('iOS — 제출 선언과 Firebase 설정', () { + group('iOS — 제출 선언', () { late final String plist = _read('ios/Runner/Info.plist'); + late final String entitlements = _read('ios/Runner/Runner.entitlements'); test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () { expect(plist, contains('ITSAppUsesNonExemptEncryption')); }); - test('Google 로그인 콜백 스킴이 설정 파일 값으로 채워진다', () { + test('Google 로그인 콜백 스킴 자리가 있다', () { + expect(plist, contains('CFBundleURLTypes')); + // 값은 Firebase 설정 파일에서 뽑아 넣는다. 손으로 적으면 오타로 조용히 실패한다. expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)')); - for (final c in const ['Debug', 'Release']) { - expect( - _read('ios/Flutter/$c.xcconfig'), - contains('#include? "../config/Google.xcconfig"'), - reason: c, - ); - } + }); + + test('Sign in with Apple Capability 가 있다', () { + // 정책 §P-01 — 소셜 로그인을 제공하면 Apple 심사 필수 조건이다. + 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 가 앱 번들 리소스로 들어간다', () { @@ -69,18 +85,76 @@ void main() { }); }); - test('설정·서명 자산을 git 이 추적하지 않는다', () { - final result = Process.runSync('git', [ - 'ls-files', - '--', - 'ios/Runner/GoogleService-Info.plist', - 'ios/config/Google.xcconfig', - 'android/app/google-services.json', - '*.p12', - '*.p8', - '*.mobileprovision', - ]); - expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다'); - expect((result.stdout as String).trim(), isEmpty); + group('Android — 계약값과 배포 경계', () { + late final String gradle = _read('android/app/build.gradle.kts'); + + test('최소 지원 OS 가 계약값 API 29 다', () { + // 별첨 1 v1.0 이 Android 10 으로 정했다. Flutter 기본값 24 로 두면 + // 검증하지 않은 기기까지 설치가 열린다. + 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', + '*.p8', + '*.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 을 실행하지 못했습니다'); + final tracked = (result.stdout as String).trim(); + expect( + tracked, + isEmpty, + reason: '서명·설정 자산이 저장소에 올라가 있습니다:\n$tracked', + ); + }); }); } diff --git a/app/test/timeattack_briefing_test.dart b/app/test/timeattack_briefing_test.dart new file mode 100644 index 0000000..f58ed66 --- /dev/null +++ b/app/test/timeattack_briefing_test.dart @@ -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 = []; + + @override + Future> call( + String name, [ + Map 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> call(String name, [Map payload = const {}]) async { + calls++; + if (fail) throw const BackendException('E-SYS-01', 'down'); + return {'code': 'ABCDEFGH23'}; + } +} + +Future _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.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + expect(find.text('남은 시간'), findsOneWidget); + await tester.runAsync(() => Future.delayed(const Duration(seconds: 3))); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('마감'), findsNWidgets(2)); + final button = tester.widget(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.delayed(const Duration(milliseconds: 20)); + } + }); + expect(sent?.text, contains('1. 빌보드 수상')); + expect(sent?.files, hasLength(1)); + // 웹 브릿지 주소가 없으면 링크가 없으니 「문구 복사」로 부른다. + expect(find.byTooltip('문구 복사'), findsOneWidget); + expect(find.byTooltip('링크 복사'), findsNothing); + }); + }); +} diff --git a/app/test/translation_test.dart b/app/test/translation_test.dart new file mode 100644 index 0000000..030e7df --- /dev/null +++ b/app/test/translation_test.dart @@ -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 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 pump(WidgetTester tester, TranslationRepository repo) => + pumpScreen( + tester, + InsideDetailScreen( + question: question, + answers: answers, + translation: repo, + ), + ); + + Future 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( + 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( + 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( + 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( + 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( + 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( + 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().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().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().having( + (e) => e.kind, + 'kind', + TranslationFailure.selfMark, + ), + ), + ); + expect( + (await db.doc('translationEntries/e1/markings/bob').get()).exists, + isFalse, + ); + }); + }); +} + +/// 마킹은 성공하지만 스트림은 다시 오지 않는 저장소 — 낙관적 갱신을 본다. +class _SilentMarkRepository extends SampleTranslationRepository { + _SilentMarkRepository(List seed) + : super(uid: 'me', seed: seed); + + int marks = 0; + + @override + Future mark(TranslationEntry entry, {required bool valid}) async => + marks++; +} + +/// 고정 목록을 돌려주는 저장소. +class _FixedRepository extends SampleTranslationRepository { + _FixedRepository(this.rows) : super(uid: 'me'); + + final List rows; + + @override + Stream> watch( + String rootPath, { + int limit = translationPageSize, + }) => Stream.value(rows); +} diff --git a/app/test_driver/capture_driver.dart b/app/test_driver/capture_driver.dart new file mode 100644 index 0000000..11ce97e --- /dev/null +++ b/app/test_driver/capture_driver.dart @@ -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 main() => integrationDriver( + onScreenshot: (String name, List bytes, [Map? _]) async { + final file = File('build/captures/$name.png'); + file.parent.createSync(recursive: true); + file.writeAsBytesSync(bytes); + stdout.writeln('[capture] ${file.path}'); + return true; + }, +); diff --git a/app/test_driver/perf_driver.dart b/app/test_driver/perf_driver.dart new file mode 100644 index 0000000..9794709 --- /dev/null +++ b/app/test_driver/perf_driver.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:integration_test/integration_test_driver.dart'; + +/// 프레임 타이밍 결과를 파일로 떨어뜨리는 드라이버. +/// +/// 기본 드라이버는 요약을 콘솔에만 남긴다. 성능 리포트가 수치를 인용해야 하므로 +/// 원본 JSON 을 남겨 근거를 되짚을 수 있게 한다. +Future 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}'); + } + }, +);