앱 단위·화면·통합 테스트
This commit is contained in:
@@ -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 <device>
|
||||
/// 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 <device>
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// 구현 확인물 캡처 공용 도구 — 화면군마다 `capture_f*_test.dart` 가 이것을 쓴다.
|
||||
//
|
||||
// **앱 코드를 고치지 않는다.** 앱이 쓰는 위젯과 테마를 그대로 기기 위에 띄우고
|
||||
// 화면마다 PNG 를 한 장씩 뽑는다. `adb shell input` 으로 41 화면을 눌러 다니는
|
||||
// 방식은 이 에뮬레이터에서 한 번 누르는 데 최대 60초가 걸려 재현되지 않았다.
|
||||
//
|
||||
// 화면군을 파일로 나눈 이유 — 한 실행이 중간에 끊기면 드라이버가 그때까지 모은
|
||||
// 캡처를 통째로 잃는다. 군마다 실행을 나누면 한 군이 실패해도 나머지는 남는다.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'package:fancake/core/theme/app_theme.dart';
|
||||
|
||||
/// 캡처 도구 한 벌. `main()` 첫 줄에서 만든다.
|
||||
class Capture {
|
||||
Capture() : binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final IntegrationTestWidgetsFlutterBinding binding;
|
||||
|
||||
/// Android 는 이걸 부르지 않으면 화면을 이미지로 뜨지 못한다.
|
||||
Future<void> prepare() => binding.convertFlutterSurfaceToImage();
|
||||
|
||||
/// 정해진 시간만큼 프레임을 돌린다.
|
||||
///
|
||||
/// `pumpAndSettle` 은 로딩 스피너처럼 끝나지 않는 애니메이션이 하나라도 있으면
|
||||
/// 던진다. 캡처는 그런 화면에서도 나와야 하고, 저장소를 읽고 다시 그리는
|
||||
/// 시간도 필요하므로 고정 프레임 수로 돌린다.
|
||||
Future<void> settle(WidgetTester tester, {int frames = 16}) async {
|
||||
for (var i = 0; i < frames; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
|
||||
/// 화면 하나를 앱 테마 그대로 띄운다.
|
||||
///
|
||||
/// [tag] 로 키를 준다. **없으면 안 된다** — 같은 종류의 화면을 연달아 띄우면
|
||||
/// Flutter 가 앞 화면의 State 를 그대로 재사용해 새 인자가 무시되고
|
||||
/// 앞 화면이 한 번 더 찍힌다(q1 상세가 q4 상세 자리에 찍히는 식).
|
||||
Future<void> open(WidgetTester tester, String tag, Widget screen) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
darkTheme: buildAppTheme(),
|
||||
themeMode: ThemeMode.dark,
|
||||
home: KeyedSubtree(key: ValueKey(tag), child: screen),
|
||||
),
|
||||
);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
Future<void> shot(String name) => binding.takeScreenshot(name);
|
||||
|
||||
/// 띄우고 찍기.
|
||||
Future<void> capture(WidgetTester tester, String name, Widget screen) async {
|
||||
await open(tester, name, screen);
|
||||
await shot(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// F0 공통 2화면 캡처. 방법은 `capture_common.dart` 참고.
|
||||
//
|
||||
// 하단 탭은 부분이 아니라 **앱 전체**를 띄워서 찍는다 — 탭 목록은 원격 플래그가
|
||||
// 정하므로 셸을 손으로 흉내내면 실제 빌드와 달라질 수 있다.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fancake/app.dart';
|
||||
import 'package:fancake/features/auth/auth.dart';
|
||||
import 'package:fancake/features/auth/auth_repository.dart';
|
||||
import 'package:fancake/features/notification/notification_screen.dart';
|
||||
|
||||
import 'capture_common.dart';
|
||||
|
||||
void main() {
|
||||
final cap = Capture();
|
||||
|
||||
setUpAll(cap.prepare);
|
||||
|
||||
testWidgets('F0 공통', (tester) async {
|
||||
// 실기기에서는 소셜 로그인을 통과해야 셸에 닿는데, 확정 4종 가운데
|
||||
// Google 만 열려 있고 그 계정은 이 에뮬레이터에 없다. 복원된 세션으로 연다 —
|
||||
// 로그인 이후의 화면이라는 사실은 같다.
|
||||
final auth = AuthRepository(
|
||||
session: const _RestoredSession(
|
||||
AuthAccount(uid: 'capture-user', provider: AuthProviderKind.google),
|
||||
),
|
||||
releaseMode: true,
|
||||
);
|
||||
addTearDown(auth.dispose);
|
||||
|
||||
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||
await cap.settle(tester);
|
||||
await cap.shot('F0_01_하단탭');
|
||||
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F0_02_알림',
|
||||
const NotificationScreen(repository: SampleNotificationRepository()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
final class _RestoredSession implements AuthSession {
|
||||
const _RestoredSession(this.currentAccount);
|
||||
|
||||
@override
|
||||
final AuthAccount? currentAccount;
|
||||
|
||||
@override
|
||||
Stream<AuthAccount?> get accountChanges => const Stream.empty();
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
}
|
||||
@@ -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_드래그_관성');
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// F5 캠페인(HOT ARCHIVE) 8화면 캡처. 방법은 `capture_common.dart` 참고.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fancake/features/campaign/campaign_create_screen.dart';
|
||||
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
|
||||
import 'package:fancake/features/campaign/campaign_list_screen.dart';
|
||||
import 'package:fancake/features/campaign/campaign_ranking_screen.dart';
|
||||
import 'package:fancake/features/campaign/campaign_search_screen.dart';
|
||||
import 'package:fancake/features/planet/moderation.dart';
|
||||
|
||||
import 'capture_common.dart';
|
||||
|
||||
void main() {
|
||||
final cap = Capture();
|
||||
|
||||
setUpAll(cap.prepare);
|
||||
|
||||
testWidgets('F5 캠페인', (tester) async {
|
||||
await cap.capture(tester, 'F5_01_HOT_ARCHIVE_목록', CampaignListScreen());
|
||||
|
||||
await cap.capture(tester, 'F5_02_HOT_ARCHIVE_검색', CampaignSearchScreen());
|
||||
|
||||
// 최근 검색어 칩을 눌러 검색을 돌린다. 소프트 키보드의 검색 액션을 흉내내는
|
||||
// 방식은 실기기에서 `onSubmitted` 가 오지 않아 결과 화면이 뜨지 않았다.
|
||||
await tester.tap(find.text('강남역'));
|
||||
await cap.settle(tester);
|
||||
await cap.shot('F5_03_HOT_ARCHIVE_검색결과');
|
||||
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F5_04_캠페인_상세',
|
||||
CampaignDetailScreen(campaignId: 'c1'),
|
||||
);
|
||||
|
||||
// 공유 오버레이 — Figma 480:14594.
|
||||
await tester.tap(find.byTooltip('공유'));
|
||||
await cap.settle(tester);
|
||||
await cap.shot('F5_05_캠페인_상세_공유');
|
||||
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F5_06_캠페인_상세_사용자_랭킹',
|
||||
CampaignRankingScreen(campaignId: 'c1'),
|
||||
);
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F5_07_캠페인_상세_신고하기',
|
||||
const ReportScreen(
|
||||
target: ModerationTarget(
|
||||
authorName: '다이브짱',
|
||||
title: '강남역 초대형 LED 광고',
|
||||
),
|
||||
),
|
||||
);
|
||||
await cap.capture(tester, 'F5_08_새_캠페인', CampaignCreateScreen());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// F6 MY 21화면 캡처 — 앞 11화면. 방법은 `capture_common.dart` 참고.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fancake/features/auth/auth.dart';
|
||||
import 'package:fancake/features/my/account_link_screen.dart';
|
||||
import 'package:fancake/features/my/my_data.dart';
|
||||
import 'package:fancake/features/my/my_lists_screen.dart';
|
||||
import 'package:fancake/features/my/my_screen.dart';
|
||||
import 'package:fancake/features/my/notice_screens.dart';
|
||||
import 'package:fancake/features/my/pop_history_screen.dart';
|
||||
import 'package:fancake/features/my/settings_screen.dart';
|
||||
|
||||
import 'capture_common.dart';
|
||||
|
||||
void main() {
|
||||
final cap = Capture();
|
||||
|
||||
setUpAll(cap.prepare);
|
||||
|
||||
testWidgets('F6 MY 1-11', (tester) async {
|
||||
final sample = SampleMyRepository();
|
||||
|
||||
await cap.capture(tester, 'F6_01_MY', MyScreen(repository: sample));
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_02_MY_내_인사이드',
|
||||
const MyListsScreen(kind: MyListKind.inside),
|
||||
);
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_03_MY_내_캠페인',
|
||||
const MyListsScreen(kind: MyListKind.campaign),
|
||||
);
|
||||
await cap.capture(tester, 'F6_04_MY_설정', SettingsScreen(repository: sample));
|
||||
|
||||
// 연동 화면은 제공자만 다르다. 확정 4종(별첨 B v1.4)은 Google·Apple·Kakao·LINE
|
||||
// 이며 X 는 목록에서 빠졌다 — 원장의 `MY/설정/X연결` 자리를 LINE 이 대신한다.
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_05_MY_설정_Apple연결',
|
||||
AccountLinkScreen(
|
||||
provider: AuthProviderKind.apple,
|
||||
linker: SampleAccountLinker(),
|
||||
),
|
||||
);
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_06_MY_설정_Google연결',
|
||||
AccountLinkScreen(
|
||||
provider: AuthProviderKind.google,
|
||||
linkedEmail: 'fan@gmail.com',
|
||||
linker: SampleAccountLinker(
|
||||
seed: const {AuthProviderKind.google: 'fan@gmail.com'},
|
||||
),
|
||||
),
|
||||
);
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_07_MY_설정_Kakao연결',
|
||||
AccountLinkScreen(
|
||||
provider: AuthProviderKind.kakao,
|
||||
linkedEmail: 'fan@kakao.com',
|
||||
linker: SampleAccountLinker(
|
||||
seed: const {AuthProviderKind.kakao: 'fan@kakao.com'},
|
||||
),
|
||||
),
|
||||
);
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_08_MY_설정_LINE연결',
|
||||
AccountLinkScreen(
|
||||
provider: AuthProviderKind.line,
|
||||
linker: SampleAccountLinker(),
|
||||
),
|
||||
);
|
||||
|
||||
await cap.capture(tester, 'F6_09_MY_설정_POP_상세', PopHistoryScreen(repository: sample));
|
||||
await cap.capture(tester, 'F6_10_MY_설정_공지사항', NoticeListScreen(repository: sample));
|
||||
|
||||
final notices = await sample.notices();
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_11_MY_설정_공지사항_상세',
|
||||
NoticeDetailScreen(notice: notices.first),
|
||||
);
|
||||
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// F6 MY 21화면 캡처 — 뒤 10화면. 방법은 `capture_common.dart` 참고.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fancake/features/my/my_data.dart';
|
||||
import 'package:fancake/features/my/payment_screens.dart';
|
||||
import 'package:fancake/features/my/photo_picker.dart';
|
||||
import 'package:fancake/features/my/profile_edit_screen.dart';
|
||||
import 'package:fancake/features/my/push_settings_screen.dart';
|
||||
import 'package:fancake/features/my/support_screens.dart';
|
||||
import 'package:fancake/features/my/tier_screens.dart';
|
||||
|
||||
import 'capture_common.dart';
|
||||
|
||||
void main() {
|
||||
final cap = Capture();
|
||||
|
||||
setUpAll(cap.prepare);
|
||||
|
||||
testWidgets('F6 MY 12-21', (tester) async {
|
||||
final sample = SampleMyRepository();
|
||||
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_12_MY_설정_서비스_결제_내역',
|
||||
PaymentHistoryScreen(repository: sample),
|
||||
);
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_13_MY_설정_결제_내역_상세',
|
||||
PaymentDetailScreen(paymentId: 'p1', repository: sample),
|
||||
);
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_14_MY_설정_결제_내역_환불_요청',
|
||||
RefundRequestScreen(paymentId: 'p1', repository: sample),
|
||||
);
|
||||
|
||||
await cap.capture(tester, 'F6_15_MY_티어_정보', const TierInfoScreen());
|
||||
|
||||
final profile = await sample.profile();
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_16_MY_티어_상세',
|
||||
TierDetailScreen(tiers: profile.planetTiers),
|
||||
);
|
||||
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_17_MY_프로필_설정',
|
||||
ProfileEditScreen(nickname: profile.nickname, repository: sample),
|
||||
);
|
||||
await cap.capture(
|
||||
tester,
|
||||
'F6_18_MY_프로필_사진_설정',
|
||||
const ProfilePhotoScreen(pickPhoto: _noPhoto),
|
||||
);
|
||||
await cap.capture(tester, 'F6_19_MY_1대1_문의접수', InquiryScreen(repository: sample));
|
||||
await cap.capture(tester, 'F6_20_MY_설정_푸시_알림_설정', PushSettingsScreen(repository: sample));
|
||||
await cap.capture(tester, 'F6_21_MY_설정_차단_관리', BlockedScreen(repository: sample));
|
||||
});
|
||||
}
|
||||
|
||||
/// 캡처는 사진 선택기를 띄우지 않는다. 실기기 선택기가 뜨면 캡처가 그 화면에서 멈춘다.
|
||||
Future<PickedPhoto?> _noPhoto(PhotoSource source) async => null;
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||
import 'package:fancake/features/planet/planet_data.dart';
|
||||
import 'package:fancake/features/galaxy/galaxy_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
/// 기기 위에서 재는 갤럭시 맵 프레임 타이밍.
|
||||
///
|
||||
/// 위젯 테스트는 실제로 래스터화하지 않으므로 「끊기는가」를 답하지 못한다.
|
||||
/// 이 테스트는 프로파일 빌드로 기기에 올라가 **실제 프레임 시간**을 남긴다.
|
||||
/// 별첨 1 ① 판정 기준 「기준 단말에서 행성/맵/애니메이션 체감 지연 없음」의 근거다.
|
||||
void main() {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('갤럭시 맵을 팬·줌하는 동안의 프레임 시간', (tester) async {
|
||||
await binding.watchPerformance(() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: GalaxyScreen(
|
||||
repository: SampleGalaxyRepository(),
|
||||
planetRepository: samplePlanetRepository,
|
||||
eclipse: SampleEclipseRepository(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final field = find.byType(GalaxyScreen);
|
||||
expect(field, findsOneWidget);
|
||||
|
||||
// 궤도가 도는 동안 드래그를 섞는다. 애니메이션과 제스처가 겹치는 구간이
|
||||
// 가장 무거우므로, 나눠 재면 실제로 끊기는 순간을 놓친다.
|
||||
for (var round = 0; round < 6; round++) {
|
||||
await tester.drag(field, const Offset(-120, -40));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
await tester.drag(field, const Offset(120, 40));
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
for (var frame = 0; frame < 20; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
}
|
||||
}, reportKey: 'galaxy_pan');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:fancake/core/data/backend.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:fancake/features/home/home_repository_firestore.dart';
|
||||
import 'package:fancake/features/my/my_data.dart';
|
||||
import 'package:fancake/features/my/my_repository_firestore.dart';
|
||||
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
/// POP → 행성 반영 실증 — 별첨 1 ② 완료 시나리오 1 의 **읽는 쪽**.
|
||||
///
|
||||
/// **무엇을 재는가.** 서버가 원장을 지나 쓴 값을 앱이 그대로 읽어 HOME·MY 수치로
|
||||
/// 옮기는지를 잰다. 가짜 Firestore 가 아니라 **실제 Firestore 에뮬레이터**를 물고,
|
||||
/// 문서도 앱이 만든 것이 아니라 **Cloud Functions 가 쓴 것**을 읽는다. 두 단계가
|
||||
/// 서로 다른 프로세스라, 필드 이름이 한 글자만 어긋나도 여기서 0 이 나온다.
|
||||
///
|
||||
/// 실행 — 두 단계를 순서대로 돈다.
|
||||
///
|
||||
/// ```bash
|
||||
/// # 1. 서버가 상태를 만든다(출석 지급 → 원장 → 기여 차감 → 등급·멤버 갱신)
|
||||
/// java -jar ~/.cache/firebase/emulators/cloud-firestore-emulator-v1.22.0.jar \
|
||||
/// --host=0.0.0.0 --port=8080 &
|
||||
/// CI=1 FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 GCLOUD_PROJECT=demo-fanuzu \
|
||||
/// npm --prefix firebase/functions test
|
||||
///
|
||||
/// # 2. 앱이 그 상태를 읽는다
|
||||
/// fvm flutter test integration_test/pop_loop_emulator_test.dart \
|
||||
/// -d emulator-5554 --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2
|
||||
/// ```
|
||||
///
|
||||
/// `10.0.2.2` 는 Android 에뮬레이터가 보는 호스트 PC 주소다.
|
||||
///
|
||||
/// **로그인만 흉내 낸다.** Auth 에뮬레이터는 `firebase` CLI 안에만 있어 이 환경에서
|
||||
/// 띄우지 못한다(BUILD.md 2-1). UID 는 서버 테스트가 쓴 것과 같은 값을 넣고, 읽는
|
||||
/// 경로는 실제 구현을 그대로 지난다.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
/// `firebase/functions/test/quest_e2e.test.ts` 가 쓰는 UID.
|
||||
const uid = 'test-quest-e2e';
|
||||
|
||||
/// 출석 퀘스트의 **문서 ID**. 진행 문서도 같은 ID 로 선다(설계서 §2.1).
|
||||
const attendQuest = 'daily-checkin';
|
||||
|
||||
/// 호스트 PC 주소. Android 에뮬레이터는 `10.0.2.2` 로 본다.
|
||||
const host = String.fromEnvironment('FANUZU_EMULATOR_HOST', defaultValue: '10.0.2.2');
|
||||
|
||||
late Backend backend;
|
||||
|
||||
setUpAll(() async {
|
||||
// `FirebaseBoot.init()` 을 쓰지 않는다. 그 경로는 Auth 에뮬레이터(9099)에도
|
||||
// 붙는데, Auth 는 `firebase` CLI 안에만 있어 이 환경에서 뜨지 않는다(BUILD.md 2-1).
|
||||
// 거기서 실패하면 Firestore 연결까지 건너뛰어 앱이 실 서버를 바라본다.
|
||||
//
|
||||
// 이름 있는 앱을 따로 띄운다. Android 는 `google-services.json` 으로 `[DEFAULT]`
|
||||
// 앱을 자동 초기화하므로 그 자리를 덮을 수 없고, 덮지 않으면 프로젝트 ID 가
|
||||
// 달라 에뮬레이터의 다른 칸을 읽는다.
|
||||
final app = await Firebase.initializeApp(
|
||||
name: 'fanuzu-emulator-check',
|
||||
options: const FirebaseOptions(
|
||||
apiKey: 'demo-key',
|
||||
appId: '1:0:android:0',
|
||||
messagingSenderId: '0',
|
||||
projectId: 'demo-fanuzu',
|
||||
),
|
||||
);
|
||||
final db = FirebaseFirestore.instanceFor(app: app)
|
||||
..useFirestoreEmulator(host, 8080);
|
||||
backend = Backend(
|
||||
firestore: db,
|
||||
auth: MockFirebaseAuth(
|
||||
signedIn: true,
|
||||
mockUser: MockUser(uid: uid, email: 'fan@fanuzu.co.kr'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('에뮬레이터를 물고 서버가 쓴 문서가 보인다', (tester) async {
|
||||
final user = await backend.db.doc('users/$uid').get();
|
||||
expect(user.exists, isTrue,
|
||||
reason: '서버 E2E(quest_e2e.test.ts)를 먼저 돌려 상태를 만드십시오');
|
||||
// 출석·후원을 지난 뒤의 서버 값이다. 앱이 쓴 값이 아니다.
|
||||
expect(user.data()!['totalContributedPop'], 3000);
|
||||
// 레벨은 누적 기여 POP 이 아니라 User EXP 에서 나온다(C-18) — 출석 1건 × 100(후원은 EXP 대상 아님).
|
||||
expect(user.data()!['userExp'], 100);
|
||||
expect(user.data()!['userLevel'], 1);
|
||||
expect(user.data()!['passportTier'], 'SILVER');
|
||||
});
|
||||
|
||||
testWidgets('HOME 이 출석 보상 잔액과 행성 성장 수치를 서버 값에서 읽는다', (tester) async {
|
||||
final snapshot = await FirestoreHomeRepository(backend).load();
|
||||
|
||||
// 출석 1회 보상 10 POP — `fn-quest-attend` 가 원장을 지나 올린 활동 잔액.
|
||||
expect(snapshot.popBalance, 10);
|
||||
// 공동 성장 = 출석 보상 10 + 후원 3,000. 개인 기여와 구별한다.
|
||||
expect(snapshot.growth.currentPop, 3010);
|
||||
// 공동 Stage가 아직 없는 시나리오이므로 진행률은 확정하지 않는다.
|
||||
expect(snapshot.growth.growthPercent, isNull);
|
||||
expect(snapshot.growth.fandomName, '퀘스트 루프 팬덤');
|
||||
});
|
||||
|
||||
testWidgets('MY 가 누적 기여와 등급을 서버 값에서 읽는다', (tester) async {
|
||||
final profile = await FirestoreMyRepository(backend).profile();
|
||||
|
||||
expect(profile.contributedPop, 3000);
|
||||
// 레벨은 User EXP 기반이다(C-18). 기여 POP 3,000 이 칭호를 올리지 않는다.
|
||||
expect(profile.userLevel, greaterThanOrEqualTo(1));
|
||||
expect(profile.passportTier, PassportTier.silver);
|
||||
expect(profile.balancePop, 10);
|
||||
expect(profile.planetTiers.single.fandomName, '퀘스트 루프 팬덤');
|
||||
expect(profile.planetTiers.single.contributedPop, 3000);
|
||||
});
|
||||
|
||||
testWidgets('같은 퀘스트를 다시 참여하면 서버가 막은 자국이 남아 있다', (tester) async {
|
||||
// 중복 참여 거부(E-POP-03)는 서버가 판정한다. 앱에서 보이는 자국은 진행 카운터다 —
|
||||
// 같은 날 두 번 눌러도 1 을 넘지 않는다.
|
||||
//
|
||||
// **경로가 퀘스트 문서 ID 다.** 앱이 목록을 그릴 때 읽는 자리와 서버가 쓰는 자리가
|
||||
// 같아야 화면이 「보상완료」로 넘어간다.
|
||||
final progress =
|
||||
await backend.db.doc('users/$uid/questProgress/$attendQuest').get();
|
||||
expect(progress.exists, isTrue);
|
||||
expect(progress.data()!['count'], 1);
|
||||
expect(progress.data()!['questId'], attendQuest);
|
||||
|
||||
final ledger = await backend.db
|
||||
.collection('users/$uid/popTransactions')
|
||||
.where('reasonCode', isEqualTo: 'QUEST_ATTEND')
|
||||
.get();
|
||||
expect(ledger.size, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:fancake/core/data/backend.dart';
|
||||
import 'package:fancake/core/data/repositories.dart';
|
||||
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||
import 'package:fancake/features/auth/auth.dart';
|
||||
import 'package:fancake/features/auth/auth_repository.dart';
|
||||
import 'package:fancake/features/quest/quest_data.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
/// 퀘스트 참여 실왕복 — 별첨 1 ② 「클라이언트 단독 지급 0건」과 ③ 「서버검증 경유」.
|
||||
///
|
||||
/// **앱에서 Cloud Functions 를 직접 부른다.** 앞선 `pop_loop_emulator_test.dart` 는
|
||||
/// 서버가 쓴 값을 앱이 읽는 쪽만 봤고, 여기서는 **앱 → Callable → 원장 → 앱** 한 바퀴를
|
||||
/// 돈다. 로그인도 실제 Auth 에뮬레이터를 지난다.
|
||||
///
|
||||
/// 실행 — 에뮬레이터 세 개를 함께 띄운다. **저장소 안의 firebase-tools** 를 쓴다.
|
||||
///
|
||||
/// ```bash
|
||||
/// adb reverse tcp:8080 tcp:8080 && adb reverse tcp:9099 tcp:9099 \
|
||||
/// && adb reverse tcp:5001 tcp:5001
|
||||
/// cd firebase
|
||||
/// JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home \
|
||||
/// test/node_modules/.bin/firebase emulators:exec \
|
||||
/// --only auth,firestore,functions --project demo-fanuzu \
|
||||
/// "bash ../tool/emulator_quest_roundtrip.sh"
|
||||
/// ```
|
||||
///
|
||||
/// 시드(퀘스트 정의·운영값)는 그 스크립트가 에뮬레이터 REST 로 넣는다 — 보안 규칙이
|
||||
/// `quests` 쓰기를 마스터로 막아 두어 앱이 만들 수 없다(그게 맞다).
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
/// 시드 스크립트가 넣는 퀘스트 문서 ID.
|
||||
const questId = 'daily-checkin';
|
||||
|
||||
late AuthRepository auth;
|
||||
late Repositories repositories;
|
||||
|
||||
setUpAll(() async {
|
||||
await FirebaseBoot.init();
|
||||
auth = AuthRepository();
|
||||
// 매 회차 새 계정이다. `fn-auth-oncreate` 트리거가 계정·지갑 문서를 만드는 것까지
|
||||
// 이 왕복에 들어간다 — 앱은 `users/{uid}` 를 직접 만들지 못한다(보안 규칙).
|
||||
final email = 'quest+${DateTime.now().microsecondsSinceEpoch}@fanuzu.co.kr';
|
||||
final outcome = await auth.signInWithEmail(
|
||||
email: email,
|
||||
password: 'password12',
|
||||
signUp: true,
|
||||
);
|
||||
expect(outcome, isA<AuthSucceeded>(), reason: 'Auth 에뮬레이터에 붙지 못했습니다');
|
||||
repositories = Repositories(live: true);
|
||||
});
|
||||
|
||||
testWidgets('에뮬레이터를 물고 로그인까지 지난다', (tester) async {
|
||||
expect(FirebaseBoot.useEmulator, isTrue,
|
||||
reason: '--dart-define=FANUZU_EMULATOR_HOST 를 주고 실행하십시오');
|
||||
expect(FirebaseBoot.isLive, isTrue, reason: FirebaseBoot.reason);
|
||||
expect(auth.isSignedIn, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('출석 참여 한 번이 서버 지급과 원장까지 간다', (tester) async {
|
||||
final quests = await repositories.quest.load();
|
||||
final attend = quests.firstWhere((card) => card.id == questId);
|
||||
expect(attend.type, QuestType.attend);
|
||||
expect(attend.stage, QuestStage.notStarted);
|
||||
|
||||
// 앱이 부르는 것은 Firestore 쓰기가 아니라 Callable 이다.
|
||||
final reward = await repositories.quest.verify(attend);
|
||||
expect(reward.rewardedPop, 10);
|
||||
expect(reward.balanceAfter, 10);
|
||||
expect(reward.txId, isNotNull);
|
||||
|
||||
// 원장 한 행 — 앱이 쓴 것이 아니라 서버가 쓴 것이다.
|
||||
final uid = repositories.backend.requireUid;
|
||||
final ledger =
|
||||
await repositories.backend.db.collection('users/$uid/popTransactions').get();
|
||||
expect(ledger.size, 1);
|
||||
expect(ledger.docs.single.data()['reasonCode'], 'QUEST_ATTEND');
|
||||
expect(ledger.docs.single.data()['serverVerified'], isTrue);
|
||||
});
|
||||
|
||||
testWidgets('보유 POP 과 퀘스트 상태가 서버 값으로 다시 그려진다', (tester) async {
|
||||
// HOME 은 지갑을 읽는다. 출석 보상 10 POP 이 그대로 보인다.
|
||||
final home = await repositories.home.load();
|
||||
expect(home.popBalance, 10);
|
||||
|
||||
// 목록을 다시 읽으면 서버 진행 문서로 「보상완료」다 — 앱이 기억한 값이 아니다.
|
||||
final again = await repositories.quest.load();
|
||||
expect(again.firstWhere((card) => card.id == questId).stage, QuestStage.rewarded);
|
||||
});
|
||||
|
||||
testWidgets('같은 날 두 번째 참여는 서버가 거부한다', (tester) async {
|
||||
final quests = await repositories.quest.load();
|
||||
final attend = quests.firstWhere((card) => card.id == questId);
|
||||
|
||||
await expectLater(
|
||||
repositories.quest.verify(attend),
|
||||
// 화면은 gRPC 상태가 아니라 에러 카탈로그 코드로 분기한다.
|
||||
throwsA(isA<BackendException>().having((e) => e.code, 'code', 'E-POP-03')),
|
||||
);
|
||||
|
||||
final uid = repositories.backend.requireUid;
|
||||
final ledger =
|
||||
await repositories.backend.db.collection('users/$uid/popTransactions').get();
|
||||
expect(ledger.size, 1, reason: '거부된 호출이 원장을 늘리면 안 된다');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user