앱 단위·화면·통합 테스트
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
|
/// firebase emulators:start --only auth,firestore,functions --project demo-fanuzu
|
||||||
/// flutter test integration_test/auth_emulator_test.dart \
|
/// flutter drive --driver=test_driver/perf_driver.dart \
|
||||||
/// --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 -d <device>
|
/// --target=integration_test/auth_emulator_test.dart \
|
||||||
|
/// --dart-define=FANUZU_EMULATOR_HOST=10.0.2.2 --no-dds -d <device>
|
||||||
void main() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
|||||||
@@ -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: '거부된 호출이 원장을 늘리면 안 된다');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/core/ui/adaptive_planet_effects.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_25d.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_evolution.dart';
|
||||||
|
|
||||||
|
FrameTiming frame(int cost) => FrameTiming(
|
||||||
|
vsyncStart: 0,
|
||||||
|
buildStart: 0,
|
||||||
|
buildFinish: cost,
|
||||||
|
rasterStart: cost,
|
||||||
|
rasterFinish: cost * 2,
|
||||||
|
rasterFinishWallTime: cost * 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('일시 지연은 무시하고 지속 지연에서 단계적으로 축소하며 진화 결과는 유지한다', (tester) async {
|
||||||
|
Future<void> show(int stage) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: AdaptivePlanetEffects(
|
||||||
|
child: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 200,
|
||||||
|
stage: stage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
int level() => tester
|
||||||
|
.widget<PlanetEffectsQuality>(find.byType(PlanetEffectsQuality))
|
||||||
|
.level;
|
||||||
|
Future<void> timings(int count, int cost) async {
|
||||||
|
tester.binding.platformDispatcher.onReportTimings!(
|
||||||
|
List.generate(count, (_) => frame(cost)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
await show(3);
|
||||||
|
await timings(60, 40000); // 시작 시 리소스 로딩은 판정 제외.
|
||||||
|
expect(level(), 0);
|
||||||
|
await timings(20, 40000);
|
||||||
|
await timings(100, 2000);
|
||||||
|
expect(level(), 0, reason: '짧은 지연만으로 품질을 떨어뜨리지 않는다');
|
||||||
|
await timings(120, 40000);
|
||||||
|
expect(level(), 1);
|
||||||
|
await timings(120, 40000);
|
||||||
|
expect(level(), 2);
|
||||||
|
expect(
|
||||||
|
tester.widget<PlanetEvolution>(find.byType(PlanetEvolution)).lowEffects,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await show(4);
|
||||||
|
expect(find.text('이상 반응'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(milliseconds: 1800));
|
||||||
|
expect(find.text('Stage 4 · 동행'), findsOneWidget);
|
||||||
|
await timings(240, 2000);
|
||||||
|
expect(level(), 2, reason: '세션 중 품질이 반복 왕복하지 않는다');
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:fancake/app.dart';
|
import 'package:fancake/app.dart';
|
||||||
import 'package:fancake/core/config/feature_flags.dart';
|
import 'package:fancake/core/config/feature_flags.dart';
|
||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
import 'package:fancake/core/firebase/firebase_boot.dart';
|
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||||
import 'package:fancake/core/router/app_router.dart';
|
import 'package:fancake/core/router/app_router.dart';
|
||||||
import 'package:fancake/features/auth/auth.dart';
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
@@ -96,6 +97,25 @@ void main() {
|
|||||||
auth.dispose();
|
auth.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('회원가입 직후에는 가입 완료 화면(182:29344)을 거쳐 셸로 들어간다', (tester) async {
|
||||||
|
final auth = AuthRepository(
|
||||||
|
providers: [_SignupProvider()],
|
||||||
|
releaseMode: true,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(FancakeApp(auth: auth));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await auth.signIn(SocialProvider.google, signUp: true);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('회원가입이 완료되었습니다'), findsOneWidget);
|
||||||
|
expect(find.byType(NavigationBar), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('시작'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(NavigationBar), findsOneWidget);
|
||||||
|
auth.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async {
|
testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async {
|
||||||
await _enterShell(tester);
|
await _enterShell(tester);
|
||||||
|
|
||||||
@@ -157,9 +177,10 @@ void main() {
|
|||||||
await source.close();
|
await source.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('STATION 라우트는 남아 있다 — 비노출이지 삭제가 아니다', () {
|
test('STATION 라우트와 저장소는 남아 있다 — 비노출이지 삭제가 아니다', () {
|
||||||
final station = appTabs.firstWhere((t) => t.label == 'STATION');
|
final station = appTabs.firstWhere((t) => t.label == 'STATION');
|
||||||
expect(station.path, '/station');
|
expect(station.path, '/station');
|
||||||
|
expect(Repositories(live: false).station, isNotNull);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,44 +270,51 @@ void main() {
|
|||||||
testWidgets('3. 탭을 전환하면 각 탭 루트 화면이 바뀐다', (tester) async {
|
testWidgets('3. 탭을 전환하면 각 탭 루트 화면이 바뀐다', (tester) async {
|
||||||
await _enterShell(tester);
|
await _enterShell(tester);
|
||||||
|
|
||||||
const roots = {
|
// 5탭 모두 실제 화면이 들어갔다. 자리표시가 남아 있으면 발주사가 설치본에서
|
||||||
'HOME': ('S-H-HOME', '오늘의 퀘스트'),
|
// 「2주차 범위」 문구를 보게 되므로, 탭마다 그 화면의 고정 구획명으로 확인한다.
|
||||||
'INSIDE': ('S-I-LOUNGE', '팬덤 라운지'),
|
// STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다.
|
||||||
'GALAXY': ('S-G-PLANET', '내 행성'),
|
const implemented = {
|
||||||
// STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다.
|
'HOME': 'TODAY’S QUEST',
|
||||||
'MY': ('S-M-HOME', '마이 페이지'),
|
'INSIDE': '최신순',
|
||||||
|
'GALAXY': 'GALAXY',
|
||||||
|
'MY': '행성/성운',
|
||||||
};
|
};
|
||||||
|
final roots = implemented.keys;
|
||||||
|
|
||||||
for (final entry in roots.entries) {
|
for (final tab in roots) {
|
||||||
await tester.tap(
|
await tester.tap(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byType(NavigationBar),
|
of: find.byType(NavigationBar),
|
||||||
matching: find.text(entry.key),
|
matching: find.text(tab),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
// GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 영원히 기다린다.
|
||||||
|
// 탭 전환은 프레임 몇 장이면 끝나므로 시간을 정해 넘긴다.
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
|
||||||
final (screenId, screenName) = entry.value;
|
// GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 끝나지 않는다.
|
||||||
expect(
|
expect(
|
||||||
find.text(screenId),
|
find.text(implemented[tab]!),
|
||||||
findsOneWidget,
|
|
||||||
reason: '${entry.key} → $screenId',
|
|
||||||
);
|
|
||||||
// AppBar 제목과 본문 제목 두 군데에 나온다.
|
|
||||||
expect(
|
|
||||||
find.text(screenName),
|
|
||||||
findsWidgets,
|
findsWidgets,
|
||||||
reason: '${entry.key} → $screenName',
|
reason: '$tab → ${implemented[tab]}',
|
||||||
|
);
|
||||||
|
// 자리표시가 되살아나면 여기서 잡힌다.
|
||||||
|
expect(
|
||||||
|
find.textContaining('2주차 범위'),
|
||||||
|
findsNothing,
|
||||||
|
reason: '$tab 이 자리표시로 돌아갔다',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('wireframe/'),
|
find.textContaining('wireframe/'),
|
||||||
findsNothing,
|
findsNothing,
|
||||||
reason: '${entry.key} 화면에 내부 저장소 경로가 노출되면 안 된다',
|
reason: '$tab 화면에 내부 저장소 경로가 노출되면 안 된다',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('screen-spec'),
|
find.textContaining('screen-spec'),
|
||||||
findsNothing,
|
findsNothing,
|
||||||
reason: '${entry.key} 화면에 내부 명세 경로가 노출되면 안 된다',
|
reason: '\$tab 화면에 내부 명세 경로가 노출되면 안 된다',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -297,6 +325,13 @@ void main() {
|
|||||||
/// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로,
|
/// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로,
|
||||||
/// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable).
|
/// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable).
|
||||||
Future<void> _enterShell(WidgetTester tester) async {
|
Future<void> _enterShell(WidgetTester tester) async {
|
||||||
|
// 홈 히어로가 시안대로 첫 화면의 78% 를 차지하므로 기본 800×600 창에서는 아래
|
||||||
|
// 구획이 지연 빌드되지 않는다. 실기기에 가까운 세로 길이를 준다(F3 테스트와 같다).
|
||||||
|
// 목 모드에서는 이클립스 경보 배너와 라이브 띠가 히어로 아래에 더 붙으므로
|
||||||
|
// 2400 으로는 TODAY'S QUEST 가 지연 빌드 밖으로 밀린다.
|
||||||
|
tester.view.physicalSize = const Size(1080, 3200);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
|
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.longPress(find.byKey(loginLogoKey));
|
await tester.longPress(find.byKey(loginLogoKey));
|
||||||
@@ -319,6 +354,10 @@ Future<void> _enterShell(WidgetTester tester) async {
|
|||||||
///
|
///
|
||||||
/// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면
|
/// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면
|
||||||
/// 계약 대조가 조용히 사라진다.
|
/// 계약 대조가 조용히 사라진다.
|
||||||
|
///
|
||||||
|
/// **사본 표류도 실패로 끝낸다.** 후보가 둘 이상 보이면(환경 변수로 가리킨 정본과
|
||||||
|
/// 저장소 내 사본이 함께 있는 경우) `APP_TABS` 줄을 서로 비교한다. 사본만 고치거나
|
||||||
|
/// 정본만 고치면 저장소마다 다른 답이 나오고, 그건 대조가 아니라 우연이다.
|
||||||
List<String> _contractTabs() {
|
List<String> _contractTabs() {
|
||||||
final candidates = [
|
final candidates = [
|
||||||
if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?)
|
if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?)
|
||||||
@@ -326,24 +365,31 @@ List<String> _contractTabs() {
|
|||||||
'design/craft-allowlist.yaml',
|
'design/craft-allowlist.yaml',
|
||||||
'../design/craft-allowlist.yaml',
|
'../design/craft-allowlist.yaml',
|
||||||
];
|
];
|
||||||
final file = candidates
|
final found = candidates.map(File.new).where((f) => f.existsSync()).toList();
|
||||||
.map(File.new)
|
if (found.isEmpty) {
|
||||||
.firstWhere(
|
throw StateError(
|
||||||
(f) => f.existsSync(),
|
'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. '
|
||||||
orElse: () => throw StateError(
|
'찾아본 곳: ${candidates.join(' · ')}',
|
||||||
'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. '
|
);
|
||||||
'찾아본 곳: ${candidates.join(' · ')}',
|
}
|
||||||
),
|
final lines = {for (final file in found) _appTabsLine(file)};
|
||||||
);
|
if (lines.length > 1) {
|
||||||
final line = file.readAsLinesSync().firstWhere(
|
throw StateError(
|
||||||
(l) => l.trimLeft().startsWith('APP_TABS:'),
|
'craft-allowlist.yaml 의 APP_TABS 가 사본마다 다르다. 정본과 사본을 맞추라. '
|
||||||
orElse: () =>
|
'${found.map((f) => f.path).join(' · ')} → ${lines.join(' / ')}',
|
||||||
throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
|
);
|
||||||
);
|
}
|
||||||
final inner = RegExp(r'\[(.*)\]').firstMatch(line)!.group(1)!;
|
final inner = RegExp(r'\[(.*)\]').firstMatch(lines.single)!.group(1)!;
|
||||||
return inner.split(',').map((s) => s.trim()).toList();
|
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 {
|
final class _SmokeSession implements AuthSession {
|
||||||
const _SmokeSession(this.currentAccount);
|
const _SmokeSession(this.currentAccount);
|
||||||
|
|
||||||
@@ -356,3 +402,18 @@ final class _SmokeSession implements AuthSession {
|
|||||||
@override
|
@override
|
||||||
Future<void> signOut() async {}
|
Future<void> signOut() async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class _SignupProvider implements SocialAuthProvider {
|
||||||
|
@override
|
||||||
|
AuthProviderKind get provider => SocialProvider.google;
|
||||||
|
@override
|
||||||
|
bool get isLive => true;
|
||||||
|
@override
|
||||||
|
List<String> get missingConfig => const [];
|
||||||
|
@override
|
||||||
|
Future<AuthOutcome> signIn() async => const AuthSucceeded(
|
||||||
|
AuthAccount(uid: 'new-user', provider: SocialProvider.google),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Future<void> signOut() async {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,27 @@ void main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('회원가입 모드 성공만 가입 완료 화면 플래그를 세우고, 시작하면 내린다', () async {
|
||||||
|
const account = AuthAccount(uid: 'new', provider: SocialProvider.google);
|
||||||
|
final auth = AuthRepository(
|
||||||
|
providers: [
|
||||||
|
_FakeProvider(
|
||||||
|
SocialProvider.google,
|
||||||
|
result: const AuthSucceeded(account),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
releaseMode: true,
|
||||||
|
);
|
||||||
|
await auth.signIn(SocialProvider.google);
|
||||||
|
expect(auth.justSignedUp, isFalse);
|
||||||
|
await auth.signOut();
|
||||||
|
await auth.signIn(SocialProvider.google, signUp: true);
|
||||||
|
expect(auth.justSignedUp, isTrue);
|
||||||
|
auth.finishSignup();
|
||||||
|
expect(auth.justSignedUp, isFalse);
|
||||||
|
auth.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('4개 provider의 취소는 실패 안내나 로그인 상태를 만들지 않는다', () async {
|
test('4개 provider의 취소는 실패 안내나 로그인 상태를 만들지 않는다', () async {
|
||||||
for (final provider in SocialProvider.values) {
|
for (final provider in SocialProvider.values) {
|
||||||
final auth = AuthRepository(
|
final auth = AuthRepository(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
|||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
||||||
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
|
||||||
import 'package:fancake/core/data/backend.dart';
|
|
||||||
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
/// 서버 연동 계층 — 로그인 강제와 문서 값 읽기 규칙.
|
|
||||||
///
|
|
||||||
/// 문서 값 읽기가 틀리면 화면은 조용히 0 이나 빈 칸을 그리므로 눈으로는 잡히지 않는다.
|
|
||||||
void main() {
|
|
||||||
group('로그인 강제', () {
|
|
||||||
test('로그인하지 않았으면 카탈로그 코드 E-AUTH-03 으로 끊는다', () {
|
|
||||||
final backend = Backend(
|
|
||||||
firestore: FakeFirebaseFirestore(),
|
|
||||||
auth: MockFirebaseAuth(),
|
|
||||||
);
|
|
||||||
expect(backend.uid, isNull);
|
|
||||||
expect(
|
|
||||||
() => backend.requireUid,
|
|
||||||
throwsA(isA<BackendException>().having((e) => e.code, 'code', 'E-AUTH-03')),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('로그인했으면 UID 를 돌려준다', () {
|
|
||||||
final backend = Backend(
|
|
||||||
firestore: FakeFirebaseFirestore(),
|
|
||||||
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: 'u-test')),
|
|
||||||
);
|
|
||||||
expect(backend.requireUid, 'u-test');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('문서 값 읽기', () {
|
|
||||||
test('없는 필드는 기본값으로 받는다', () {
|
|
||||||
final doc = <String, dynamic>{};
|
|
||||||
expect(doc.intOr('pop'), 0);
|
|
||||||
expect(doc.strOr('name', '이름 없음'), '이름 없음');
|
|
||||||
expect(doc.boolOr('active'), isFalse);
|
|
||||||
expect(doc.mapsOr('items'), isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('실수는 반올림해 정수로 받는다', () {
|
|
||||||
expect(<String, dynamic>{'pop': 12.6}.intOr('pop'), 13);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('빈 문자열은 없는 값으로 본다', () {
|
|
||||||
expect(<String, dynamic>{'name': ''}.strOr('name', '팬'), '팬');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('서버 시각은 DateTime 으로 옮긴다', () {
|
|
||||||
final at = DateTime(2026, 9, 1, 9);
|
|
||||||
final doc = <String, dynamic>{'createdAt': Timestamp.fromDate(at)};
|
|
||||||
expect(doc.dateOr('createdAt', DateTime(2000)), at);
|
|
||||||
expect(doc.dateOr('missing', DateTime(2000)), DateTime(2000));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('목록 안의 맵만 골라 받는다', () {
|
|
||||||
final doc = <String, dynamic>{
|
|
||||||
'items': [
|
|
||||||
{'id': 1},
|
|
||||||
'x',
|
|
||||||
{'id': 2},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(doc.mapsOr('items').map((m) => m['id']), [1, 2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart' show FieldValue;
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_data.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_repository_firestore.dart';
|
||||||
|
import 'package:fancake/features/my/profile_edit_screen.dart';
|
||||||
|
import 'package:fancake/features/translation/translation_comments.dart';
|
||||||
|
import 'package:fancake/features/translation/translation_data.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 캠페인 응원 게시판 댓글 — 서버 문서 모양 · 최신순 · 페이지 · 신고 · 차단.
|
||||||
|
///
|
||||||
|
/// 규칙 판정(공개 캠페인 · 표시명 · 시각 · 1,000자 · 신고 ID)은 `firebase/test` 의
|
||||||
|
/// 규칙 테스트가, 댓글 수 집계는 functions 테스트가 본다.
|
||||||
|
|
||||||
|
const _me = 'u-me';
|
||||||
|
|
||||||
|
Future<(FakeFirebaseFirestore, FirestoreCampaignRepository)> _repo() async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('users/$_me').set({'displayName': '김실명', 'nickname': '나'});
|
||||||
|
await db.doc('campaigns/c1').set({'status': 'funding', 'commentCount': 3});
|
||||||
|
final backend = Backend(
|
||||||
|
firestore: db,
|
||||||
|
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _me)),
|
||||||
|
);
|
||||||
|
return (db, FirestoreCampaignRepository(backend));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _seed(FakeFirebaseFirestore db, String uid, int i) =>
|
||||||
|
db.collection('campaigns/c1/comments').add({
|
||||||
|
'authorUid': uid,
|
||||||
|
'authorName': uid,
|
||||||
|
'body': '$uid$i',
|
||||||
|
'visibility': 'visible',
|
||||||
|
'createdAt': DateTime(2026, 9, 1).add(Duration(minutes: i)),
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> _pump(
|
||||||
|
WidgetTester tester,
|
||||||
|
CampaignRepository repo, {
|
||||||
|
TranslationRepository? translation,
|
||||||
|
bool board = true,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2600);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
// 차단하면 상세가 닫히므로 아래에 첫 화면을 하나 둔다.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => CampaignDetailScreen(
|
||||||
|
campaignId: 'c1',
|
||||||
|
repository: repo,
|
||||||
|
translation: translation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
if (!board) return;
|
||||||
|
await tester.tap(find.textContaining('응원 게시판'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _menu(WidgetTester tester, String body, String item) async {
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: find.ancestor(of: find.text(body), matching: find.byType(Row)).first,
|
||||||
|
matching: find.byType(PopupMenuButton<String>),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text(item).last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('응원 게시판 — Firestore', () {
|
||||||
|
test('댓글은 규칙이 받는 필드로만, 공개 닉네임으로 남는다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
final c = await repo.addComment('c1', '응원해요');
|
||||||
|
final data = (await db.doc('campaigns/c1/comments/${c.id}').get())
|
||||||
|
.data()!;
|
||||||
|
expect(data.keys.toSet(), {
|
||||||
|
'authorUid',
|
||||||
|
'authorName',
|
||||||
|
'body',
|
||||||
|
'visibility',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
});
|
||||||
|
expect(data['authorName'], '나');
|
||||||
|
expect(c.isMine, isTrue);
|
||||||
|
|
||||||
|
// 작성 자격 — 공개 닉네임. 비었거나 이메일 형태면 없다(규칙 hasPublicNickname).
|
||||||
|
expect(await repo.hasPublicNickname(), isTrue);
|
||||||
|
await db.doc('users/$_me').update({'nickname': 'me@mail.com'});
|
||||||
|
expect(await repo.hasPublicNickname(), isFalse);
|
||||||
|
await db.doc('users/$_me').update({'nickname': FieldValue.delete()});
|
||||||
|
expect(await repo.hasPublicNickname(), isFalse);
|
||||||
|
|
||||||
|
await repo.deleteComment('c1', c.id);
|
||||||
|
expect(
|
||||||
|
(await db.doc('campaigns/c1/comments/${c.id}').get()).exists,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('탭의 댓글 수는 서버가 센 commentCount 다', () async {
|
||||||
|
final (_, repo) = await _repo();
|
||||||
|
expect((await repo.detail('c1')).card.comments, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('최신순으로 한 장씩 읽고, 숨김 · 차단 작성자의 댓글은 뺀다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
for (var i = 0; i < 25; i++) {
|
||||||
|
await _seed(db, 'fan', i);
|
||||||
|
}
|
||||||
|
await db.collection('campaigns/c1/comments').add({
|
||||||
|
'authorUid': 'fan',
|
||||||
|
'body': '숨김',
|
||||||
|
'visibility': 'hidden',
|
||||||
|
'createdAt': DateTime(2027),
|
||||||
|
});
|
||||||
|
final first = await repo.comments('c1');
|
||||||
|
expect(first.length, campaignCommentPageSize);
|
||||||
|
expect(first.first.body, 'fan24');
|
||||||
|
final next = await repo.comments('c1', more: true);
|
||||||
|
expect(next.map((c) => c.body), ['fan4', 'fan3', 'fan2', 'fan1', 'fan0']);
|
||||||
|
|
||||||
|
await _seed(db, 'troll', 99);
|
||||||
|
final troll = (await repo.comments('c1')).first;
|
||||||
|
expect(troll.body, 'troll99');
|
||||||
|
await repo.blockAuthor(troll);
|
||||||
|
expect((await db.doc('users/$_me/blocks/troll').get()).exists, isTrue);
|
||||||
|
expect((await repo.comments('c1')).first.body, 'fan24');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('신고는 reports 에 댓글 + 신고자 ID 로 쌓인다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
await _seed(db, 'troll', 1);
|
||||||
|
final c = (await repo.comments('c1')).single;
|
||||||
|
await repo.reportComment('c1', c, '스팸 및 영리 목적 광고');
|
||||||
|
final report =
|
||||||
|
(await db.doc('reports/campaignComment-c1-${c.id}-$_me').get())
|
||||||
|
.data()!;
|
||||||
|
expect(report['targetType'], 'campaignComment');
|
||||||
|
expect(report['targetPath'], 'campaigns/c1/comments/${c.id}');
|
||||||
|
expect(report['reasonCode'], 'spam');
|
||||||
|
expect(report['status'], 'pending');
|
||||||
|
expect(
|
||||||
|
repo.reportComment('c1', c, '목록에 없는 사유'),
|
||||||
|
throwsA(isA<BackendException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('좋아요 · 캠페인 신고 · 개설자 차단 — Firestore', () {
|
||||||
|
test('댓글 좋아요는 내 문서 하나로 남고, 목록은 서버 수와 내 상태를 읽는다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
await _seed(db, 'fan', 1);
|
||||||
|
var c = (await repo.comments('c1')).single;
|
||||||
|
expect((c.likes, c.liked), (0, false));
|
||||||
|
await repo.likeComment('c1', c.id, true);
|
||||||
|
final like = await db
|
||||||
|
.doc('campaigns/c1/comments/${c.id}/likes/$_me')
|
||||||
|
.get();
|
||||||
|
expect(like.data()!.keys, ['createdAt']);
|
||||||
|
// 수는 서버 트리거가 적는다.
|
||||||
|
await db.doc('campaigns/c1/comments/${c.id}').update({'likeCount': 1});
|
||||||
|
c = (await repo.comments('c1')).single;
|
||||||
|
expect((c.likes, c.liked), (1, true));
|
||||||
|
await repo.likeComment('c1', c.id, false);
|
||||||
|
expect((await repo.comments('c1')).single.liked, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('캠페인 신고는 campaign-{캠페인}-{신고자} 로 남는다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
await repo.reportCampaign('c1', '스팸 및 영리 목적 광고');
|
||||||
|
final r = (await db.doc('reports/campaign-c1-$_me').get()).data()!;
|
||||||
|
expect(r['targetType'], 'campaign');
|
||||||
|
expect(r['targetPath'], 'campaigns/c1');
|
||||||
|
expect(r['reasonCode'], 'spam');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('개설자를 차단하면 그 캠페인과 그 사람의 댓글이 빠진다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
await db.doc('campaigns/c1').update({'creatorUid': 'maker'});
|
||||||
|
await db.doc('campaigns/c2').set({
|
||||||
|
'status': 'funding',
|
||||||
|
'creatorUid': 'other',
|
||||||
|
});
|
||||||
|
await _seed(db, 'maker', 1);
|
||||||
|
final card = (await repo.detail('c1')).card;
|
||||||
|
expect(card.creatorUid, 'maker');
|
||||||
|
await repo.blockCreator(card);
|
||||||
|
expect((await db.doc('users/$_me/blocks/maker').get()).exists, isTrue);
|
||||||
|
expect((await repo.load()).map((c) => c.id), ['c2']);
|
||||||
|
expect(await repo.comments('c1'), isEmpty);
|
||||||
|
|
||||||
|
// 차단 목록은 읽을 때마다 새로 읽는다 — MY 에서 풀면 바로 다시 보인다.
|
||||||
|
await db.doc('users/$_me/blocks/maker').delete();
|
||||||
|
expect((await repo.load()).map((c) => c.id).toSet(), {'c1', 'c2'});
|
||||||
|
expect(await repo.comments('c1'), hasLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('응원 게시판 — 화면', () {
|
||||||
|
testWidgets('기본 입력은 일반 댓글이고 맨 위에 붙어 탭 수가 는다', (tester) async {
|
||||||
|
final repo = SampleCampaignRepository();
|
||||||
|
await _pump(tester, repo);
|
||||||
|
expect(find.text('응원 게시판 24'), findsOneWidget);
|
||||||
|
await tester.enterText(find.byType(TextField), '화이팅');
|
||||||
|
await tester.tap(find.byTooltip('보내기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('화이팅'), findsOneWidget);
|
||||||
|
expect(find.text('응원 게시판 25'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.text('화이팅')).dy,
|
||||||
|
lessThan(tester.getTopLeft(find.text('꼭 성공했으면 좋겠어요!')).dy),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 내 댓글은 삭제만 — 확인하면 목록과 수에서 빠진다.
|
||||||
|
await _menu(tester, '화이팅', '삭제하기');
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('화이팅'), findsNothing);
|
||||||
|
expect(find.text('응원 게시판 24'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('남의 댓글은 신고 · 차단 — 차단하면 그 작성자의 댓글이 사라진다', (tester) async {
|
||||||
|
final repo = _Spy();
|
||||||
|
await _pump(tester, repo);
|
||||||
|
|
||||||
|
await _menu(tester, '꼭 성공했으면 좋겠어요!', '신고하기');
|
||||||
|
await tester.tap(find.text('스팸 및 영리 목적 광고'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('신고'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.reported, ['m1']);
|
||||||
|
expect(find.text('신고를 접수했습니다.'), findsOneWidget);
|
||||||
|
|
||||||
|
await _menu(tester, '꼭 성공했으면 좋겠어요!', '차단하기');
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('꼭 성공했으면 좋겠어요!'), findsNothing);
|
||||||
|
expect(find.text('조감도 너무 예쁘네요'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('한 장이 꽉 차면 더 보기로 다음 장을 붙인다', (tester) async {
|
||||||
|
await _pump(tester, _Paged());
|
||||||
|
expect(find.text('p0'), findsOneWidget);
|
||||||
|
expect(find.text('next'), findsNothing);
|
||||||
|
await tester.ensureVisible(find.text('더 보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('더 보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('next'), findsOneWidget);
|
||||||
|
expect(find.text('더 보기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('댓글 좋아요는 저장 뒤에 수가 바뀌고 다시 누르면 취소된다', (tester) async {
|
||||||
|
final repo = _Spy();
|
||||||
|
await _pump(tester, repo);
|
||||||
|
// 댓글 카드의 하트는 14 — 캠페인 본문의 하트(16)와 가른다.
|
||||||
|
Finder heart(IconData icon) => find.byWidgetPredicate(
|
||||||
|
(w) => w is Icon && w.icon == icon && w.size == 14,
|
||||||
|
);
|
||||||
|
await tester.tap(heart(Icons.favorite_border).first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.liked, [('m1', true)]);
|
||||||
|
expect(heart(Icons.favorite), findsOneWidget);
|
||||||
|
await tester.tap(heart(Icons.favorite));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.liked.last, ('m1', false));
|
||||||
|
expect(heart(Icons.favorite), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('댓글의 지구본으로 그 댓글을 번역하면 대댓글 자리에 붙는다', (tester) async {
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
SampleCampaignRepository(),
|
||||||
|
translation: SampleTranslationRepository(uid: 'me'),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byTooltip('번역 댓글 작성').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('님 댓글 번역'), findsOneWidget);
|
||||||
|
await tester.enterText(find.byType(TextField), 'Good luck');
|
||||||
|
await tester.tap(find.byTooltip('보내기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final card = tester.widget<TranslationCommentCard>(
|
||||||
|
find.byType(TranslationCommentCard),
|
||||||
|
);
|
||||||
|
expect(card.entry.targetPath, 'campaigns/c1/comments/m1');
|
||||||
|
expect(card.reply, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('캠페인 신고는 저장소에 남고, 개설자 차단은 상세를 닫는다', (tester) async {
|
||||||
|
final repo = _Spy();
|
||||||
|
await _pump(tester, repo, board: false);
|
||||||
|
Future<void> appMenu(String item) async {
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(AppBar),
|
||||||
|
matching: find.byType(PopupMenuButton<String>),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text(item).last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
await appMenu('신고하기');
|
||||||
|
await tester.tap(find.text('스팸 및 영리 목적 광고'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('신고'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.reported, ['campaign:c1']);
|
||||||
|
|
||||||
|
await appMenu('차단하기');
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.blockedCreators, ['c1']);
|
||||||
|
expect(find.byType(CampaignDetailScreen), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('닉네임이 없으면 입력 대신 안내와 닉네임 정하기 버튼이 뜬다', (tester) async {
|
||||||
|
final repo = _NoNickname();
|
||||||
|
await _pump(tester, repo);
|
||||||
|
expect(find.text('닉네임을 먼저 정해 주세요'), findsOneWidget);
|
||||||
|
expect(find.byType(CommentInputBar), findsNothing);
|
||||||
|
await tester.tap(find.text('닉네임 정하기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(ProfileEditScreen), findsOneWidget);
|
||||||
|
// 닉네임을 정하고 돌아오면 입력이 열린다.
|
||||||
|
repo.named = true;
|
||||||
|
Navigator.of(tester.element(find.byType(ProfileEditScreen))).pop();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(CommentInputBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Spy extends SampleCampaignRepository {
|
||||||
|
final reported = <String>[];
|
||||||
|
final liked = <(String, bool)>[];
|
||||||
|
final blockedCreators = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> likeComment(
|
||||||
|
String campaignId,
|
||||||
|
String commentId,
|
||||||
|
bool on,
|
||||||
|
) async => liked.add((commentId, on));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reportCampaign(String campaignId, String reason) async =>
|
||||||
|
reported.add('campaign:$campaignId');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> blockCreator(CampaignCardData card) async =>
|
||||||
|
blockedCreators.add(card.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reportComment(
|
||||||
|
String campaignId,
|
||||||
|
CampaignComment comment,
|
||||||
|
String reason,
|
||||||
|
) async => reported.add(comment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Paged extends SampleCampaignRepository {
|
||||||
|
@override
|
||||||
|
Future<List<CampaignComment>> comments(
|
||||||
|
String campaignId, {
|
||||||
|
bool more = false,
|
||||||
|
}) async => [
|
||||||
|
if (more)
|
||||||
|
CampaignComment(
|
||||||
|
id: 'n',
|
||||||
|
authorName: 'b',
|
||||||
|
body: 'next',
|
||||||
|
createdAt: DateTime(2026),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
for (var i = 0; i < campaignCommentPageSize; i++)
|
||||||
|
CampaignComment(
|
||||||
|
id: 'p$i',
|
||||||
|
authorName: 'a',
|
||||||
|
body: 'p$i',
|
||||||
|
createdAt: DateTime(2026),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoNickname extends SampleCampaignRepository {
|
||||||
|
bool named = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hasPublicNickname() async => named;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
@Tags(['design'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
|
import 'package:fancake/core/router/app_router.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
|
||||||
|
import 'package:fancake/features/home/home_onboarding_screen.dart';
|
||||||
|
import 'package:fancake/features/home/nebula_create_screen.dart';
|
||||||
|
import 'package:fancake/features/home/planet_create_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../eclipse_stubs.dart';
|
||||||
|
import 'home_fixtures.dart';
|
||||||
|
|
||||||
|
/// 디자인 일치 그룹 A(F1 홈 · F7 이클립스) 대조 렌더.
|
||||||
|
///
|
||||||
|
/// 공유 하네스(`design_match_test.dart`)와 같은 방식으로 찍되, 표본을 Figma 사본의
|
||||||
|
/// 기본 상태에 맞춘다 — 홈은 라이브 띠·이클립스 경보가 없는 「ZAP 닫힘」 상태다.
|
||||||
|
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/a_test.dart`
|
||||||
|
|
||||||
|
const _width = 375.0;
|
||||||
|
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
|
||||||
|
|
||||||
|
typedef Build = Future<Widget> Function(Repositories r);
|
||||||
|
|
||||||
|
class Shot {
|
||||||
|
const Shot(
|
||||||
|
this.id, {
|
||||||
|
this.build,
|
||||||
|
this.tab,
|
||||||
|
this.h = 812,
|
||||||
|
this.after,
|
||||||
|
this.repos = QuietRepos.new,
|
||||||
|
});
|
||||||
|
final String id;
|
||||||
|
final QuietRepos Function() repos;
|
||||||
|
final Build? build;
|
||||||
|
final String? tab;
|
||||||
|
final double h;
|
||||||
|
final Future<void> Function(WidgetTester t)? after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이클립스 화면 — 사본 문구의 「60초」가 나오도록 마감을 60초 뒤로 둔다.
|
||||||
|
Widget _defend(EclipseState state) => EclipseDefendScreen(
|
||||||
|
repository: FakeEclipseRepository(state: state),
|
||||||
|
fandomId: 'bts',
|
||||||
|
);
|
||||||
|
|
||||||
|
DateTime get _in60 => DateTime.now().add(const Duration(seconds: 60));
|
||||||
|
|
||||||
|
final shots = <Shot>[
|
||||||
|
// 사본 주 프레임(`252:159354`)은 헤더 지갑의 「아이템 구입」 시트가 열린 상태다.
|
||||||
|
Shot(
|
||||||
|
'F1-01',
|
||||||
|
tab: '/home',
|
||||||
|
after: (t) async {
|
||||||
|
await t.tap(find.byTooltip('아이템'));
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// 기본 상태(`343:205364`) · ZAP 옵션 「아이템 선택」(`343:204868`) — 그룹 대조 폴더용.
|
||||||
|
Shot('F1-01d', tab: '/home'),
|
||||||
|
// 아래 구획(TODAY'S QUEST 등) 눈 대조용 긴 렌더.
|
||||||
|
Shot('F1-01L', tab: '/home', h: 1400),
|
||||||
|
Shot(
|
||||||
|
'F1-01i',
|
||||||
|
tab: '/home',
|
||||||
|
after: (t) async {
|
||||||
|
final hold = await t.startGesture(t.getCenter(find.text('기여')));
|
||||||
|
await t.pump(const Duration(seconds: 2));
|
||||||
|
await hold.up();
|
||||||
|
await _settle(t);
|
||||||
|
await t.tap(find.text('아이템'));
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot('F1-02', build: (r) async => const NebulaCreateScreen()),
|
||||||
|
// 성장 완료는 HOME 탭 성운 상태를 그대로 찍는다(ZAP 은 HOME 의 갤럭시 연결로 뜬다).
|
||||||
|
Shot('F1-03', tab: '/home', repos: NebulaRepos.new),
|
||||||
|
Shot('F1-04', build: (r) async => const PlanetCreateScreen()),
|
||||||
|
// 이름 입력 뒤(`340:75644`) · 색 단계(`373:358639` 의 칩 줄) — 그룹 대조 폴더에서만 쓴다.
|
||||||
|
Shot(
|
||||||
|
'F1-02n',
|
||||||
|
build: (r) async => const NebulaCreateScreen(),
|
||||||
|
after: (t) async {
|
||||||
|
await t.enterText(find.byType(TextField), 'BTS_RM');
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F1-04c',
|
||||||
|
build: (r) async => const PlanetCreateScreen(),
|
||||||
|
after: (t) async {
|
||||||
|
await t.tap(find.text('다음'));
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// 사본 주 프레임은 「+」 시트가 열린 상태다.
|
||||||
|
Shot(
|
||||||
|
'F1-05',
|
||||||
|
build: (r) async => const HomeOnboardingScreen(),
|
||||||
|
after: (t) async {
|
||||||
|
await t.tap(find.bySemanticsLabel('행성 생성 또는 가입'));
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F7-01',
|
||||||
|
h: 815,
|
||||||
|
build: (r) async => _defend(eclipseFixture(endsAt: _in60)),
|
||||||
|
),
|
||||||
|
// 흔드는 중 — 사본 문구의 「10초」 자리는 남은 시간이다.
|
||||||
|
Shot(
|
||||||
|
'F7-02',
|
||||||
|
h: 815,
|
||||||
|
build: (r) async => _defend(
|
||||||
|
eclipseFixture(endsAt: DateTime.now().add(const Duration(seconds: 10))),
|
||||||
|
),
|
||||||
|
after: (t) async {
|
||||||
|
await t.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F7-03',
|
||||||
|
h: 815,
|
||||||
|
build: (r) async => _defend(eclipseFixture(defended: true)),
|
||||||
|
),
|
||||||
|
// 실패 변형(`494:14292`) — 공유 비교에는 없는 ID 라 그룹 대조 폴더에서만 쓴다.
|
||||||
|
Shot(
|
||||||
|
'F7-02f',
|
||||||
|
h: 815,
|
||||||
|
build: (r) async => _defend(eclipseFixture(result: EclipseResult.failure)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> _settle(WidgetTester t) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await t.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFonts() async {
|
||||||
|
final manifest =
|
||||||
|
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
|
||||||
|
for (final font in manifest.cast<Map<String, dynamic>>()) {
|
||||||
|
final loader = FontLoader(font['family'] as String);
|
||||||
|
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
|
||||||
|
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
|
||||||
|
}
|
||||||
|
await loader.load();
|
||||||
|
}
|
||||||
|
await (FontLoader(
|
||||||
|
'Roboto',
|
||||||
|
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final out = Platform.environment['DESIGN_MATCH_OUT'];
|
||||||
|
if (out == null) return;
|
||||||
|
setUpAll(() async {
|
||||||
|
Directory(out).createSync(recursive: true);
|
||||||
|
await _loadFonts();
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
|
||||||
|
(_) async => null,
|
||||||
|
);
|
||||||
|
messenger.setMockStreamHandler(
|
||||||
|
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
|
||||||
|
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final s in shots) {
|
||||||
|
testWidgets(s.id, (t) async {
|
||||||
|
t.view.devicePixelRatio = 2;
|
||||||
|
t.view.physicalSize = Size(_width * 2, s.h * 2);
|
||||||
|
t.view.padding = _padding;
|
||||||
|
t.view.viewPadding = _padding;
|
||||||
|
addTearDown(t.view.reset);
|
||||||
|
|
||||||
|
final repos = s.repos();
|
||||||
|
final boundary = GlobalKey();
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
Widget? pushed;
|
||||||
|
Widget app;
|
||||||
|
if (s.tab != null) {
|
||||||
|
final router = buildRouter(
|
||||||
|
AuthRepository()..signInWithMockSession(),
|
||||||
|
repositories: repos,
|
||||||
|
);
|
||||||
|
router.go(s.tab!);
|
||||||
|
app = MaterialApp.router(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
routerConfig: router,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pushed = (await t.runAsync(() => s.build!(repos)))!;
|
||||||
|
app = MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: const SizedBox(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
|
||||||
|
if (pushed case final screen?) {
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => screen),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _settle(t);
|
||||||
|
await s.after?.call(t);
|
||||||
|
await t.runAsync(() async {
|
||||||
|
for (final e in find.byType(Image).evaluate().toList()) {
|
||||||
|
await precacheImage((e.widget as Image).image, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await _settle(t);
|
||||||
|
|
||||||
|
await t.runAsync(() async {
|
||||||
|
final image =
|
||||||
|
await (boundary.currentContext!.findRenderObject()!
|
||||||
|
as RenderRepaintBoundary)
|
||||||
|
.toImage(pixelRatio: 2);
|
||||||
|
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
final texts = [
|
||||||
|
for (final e in find.byType(RichText).evaluate())
|
||||||
|
if (e.renderObject case final RenderParagraph p
|
||||||
|
when p.attached && p.text.toPlainText().trim().isNotEmpty)
|
||||||
|
{
|
||||||
|
'text': p.text.toPlainText(),
|
||||||
|
'family': p.text.style?.fontFamily,
|
||||||
|
'size': p.text.style?.fontSize,
|
||||||
|
'weight': p.text.style?.fontWeight?.value,
|
||||||
|
'color': p.text.style?.color?.toARGB32().toRadixString(16),
|
||||||
|
'rect': [
|
||||||
|
for (final v in [
|
||||||
|
p.localToGlobal(Offset.zero).dx,
|
||||||
|
p.localToGlobal(Offset.zero).dy,
|
||||||
|
p.size.width,
|
||||||
|
p.size.height,
|
||||||
|
])
|
||||||
|
v.roundToDouble(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
File(
|
||||||
|
'$out/${s.id}.texts.json',
|
||||||
|
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
@Tags(['design'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
|
import 'package:fancake/core/router/app_router.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_search_screen.dart';
|
||||||
|
import 'package:fancake/features/planet/fpi_ranking_screen.dart';
|
||||||
|
import 'package:fancake/features/planet/lounge_post_screens.dart';
|
||||||
|
import 'package:fancake/features/planet/moderation.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_detail_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../eclipse_stubs.dart';
|
||||||
|
import 'design_match_test.dart' as base;
|
||||||
|
|
||||||
|
/// 디자인 일치 2단계 **그룹 B**(F2 갤럭시 · F3 행성/성운 상세) 렌더.
|
||||||
|
///
|
||||||
|
/// 공유 하네스([base.main])를 그대로 돌리되 shots 만 이 그룹 것으로 바꾼다.
|
||||||
|
/// 표본은 Figma 사본 문구와 같게 여기서 만든다(공유 표본 저장소는 건드리지 않는다).
|
||||||
|
///
|
||||||
|
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/b_test.dart`
|
||||||
|
void main() {
|
||||||
|
base.shots
|
||||||
|
..clear()
|
||||||
|
..addAll(_shots);
|
||||||
|
base.main();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _settle(WidgetTester t) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await t.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tap(WidgetTester t, Finder f) async {
|
||||||
|
await t.tap(f.first, warnIfMissed: false);
|
||||||
|
await _settle(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _overlay(
|
||||||
|
WidgetTester t,
|
||||||
|
void Function(BuildContext c) open,
|
||||||
|
) async {
|
||||||
|
open(t.element(find.byType(Scaffold).last));
|
||||||
|
await _settle(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _now = DateTime.now();
|
||||||
|
|
||||||
|
const _photo = 'assets/figma/lounge-post-sample.png';
|
||||||
|
|
||||||
|
LoungePost _post(
|
||||||
|
String author,
|
||||||
|
Duration ago,
|
||||||
|
String text, {
|
||||||
|
int likes = 8,
|
||||||
|
int comments = 24,
|
||||||
|
List<String> images = const [],
|
||||||
|
String? photo,
|
||||||
|
Duration? video,
|
||||||
|
}) => LoungePost(
|
||||||
|
id: author,
|
||||||
|
authorName: author,
|
||||||
|
createdAt: _now.subtract(ago),
|
||||||
|
title: '',
|
||||||
|
body: text,
|
||||||
|
likes: likes,
|
||||||
|
comments: comments,
|
||||||
|
images: images,
|
||||||
|
authorPhoto: photo,
|
||||||
|
videoDuration: video,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// F3-05 `244:22509` — 첫 댓글 전 글.
|
||||||
|
final _hongPost = _post(
|
||||||
|
'BTS_Hong',
|
||||||
|
Duration.zero,
|
||||||
|
'안녕하세요! BTS 팬이에요!',
|
||||||
|
likes: 0,
|
||||||
|
comments: 0,
|
||||||
|
images: const [_photo],
|
||||||
|
photo: 'assets/figma/avatar-hong.png',
|
||||||
|
video: const Duration(seconds: 36),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// F3-07 `252:163306` — 사진 세 장 글.
|
||||||
|
final _ilaPost = _post(
|
||||||
|
'ILA_WITH',
|
||||||
|
const Duration(days: 3),
|
||||||
|
'여러분 지금 미국 컴백 무대 보셨나요?\n사진 공유해드릴게요 모두 온라인 직관하세요',
|
||||||
|
likes: 8,
|
||||||
|
comments: 5,
|
||||||
|
images: const [_photo, _photo, _photo],
|
||||||
|
);
|
||||||
|
|
||||||
|
FpiScore _score(FpiMetric m, int v, int rank, int trend) =>
|
||||||
|
FpiScore(metric: m, value: v, rank: rank, trend: trend, myTopPercent: 1);
|
||||||
|
|
||||||
|
/// 시안 수치 그대로의 행성 · 성운 상세.
|
||||||
|
class _FigmaPlanet extends SamplePlanetRepository {
|
||||||
|
const _FigmaPlanet();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PlanetDetail> load({required bool nebula}) async => nebula
|
||||||
|
? PlanetDetail(
|
||||||
|
fandomId: 'bts-rm',
|
||||||
|
name: 'BTS_RM',
|
||||||
|
isNebula: true,
|
||||||
|
stage: 0,
|
||||||
|
totalFpi: 0,
|
||||||
|
members: 1,
|
||||||
|
ownerName: 'BTS_Hong',
|
||||||
|
ownerPhoto: 'assets/figma/avatar-hong.png',
|
||||||
|
scores: const [],
|
||||||
|
posts: const [],
|
||||||
|
)
|
||||||
|
: PlanetDetail(
|
||||||
|
fandomId: 'bts',
|
||||||
|
name: 'BTS',
|
||||||
|
isNebula: false,
|
||||||
|
totalFpi: 1220,
|
||||||
|
members: 850,
|
||||||
|
ownerName: 'BTS_Maker',
|
||||||
|
ownerPhoto: 'assets/figma/avatar-maker.png',
|
||||||
|
scores: [
|
||||||
|
_score(FpiMetric.bpm, 34200, 2, 1),
|
||||||
|
_score(FpiMetric.health, 1180000, 4, -1),
|
||||||
|
_score(FpiMetric.referral, 34200, 3, 0),
|
||||||
|
_score(FpiMetric.contribution, 450100, 2, 2),
|
||||||
|
_score(FpiMetric.community, 1000, 4, -2),
|
||||||
|
],
|
||||||
|
posts: [
|
||||||
|
_post(
|
||||||
|
'arrrmy_',
|
||||||
|
const Duration(minutes: 3),
|
||||||
|
'I love BTS sooooo much 😭😭😭',
|
||||||
|
),
|
||||||
|
_post(
|
||||||
|
'AR_NNJO',
|
||||||
|
const Duration(hours: 3),
|
||||||
|
'NAMJOON so handsome isn’t it???',
|
||||||
|
images: const [_photo],
|
||||||
|
),
|
||||||
|
_ilaPost,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<LoungePost>> morePosts({
|
||||||
|
required int offset,
|
||||||
|
int limit = 5,
|
||||||
|
}) async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FpiRanking> planetRanking({
|
||||||
|
required FpiMetric metric,
|
||||||
|
int limit = 30,
|
||||||
|
}) async => FpiRanking(
|
||||||
|
mine: const FpiRankRow(
|
||||||
|
rank: 24,
|
||||||
|
id: 'bts',
|
||||||
|
name: 'BTS',
|
||||||
|
value: 34200,
|
||||||
|
change: 12,
|
||||||
|
isMine: true,
|
||||||
|
),
|
||||||
|
rows: _rows(const [
|
||||||
|
(1, 'Bigbnag', 782200, 1),
|
||||||
|
(2, 'NewJeans', 703200, 0),
|
||||||
|
(3, 'Blackpink', 689700, 1),
|
||||||
|
(5, 'Twice', 531100, -1),
|
||||||
|
(6, 'ITZY', 527800, 2),
|
||||||
|
(7, 'Aespa', 422400, 3),
|
||||||
|
(8, 'Seventeen', 329600, 8),
|
||||||
|
(9, 'Eighteen', 311200, -1),
|
||||||
|
(10, 'Nineteen', 134500, 1),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FpiRanking> userRanking({
|
||||||
|
required FpiMetric metric,
|
||||||
|
required String fandomId,
|
||||||
|
int limit = 30,
|
||||||
|
}) async => FpiRanking(
|
||||||
|
mine: const FpiRankRow(
|
||||||
|
rank: 82,
|
||||||
|
id: 'me',
|
||||||
|
name: 'BTS_Hong',
|
||||||
|
value: 3,
|
||||||
|
change: 1,
|
||||||
|
),
|
||||||
|
rows: _rows(const [
|
||||||
|
(1, 'GLAS_SMILE', 3000, 0),
|
||||||
|
(2, 'Suyom_mo', 2600, 0),
|
||||||
|
(3, 'Bool_huuu', 1400, 0),
|
||||||
|
(4, 'Wachooo', 1200, 1),
|
||||||
|
(5, 'Jeans_Bewn', 1200, 0),
|
||||||
|
(6, 'pinkyy', 1100, 1),
|
||||||
|
(7, 'T_T', 1000, -1),
|
||||||
|
(8, 'Yogi_ITZY', 98, 2),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FpiRankRow> _rows(List<(int, String, int, int)> t) => [
|
||||||
|
for (final (rank, name, value, change) in t)
|
||||||
|
FpiRankRow(rank: rank, id: name, name: name, value: value, change: change),
|
||||||
|
];
|
||||||
|
|
||||||
|
Widget _detail({bool nebula = false}) => PlanetDetailScreen(
|
||||||
|
nebula: nebula,
|
||||||
|
repository: const _FigmaPlanet(),
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const _report = ModerationTarget(
|
||||||
|
authorName: 'BTS_Hong',
|
||||||
|
title: '공방 신청 꿀팁 공유합니다.',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 이클립스가 없는 표본 저장소. 탭 화면은 공유 하네스가 저장소를 직접 만들어서
|
||||||
|
/// 바꿀 수 없으므로, 탭 셸을 통째로 한 번 더 띄운다(하단 탭까지 그려진다).
|
||||||
|
class _NoEclipseRepos extends Repositories {
|
||||||
|
_NoEclipseRepos() : super(live: false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
EclipseRepository get eclipse => const NoEclipseRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tab(String path) {
|
||||||
|
final router = buildRouter(
|
||||||
|
AuthRepository()..signInWithMockSession(),
|
||||||
|
repositories: _NoEclipseRepos(),
|
||||||
|
)..go(path);
|
||||||
|
return MaterialApp.router(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
routerConfig: router,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _shots = <base.Shot>[
|
||||||
|
// F2 갤럭시
|
||||||
|
base.Shot('F2-01', build: (_) async => _tab('/galaxy')),
|
||||||
|
base.Shot(
|
||||||
|
'F2-02',
|
||||||
|
build: (r) async => GalaxySearchScreen(repository: r.galaxy),
|
||||||
|
),
|
||||||
|
// F3 행성/성운 상세
|
||||||
|
base.Shot('F3-01', h: 1042, build: (_) async => _detail(nebula: true)),
|
||||||
|
base.Shot(
|
||||||
|
'F3-02',
|
||||||
|
build: (_) async => const ReportScreen(
|
||||||
|
target: ModerationTarget(authorName: 'BTS_Hong', title: 'BTS_RM'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
base.Shot(
|
||||||
|
'F3-03',
|
||||||
|
build: (_) async => _detail(nebula: true),
|
||||||
|
after: (t) => _overlay(
|
||||||
|
t,
|
||||||
|
(c) => confirmBlock(c, 'BTS_Hong', message: '이 성운을 차단할까요?'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
base.Shot('F3-04', h: 1243, build: (_) async => _detail()),
|
||||||
|
base.Shot('F3-05', build: (_) async => LoungePostScreen(post: _hongPost)),
|
||||||
|
base.Shot('F3-06', build: (_) async => const ReportScreen(target: _report)),
|
||||||
|
base.Shot(
|
||||||
|
'F3-07',
|
||||||
|
build: (_) async => LoungePostScreen(post: _ilaPost),
|
||||||
|
after: (t) => _overlay(t, (c) => confirmBlock(c, 'ILA_WITH')),
|
||||||
|
),
|
||||||
|
base.Shot('F3-09', build: (_) async => const LoungeComposeScreen()),
|
||||||
|
base.Shot(
|
||||||
|
'F3-10',
|
||||||
|
build: (_) async => const FpiRankingScreen(
|
||||||
|
metric: FpiMetric.bpm,
|
||||||
|
repository: _FigmaPlanet(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
base.Shot(
|
||||||
|
'F3-11',
|
||||||
|
build: (_) async => const FpiUserRankingScreen(
|
||||||
|
metric: FpiMetric.bpm,
|
||||||
|
fandomId: 'bts',
|
||||||
|
planetName: 'BTS',
|
||||||
|
repository: _FigmaPlanet(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
base.Shot(
|
||||||
|
'F3-12',
|
||||||
|
build: (_) async => _detail(),
|
||||||
|
after: (t) async {
|
||||||
|
await _tap(t, find.text('팬덤 라운지'));
|
||||||
|
await _tap(
|
||||||
|
t,
|
||||||
|
find.image(const AssetImage('assets/figma/icon-post-more.png')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
base.Shot('F3-13', build: (_) async => const ReportScreen(target: _report)),
|
||||||
|
];
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
@Tags(['design'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
|
import 'package:fancake/core/router/app_router.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_create_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_data.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_list_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_ranking_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_search_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/widgets/campaign_period_sheet.dart';
|
||||||
|
import 'package:fancake/features/planet/moderation.dart';
|
||||||
|
import 'package:fancake/features/quest/place_picker_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_ad_screens.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_create_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_list_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_reward_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 디자인 일치 그룹 C(F4 퀘스트 · F5 캠페인) 전용 하네스. 본체는 design_match_test.dart 와 같다.
|
||||||
|
///
|
||||||
|
/// Figma 「계약 시점 사본」 화면과 나란히 놓을 앱 렌더를 뽑는다.
|
||||||
|
///
|
||||||
|
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match` (단일: `--plain-name F6-01`)
|
||||||
|
/// 출력: `$DESIGN_MATCH_OUT/<id>.png`. 변수가 없으면 일반 `flutter test` 에서 건너뛴다. 비교는 tools/design/compare.py.
|
||||||
|
///
|
||||||
|
/// 화면 ID·Figma 노드는 디자인 대조 자료(화면 목록 · 매핑 근거).
|
||||||
|
/// 여기 없는 ID 는 앱 화면이 없거나(없음) 계약 제외(이메일·X·WeChat 가입 등)다.
|
||||||
|
|
||||||
|
/// Figma 프레임 폭. 높이는 화면마다 Figma 프레임 높이를 따른다.
|
||||||
|
const _width = 375.0;
|
||||||
|
|
||||||
|
/// iPhone X 계열 Figma 프레임의 상태 표시줄(48) · 홈 인디케이터(34).
|
||||||
|
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
|
||||||
|
|
||||||
|
typedef Build = Future<Widget> Function(Repositories r);
|
||||||
|
typedef After = Future<void> Function(WidgetTester t);
|
||||||
|
|
||||||
|
class Shot {
|
||||||
|
const Shot(this.id, {this.build, this.tab, this.h = 812, this.after});
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// 탭 밖 화면. [tab] 과 둘 중 하나만 준다.
|
||||||
|
final Build? build;
|
||||||
|
|
||||||
|
/// 탭 루트 화면 — 실제 셸(하단 탭 포함)을 라우터로 띄운다.
|
||||||
|
final String? tab;
|
||||||
|
final double h;
|
||||||
|
|
||||||
|
/// 시트·다이얼로그·탭 전환처럼 화면을 띄운 뒤 한 번 더 조작해야 하는 상태.
|
||||||
|
final After? after;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tap(WidgetTester t, String text) async {
|
||||||
|
await t.tap(find.text(text).first, warnIfMissed: false);
|
||||||
|
await _settle(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다.
|
||||||
|
Future<void> _settle(WidgetTester t) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await t.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _period(WidgetTester t, List<String> days) async {
|
||||||
|
showCampaignPeriodSheet(
|
||||||
|
t.element(find.byType(Scaffold).first),
|
||||||
|
today: DateTime(2026, 4, 1),
|
||||||
|
);
|
||||||
|
await _settle(t);
|
||||||
|
for (final d in days) {
|
||||||
|
await t.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(CampaignPeriodSheet),
|
||||||
|
matching: find.text(d),
|
||||||
|
).first,
|
||||||
|
warnIfMissed: false,
|
||||||
|
);
|
||||||
|
await _settle(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final shots = <Shot>[
|
||||||
|
// F4 퀘스트 — 표본은 Figma 문구·수치와 같은 _F4Quests.
|
||||||
|
Shot(
|
||||||
|
'F4-01',
|
||||||
|
build: (r) async => QuestListScreen(repository: _F4Quests.mine(), myPop: 12540),
|
||||||
|
after: (t) => _tap(t, '내 퀘스트'),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F4-02',
|
||||||
|
build: (r) async =>
|
||||||
|
QuestDetailScreen(questId: 'm1', repository: _F4Quests.detail()),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F4-03',
|
||||||
|
build: (r) async => const ReportScreen(
|
||||||
|
target: ModerationTarget(authorName: 'BTS_Hong', title: '만보기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Shot('F4-04', build: (r) async => QuestCreateScreen(repository: _F4Quests.all())),
|
||||||
|
Shot('F4-05', build: (r) async => const PlacePickerScreen()),
|
||||||
|
Shot(
|
||||||
|
'F4-06',
|
||||||
|
build: (r) async => QuestListScreen(repository: _F4Quests.all(), myPop: 12540),
|
||||||
|
),
|
||||||
|
// 추가 보상 모달 — 관리자 퀘스트에서 「보상받기」를 누른 상태.
|
||||||
|
Shot(
|
||||||
|
'F4-07',
|
||||||
|
build: (r) async =>
|
||||||
|
QuestDetailScreen(questId: 'a0', repository: _F4Quests.attend()),
|
||||||
|
after: (t) => _tap(t, '보상받기'),
|
||||||
|
),
|
||||||
|
Shot('F4-08', build: (r) async => const QuestAdWarpScreen.before()),
|
||||||
|
Shot('F4-09', build: (r) async => const QuestAdWarpScreen.after()),
|
||||||
|
Shot(
|
||||||
|
'F4-10',
|
||||||
|
build: (r) async => QuestRewardScreen(
|
||||||
|
detail: await _F4Quests.all().detail('u1'),
|
||||||
|
reward: const QuestReward(rewardedPop: 10, balanceAfter: 12550),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// F5 캠페인 — 표본은 Figma 문구·수치와 같은 _F5Campaigns.
|
||||||
|
Shot('F5-01', build: (r) async => CampaignListScreen(repository: _F5Campaigns())),
|
||||||
|
// 시안은 검색어 「BTS」를 입력한 채 최근 검색어를 보여 준다(제출 전).
|
||||||
|
Shot(
|
||||||
|
'F5-02',
|
||||||
|
build: (r) async => CampaignSearchScreen(repository: _F5Campaigns()),
|
||||||
|
after: (t) async {
|
||||||
|
await t.enterText(find.byType(TextField), 'BTS');
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F5-03',
|
||||||
|
build: (r) async => CampaignSearchScreen(repository: _F5Campaigns()),
|
||||||
|
after: (t) async {
|
||||||
|
await t.enterText(find.byType(TextField), 'BTS');
|
||||||
|
await t.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F5-04',
|
||||||
|
build: (r) async =>
|
||||||
|
CampaignDetailScreen(campaignId: 'c1', repository: _F5Campaigns()),
|
||||||
|
after: (t) async {
|
||||||
|
await t.tap(find.byTooltip('공유'));
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F5-05',
|
||||||
|
h: 975,
|
||||||
|
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
|
||||||
|
),
|
||||||
|
// 캘린더 시트 두 상태 — 사본 `333:72497`(시작일 9 만) · `333:72719`(9 ~ 23).
|
||||||
|
// 사본 달력이 2026년 4월이라 「오늘」을 4월 1일로 고정한다(1~8 도 고를 수 있는 날).
|
||||||
|
Shot(
|
||||||
|
'F5-05__333-72497',
|
||||||
|
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
|
||||||
|
after: (t) => _period(t, ['9']),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F5-05__333-72719',
|
||||||
|
build: (r) async => CampaignCreateScreen(repository: _F5Campaigns()),
|
||||||
|
after: (t) => _period(t, ['9', '23']),
|
||||||
|
),
|
||||||
|
// Figma 기본 프레임은 「기여하기」 시트가 열린 상태다.
|
||||||
|
Shot(
|
||||||
|
'F5-06',
|
||||||
|
build: (r) async =>
|
||||||
|
CampaignDetailScreen(campaignId: 'c1', repository: _F5Campaigns()),
|
||||||
|
after: (t) => _tap(t, '기여하기'),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F5-07',
|
||||||
|
build: (r) async =>
|
||||||
|
CampaignRankingScreen(campaignId: 'c1', repository: _F5Campaigns()),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F5-08',
|
||||||
|
build: (r) async => const ReportScreen(
|
||||||
|
target: ModerationTarget(authorName: 'BTS_Hong', title: '강남역 초대형 LED 광고'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// F5 표본 — Figma 사본(272:241184 · 272:245738 · 333:70971 · 272:241681)의 문구·수치.
|
||||||
|
/// F4 표본 — 시안 `264:46867`(전체) · `259:46109`(내 퀘스트) · `264:48835`(상세) 문구·수치.
|
||||||
|
class _F4Quests extends SampleQuestRepository {
|
||||||
|
const _F4Quests(this.cards);
|
||||||
|
|
||||||
|
_F4Quests.all()
|
||||||
|
: cards = const [
|
||||||
|
QuestCard(
|
||||||
|
id: 'a1', title: '출석체크', categoryName: '오늘의 출석체크',
|
||||||
|
type: QuestType.attend, author: QuestAuthor.admin,
|
||||||
|
stage: QuestStage.notStarted, goal: 1, progress: 0, rewardPop: 10,
|
||||||
|
participants: 0,
|
||||||
|
),
|
||||||
|
QuestCard(
|
||||||
|
id: 'a2', title: '좋아요', categoryName: '커뮤니티',
|
||||||
|
author: QuestAuthor.admin, stage: QuestStage.notStarted,
|
||||||
|
goal: 5, progress: 0, rewardPop: 10, participants: 0,
|
||||||
|
),
|
||||||
|
QuestCard(
|
||||||
|
id: 'a3', title: '댓글', categoryName: '커뮤니티',
|
||||||
|
author: QuestAuthor.admin, stage: QuestStage.notStarted,
|
||||||
|
goal: 5, progress: 0, rewardPop: 10, participants: 0,
|
||||||
|
),
|
||||||
|
QuestCard(
|
||||||
|
id: 'u1', title: '댓글', categoryName: '커뮤니티',
|
||||||
|
author: QuestAuthor.user, stage: QuestStage.completed,
|
||||||
|
goal: 5, progress: 5, rewardPop: 10, participants: 0, capacity: 5,
|
||||||
|
),
|
||||||
|
QuestCard(
|
||||||
|
id: 'u2', title: '좋아요', categoryName: '커뮤니티',
|
||||||
|
author: QuestAuthor.user, stage: QuestStage.inProgress,
|
||||||
|
goal: 10, progress: 0, rewardPop: 15, participants: 0, capacity: 5,
|
||||||
|
),
|
||||||
|
QuestCard(
|
||||||
|
id: 'u3', title: '만보기', categoryName: '건강',
|
||||||
|
type: QuestType.steps, author: QuestAuthor.user,
|
||||||
|
stage: QuestStage.notStarted, goal: 10000, progress: 0,
|
||||||
|
rewardPop: 150, participants: 0, capacity: 5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
_F4Quests.mine()
|
||||||
|
: cards = const [
|
||||||
|
QuestCard(
|
||||||
|
id: 'm1', title: '만보기', categoryName: '건강',
|
||||||
|
type: QuestType.steps, author: QuestAuthor.user,
|
||||||
|
stage: QuestStage.mine, goal: 10000, progress: 0, rewardPop: 150,
|
||||||
|
participants: 0, capacity: 5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
_F4Quests.attend()
|
||||||
|
: cards = const [
|
||||||
|
QuestCard(
|
||||||
|
id: 'a0', title: '출석 체크 보상을 받아보세요',
|
||||||
|
categoryName: '오늘의 출석체크', type: QuestType.attend,
|
||||||
|
author: QuestAuthor.admin, stage: QuestStage.completed,
|
||||||
|
goal: 1, progress: 1, rewardPop: 50, participants: 0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 상세 시안은 목록과 제목이 다르다.
|
||||||
|
_F4Quests.detail()
|
||||||
|
: cards = const [
|
||||||
|
QuestCard(
|
||||||
|
id: 'm1', title: '뉴진스 미니앨범 축하 퀘스트', categoryName: '건강',
|
||||||
|
type: QuestType.steps, author: QuestAuthor.user,
|
||||||
|
stage: QuestStage.mine, goal: 10000, progress: 0, rewardPop: 150,
|
||||||
|
participants: 0, capacity: 5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final List<QuestCard> cards;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<QuestCard>> load({int limit = 10}) async => cards;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<QuestCard>> more({required int offset, int limit = 10}) async =>
|
||||||
|
const [];
|
||||||
|
|
||||||
|
/// 시안의 카테고리 네 개(C-01 분류 키).
|
||||||
|
@override
|
||||||
|
Future<QuestCatalog> catalog() async => const QuestCatalog(categories: [
|
||||||
|
QuestCategory(id: 'health', name: '건강', activities: [QuestActivity(key: 'walk', name: '걷기')]),
|
||||||
|
QuestCategory(id: 'planet', name: '행성 활동', activities: [QuestActivity(key: 'vote', name: '투표 참여')]),
|
||||||
|
QuestCategory(id: 'community', name: '커뮤니티', activities: [QuestActivity(key: 'community', name: '커뮤니티')]),
|
||||||
|
QuestCategory(id: 'location', name: '특정 위치', activities: [QuestActivity(key: 'location', name: '특정 위치', fields: {'place'})]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<QuestDetail> detail(String questId) async => QuestDetail(
|
||||||
|
card: cards.firstWhere((c) => c.id == questId),
|
||||||
|
imageLabel: '',
|
||||||
|
description: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _F5Campaigns extends SampleCampaignRepository {
|
||||||
|
static final _now = DateTime.now();
|
||||||
|
|
||||||
|
static CampaignCardData _card(String id) => CampaignCardData(
|
||||||
|
id: id,
|
||||||
|
title: '강남역 초대형 LED 광고',
|
||||||
|
body: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다.\n도심을 BTS의 색으로 물들여주세요.',
|
||||||
|
authorName: '다이브짱',
|
||||||
|
createdAt: _now.subtract(const Duration(minutes: 10)),
|
||||||
|
status: CampaignStatus.funding,
|
||||||
|
goalPop: 1000000,
|
||||||
|
raisedPop: 920000,
|
||||||
|
supporters: 1834,
|
||||||
|
likes: 48,
|
||||||
|
comments: 24,
|
||||||
|
views: 82,
|
||||||
|
shares: 856,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _ranks = [
|
||||||
|
ContributorRank(rank: 1, name: 'GLAS_SMILE', pop: 3000),
|
||||||
|
ContributorRank(rank: 2, name: 'Suyom_mo', pop: 2600),
|
||||||
|
ContributorRank(rank: 3, name: 'Bool_huuu', pop: 1400),
|
||||||
|
ContributorRank(rank: 4, name: 'Wachooo', pop: 1200, change: 1),
|
||||||
|
ContributorRank(rank: 5, name: 'Jeans_Bewn', pop: 1200),
|
||||||
|
ContributorRank(rank: 6, name: 'pinkyy', pop: 1100, change: 1),
|
||||||
|
ContributorRank(rank: 7, name: 'T_T', pop: 1000, change: -1),
|
||||||
|
ContributorRank(rank: 8, name: 'Yogi_ITZY', pop: 98, change: 2),
|
||||||
|
ContributorRank(rank: 9, name: 'SaunaSupa', pop: 97, change: 3),
|
||||||
|
ContributorRank(rank: 10, name: '17_10', pop: 88, change: 8),
|
||||||
|
ContributorRank(rank: 11, name: 'Getting_OLD', pop: 87, change: -1),
|
||||||
|
];
|
||||||
|
|
||||||
|
final _queries = ['BTS', 'BTS 광화문 공연', '아미', '방탄소년단', '지민'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CampaignCardData>> load({
|
||||||
|
CampaignSort sort = CampaignSort.latest,
|
||||||
|
int limit = 10,
|
||||||
|
}) async => [_card('c1'), _card('c2')];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CampaignCardData>> more({required int offset, int limit = 10}) async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CampaignDetail> detail(String campaignId) async => CampaignDetail(
|
||||||
|
card: _card(campaignId),
|
||||||
|
mediaLabels: const ['대표 이미지'],
|
||||||
|
endsAt: DateTime.now().add(
|
||||||
|
const Duration(days: 6, hours: 23, minutes: 59, seconds: 6, milliseconds: 900),
|
||||||
|
),
|
||||||
|
topContributors: _ranks.take(7).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ContributorRank>> ranking(
|
||||||
|
String campaignId, {
|
||||||
|
RankWindow window = RankWindow.live,
|
||||||
|
}) async => [
|
||||||
|
..._ranks,
|
||||||
|
const ContributorRank(rank: 82, name: 'BTS_Hong', pop: 3, change: 1, isMe: true),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> recentQueries() async => [..._queries];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CampaignCardData>> search(String query) async => [_card('c1'), _card('c2')];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> balance() async => 12500;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFonts() async {
|
||||||
|
final manifest =
|
||||||
|
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
|
||||||
|
for (final font in manifest.cast<Map<String, dynamic>>()) {
|
||||||
|
final loader = FontLoader(font['family'] as String);
|
||||||
|
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
|
||||||
|
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
|
||||||
|
}
|
||||||
|
await loader.load();
|
||||||
|
}
|
||||||
|
// 글꼴을 지정하지 않은 글자는 테스트 기본(Ahem 네모)으로 그려진다. 기기에서는 시스템 글꼴이므로 본문 글꼴로 대신한다.
|
||||||
|
await (FontLoader(
|
||||||
|
'Roboto',
|
||||||
|
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
|
||||||
|
for (final e in find.byType(Image).evaluate().toList()) {
|
||||||
|
await precacheImage((e.widget as Image).image, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final out = Platform.environment['DESIGN_MATCH_OUT'];
|
||||||
|
if (out == null) return;
|
||||||
|
setUpAll(() async {
|
||||||
|
Directory(out).createSync(recursive: true);
|
||||||
|
await _loadFonts();
|
||||||
|
// 이클립스 방어의 흔들기 센서. 플러그인이 없는 테스트 환경에서는 조용히 비운다.
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
|
||||||
|
(_) async => null,
|
||||||
|
);
|
||||||
|
messenger.setMockStreamHandler(
|
||||||
|
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
|
||||||
|
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final s in shots) {
|
||||||
|
testWidgets(s.id, (t) async {
|
||||||
|
t.view.devicePixelRatio = 2;
|
||||||
|
t.view.physicalSize = Size(_width * 2, s.h * 2);
|
||||||
|
t.view.padding = _padding;
|
||||||
|
t.view.viewPadding = _padding;
|
||||||
|
addTearDown(t.view.reset);
|
||||||
|
|
||||||
|
final repos = Repositories(live: false);
|
||||||
|
final boundary = GlobalKey();
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
Widget? pushed;
|
||||||
|
Widget app;
|
||||||
|
if (s.tab != null) {
|
||||||
|
final router = buildRouter(
|
||||||
|
AuthRepository()..signInWithMockSession(),
|
||||||
|
repositories: repos,
|
||||||
|
);
|
||||||
|
router.go(s.tab!);
|
||||||
|
app = MaterialApp.router(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
routerConfig: router,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final screen = (await t.runAsync(() => s.build!(repos)))!;
|
||||||
|
// 빈 화면 위에 push 해야 AppBar 가 뒤로가기 버튼을 그린다(앱에서도 push 로 연다).
|
||||||
|
app = MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: const SizedBox(),
|
||||||
|
);
|
||||||
|
pushed = screen;
|
||||||
|
}
|
||||||
|
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
|
||||||
|
if (pushed != null) {
|
||||||
|
final screen = pushed;
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => screen),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _settle(t);
|
||||||
|
await s.after?.call(t);
|
||||||
|
await _precacheImages(t);
|
||||||
|
await _settle(t);
|
||||||
|
|
||||||
|
await t.runAsync(() async {
|
||||||
|
final image =
|
||||||
|
await (boundary.currentContext!.findRenderObject()!
|
||||||
|
as RenderRepaintBoundary)
|
||||||
|
.toImage(pixelRatio: 2);
|
||||||
|
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
// 글자 대조용 — compare.py 가 Figma spec 의 TEXT 노드와 맞춰 본다.
|
||||||
|
final texts = [
|
||||||
|
for (final e in find.byType(RichText).evaluate())
|
||||||
|
if (e.renderObject case final RenderParagraph p
|
||||||
|
when p.attached && p.text.toPlainText().trim().isNotEmpty)
|
||||||
|
{
|
||||||
|
'text': p.text.toPlainText(),
|
||||||
|
'family': p.text.style?.fontFamily,
|
||||||
|
'size': p.text.style?.fontSize,
|
||||||
|
'weight': p.text.style?.fontWeight?.value,
|
||||||
|
'color': p.text.style?.color?.toARGB32().toRadixString(16),
|
||||||
|
'rect': [
|
||||||
|
for (final v in [
|
||||||
|
p.localToGlobal(Offset.zero).dx,
|
||||||
|
p.localToGlobal(Offset.zero).dy,
|
||||||
|
p.size.width,
|
||||||
|
p.size.height,
|
||||||
|
])
|
||||||
|
v.roundToDouble(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
File(
|
||||||
|
'$out/${s.id}.texts.json',
|
||||||
|
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
|
||||||
|
// 렌더 예외(오버플로 등)는 실패로 올린다 — 비교 전에 고쳐야 할 것이다.
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
@Tags(['design'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
|
import 'package:fancake/core/router/app_router.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_data.dart';
|
||||||
|
import 'package:fancake/features/my/my_data.dart';
|
||||||
|
import 'package:fancake/features/my/my_lists_screen.dart';
|
||||||
|
import 'package:fancake/features/my/notice_screens.dart';
|
||||||
|
import 'package:fancake/features/my/payment_screens.dart';
|
||||||
|
import 'package:fancake/features/my/pop_history_screen.dart';
|
||||||
|
import 'package:fancake/features/my/profile_edit_screen.dart';
|
||||||
|
import 'package:fancake/features/my/push_settings_screen.dart';
|
||||||
|
import 'package:fancake/features/my/settings_screen.dart';
|
||||||
|
import 'package:fancake/features/my/support_screens.dart';
|
||||||
|
import 'package:fancake/features/my/tier_screens.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 그룹 D(F6 MY) 디자인 대조 렌더 — 표본을 Figma 사본 문구에 맞춘다.
|
||||||
|
///
|
||||||
|
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/d_test.dart`
|
||||||
|
/// 렌더 방식은 공유 하네스(design_match_test.dart)와 같고, 저장소만 [_FigmaMy] 로 바꾼다.
|
||||||
|
|
||||||
|
const _width = 375.0;
|
||||||
|
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
|
||||||
|
|
||||||
|
/// Figma 사본의 표본 문구(BTS_Hong · hon******@gmail.com …). 숫자는 원장 체계(C-18)로 담는다.
|
||||||
|
class _FigmaMy extends SampleMyRepository {
|
||||||
|
@override
|
||||||
|
Future<MyProfile> profile() async => const MyProfile(
|
||||||
|
nickname: 'BTS_Hong',
|
||||||
|
email: 'hon******@gmail.com',
|
||||||
|
contributedPop: 350,
|
||||||
|
userExp: 100,
|
||||||
|
userLevel: 1,
|
||||||
|
balancePop: 12540,
|
||||||
|
steps: 1000,
|
||||||
|
completedQuests: 3,
|
||||||
|
planetTiers: [
|
||||||
|
PlanetTier(fandomName: 'BTS', contributedPop: 100, isPrimary: true),
|
||||||
|
PlanetTier(fandomName: 'NewJeans', contributedPop: 100),
|
||||||
|
PlanetTier(fandomName: 'IVE', contributedPop: 100),
|
||||||
|
PlanetTier(fandomName: 'aespa', contributedPop: 100),
|
||||||
|
PlanetTier(fandomName: 'SEVENTEEN', contributedPop: 100),
|
||||||
|
PlanetTier(fandomName: 'LE SSERAFIM', contributedPop: 100),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PopLedgerEntry>> popHistory({
|
||||||
|
int limit = 20,
|
||||||
|
PopCursor? after,
|
||||||
|
}) async => after != null
|
||||||
|
? const []
|
||||||
|
: [
|
||||||
|
for (final (d, title, type, delta, balance) in const [
|
||||||
|
(5, 'BTS', '행성 기여', -100, 12640),
|
||||||
|
(4, '만보기', '퀘스트 생성', -750, 13390),
|
||||||
|
(4, '좋아요', '퀘스트 완료', 150, 13240),
|
||||||
|
(3, '강남역 초대형 LED 광고', '캠페인 기여', -300, 13540),
|
||||||
|
(2, 'BTS_RM', '성운 기여', -100, 13640),
|
||||||
|
(1, 'BTS 컴백 알려주세요!', '인사이드 생성', -100, 13640),
|
||||||
|
(0, '친구 초대', '레퍼럴', 150, 13640),
|
||||||
|
(-1, '여기 화면 어떻게 하나요?', '인사이드 채택', 150, 13640),
|
||||||
|
])
|
||||||
|
PopLedgerEntry(
|
||||||
|
at: DateTime(2026, 5, d, 12),
|
||||||
|
title: title,
|
||||||
|
type: type,
|
||||||
|
kind: PopKind.general,
|
||||||
|
txType: delta < 0 ? PopTxType.spend : PopTxType.grant,
|
||||||
|
delta: delta,
|
||||||
|
balanceAfter: balance,
|
||||||
|
cursor: PopCursor(at: DateTime(2026, 5, d, 12), id: title),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<NoticeItem>> notices() async => [
|
||||||
|
for (final (i, (title, m, d)) in const [
|
||||||
|
('[공지] 2026년 하반기 업데이트 관련 안내', 4, 30),
|
||||||
|
('[공지] 2026년 하반기 업데이트 관련 안내', 4, 30),
|
||||||
|
('서비스 첫 출시 관련 수정 안내', 4, 30),
|
||||||
|
('새로운 기능 추가 및 성능 개선', 5, 10),
|
||||||
|
('봄맞이 할인 이벤트 안내', 5, 5),
|
||||||
|
('서버 정기 점검 안내', 5, 15),
|
||||||
|
('이용약관 변경 안내', 5, 20),
|
||||||
|
('신규 사용자 가이드 배포', 5, 25),
|
||||||
|
('휴대폰 앱 업데이트 권장', 5, 30),
|
||||||
|
('긴급 보안 패치 적용', 6, 1),
|
||||||
|
].indexed)
|
||||||
|
NoticeItem(
|
||||||
|
id: 'n$i',
|
||||||
|
title: title,
|
||||||
|
// 시안 본문의 「팬케이크」는 사용자 노출 브랜드 규칙으로 「팬우주」.
|
||||||
|
body:
|
||||||
|
'안녕하세요. 팬우주 운영진 입니다.\n2026년 하반기를 앞두고 아래의 업데이트 관련 내용을 전달드리려고 합니다!\n\n아래의 내용들은 이미지와 함께 첨부했으니 확인해주시기 바랍니다.\n\n감사합니다.',
|
||||||
|
at: DateTime(2026, m, d),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<InquiryItem>> myInquiries() async => [
|
||||||
|
for (final (i, s) in const [
|
||||||
|
InquiryStatus.answered,
|
||||||
|
InquiryStatus.inProgress,
|
||||||
|
InquiryStatus.pending,
|
||||||
|
].indexed)
|
||||||
|
InquiryItem(
|
||||||
|
id: 'q$i',
|
||||||
|
title: i == 0 ? '구매 상품 환불 관련 문의 접수' : '서비스 이용 관련 문의 접수',
|
||||||
|
body: '문의 내용',
|
||||||
|
at: DateTime(2026, 4, 30),
|
||||||
|
status: s,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<PaymentEntry>> payments() async => [
|
||||||
|
for (final (i, (m, price)) in const [
|
||||||
|
(5, 3900),
|
||||||
|
(6, 4500),
|
||||||
|
(7, 5200),
|
||||||
|
(8, 6100),
|
||||||
|
(9, 2800),
|
||||||
|
(10, 4750),
|
||||||
|
(11, 3600),
|
||||||
|
(12, 5000),
|
||||||
|
// 시안 「13.05 · 14.05 · 15.05」 는 달력에 없는 날짜다. 달을 넘겨 01~03.05 로 그린다.
|
||||||
|
(13, 6450),
|
||||||
|
(14, 4900),
|
||||||
|
(15, 3750),
|
||||||
|
].indexed)
|
||||||
|
PaymentEntry(
|
||||||
|
id: 'IAP2405110${i + 1}',
|
||||||
|
at: DateTime(2026, m, 5),
|
||||||
|
itemName: '아이템${i + 1}',
|
||||||
|
price: price,
|
||||||
|
method: 'iOS',
|
||||||
|
state: PaymentState.paid,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PaymentEntry> payment(String id) async =>
|
||||||
|
(await payments()).firstWhere((p) => p.id == id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<({String current, bool latest})> appVersion() async =>
|
||||||
|
(current: '0.4.0', latest: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 시안 `370:229665` 의 내 캠페인 한 장.
|
||||||
|
class _FigmaCampaign extends SampleCampaignRepository {
|
||||||
|
@override
|
||||||
|
Future<List<CampaignCardData>> load({
|
||||||
|
CampaignSort sort = CampaignSort.latest,
|
||||||
|
int limit = 10,
|
||||||
|
}) async => [
|
||||||
|
CampaignCardData(
|
||||||
|
id: 'c-mine',
|
||||||
|
title: 'BTS 인스타 광고 캠페인',
|
||||||
|
body:
|
||||||
|
'BTS 인스타 광고 캠페인 진행하실 아미분들!!!\n매년 찾아오는 인스타 광고에서 저희 방탄을 위해 화력을 보여주세요!!',
|
||||||
|
authorName: 'BTS_Hong',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
status: CampaignStatus.funding,
|
||||||
|
goalPop: 30000,
|
||||||
|
raisedPop: 0,
|
||||||
|
supporters: 0,
|
||||||
|
likes: 0,
|
||||||
|
comments: 0,
|
||||||
|
views: 0,
|
||||||
|
shares: 0,
|
||||||
|
mine: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Repos extends Repositories {
|
||||||
|
_Repos() : super(live: false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
late final MyRepository my = _FigmaMy();
|
||||||
|
|
||||||
|
@override
|
||||||
|
late final CampaignRepository campaign = _FigmaCampaign();
|
||||||
|
|
||||||
|
@override
|
||||||
|
AccountLinker linker(List<SocialAuthProvider> providers) =>
|
||||||
|
SampleAccountLinker();
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef Build = Future<Widget> Function(Repositories r);
|
||||||
|
|
||||||
|
class Shot {
|
||||||
|
const Shot(this.id, {this.build, this.tab, this.h = 812, this.after});
|
||||||
|
final String id;
|
||||||
|
final Build? build;
|
||||||
|
final String? tab;
|
||||||
|
final double h;
|
||||||
|
final Future<void> Function(WidgetTester t)? after;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _settle(WidgetTester t) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await t.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _settings(Repositories r) =>
|
||||||
|
SettingsScreen(repository: r.my, linker: r.linker(const []));
|
||||||
|
|
||||||
|
final shots = <Shot>[
|
||||||
|
Shot('F6-01', tab: '/my'),
|
||||||
|
Shot(
|
||||||
|
'F6-02',
|
||||||
|
build: (r) async => const MyListsScreen(kind: MyListKind.inside),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F6-03',
|
||||||
|
build: (r) async =>
|
||||||
|
MyListsScreen(kind: MyListKind.campaign, campaigns: r.campaign),
|
||||||
|
),
|
||||||
|
Shot('F6-04', h: 1344, build: (r) async => _settings(r)),
|
||||||
|
// F6-05~07 은 설정 위에 뜨는 OS 제공자 시트다. 시트 자체는 OS 가 그리므로(계약 제외)
|
||||||
|
// 아래 깔린 설정 화면만 대조한다.
|
||||||
|
Shot('F6-05', build: (r) async => _settings(r)),
|
||||||
|
Shot('F6-06', build: (r) async => _settings(r)),
|
||||||
|
Shot('F6-07', build: (r) async => _settings(r)),
|
||||||
|
Shot('F6-08', h: 959, build: (r) async => PopHistoryScreen(repository: r.my)),
|
||||||
|
Shot(
|
||||||
|
'F6-10',
|
||||||
|
h: 1276,
|
||||||
|
build: (r) async => NoticeListScreen(repository: r.my),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F6-11',
|
||||||
|
build: (r) async =>
|
||||||
|
NoticeDetailScreen(notice: (await r.my.notices()).first),
|
||||||
|
),
|
||||||
|
Shot('F6-12', build: (r) async => PaymentHistoryScreen(repository: r.my)),
|
||||||
|
Shot(
|
||||||
|
'F6-13',
|
||||||
|
build: (r) async =>
|
||||||
|
PaymentDetailScreen(paymentId: 'IAP24051101', repository: r.my),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F6-14',
|
||||||
|
build: (r) async =>
|
||||||
|
RefundRequestScreen(paymentId: 'IAP24051101', repository: r.my),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F6-15',
|
||||||
|
build: (r) async =>
|
||||||
|
TierDetailScreen(tiers: (await r.my.profile()).planetTiers),
|
||||||
|
),
|
||||||
|
Shot('F6-16', build: (r) async => const TierInfoScreen()),
|
||||||
|
// 시안은 앨범에서 사진을 고른 뒤(완료 활성) 상태다.
|
||||||
|
Shot(
|
||||||
|
'F6-17',
|
||||||
|
build: (r) async {
|
||||||
|
final bytes = (await rootBundle.load(
|
||||||
|
'assets/figma/avatar-sample.png',
|
||||||
|
)).buffer.asUint8List();
|
||||||
|
return ProfilePhotoScreen(
|
||||||
|
pickPhoto: (_) async => PickedPhoto(
|
||||||
|
bytes: bytes,
|
||||||
|
contentType: 'image/png',
|
||||||
|
name: 'avatar-sample.png',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
after: (t) async {
|
||||||
|
await t.tap(find.bySemanticsLabel('앨범에서 고르기'));
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// 시안은 닉네임을 바꾼 뒤(완료 활성) 상태다.
|
||||||
|
Shot(
|
||||||
|
'F6-18',
|
||||||
|
build: (r) async => ProfileEditScreen(nickname: 'BTS', repository: r.my),
|
||||||
|
after: (t) async {
|
||||||
|
await t.enterText(find.byType(TextField), 'BTS_Hong');
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot('F6-19', build: (r) async => InquiryScreen(repository: r.my)),
|
||||||
|
// 시안 기본 상태는 기기 알림이 꺼진 쪽이다(켜진 쪽은 변형 `357:223611`).
|
||||||
|
Shot(
|
||||||
|
'F6-20',
|
||||||
|
build: (r) async =>
|
||||||
|
PushSettingsScreen(repository: r.my, deviceAllowed: false),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> _loadFonts() async {
|
||||||
|
final manifest =
|
||||||
|
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
|
||||||
|
for (final font in manifest.cast<Map<String, dynamic>>()) {
|
||||||
|
final loader = FontLoader(font['family'] as String);
|
||||||
|
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
|
||||||
|
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
|
||||||
|
}
|
||||||
|
await loader.load();
|
||||||
|
}
|
||||||
|
await (FontLoader(
|
||||||
|
'Roboto',
|
||||||
|
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
|
||||||
|
for (final e in find.byType(Image).evaluate().toList()) {
|
||||||
|
await precacheImage((e.widget as Image).image, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final out = Platform.environment['DESIGN_MATCH_OUT'];
|
||||||
|
if (out == null) return;
|
||||||
|
setUpAll(() async {
|
||||||
|
Directory(out).createSync(recursive: true);
|
||||||
|
await _loadFonts();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final s in shots) {
|
||||||
|
testWidgets(s.id, (t) async {
|
||||||
|
t.view.devicePixelRatio = 2;
|
||||||
|
t.view.physicalSize = Size(_width * 2, s.h * 2);
|
||||||
|
t.view.padding = _padding;
|
||||||
|
t.view.viewPadding = _padding;
|
||||||
|
addTearDown(t.view.reset);
|
||||||
|
|
||||||
|
final repos = _Repos();
|
||||||
|
final boundary = GlobalKey();
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
Widget? pushed;
|
||||||
|
Widget app;
|
||||||
|
if (s.tab != null) {
|
||||||
|
final router = buildRouter(
|
||||||
|
AuthRepository()..signInWithMockSession(),
|
||||||
|
repositories: repos,
|
||||||
|
);
|
||||||
|
router.go(s.tab!);
|
||||||
|
app = MaterialApp.router(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
routerConfig: router,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pushed = (await t.runAsync(() => s.build!(repos)))!;
|
||||||
|
app = MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: const SizedBox(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
|
||||||
|
if (pushed != null) {
|
||||||
|
final screen = pushed;
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => screen),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _settle(t);
|
||||||
|
await s.after?.call(t);
|
||||||
|
await _precacheImages(t);
|
||||||
|
await _settle(t);
|
||||||
|
|
||||||
|
await t.runAsync(() async {
|
||||||
|
final image =
|
||||||
|
await (boundary.currentContext!.findRenderObject()!
|
||||||
|
as RenderRepaintBoundary)
|
||||||
|
.toImage(pixelRatio: 2);
|
||||||
|
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
final texts = [
|
||||||
|
for (final e in find.byType(RichText).evaluate())
|
||||||
|
if (e.renderObject case final RenderParagraph p
|
||||||
|
when p.attached && p.text.toPlainText().trim().isNotEmpty)
|
||||||
|
{
|
||||||
|
'text': p.text.toPlainText(),
|
||||||
|
'size': p.text.style?.fontSize,
|
||||||
|
'rect': [
|
||||||
|
for (final v in [
|
||||||
|
p.localToGlobal(Offset.zero).dx,
|
||||||
|
p.localToGlobal(Offset.zero).dy,
|
||||||
|
p.size.width,
|
||||||
|
p.size.height,
|
||||||
|
])
|
||||||
|
v.roundToDouble(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
File(
|
||||||
|
'$out/${s.id}.texts.json',
|
||||||
|
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
@Tags(['design'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
|
import 'package:fancake/core/router/app_router.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
import 'package:fancake/features/my/profile_edit_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
|
||||||
|
/// Figma 「계약 시점 사본」 화면과 나란히 놓을 앱 렌더를 뽑는다.
|
||||||
|
///
|
||||||
|
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match` (단일: `--plain-name F6-01`)
|
||||||
|
/// 출력: `$DESIGN_MATCH_OUT/<id>.png`. 변수가 없으면 일반 `flutter test` 에서 건너뛴다. 비교는 tools/design/compare.py.
|
||||||
|
///
|
||||||
|
/// 화면 ID·Figma 노드는 디자인 대조 자료(화면 목록 · 매핑 근거).
|
||||||
|
/// 여기 없는 ID 는 앱 화면이 없거나(없음) 계약 제외(이메일·X·WeChat 가입 등)다.
|
||||||
|
|
||||||
|
/// Figma 프레임 폭. 높이는 화면마다 Figma 프레임 높이를 따른다.
|
||||||
|
const _width = 375.0;
|
||||||
|
|
||||||
|
/// iPhone X 계열 Figma 프레임의 상태 표시줄(48) · 홈 인디케이터(34).
|
||||||
|
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
|
||||||
|
|
||||||
|
typedef Build = Future<Widget> Function(Repositories r);
|
||||||
|
typedef After = Future<void> Function(WidgetTester t);
|
||||||
|
|
||||||
|
class Shot {
|
||||||
|
const Shot(
|
||||||
|
this.id, {
|
||||||
|
this.build,
|
||||||
|
this.tab,
|
||||||
|
this.h = 812,
|
||||||
|
this.after,
|
||||||
|
this.repos,
|
||||||
|
});
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// 표본 저장소. 없으면 기본 표본(`Repositories(live: false)`).
|
||||||
|
final Repositories Function()? repos;
|
||||||
|
|
||||||
|
/// 탭 밖 화면. [tab] 과 둘 중 하나만 준다.
|
||||||
|
final Build? build;
|
||||||
|
|
||||||
|
/// 탭 루트 화면 — 실제 셸(하단 탭 포함)을 라우터로 띄운다.
|
||||||
|
final String? tab;
|
||||||
|
final double h;
|
||||||
|
|
||||||
|
/// 시트·다이얼로그·탭 전환처럼 화면을 띄운 뒤 한 번 더 조작해야 하는 상태.
|
||||||
|
final After? after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다.
|
||||||
|
Future<void> _settle(WidgetTester t) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await t.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 화면별 표본은 그룹 파일(a~e_test.dart)이 가진다 — 같은 ID 를 두 곳에서 그리면 실행 순서에 따라
|
||||||
|
/// 결과가 바뀌므로, 여기에는 그룹 파일에 없는 ID 만 둔다(쓰레기통 F6-21 · 점수 제외).
|
||||||
|
/// b_test 는 이 목록을 비우고 자기 표본을 채워 [main] 을 그대로 쓴다.
|
||||||
|
final shots = <Shot>[
|
||||||
|
Shot(
|
||||||
|
'F6-21',
|
||||||
|
build: (r) async =>
|
||||||
|
ProfileEditScreen(nickname: 'BTS_Hong', repository: r.my),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> _loadFonts() async {
|
||||||
|
final manifest =
|
||||||
|
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
|
||||||
|
for (final font in manifest.cast<Map<String, dynamic>>()) {
|
||||||
|
final loader = FontLoader(font['family'] as String);
|
||||||
|
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
|
||||||
|
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
|
||||||
|
}
|
||||||
|
await loader.load();
|
||||||
|
}
|
||||||
|
// 글꼴을 지정하지 않은 글자는 테스트 기본(Ahem 네모)으로 그려진다. 기기에서는 시스템 글꼴이므로 본문 글꼴로 대신한다.
|
||||||
|
await (FontLoader(
|
||||||
|
'Roboto',
|
||||||
|
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
|
||||||
|
for (final e in find.byType(Image).evaluate().toList()) {
|
||||||
|
await precacheImage((e.widget as Image).image, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final out = Platform.environment['DESIGN_MATCH_OUT'];
|
||||||
|
if (out == null) return;
|
||||||
|
setUpAll(() async {
|
||||||
|
Directory(out).createSync(recursive: true);
|
||||||
|
await _loadFonts();
|
||||||
|
// 이클립스 방어의 흔들기 센서. 플러그인이 없는 테스트 환경에서는 조용히 비운다.
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
|
||||||
|
(_) async => null,
|
||||||
|
);
|
||||||
|
messenger.setMockStreamHandler(
|
||||||
|
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
|
||||||
|
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final s in shots) {
|
||||||
|
testWidgets(s.id, (t) async {
|
||||||
|
t.view.devicePixelRatio = 2;
|
||||||
|
t.view.physicalSize = Size(_width * 2, s.h * 2);
|
||||||
|
t.view.padding = _padding;
|
||||||
|
t.view.viewPadding = _padding;
|
||||||
|
addTearDown(t.view.reset);
|
||||||
|
|
||||||
|
final repos = s.repos?.call() ?? Repositories(live: false);
|
||||||
|
final boundary = GlobalKey();
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
Widget? pushed;
|
||||||
|
Widget app;
|
||||||
|
if (s.tab != null) {
|
||||||
|
final router = buildRouter(
|
||||||
|
AuthRepository()..signInWithMockSession(),
|
||||||
|
repositories: repos,
|
||||||
|
);
|
||||||
|
router.go(s.tab!);
|
||||||
|
app = MaterialApp.router(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
routerConfig: router,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final screen = (await t.runAsync(() => s.build!(repos)))!;
|
||||||
|
// 빈 화면 위에 push 해야 AppBar 가 뒤로가기 버튼을 그린다(앱에서도 push 로 연다).
|
||||||
|
app = MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: const SizedBox(),
|
||||||
|
);
|
||||||
|
pushed = screen;
|
||||||
|
}
|
||||||
|
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
|
||||||
|
if (pushed != null) {
|
||||||
|
final screen = pushed;
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => screen),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _settle(t);
|
||||||
|
await s.after?.call(t);
|
||||||
|
await _precacheImages(t);
|
||||||
|
await _settle(t);
|
||||||
|
|
||||||
|
await t.runAsync(() async {
|
||||||
|
final image =
|
||||||
|
await (boundary.currentContext!.findRenderObject()!
|
||||||
|
as RenderRepaintBoundary)
|
||||||
|
.toImage(pixelRatio: 2);
|
||||||
|
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
// 글자 대조용 — compare.py 가 Figma spec 의 TEXT 노드와 맞춰 본다.
|
||||||
|
final texts = [
|
||||||
|
for (final e in find.byType(RichText).evaluate())
|
||||||
|
if (e.renderObject case final RenderParagraph p
|
||||||
|
when p.attached && p.text.toPlainText().trim().isNotEmpty)
|
||||||
|
{
|
||||||
|
'text': p.text.toPlainText(),
|
||||||
|
'family': p.text.style?.fontFamily,
|
||||||
|
'size': p.text.style?.fontSize,
|
||||||
|
'weight': p.text.style?.fontWeight?.value,
|
||||||
|
'color': p.text.style?.color?.toARGB32().toRadixString(16),
|
||||||
|
'rect': [
|
||||||
|
for (final v in [
|
||||||
|
p.localToGlobal(Offset.zero).dx,
|
||||||
|
p.localToGlobal(Offset.zero).dy,
|
||||||
|
p.size.width,
|
||||||
|
p.size.height,
|
||||||
|
])
|
||||||
|
v.roundToDouble(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
File(
|
||||||
|
'$out/${s.id}.texts.json',
|
||||||
|
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
|
||||||
|
// 렌더 예외(오버플로 등)는 실패로 올린다 — 비교 전에 고쳐야 할 것이다.
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
@Tags(['design'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
|
import 'package:fancake/core/router/app_router.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
|
import 'package:fancake/features/auth/login_screen.dart';
|
||||||
|
import 'package:fancake/features/auth/signup_screens.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_data.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_search_screen.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_write_screen.dart';
|
||||||
|
import 'package:fancake/features/notification/notification_screen.dart';
|
||||||
|
import 'package:fancake/features/planet/moderation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 디자인 일치 2단계 그룹 E — INSIDE(F9) · 로그인(F10) · 알림(F0-02).
|
||||||
|
///
|
||||||
|
/// 러너는 `design_match_test.dart` 와 같고, 화면 목록과 표본만 이 그룹 것이다.
|
||||||
|
/// 실행: `DESIGN_MATCH_OUT=<폴더> fvm flutter test --tags design test/design_match/e_test.dart`
|
||||||
|
|
||||||
|
/// Figma 프레임 폭. 높이는 화면마다 Figma 프레임 높이를 따른다.
|
||||||
|
const _width = 375.0;
|
||||||
|
|
||||||
|
/// iPhone X 계열 Figma 프레임의 상태 표시줄(48) · 홈 인디케이터(34).
|
||||||
|
const _padding = FakeViewPadding(top: 48 * 2, bottom: 34 * 2);
|
||||||
|
|
||||||
|
typedef Build = Future<Widget> Function(Repositories r);
|
||||||
|
typedef After = Future<void> Function(WidgetTester t);
|
||||||
|
|
||||||
|
class Shot {
|
||||||
|
const Shot(this.id, {this.build, this.tab, this.h = 812, this.after});
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// 탭 밖 화면. [tab] 과 둘 중 하나만 준다.
|
||||||
|
final Build? build;
|
||||||
|
|
||||||
|
/// 탭 루트 화면 — 실제 셸(하단 탭 포함)을 라우터로 띄운다.
|
||||||
|
final String? tab;
|
||||||
|
final double h;
|
||||||
|
|
||||||
|
/// 시트·다이얼로그·탭 전환처럼 화면을 띄운 뒤 한 번 더 조작해야 하는 상태.
|
||||||
|
final After? after;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _tap(WidgetTester t, String text) async {
|
||||||
|
await t.tap(find.text(text).first, warnIfMissed: false);
|
||||||
|
await _settle(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 표본 저장소는 Future.value · 짧은 delay 만 쓴다. 무한 애니메이션이 있어 pumpAndSettle 은 쓰지 않는다.
|
||||||
|
Future<void> _settle(WidgetTester t) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await t.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _overlay(
|
||||||
|
WidgetTester t,
|
||||||
|
void Function(BuildContext c) open,
|
||||||
|
) async {
|
||||||
|
open(t.element(find.byType(Scaffold).first));
|
||||||
|
await _settle(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 시안 알림(229:11265) 표본. 날짜 머리글이 「오늘 · 어제 · 과거」로 갈리도록 지금 기준으로 잡는다.
|
||||||
|
List<AppNotification> _notifications() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
DateTime at(int daysAgo, int h, int m) =>
|
||||||
|
DateTime(now.year, now.month, now.day - daysAgo, h, m);
|
||||||
|
return [
|
||||||
|
AppNotification(
|
||||||
|
id: '1',
|
||||||
|
kind: NotificationKind.comment,
|
||||||
|
title: '내 INSIDE에 새로운 댓글이 달렸어요.',
|
||||||
|
at: at(0, 10, 40),
|
||||||
|
source: 'INSIDE',
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: '2',
|
||||||
|
kind: NotificationKind.comment,
|
||||||
|
title: '내 캠페인에 새로운 댓글이 달렸어요.',
|
||||||
|
at: at(0, 10, 40),
|
||||||
|
source: 'CAMPAIGN',
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: '3',
|
||||||
|
kind: NotificationKind.like,
|
||||||
|
title: '내 INSIDE에 좋아요가 추가됐어요.',
|
||||||
|
at: at(0, 9, 28),
|
||||||
|
source: 'INSIDE',
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: '4',
|
||||||
|
kind: NotificationKind.eclipse,
|
||||||
|
title: '내 행성의 이클립스를 방어했어요.',
|
||||||
|
at: at(1, 10, 40),
|
||||||
|
source: 'BTS',
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: '5',
|
||||||
|
kind: NotificationKind.eclipse,
|
||||||
|
title: '내 행성의 이클립스가 발생했어요.',
|
||||||
|
at: at(1, 10, 40),
|
||||||
|
source: 'BTS',
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: '6',
|
||||||
|
kind: NotificationKind.itemUsed,
|
||||||
|
title: '팜하니짱님의 고귀한 기여로 영웅의 선물이 발동되었습니다!',
|
||||||
|
at: at(2, 16, 27),
|
||||||
|
source: '아이템',
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: '7',
|
||||||
|
kind: NotificationKind.quest,
|
||||||
|
title: '[만보기] 퀘스트를 완료했어요!',
|
||||||
|
at: at(2, 13, 27),
|
||||||
|
source: '퀘스트',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<InsideQuestion> _question(Repositories r, String id) async =>
|
||||||
|
(await r.inside.load()).questions.firstWhere((q) => q.id == id);
|
||||||
|
|
||||||
|
final shots = <Shot>[
|
||||||
|
Shot(
|
||||||
|
'F0-02',
|
||||||
|
build: (r) async => NotificationScreen(
|
||||||
|
repository: SampleNotificationRepository(items: _notifications()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// F9 INSIDE
|
||||||
|
Shot('F9-01', build: (r) async => InsideSearchScreen(repository: r.inside)),
|
||||||
|
Shot(
|
||||||
|
'F9-02',
|
||||||
|
build: (r) async => InsideSearchScreen(repository: r.inside),
|
||||||
|
after: (t) async {
|
||||||
|
await t.enterText(find.byType(TextField), 'BTS');
|
||||||
|
await t.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await _settle(t);
|
||||||
|
await _tap(t, 'POP순');
|
||||||
|
// 커서 깜빡임을 없앤다.
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await _settle(t);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F9-03',
|
||||||
|
h: 1360,
|
||||||
|
build: (r) async => InsideDetailScreen(
|
||||||
|
question: await _question(r, 'q-1'),
|
||||||
|
answers: sampleInsideAnswers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F9-04',
|
||||||
|
build: (r) async => const ReportScreen(
|
||||||
|
target: ModerationTarget(
|
||||||
|
authorName: 'BTS_Hong',
|
||||||
|
title: '공방 신청 꿀팁 공유합니다.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Shot(
|
||||||
|
'F9-05',
|
||||||
|
build: (r) async => InsideDetailScreen(
|
||||||
|
question: await _question(r, 'q-1'),
|
||||||
|
answers: sampleInsideAnswers,
|
||||||
|
),
|
||||||
|
after: (t) => _overlay(t, (c) => confirmBlock(c, '다이브짱')),
|
||||||
|
),
|
||||||
|
Shot('F9-06', build: (r) async => const InsideWriteScreen(myPop: 12500)),
|
||||||
|
Shot('F9-07', tab: '/inside', after: (t) => _tap(t, 'POP순')),
|
||||||
|
Shot('F9-08', tab: '/inside', after: (t) => _tap(t, '인기순')),
|
||||||
|
Shot(
|
||||||
|
'F9-09',
|
||||||
|
h: 1301,
|
||||||
|
build: (r) async => InsideDetailScreen(question: await _question(r, 'q-4')),
|
||||||
|
),
|
||||||
|
Shot('F9-10', tab: '/inside'),
|
||||||
|
// F10 로그인
|
||||||
|
Shot('F10-01', build: (r) async => LoginScreen(auth: AuthRepository())),
|
||||||
|
Shot('F10-02', build: (r) async => SimpleLoginScreen(auth: AuthRepository())),
|
||||||
|
Shot(
|
||||||
|
'F10-12',
|
||||||
|
build: (r) async => SimpleLoginScreen(auth: AuthRepository(), signUp: true),
|
||||||
|
),
|
||||||
|
Shot('F10-15', h: 906, build: (r) async => const TermsScreen()),
|
||||||
|
Shot('F10-16', build: (r) async => const SignupCompleteScreen()),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> _loadFonts() async {
|
||||||
|
final manifest =
|
||||||
|
json.decode(await rootBundle.loadString('FontManifest.json')) as List;
|
||||||
|
for (final font in manifest.cast<Map<String, dynamic>>()) {
|
||||||
|
final loader = FontLoader(font['family'] as String);
|
||||||
|
for (final f in (font['fonts'] as List).cast<Map<String, dynamic>>()) {
|
||||||
|
loader.addFont(rootBundle.load(Uri.decodeFull(f['asset'] as String)));
|
||||||
|
}
|
||||||
|
await loader.load();
|
||||||
|
}
|
||||||
|
// 글꼴을 지정하지 않은 글자는 테스트 기본(Ahem 네모)으로 그려진다. 기기에서는 시스템 글꼴이므로 본문 글꼴로 대신한다.
|
||||||
|
await (FontLoader(
|
||||||
|
'Roboto',
|
||||||
|
)..addFont(rootBundle.load('assets/fonts/42dotSans[wght].ttf'))).load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _precacheImages(WidgetTester t) => t.runAsync(() async {
|
||||||
|
for (final e in find.byType(Image).evaluate().toList()) {
|
||||||
|
await precacheImage((e.widget as Image).image, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final out = Platform.environment['DESIGN_MATCH_OUT'];
|
||||||
|
if (out == null) return;
|
||||||
|
setUpAll(() async {
|
||||||
|
Directory(out).createSync(recursive: true);
|
||||||
|
await _loadFonts();
|
||||||
|
// 이클립스 방어의 흔들기 센서. 플러그인이 없는 테스트 환경에서는 조용히 비운다.
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/sensors/method'),
|
||||||
|
(_) async => null,
|
||||||
|
);
|
||||||
|
messenger.setMockStreamHandler(
|
||||||
|
const EventChannel('dev.fluttercommunity.plus/sensors/accelerometer'),
|
||||||
|
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final s in shots) {
|
||||||
|
testWidgets(s.id, (t) async {
|
||||||
|
t.view.devicePixelRatio = 2;
|
||||||
|
t.view.physicalSize = Size(_width * 2, s.h * 2);
|
||||||
|
t.view.padding = _padding;
|
||||||
|
t.view.viewPadding = _padding;
|
||||||
|
addTearDown(t.view.reset);
|
||||||
|
|
||||||
|
final repos = Repositories(live: false);
|
||||||
|
final boundary = GlobalKey();
|
||||||
|
final navigator = GlobalKey<NavigatorState>();
|
||||||
|
Widget? pushed;
|
||||||
|
Widget app;
|
||||||
|
if (s.tab != null) {
|
||||||
|
final router = buildRouter(
|
||||||
|
AuthRepository()..signInWithMockSession(),
|
||||||
|
repositories: repos,
|
||||||
|
);
|
||||||
|
router.go(s.tab!);
|
||||||
|
app = MaterialApp.router(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
routerConfig: router,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final screen = (await t.runAsync(() => s.build!(repos)))!;
|
||||||
|
// 빈 화면 위에 push 해야 AppBar 가 뒤로가기 버튼을 그린다(앱에서도 push 로 연다).
|
||||||
|
app = MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
navigatorKey: navigator,
|
||||||
|
home: const SizedBox(),
|
||||||
|
);
|
||||||
|
pushed = screen;
|
||||||
|
}
|
||||||
|
await t.pumpWidget(RepaintBoundary(key: boundary, child: app));
|
||||||
|
if (pushed != null) {
|
||||||
|
final screen = pushed;
|
||||||
|
navigator.currentState!.push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => screen),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await _settle(t);
|
||||||
|
await s.after?.call(t);
|
||||||
|
await _precacheImages(t);
|
||||||
|
await _settle(t);
|
||||||
|
|
||||||
|
await t.runAsync(() async {
|
||||||
|
final image =
|
||||||
|
await (boundary.currentContext!.findRenderObject()!
|
||||||
|
as RenderRepaintBoundary)
|
||||||
|
.toImage(pixelRatio: 2);
|
||||||
|
final png = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
await File('$out/${s.id}.png').writeAsBytes(png!.buffer.asUint8List());
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
// 글자 대조용 — compare.py 가 Figma spec 의 TEXT 노드와 맞춰 본다.
|
||||||
|
final texts = [
|
||||||
|
for (final e in find.byType(RichText).evaluate())
|
||||||
|
if (e.renderObject case final RenderParagraph p
|
||||||
|
when p.attached && p.text.toPlainText().trim().isNotEmpty)
|
||||||
|
{
|
||||||
|
'text': p.text.toPlainText(),
|
||||||
|
'family': p.text.style?.fontFamily,
|
||||||
|
'size': p.text.style?.fontSize,
|
||||||
|
'weight': p.text.style?.fontWeight?.value,
|
||||||
|
'color': p.text.style?.color?.toARGB32().toRadixString(16),
|
||||||
|
'rect': [
|
||||||
|
for (final v in [
|
||||||
|
p.localToGlobal(Offset.zero).dx,
|
||||||
|
p.localToGlobal(Offset.zero).dy,
|
||||||
|
p.size.width,
|
||||||
|
p.size.height,
|
||||||
|
])
|
||||||
|
v.roundToDouble(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
File(
|
||||||
|
'$out/${s.id}.texts.json',
|
||||||
|
).writeAsStringSync(const JsonEncoder.withIndent(' ').convert(texts));
|
||||||
|
// 렌더 예외(오버플로 등)는 실패로 올린다 — 비교 전에 고쳐야 할 것이다.
|
||||||
|
expect(t.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
|
||||||
|
import '../eclipse_stubs.dart';
|
||||||
|
|
||||||
|
/// 디자인 대조용 HOME 표본 — 공유 하네스(`design_match_test.dart`)와 A 그룹(`a_test.dart`)이 같이 쓴다.
|
||||||
|
|
||||||
|
/// 사본 기본 홈 — 이클립스·라이브 이벤트 없음.
|
||||||
|
class QuietRepos extends Repositories {
|
||||||
|
QuietRepos() : super(live: false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
EclipseRepository get eclipse => const NoEclipseRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
HomeRepository get home => const QuietHome();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사본 `홈_성장 완료`(`344:213778`) — HOME 탭의 성운 상태. 성운 가입자라 ZAP 이 뜬다.
|
||||||
|
class NebulaRepos extends QuietRepos {
|
||||||
|
@override
|
||||||
|
HomeRepository get home => const QuietHome(
|
||||||
|
growth: PlanetGrowth(
|
||||||
|
stage: 0,
|
||||||
|
fandomName: 'BTS_RM',
|
||||||
|
contributorName: 'BTS_Hong',
|
||||||
|
contributedPop: 1240,
|
||||||
|
growthPercent: 100,
|
||||||
|
currentPop: 1240,
|
||||||
|
requiredPop: 1000,
|
||||||
|
members: 12,
|
||||||
|
requiredMembers: 10,
|
||||||
|
isFounder: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuietHome extends SampleHomeRepository {
|
||||||
|
const QuietHome({this.growth});
|
||||||
|
|
||||||
|
final PlanetGrowth? growth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeSnapshot> load() async {
|
||||||
|
final s = await super.load();
|
||||||
|
return HomeSnapshot(
|
||||||
|
popBalance: s.popBalance,
|
||||||
|
liveTicker: null,
|
||||||
|
fandomId: s.fandomId,
|
||||||
|
growth: growth ?? s.growth,
|
||||||
|
quests: s.quests,
|
||||||
|
briefings: s.briefings,
|
||||||
|
campaign: s.campaign,
|
||||||
|
insides: s.insides,
|
||||||
|
items: s.items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/core/data/shared_stream.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/features/eclipse/widgets/live_proof.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
/// 이클립스 방어·침식·실시간 동기화·라이브 소셜 프루프.
|
||||||
|
///
|
||||||
|
/// 판정 근거
|
||||||
|
/// - 별첨 1 ① 완료 시나리오 4 「이클립스 발동 → 워닝 → 방어 → 침식률 회복」
|
||||||
|
/// - 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프가 타 사용자 화면에
|
||||||
|
/// 실시간 노출」
|
||||||
|
/// - 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱 워닝 배너 병행」
|
||||||
|
/// - 별첨 1 ② 서버검증 원칙 「클라이언트 단독 지급 0건」 — 화면이 잔액을 스스로
|
||||||
|
/// 확정하지 않고 서버 응답으로 덮는지 본다.
|
||||||
|
///
|
||||||
|
/// **스트림을 밖에서 밀어 넣어 본다.** 화면이 첫 값만 그리고 갱신을 놓치는 것이
|
||||||
|
/// 실시간 동기화에서 가장 흔한 고장이고, 눈으로는 「조금 늦는다」로만 보인다.
|
||||||
|
|
||||||
|
/// 궤도 맵은 궤도 회전이 멈추지 않아 `pumpAndSettle` 이 끝나지 않는다.
|
||||||
|
Future<void> _pumpGalaxy(
|
||||||
|
WidgetTester tester,
|
||||||
|
EclipseRepository eclipse, {
|
||||||
|
GalaxyRepository? galaxy,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: GalaxyScreen(
|
||||||
|
repository: galaxy ?? const SampleGalaxyRepository(),
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
eclipse: eclipse,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 흔들어 방어 화면. 센서가 없는 테스트 환경이라 눌러서 참여하는 경로로 본다.
|
||||||
|
Future<void> _pumpDefend(WidgetTester tester, EclipseRepository repo) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: EclipseDefendScreen(repository: repo, fandomId: 'bts'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await _settle(tester);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _settle(WidgetTester tester) async {
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('이클립스 경보 배너 (별첨 1 ① 「인앱 워닝 배너 병행」)', () {
|
||||||
|
testWidgets('활성 이클립스가 없으면 배너가 뜨지 않는다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
|
||||||
|
expect(find.textContaining('침식'), findsNothing);
|
||||||
|
expect(find.textContaining('방어 '), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('발동하면 침식률과 방어 진행이 배너에 뜬다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(
|
||||||
|
state: eclipseFixture(erosionRate: 40, defense: 60000, targetDefense: 100000),
|
||||||
|
);
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
|
||||||
|
expect(find.textContaining('이클립스 발동 — BTS 침식 40%'), findsOneWidget);
|
||||||
|
// 행성 HUD 도 같은 침식률을 띄운다(스펙 B — 갤럭시·행성 상세 HUD).
|
||||||
|
expect(find.textContaining('침식 40% · 방어 60%'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('유효 참여 60,000 / 100,000명'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('스트림이 새 집계를 내면 배너가 따라 바뀐다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture(erosionRate: 40));
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
// 배너와 행성 HUD 가 같은 집계를 보므로 둘 다 잡힌다 — 값이 갈리면 안 된다.
|
||||||
|
expect(find.textContaining('침식 40%'), findsWidgets);
|
||||||
|
|
||||||
|
// 다른 단말의 방어가 집계에 반영된 상황.
|
||||||
|
repo.push(eclipseFixture(erosionRate: 15, defense: 85000, defenseCount: 40));
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(find.textContaining('침식 40%'), findsNothing);
|
||||||
|
expect(find.textContaining('침식 15%'), findsWidgets);
|
||||||
|
expect(find.textContaining('유효 참여 85,000 / 100,000명'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('방어에 성공하면 감소 없음 문구로 바뀐다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture(erosionRate: 20));
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
|
||||||
|
repo.push(eclipseFixture(
|
||||||
|
erosionRate: 0,
|
||||||
|
defense: 100000,
|
||||||
|
defended: true,
|
||||||
|
));
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(find.textContaining('방어 성공 — BTS FPI 감소 없음'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ZAP — 행성 성장 기여 (별첨 B B-23)', () {
|
||||||
|
testWidgets('ZAP 은 이클립스가 없어도 눌린다', (tester) async {
|
||||||
|
// 방어는 POP 을 쓰지 않는 공동 Shake 참여이고(설계서 v1.1 §9-#5) ZAP 은
|
||||||
|
// 행성 성장 기여다. 둘이 분리되어 있으므로 이클립스 유무와 무관하다.
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
final galaxy = RecordingGalaxyRepository();
|
||||||
|
await _pumpGalaxy(tester, repo, galaxy: galaxy);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(galaxy.zaps, hasLength(1));
|
||||||
|
// 표본의 내 행성은 `bts` 다 — ZAP 은 내 행성의 성장에 기여한다.
|
||||||
|
expect(galaxy.zaps.single.$1, 'bts');
|
||||||
|
expect(galaxy.zaps.single.$3, 1);
|
||||||
|
// 방어 호출은 나가지 않는다 — 이클립스 샤드와 분리되어 있다.
|
||||||
|
expect(repo.calls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('연타는 한 번의 호출로 묶이고 count 가 누적된다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
final galaxy = RecordingGalaxyRepository();
|
||||||
|
await _pumpGalaxy(tester, repo, galaxy: galaxy);
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
}
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
// 호출 1회 · count 5 — 원장 한 행에 묶음 횟수가 남아야 MY 가 「Zap 5회」를 센다.
|
||||||
|
expect(galaxy.zaps, hasLength(1));
|
||||||
|
expect(galaxy.zaps.single.$3, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('시간창이 닫힌 뒤의 연타는 새 묶음이고 멱등키가 다르다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
final galaxy = RecordingGalaxyRepository()..balanceAfter = 9_000;
|
||||||
|
await _pumpGalaxy(tester, repo, galaxy: galaxy);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await _settle(tester);
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(galaxy.zaps, hasLength(2));
|
||||||
|
// 멱등키가 같으면 서버가 두 번째를 같은 거래로 보아 기여가 사라진다.
|
||||||
|
expect(galaxy.zaps[0].$2, isNot(galaxy.zaps[1].$2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('잔액은 서버가 확정한 값으로 덮인다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
final galaxy = RecordingGalaxyRepository()..balanceAfter = 7_777;
|
||||||
|
await _pumpGalaxy(tester, repo, galaxy: galaxy);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
final button = tester.widget<ZapButton>(find.byType(ZapButton));
|
||||||
|
expect(button.myPop, 7_777);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('잔액 부족은 서버 오류 코드로 안내한다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
final galaxy = RecordingGalaxyRepository()
|
||||||
|
..failWith = const BackendException('E-POP-01', 'POP 잔액이 부족합니다.');
|
||||||
|
await _pumpGalaxy(tester, repo, galaxy: galaxy);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(find.textContaining('POP 이 모자라'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('자동(오토 도네이션) 옵션은 없다', (tester) async {
|
||||||
|
// 별첨 1 ① 시나리오 4 가 적었으나 별첨 B 에 없는 기능이다(공통 기준 2항).
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
|
||||||
|
final hold = await tester.startGesture(tester.getCenter(find.byType(ZapButton)));
|
||||||
|
await tester.pump(ZapButton.longPress);
|
||||||
|
await hold.up();
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(find.text('기여'), findsOneWidget);
|
||||||
|
expect(find.text('아이템'), findsOneWidget);
|
||||||
|
expect(find.text('자동'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('라이브 소셜 프루프 (별첨 1 ① 시나리오 5)', () {
|
||||||
|
testWidgets('갤럭시 섬광 배너가 다른 사용자의 방어를 흘린다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
|
||||||
|
repo.pushEvents([
|
||||||
|
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
|
||||||
|
]);
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('[LIVE] 버니즈님이 이클립스 방어에 참여했습니다! (+30)'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('새 이벤트가 오면 문구가 갈린다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
|
||||||
|
repo.pushEvents([
|
||||||
|
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
|
||||||
|
]);
|
||||||
|
await _settle(tester);
|
||||||
|
repo.pushEvents([
|
||||||
|
LiveEvent(id: 'e2', nickname: '캐럿', defense: 50, at: DateTime(2026, 9, 14, 1)),
|
||||||
|
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
|
||||||
|
]);
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(find.textContaining('버니즈'), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.text('[LIVE] 캐럿님이 이클립스 방어에 참여했습니다! (+50)'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('홈 라이브 띠가 라이브 이벤트를 그린다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: HomeScreen(
|
||||||
|
repository: const SampleHomeRepository(),
|
||||||
|
questRepository: const SampleQuestRepository(),
|
||||||
|
eclipse: repo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 이벤트가 없으면 스냅샷의 폴백 문구를 그린다.
|
||||||
|
expect(find.textContaining('아이템 사용으로 버프가 발동'), findsOneWidget);
|
||||||
|
|
||||||
|
repo.pushEvents([
|
||||||
|
LiveEvent(id: 'e1', nickname: '무민', defense: 20, at: DateTime(2026, 9, 14)),
|
||||||
|
]);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// 이벤트가 오면 그 문구가 폴백을 대체한다.
|
||||||
|
expect(find.textContaining('아이템 사용으로 버프가 발동'), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.text('[LIVE] 무민님이 이클립스 방어에 참여했습니다! (+20)'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('문구는 종류마다 한 곳에서 만든다', () {
|
||||||
|
final at = DateTime(2026, 9, 14);
|
||||||
|
expect(
|
||||||
|
liveEventMessage(LiveEvent(id: 'x', nickname: '나', defense: 1200, at: at)),
|
||||||
|
'[LIVE] 나님이 이클립스 방어에 참여했습니다! (+1,200)',
|
||||||
|
);
|
||||||
|
// 별첨 B 「Global Ticker」 — 다른 종류가 같은 컬렉션에 들어온다.
|
||||||
|
expect(
|
||||||
|
liveEventMessage(LiveEvent(
|
||||||
|
id: 'y',
|
||||||
|
nickname: '나',
|
||||||
|
at: at,
|
||||||
|
kind: LiveEventKind.heroGift,
|
||||||
|
)),
|
||||||
|
'[LIVE] 나님의 영웅의 선물이 도착했습니다!',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('실시간 구독 — 해제와 백그라운드 복귀 (스펙 C)', () {
|
||||||
|
testWidgets('화면이 사라지면 구독을 끊는다', (tester) async {
|
||||||
|
final repo = CountingEclipseRepository(eclipseFixture());
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
expect(repo.subscriptions, 1);
|
||||||
|
expect(repo.active, 1);
|
||||||
|
|
||||||
|
// 화면을 치운다. 구독이 남으면 백그라운드에서 읽기 비용이 계속 든다.
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
|
||||||
|
await _settle(tester);
|
||||||
|
expect(repo.active, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('백그라운드에서 돌아오면 다시 구독한다', (tester) async {
|
||||||
|
final repo = CountingEclipseRepository(eclipseFixture());
|
||||||
|
await _pumpGalaxy(tester, repo);
|
||||||
|
expect(repo.subscriptions, 1);
|
||||||
|
|
||||||
|
// 오래 잠든 리스너는 서버와 끊긴 채 남을 수 있다. 복귀 때 새로 열어
|
||||||
|
// 첫 스냅샷을 다시 받는다 — 별첨 1 ① 「백그라운드 복귀 시 렌더링 정상 재개」.
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
||||||
|
await _settle(tester);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(repo.subscriptions, 2);
|
||||||
|
// 옛 구독은 남지 않는다 — 남으면 같은 문서를 두 번 읽는다.
|
||||||
|
expect(repo.active, 1);
|
||||||
|
expect(find.textContaining('이클립스 발동'), findsWidgets);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('흔들어 방어 화면 (B-55 공동 Shake)', () {
|
||||||
|
testWidgets('POP 을 쓰지 않는다는 것을 화면이 말한다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpDefend(tester, repo);
|
||||||
|
|
||||||
|
expect(find.textContaining('POP은 소모되지 않습니다'), findsOneWidget);
|
||||||
|
expect(find.textContaining('흔들어 방어'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('누르면 흔들기가 묶여 서버로 나간다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpDefend(tester, repo);
|
||||||
|
|
||||||
|
// 센서가 없는 단말용 버튼. 판정은 서버가 하므로 흔들기와 같은 경로다.
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 80));
|
||||||
|
}
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(repo.calls, hasLength(1));
|
||||||
|
expect(repo.calls.single.$3, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('서버가 받아들인 값만 내 참여로 그린다', (tester) async {
|
||||||
|
// 단말이 센 값을 그대로 믿으면 서버가 깎은 뒤 숫자가 줄어 보인다.
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture())
|
||||||
|
..acceptedOverride = 2;
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpDefend(tester, repo);
|
||||||
|
|
||||||
|
// 다섯 번 참여했는데 서버가 둘만 받아들인 상황.
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 80));
|
||||||
|
}
|
||||||
|
await _settle(tester);
|
||||||
|
|
||||||
|
expect(repo.calls.single.$3, 5);
|
||||||
|
expect(find.textContaining('참여 상한에 닿아'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('성공한 이클립스에서는 참여 버튼이 닫힌다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture(defended: true));
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpDefend(tester, repo);
|
||||||
|
|
||||||
|
expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing);
|
||||||
|
expect(find.text('운석 충돌을\n성공적으로 막았어요!'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('마감은 판정 대기이며 서버 실패·취소만 해당 결과로 표시한다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpDefend(tester, repo);
|
||||||
|
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||||
|
repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1))));
|
||||||
|
await tester.pump();
|
||||||
|
await _settle(tester);
|
||||||
|
expect(find.text('방어 결과 확인 중'), findsOneWidget);
|
||||||
|
expect(find.textContaining('FAIL'), findsNothing);
|
||||||
|
expect(find.byType(FilledButton), findsNothing);
|
||||||
|
expect(repo.calls, isEmpty);
|
||||||
|
repo.push(eclipseFixture(result: EclipseResult.failure));
|
||||||
|
await _settle(tester);
|
||||||
|
expect(find.text('아쉽게 행성 방어를\n실패했어요'), findsOneWidget);
|
||||||
|
repo.push(eclipseFixture(result: EclipseResult.cancelled));
|
||||||
|
await _settle(tester);
|
||||||
|
expect(find.text('이클립스 취소'), findsOneWidget);
|
||||||
|
expect(find.textContaining('FPI는 차감되지 않았습니다'), findsOneWidget);
|
||||||
|
expect(find.textContaining('FAIL'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('계속 입력해도 700ms마다 전송하고 멈춘 뒤 남은 입력을 보낸다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture());
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpDefend(tester, repo);
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
expect(repo.calls, isNotEmpty);
|
||||||
|
await _settle(tester);
|
||||||
|
expect(repo.calls.fold<int>(0, (sum, call) => sum + call.$3), 12);
|
||||||
|
expect(repo.calls.length, greaterThan(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('응답 유실은 마감 후에도 같은 회차·요청ID·횟수로 재확인한다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository(state: eclipseFixture())
|
||||||
|
..failWith = const BackendException('unavailable', 'private backend error');
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpDefend(tester, repo);
|
||||||
|
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
|
||||||
|
await _settle(tester);
|
||||||
|
expect(repo.calls, hasLength(1));
|
||||||
|
final original = repo.calls.single;
|
||||||
|
expect(find.textContaining('private backend error'), findsNothing);
|
||||||
|
repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1))));
|
||||||
|
await _settle(tester);
|
||||||
|
repo.failWith = null;
|
||||||
|
await tester.tap(find.text('같은 참여 다시 확인'));
|
||||||
|
await _settle(tester);
|
||||||
|
expect(repo.calls, [original, original]);
|
||||||
|
expect(find.text('같은 참여 다시 확인'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('진행 중인 이클립스가 없으면 그 사실을 알린다', (tester) async {
|
||||||
|
final repo = FakeEclipseRepository();
|
||||||
|
addTearDown(repo.dispose);
|
||||||
|
await _pumpDefend(tester, repo);
|
||||||
|
expect(find.text('진행 중인 이클립스가 없습니다'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('구독 공유 — 팬덤당 하나 (검수 D5)', () {
|
||||||
|
test('여러 화면이 같은 원본 하나를 나눠 쓴다', () async {
|
||||||
|
// 화면마다 snapshots() 를 열면 한 팬덤에 리스너가 화면 수만큼 붙는다.
|
||||||
|
var opened = 0;
|
||||||
|
final controller = StreamController<int>.broadcast();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final shared = SharedStream<int>(() {
|
||||||
|
opened += 1;
|
||||||
|
return controller.stream;
|
||||||
|
});
|
||||||
|
|
||||||
|
final a = <int>[];
|
||||||
|
final b = <int>[];
|
||||||
|
final subA = shared.stream.listen(a.add);
|
||||||
|
final subB = shared.stream.listen(b.add);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
controller.add(7);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
// 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다.
|
||||||
|
expect(opened, 1);
|
||||||
|
expect(a, [7]);
|
||||||
|
expect(b, [7]);
|
||||||
|
|
||||||
|
await subA.cancel();
|
||||||
|
await subB.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async {
|
||||||
|
final controller = StreamController<int>.broadcast();
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final shared = SharedStream<int>(() => controller.stream);
|
||||||
|
|
||||||
|
final first = <int>[];
|
||||||
|
final subA = shared.stream.listen(first.add);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
controller.add(42);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
// 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다.
|
||||||
|
final late = <int>[];
|
||||||
|
final subB = shared.stream.listen(late.add);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(late, [42]);
|
||||||
|
|
||||||
|
await subA.cancel();
|
||||||
|
await subB.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마지막 화면이 떠나면 원본을 닫는다', () async {
|
||||||
|
var cancelled = false;
|
||||||
|
final controller = StreamController<int>(onCancel: () => cancelled = true);
|
||||||
|
addTearDown(controller.close);
|
||||||
|
final shared = SharedStream<int>(() => controller.stream);
|
||||||
|
|
||||||
|
final sub = shared.stream.listen((_) {});
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(cancelled, isFalse);
|
||||||
|
|
||||||
|
await sub.cancel();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
// 남겨 두면 화면이 없는 동안에도 읽기 비용이 계속 든다.
|
||||||
|
expect(cancelled, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('이클립스 상태 계산', () {
|
||||||
|
test('진행률은 목표가 0 이면 0 이다', () {
|
||||||
|
expect(eclipseFixture(targetDefense: 0, defense: 10).progress, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('진행률은 1 을 넘지 않는다', () {
|
||||||
|
expect(
|
||||||
|
eclipseFixture(targetDefense: 100, defense: 400).progress,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마감이 지났으면 남은 시간을 말하지 않는다', () {
|
||||||
|
final now = DateTime(2026, 9, 14, 12);
|
||||||
|
expect(
|
||||||
|
eclipseFixture(endsAt: now.subtract(const Duration(minutes: 1)))
|
||||||
|
.remaining(now),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
eclipseFixture(endsAt: now.add(const Duration(minutes: 20)))
|
||||||
|
.remaining(now),
|
||||||
|
const Duration(minutes: 20),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('서버 kind 문자열을 모르는 값이면 공지로 읽는다', () {
|
||||||
|
// 티커가 새 종류에 막혀 멈추지 않게 한다(별첨 B Global Ticker).
|
||||||
|
expect(LiveEventKind.of('eclipse_defense'), LiveEventKind.eclipseDefense);
|
||||||
|
expect(LiveEventKind.of('hero_gift'), LiveEventKind.heroGift);
|
||||||
|
expect(LiveEventKind.of('무엇인가'), LiveEventKind.notice);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마감이 지난 이클립스는 만료로 읽는다 (검수 D3)', () {
|
||||||
|
final now = DateTime(2026, 9, 14, 12);
|
||||||
|
expect(
|
||||||
|
eclipseFixture(endsAt: now.subtract(const Duration(seconds: 1)))
|
||||||
|
.isExpired(now),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
eclipseFixture(endsAt: now.add(const Duration(minutes: 1))).isExpired(now),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_repository_firestore.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('추가 스냅샷 없이 마감시각에 위험 상태가 끝난다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
final repo = FirestoreEclipseRepository(
|
||||||
|
Backend(firestore: db, auth: MockFirebaseAuth()),
|
||||||
|
);
|
||||||
|
await db.doc('fandoms/bts/eclipseRounds/one').set({
|
||||||
|
'status': 'active',
|
||||||
|
'startAt': Timestamp.now(),
|
||||||
|
'endsAt': Timestamp.fromDate(
|
||||||
|
DateTime.now().add(const Duration(milliseconds: 150)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
final states = <EclipseState?>[];
|
||||||
|
final sub = repo.watch(fandomId: 'bts').listen(states.add);
|
||||||
|
addTearDown(sub.cancel);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last?.eclipseId, 'one');
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
expect(states.last, isNull);
|
||||||
|
});
|
||||||
|
test('활성 회차의 성공·종료·새 회차를 샤드 구독 중에도 반영한다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
final repo = FirestoreEclipseRepository(
|
||||||
|
Backend(firestore: db, auth: MockFirebaseAuth()),
|
||||||
|
);
|
||||||
|
final doc = db.doc('fandoms/bts/eclipseRounds/one');
|
||||||
|
final data = {
|
||||||
|
'status': 'active',
|
||||||
|
'startAt': Timestamp.now(),
|
||||||
|
'endsAt': Timestamp.fromDate(
|
||||||
|
DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
'targetPop': 60, // 옛 필드는 목표 인원으로 재해석하지 않는다.
|
||||||
|
'target': 2,
|
||||||
|
'defended': false,
|
||||||
|
};
|
||||||
|
await doc.set(data);
|
||||||
|
await doc.collection('shards').doc('0').set({'defense': 600, 'defenseCount': 25, 'qualifiedParticipants': 1});
|
||||||
|
final states = <EclipseState?>[];
|
||||||
|
final sub = repo.watch(fandomId: 'bts').listen(states.add);
|
||||||
|
addTearDown(sub.cancel);
|
||||||
|
Future<void> flush() =>
|
||||||
|
Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
await flush();
|
||||||
|
expect(states.last?.defense, 1);
|
||||||
|
expect(states.last?.targetDefense, 2);
|
||||||
|
expect(states.last?.progress, 0.5);
|
||||||
|
await doc.update({'defended': true});
|
||||||
|
await flush();
|
||||||
|
expect(states.last?.defended, true);
|
||||||
|
await doc.update({'status': 'ended'});
|
||||||
|
await flush();
|
||||||
|
expect(states.last, isNull);
|
||||||
|
await db.doc('fandoms/bts/eclipseRounds/two').set(data);
|
||||||
|
await flush();
|
||||||
|
expect(states.last?.eclipseId, 'two');
|
||||||
|
await doc.collection('shards').doc('0').update({'defense': 99});
|
||||||
|
await flush();
|
||||||
|
expect(states.last?.eclipseId, 'two');
|
||||||
|
});
|
||||||
|
test('방어 화면 구독은 마감 뒤에도 같은 회차의 서버 결과를 받는다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
final repo = FirestoreEclipseRepository(Backend(firestore: db, auth: MockFirebaseAuth()));
|
||||||
|
final ref = db.doc('fandoms/bts/eclipseRounds/final');
|
||||||
|
await ref.set({
|
||||||
|
'status': 'active', 'startAt': Timestamp.now(),
|
||||||
|
'endsAt': Timestamp.fromDate(DateTime.now().add(const Duration(milliseconds: 150))),
|
||||||
|
'target': 2, 'qualifiedParticipants': 1,
|
||||||
|
});
|
||||||
|
final results = <EclipseState?>[];
|
||||||
|
final banner = <EclipseState?>[];
|
||||||
|
final sub = repo.watch(fandomId: 'bts', includeResults: true).listen(results.add);
|
||||||
|
final active = repo.watch(fandomId: 'bts').listen(banner.add);
|
||||||
|
addTearDown(sub.cancel);
|
||||||
|
addTearDown(active.cancel);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||||
|
expect(banner.last, isNull);
|
||||||
|
expect(results.last?.eclipseId, 'final');
|
||||||
|
expect(results.last?.canDefend, isFalse);
|
||||||
|
expect(results.last?.result, isNull);
|
||||||
|
await ref.update({'status': 'ended', 'result': 'failed'});
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(results.last?.result, EclipseResult.failure);
|
||||||
|
expect(banner.last, isNull);
|
||||||
|
await ref.update({'status': 'cancelled', 'result': 'cancelled_error'});
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(results.last?.result, EclipseResult.cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
|
||||||
|
/// 활성 이클립스가 없는 상태.
|
||||||
|
///
|
||||||
|
/// **이클립스와 무관한 화면 검증의 기본값이다.** [SampleEclipseRepository] 는 목
|
||||||
|
/// 모드 시연을 위해 늘 활성 이클립스를 내므로, 그것을 쓰면 배너와 라이브 띠가
|
||||||
|
/// 화면 상단을 차지해 F1·F2·F3 의 레이아웃 검증이 이클립스 유무에 딸려 흔들린다.
|
||||||
|
class NoEclipseRepository implements EclipseRepository {
|
||||||
|
const NoEclipseRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) => Stream.value(null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
||||||
|
Stream.value(const []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DefendResult> defend({
|
||||||
|
required String fandomId,
|
||||||
|
required String eclipseId,
|
||||||
|
required String clientSeq,
|
||||||
|
required int shakes,
|
||||||
|
}) async =>
|
||||||
|
const DefendResult(accepted: 0, clamped: 0, myTotal: 0, duplicate: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 값을 밖에서 밀어 넣는 저장소. 스트림이 바뀔 때 화면이 따라오는지 본다.
|
||||||
|
///
|
||||||
|
/// 방어 호출은 실제로 나가지 않고 [calls] 에 기록만 한다 — 연타가 **몇 번의
|
||||||
|
/// 호출로 묶였는지**와 `count` 합이 맞는지가 검증 대상이다.
|
||||||
|
class FakeEclipseRepository implements EclipseRepository {
|
||||||
|
FakeEclipseRepository({EclipseState? state}) {
|
||||||
|
if (state != null) push(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _eclipse = StreamController<EclipseState?>.broadcast();
|
||||||
|
final _events = StreamController<List<LiveEvent>>.broadcast();
|
||||||
|
|
||||||
|
/// 마지막으로 밀어 넣은 값. 새 구독자가 곧바로 받는다.
|
||||||
|
EclipseState? _last;
|
||||||
|
List<LiveEvent> _lastEvents = const [];
|
||||||
|
|
||||||
|
/// (eclipseId, clientSeq, shakes) 기록.
|
||||||
|
final List<(String, String, int)> calls = [];
|
||||||
|
|
||||||
|
/// 다음 방어 호출이 던질 실패. null 이면 성공한다.
|
||||||
|
Object? failWith;
|
||||||
|
|
||||||
|
/// 서버가 받아들였다고 답할 양. null 이면 보낸 값을 그대로 받아들인다.
|
||||||
|
int? acceptedOverride;
|
||||||
|
|
||||||
|
void push(EclipseState? state) {
|
||||||
|
_last = state;
|
||||||
|
if (_eclipse.hasListener) _eclipse.add(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
void pushEvents(List<LiveEvent> events) {
|
||||||
|
_lastEvents = events;
|
||||||
|
if (_events.hasListener) _events.add(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await _eclipse.close();
|
||||||
|
await _events.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) async* {
|
||||||
|
yield _last;
|
||||||
|
yield* _eclipse.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) async* {
|
||||||
|
yield _lastEvents;
|
||||||
|
yield* _events.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DefendResult> defend({
|
||||||
|
required String fandomId,
|
||||||
|
required String eclipseId,
|
||||||
|
required String clientSeq,
|
||||||
|
required int shakes,
|
||||||
|
}) async {
|
||||||
|
calls.add((eclipseId, clientSeq, shakes));
|
||||||
|
final failure = failWith;
|
||||||
|
if (failure != null) throw failure;
|
||||||
|
final accepted = acceptedOverride ?? shakes;
|
||||||
|
return DefendResult(
|
||||||
|
accepted: accepted,
|
||||||
|
clamped: shakes - accepted,
|
||||||
|
myTotal: calls.fold(0, (a, c) => a + c.$3),
|
||||||
|
duplicate: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검증에 쓰는 이클립스 한 건. 값은 인자로 덮는다.
|
||||||
|
EclipseState eclipseFixture({
|
||||||
|
String fandomId = 'bts',
|
||||||
|
String eclipseId = 'ecl-1',
|
||||||
|
int erosionRate = 40,
|
||||||
|
int defense = 60_000,
|
||||||
|
int targetDefense = 100_000,
|
||||||
|
int defenseCount = 12,
|
||||||
|
bool defended = false,
|
||||||
|
EclipseResult? result,
|
||||||
|
DateTime? endsAt,
|
||||||
|
}) => EclipseState(
|
||||||
|
fandomId: fandomId,
|
||||||
|
eclipseId: eclipseId,
|
||||||
|
fandomName: 'BTS',
|
||||||
|
erosionRate: erosionRate,
|
||||||
|
defense: defense,
|
||||||
|
targetDefense: targetDefense,
|
||||||
|
defenseCount: defenseCount,
|
||||||
|
defended: defended,
|
||||||
|
result: result,
|
||||||
|
// 마감이 없으면 화면이 「지났는가」를 물을 수 없다. 기본을 넉넉히 둔다.
|
||||||
|
endsAt: endsAt ?? DateTime.now().add(const Duration(minutes: 20)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 구독을 몇 번 열었고 몇 개가 살아 있는지 센다.
|
||||||
|
///
|
||||||
|
/// 실시간 동기화의 두 고장은 **끊지 않는 것**(백그라운드에서 읽기 비용이 계속
|
||||||
|
/// 든다)과 **다시 열지 않는 것**(복귀한 화면이 잠들기 전 값을 그린다)이다. 둘 다
|
||||||
|
/// 화면만 보고는 구분되지 않으므로 구독 수를 직접 센다.
|
||||||
|
class CountingEclipseRepository implements EclipseRepository {
|
||||||
|
CountingEclipseRepository(this.state);
|
||||||
|
|
||||||
|
final EclipseState? state;
|
||||||
|
|
||||||
|
/// 지금까지 열린 구독 수(누적).
|
||||||
|
int subscriptions = 0;
|
||||||
|
|
||||||
|
/// 아직 살아 있는 구독 수.
|
||||||
|
int active = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) {
|
||||||
|
late StreamController<EclipseState?> controller;
|
||||||
|
controller = StreamController<EclipseState?>(
|
||||||
|
onListen: () {
|
||||||
|
subscriptions += 1;
|
||||||
|
active += 1;
|
||||||
|
controller.add(state);
|
||||||
|
},
|
||||||
|
onCancel: () {
|
||||||
|
active -= 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
||||||
|
Stream.value(const []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DefendResult> defend({
|
||||||
|
required String fandomId,
|
||||||
|
required String eclipseId,
|
||||||
|
required String clientSeq,
|
||||||
|
required int shakes,
|
||||||
|
}) async =>
|
||||||
|
const DefendResult(accepted: 0, clamped: 0, myTotal: 0, duplicate: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ZAP(행성 성장 기여) 호출을 기록하는 갤럭시 저장소.
|
||||||
|
///
|
||||||
|
/// 별첨 B **B-23** — ZAP 은 이클립스 방어가 아니라 행성 성장 기여다. 이 스텁으로
|
||||||
|
/// 「ZAP 이 방어 함수가 아니라 POP 소비 경로로 나가는가」를 본다.
|
||||||
|
class RecordingGalaxyRepository implements GalaxyRepository {
|
||||||
|
RecordingGalaxyRepository({this.snapshot});
|
||||||
|
|
||||||
|
/// 돌려줄 스냅샷. 없으면 표본을 쓴다.
|
||||||
|
final GalaxySnapshot? snapshot;
|
||||||
|
|
||||||
|
/// (fandomId, clientSeq, count) 기록.
|
||||||
|
final List<(String, String, int)> zaps = [];
|
||||||
|
|
||||||
|
/// 서버가 확정했다고 답할 잔액. -1 이면 「모름」이다.
|
||||||
|
int balanceAfter = -1;
|
||||||
|
|
||||||
|
/// 다음 호출이 던질 실패. null 이면 성공한다.
|
||||||
|
Object? failWith;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setPrimary(String fandomId) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GalaxySnapshot> load() async =>
|
||||||
|
snapshot ?? await const SampleGalaxyRepository().load();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ZapResult> zap({
|
||||||
|
required String fandomId,
|
||||||
|
required int count,
|
||||||
|
required String clientSeq,
|
||||||
|
}) async {
|
||||||
|
zaps.add((fandomId, clientSeq, count));
|
||||||
|
final failure = failWith;
|
||||||
|
if (failure != null) throw failure;
|
||||||
|
return ZapResult(txId: 'tx-${zaps.length}', balanceAfter: balanceAfter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import 'package:fancake/core/ui/app_icons.dart';
|
||||||
|
import 'package:fancake/features/notification/notification_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// F0 공통 — 화면 대조 원장 2화면.
|
||||||
|
/// Bottom Navigation(`350:215853`)은 5탭 셸이라 `app_smoke_test` 가 검증한다.
|
||||||
|
|
||||||
|
Future<void> pumpWide(WidgetTester tester, Widget child) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: child));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('날짜 머리글 (F0 · 518:88353)', () {
|
||||||
|
final now = DateTime(2026, 9, 1, 12);
|
||||||
|
|
||||||
|
test('오늘과 어제만 이름으로 부른다', () {
|
||||||
|
expect(notificationDayLabel(DateTime(2026, 9, 1, 3), now: now), '오늘');
|
||||||
|
expect(notificationDayLabel(DateTime(2026, 8, 31, 23), now: now), '어제');
|
||||||
|
expect(
|
||||||
|
notificationDayLabel(DateTime(2026, 8, 30), now: now),
|
||||||
|
'2026.08.30',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('생성일시는 명세 형식을 그대로 쓴다', () {
|
||||||
|
expect(
|
||||||
|
notificationTimestamp(DateTime(2026, 1, 2, 3, 4)),
|
||||||
|
'2026.01.02 03:04',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('날짜 그룹화', () {
|
||||||
|
final now = DateTime(2026, 9, 1, 12);
|
||||||
|
|
||||||
|
test('날짜로 묶고 최신이 앞에 온다', () {
|
||||||
|
final items = [
|
||||||
|
AppNotification(
|
||||||
|
id: 'a',
|
||||||
|
kind: NotificationKind.like,
|
||||||
|
title: 'a',
|
||||||
|
at: DateTime(2026, 8, 30, 9),
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: 'b',
|
||||||
|
kind: NotificationKind.like,
|
||||||
|
title: 'b',
|
||||||
|
at: DateTime(2026, 9, 1, 9),
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: 'c',
|
||||||
|
kind: NotificationKind.like,
|
||||||
|
title: 'c',
|
||||||
|
at: DateTime(2026, 9, 1, 11),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final groups = groupNotifications(items, now: now);
|
||||||
|
expect(groups.map((g) => g.label).toList(), ['오늘', '2026.08.30']);
|
||||||
|
expect(groups.first.items.map((i) => i.id).toList(), ['c', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('종류별 이동 (F0 · 518:88353)', () {
|
||||||
|
test('이클립스와 아이템 사용은 이동하지 않는다', () {
|
||||||
|
expect(NotificationKind.eclipse.navigable, isFalse);
|
||||||
|
expect(NotificationKind.itemUsed.navigable, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('나머지 넷은 갈 곳이 있다', () {
|
||||||
|
for (final kind in const [
|
||||||
|
NotificationKind.like,
|
||||||
|
NotificationKind.comment,
|
||||||
|
NotificationKind.campaign,
|
||||||
|
NotificationKind.quest,
|
||||||
|
]) {
|
||||||
|
expect(kind.navigable, isTrue, reason: kind.label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('알림 화면 (F0 · 229:11265)', () {
|
||||||
|
testWidgets('날짜 머리글과 항목을 함께 보여 준다', (tester) async {
|
||||||
|
// 표본은 DateTime.now() 기준이라 자정 전후로 「오늘/어제」가 갈린다.
|
||||||
|
// 시각을 못박아 넣는다.
|
||||||
|
final now = DateTime.now();
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
NotificationScreen(
|
||||||
|
repository: SampleNotificationRepository(
|
||||||
|
items: [
|
||||||
|
AppNotification(
|
||||||
|
id: 'q',
|
||||||
|
kind: NotificationKind.quest,
|
||||||
|
title: '만보기 퀘스트를 완료했습니다',
|
||||||
|
at: now,
|
||||||
|
),
|
||||||
|
AppNotification(
|
||||||
|
id: 'o',
|
||||||
|
kind: NotificationKind.like,
|
||||||
|
title: '오래된 알림',
|
||||||
|
at: now.subtract(const Duration(days: 9)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('오늘'), findsOneWidget);
|
||||||
|
expect(find.textContaining('만보기 퀘스트를 완료'), findsOneWidget);
|
||||||
|
expect(find.text('오래된 알림'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('이동하지 않는 알림에는 화살표를 그리지 않는다', (tester) async {
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
NotificationScreen(
|
||||||
|
repository: SampleNotificationRepository(
|
||||||
|
items: [
|
||||||
|
AppNotification(
|
||||||
|
id: 'e',
|
||||||
|
kind: NotificationKind.eclipse,
|
||||||
|
title: '이클립스',
|
||||||
|
at: DateTime.now(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is ImageIcon && w.image == AppIcons.chevronRight,
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('누르면 읽음으로 바뀌고 뒤로가기가 읽은 목록을 돌려준다', (tester) async {
|
||||||
|
final items = [
|
||||||
|
AppNotification(
|
||||||
|
id: 'n1',
|
||||||
|
kind: NotificationKind.quest,
|
||||||
|
title: '만보기 퀘스트를 완료했습니다',
|
||||||
|
at: DateTime.now(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
Set<String>? returned;
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
Builder(
|
||||||
|
builder: (context) => TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
returned = await Navigator.of(context).push<Set<String>>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => NotificationScreen(
|
||||||
|
repository: SampleNotificationRepository(items: items),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('열기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('열기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.textContaining('만보기 퀘스트를 완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(returned, contains('n1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('비어 있으면 그렇게 알린다', (tester) async {
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
NotificationScreen(
|
||||||
|
repository: const SampleNotificationRepository(items: []),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('알림이 없습니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('문구에 내부 경로가 새지 않는다', (tester) async {
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
NotificationScreen(repository: const SampleNotificationRepository()),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
for (final text in tester.widgetList<Text>(find.byType(Text))) {
|
||||||
|
final data = text.data ?? '';
|
||||||
|
expect(data, isNot(contains('lib/')));
|
||||||
|
expect(data, isNot(contains('.dart')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/backend.dart' show BackendException;
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/home/growth_complete_screen.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_onboarding_screen.dart';
|
||||||
|
import 'package:fancake/features/home/nebula_create_screen.dart';
|
||||||
|
import 'package:fancake/features/home/planet_create_screen.dart';
|
||||||
|
|
||||||
|
/// 생성 호출을 기록하는 표본. [error] 가 있으면 서버 거부를 흉내 낸다.
|
||||||
|
class _CreateRepo extends SampleHomeRepository {
|
||||||
|
_CreateRepo({this.error, this.primaryChanged = true});
|
||||||
|
final BackendException? error;
|
||||||
|
final bool primaryChanged;
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<({String fandomId, bool primaryChanged})> createNebula(String name) async {
|
||||||
|
calls.add(name);
|
||||||
|
if (error case final e?) throw e;
|
||||||
|
return (fandomId: 'new-nebula', primaryChanged: primaryChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> createPlanet({
|
||||||
|
required String fandomId,
|
||||||
|
required String planetType,
|
||||||
|
required String colorKey,
|
||||||
|
}) async {
|
||||||
|
calls.add('$fandomId · $planetType · $colorKey');
|
||||||
|
if (error case final e?) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 운영값을 바꾼 `config/policy` 흉내.
|
||||||
|
class _PolicyRepo extends SampleHomeRepository {
|
||||||
|
@override
|
||||||
|
Future<NebulaTargets> nebulaTargets() async =>
|
||||||
|
nebulaTargetsFrom({'nebulaMinPop': 2000, 'nebulaMinMembers': 20, 'nebulaDays': 5});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 생성 화면을 위에 얹고, 닫힐 때 돌려준 값을 받는다.
|
||||||
|
Future<List<Object?>> _pushed(WidgetTester tester, Widget screen) async {
|
||||||
|
final results = <Object?>[];
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) => TextButton(
|
||||||
|
onPressed: () async => results.add(
|
||||||
|
await Navigator.of(context).push<String>(
|
||||||
|
MaterialPageRoute(builder: (_) => screen),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('열기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('열기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pump(WidgetTester tester, Widget screen) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('성운 생성 (F1 · 340:75466)', () {
|
||||||
|
testWidgets('이름을 넣기 전에는 「성운 생성」이 없고 목표는 원장 고정값이다', (tester) async {
|
||||||
|
await _pump(tester, const NebulaCreateScreen());
|
||||||
|
expect(find.text('성운 생성'), findsNothing);
|
||||||
|
// 정책 원장 B-10 — 1,000 POP · 10명 · 7일. 사본의 슬라이더는 조작할 수 없다.
|
||||||
|
expect(find.text('1,000P / 10'), findsOneWidget);
|
||||||
|
expect(find.byType(Slider), findsNothing);
|
||||||
|
expect(find.textContaining('7일내'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('목표는 config/policy 운영값(서버와 같은 키)을 따른다', (tester) async {
|
||||||
|
await _pump(tester, NebulaCreateScreen(repository: _PolicyRepo()));
|
||||||
|
expect(find.text('2,000P / 20'), findsOneWidget);
|
||||||
|
expect(find.textContaining('5일내'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('운영값이 없거나 잘못되면 원장 기본값 1,000 · 10 · 7', () {
|
||||||
|
expect(nebulaTargetsFrom(null), defaultNebulaTargets);
|
||||||
|
expect(nebulaTargetsFrom({'nebulaMinPop': -1, 'nebulaMinMembers': 'x'}),
|
||||||
|
(pop: 1000, members: 10, days: 7));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('서버가 쓰이는 이름(E-FDM-01)이라 하면 입력칸 오류로 알리고 만들 수 없다', (tester) async {
|
||||||
|
final repo = _CreateRepo(
|
||||||
|
error: const BackendException('E-FDM-01', '이미 사용중인 이름입니다.'),
|
||||||
|
);
|
||||||
|
await _pump(tester, NebulaCreateScreen(repository: repo));
|
||||||
|
await tester.enterText(find.byType(TextField), 'BTS');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 중복 판정은 서버만 한다 — 입력만으로는 오류를 띄우지 않는다.
|
||||||
|
expect(find.text('이미 사용중인 이름입니다.'), findsNothing);
|
||||||
|
await tester.tap(find.text('성운 생성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('변경'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('이미 사용중인 이름입니다.'), findsOneWidget);
|
||||||
|
final btn = tester.widget<FilledButton>(
|
||||||
|
find.widgetWithText(FilledButton, '성운 생성'),
|
||||||
|
);
|
||||||
|
expect(btn.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('이름을 넣고 만들면 기본 행성 변경을 되묻는다', (tester) async {
|
||||||
|
await _pump(tester, const NebulaCreateScreen());
|
||||||
|
await tester.enterText(find.byType(TextField), 'BTS_RM');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('성운 생성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// Figma 문구 그대로여야 한다.
|
||||||
|
expect(find.text('선택한 행성을 기본 행성으로 변경할까요?'), findsOneWidget);
|
||||||
|
expect(find.text('취소'), findsOneWidget);
|
||||||
|
expect(find.text('변경'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('변경을 누르면 fn-nebula-create 로 만들고 새 성운 ID 를 들고 닫힌다', (tester) async {
|
||||||
|
final repo = _CreateRepo();
|
||||||
|
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
|
||||||
|
await tester.enterText(find.byType(TextField), 'BTS_RM');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('성운 생성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('변경'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.calls, ['BTS_RM']);
|
||||||
|
expect(results, ['new-nebula']);
|
||||||
|
expect(find.byType(NebulaCreateScreen), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('서버가 거부하면 사유를 알리고 화면에 남는다', (tester) async {
|
||||||
|
final repo = _CreateRepo(
|
||||||
|
error: const BackendException('E-FDM-02', '이미 오늘 성운을 생성하셨습니다.'),
|
||||||
|
);
|
||||||
|
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
|
||||||
|
await tester.enterText(find.byType(TextField), 'BTS_RM');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('성운 생성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('변경'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget);
|
||||||
|
expect(results, isEmpty);
|
||||||
|
expect(find.byType(NebulaCreateScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('대표 변경 30일 대기 중이면 성운만 생겼다고 알리고 닫힌다(C-15)', (tester) async {
|
||||||
|
final repo = _CreateRepo(primaryChanged: false);
|
||||||
|
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
|
||||||
|
await tester.enterText(find.byType(TextField), 'BTS_RM');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('성운 생성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('변경'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('30일이 지나야'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(results, ['new-nebula']);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('보유 POP 은 여는 쪽이 넘긴 지갑 값이다', (tester) async {
|
||||||
|
await _pump(tester, const NebulaCreateScreen(myPop: 3210));
|
||||||
|
expect(find.textContaining('3,210'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('오늘 이미 만들었으면 사유를 알리고 진행하지 않는다', (tester) async {
|
||||||
|
await _pump(tester, const NebulaCreateScreen(alreadyCreatedToday: true));
|
||||||
|
await tester.enterText(find.byType(TextField), 'BTS_RM');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('성운 생성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget);
|
||||||
|
// 제한에 걸렸으면 변경 확인으로 넘어가면 안 된다.
|
||||||
|
expect(find.text('변경'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('성장 완료 (F1 · 344:213778) — HOME 성운 상태', () {
|
||||||
|
testWidgets('두 조건을 모두 채우면 성운을 눌러 행성 생성으로 간다', (tester) async {
|
||||||
|
await _pump(tester, const GrowthCompleteScreen());
|
||||||
|
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsOneWidget);
|
||||||
|
await tester.tap(find.bySemanticsLabel('성운'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(PlanetCreateScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('창립자가 아니면 조건을 채워도 탄생 안내 · 탭이 없다(Q-PN-01 A안)', (tester) async {
|
||||||
|
await _pump(tester, const GrowthCompleteScreen(isFounder: false));
|
||||||
|
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsNothing);
|
||||||
|
await tester.tap(find.bySemanticsLabel('성운'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(PlanetCreateScreen), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('한쪽만 채우면 대기다 — 정책 원장 B-10 동시 충족', (tester) async {
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
// 인원은 넘었으나 POP 이 모자란 경우.
|
||||||
|
const GrowthCompleteScreen(members: 12, contributedPop: 400),
|
||||||
|
);
|
||||||
|
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsNothing);
|
||||||
|
await tester.tap(find.bySemanticsLabel('성운'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byType(PlanetCreateScreen),
|
||||||
|
findsNothing,
|
||||||
|
reason: '한쪽만 채웠는데 승격이 열리면 안 된다',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('두 조건을 갈라 보여 준다', (tester) async {
|
||||||
|
await _pump(tester, const GrowthCompleteScreen());
|
||||||
|
expect(find.text('활동을 통해 얻은 POP을 통해\n성운에 기여해 보세요!'), findsOneWidget);
|
||||||
|
expect(find.text('POP 조건 충족 · 1,240 / 1,000'), findsOneWidget);
|
||||||
|
expect(find.text('창립 멤버 조건 충족 · 12 / 10'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('행성 생성 (F1 · 344:214421)', () {
|
||||||
|
testWidgets('형태 → 색 두 단계만 고른다 — C-50', (tester) async {
|
||||||
|
final repo = _CreateRepo();
|
||||||
|
final results = await _pushed(
|
||||||
|
tester,
|
||||||
|
PlanetCreateScreen(fandomId: 'nebula-1', repository: repo),
|
||||||
|
);
|
||||||
|
expect(find.text('행성 타입 선택'), findsOneWidget);
|
||||||
|
await tester.tap(find.bySemanticsLabel('형태 2'));
|
||||||
|
await tester.tap(find.text('다음'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('행성 색 선택'), findsOneWidget);
|
||||||
|
expect(find.text('배경색 선택'), findsOneWidget);
|
||||||
|
// 생성 때 고르는 것은 형태와 컬러뿐 — 오브젝트 색 · 직접 고르기는 없다.
|
||||||
|
expect(find.text('오브젝트 색 선택'), findsNothing);
|
||||||
|
await tester.tap(find.bySemanticsLabel('gold'));
|
||||||
|
await tester.tap(find.text('완료').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('선택한 옵션으로\n행성을 생성할까요?'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('생성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// fn-planet-create 에는 형태 · 컬러 두 값만 간다. 성공하면 닫혀 HOME 으로 돌아간다.
|
||||||
|
expect(repo.calls, ['nebula-1 · shape2 · gold']);
|
||||||
|
expect(results, ['nebula-1']);
|
||||||
|
expect(find.byType(PlanetCreateScreen), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('전환 조건 미달 등 서버 거부는 사유를 띄우고 화면에 남는다', (tester) async {
|
||||||
|
final repo = _CreateRepo(
|
||||||
|
error: const BackendException('E-FDM-03', '성운 전환 조건을 아직 채우지 못했습니다.'),
|
||||||
|
);
|
||||||
|
await _pump(tester, PlanetCreateScreen(fandomId: 'n', repository: repo));
|
||||||
|
await tester.tap(find.text('다음'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('완료').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('생성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('성운 전환 조건을 아직 채우지 못했습니다.'), findsOneWidget);
|
||||||
|
expect(find.byType(PlanetCreateScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('색 단계에서 이전으로 돌아간다', (tester) async {
|
||||||
|
await _pump(tester, const PlanetCreateScreen());
|
||||||
|
await tester.tap(find.text('다음'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('이전'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('행성 타입 선택'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('초기 사용자 홈 (F1 · 343:212939) — HOME 무소속 상태', () {
|
||||||
|
testWidgets('「+」 를 누르면 행성 생성·가입 시트가 뜨고 고른 동작을 부른다', (tester) async {
|
||||||
|
var created = 0;
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
HomeOnboardingScreen(onCreateNebula: () => created++),
|
||||||
|
);
|
||||||
|
// 사용자 노출 브랜드는 팬우주다(공통 기준 4항).
|
||||||
|
expect(find.text('행성을 생성 또는 가입하고\n팬우주를 시작해 보세요!'), findsOneWidget);
|
||||||
|
await tester.tap(find.bySemanticsLabel('행성 생성 또는 가입'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('행성 가입'), findsOneWidget);
|
||||||
|
expect(find.text('취소'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('행성 생성 (성운)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(created, 1);
|
||||||
|
expect(find.text('행성 가입'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('헤더 지갑은 「아이템 구입」 시트를 열고 구매는 준비 중이다', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const HomeScreen(
|
||||||
|
repository: SampleHomeRepository(),
|
||||||
|
questRepository: SampleQuestRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.tap(find.byTooltip('아이템'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(find.text('아이템 구입'), findsOneWidget);
|
||||||
|
expect(find.text('₩1,200'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('베이직 드랍'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('아이템 구입은 준비 중입니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('F1 화면 문구에 내부 경로가 새지 않는다', (tester) async {
|
||||||
|
for (final screen in <Widget>[
|
||||||
|
const NebulaCreateScreen(),
|
||||||
|
const GrowthCompleteScreen(),
|
||||||
|
const PlanetCreateScreen(),
|
||||||
|
const HomeOnboardingScreen(),
|
||||||
|
]) {
|
||||||
|
await _pump(tester, screen);
|
||||||
|
final texts = tester
|
||||||
|
.widgetList<Text>(find.byType(Text))
|
||||||
|
.map((w) => w.data ?? '')
|
||||||
|
.join('\n');
|
||||||
|
for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) {
|
||||||
|
expect(texts.contains(leak), isFalse, reason: '「$leak」 노출');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_motion.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart' show BackendException;
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_home_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_search_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
/// 저장소를 전부 주입한 궤도 맵. 화면이 표본을 기본값으로 만들지 않으므로
|
||||||
|
/// (공통 규칙 — 저장소 선택은 `repositories.dart` 한 곳) 테스트가 직접 넘긴다.
|
||||||
|
GalaxyScreen _galaxy({
|
||||||
|
GalaxyRepository repository = const SampleGalaxyRepository(),
|
||||||
|
EclipseRepository eclipse = const NoEclipseRepository(),
|
||||||
|
}) => GalaxyScreen(
|
||||||
|
repository: repository,
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
eclipse: eclipse,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 갤럭시 맵은 궤도 회전 애니메이션이 끊이지 않는다. `pumpAndSettle` 은 영원히
|
||||||
|
/// 기다리므로 이 화면 계열에서는 시간을 정해 [WidgetTester.pump] 한다.
|
||||||
|
Future<void> _pump(WidgetTester tester, Widget screen) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
|
||||||
|
await tester.pump(); // FutureBuilder
|
||||||
|
await tester.pump(const Duration(milliseconds: 950)); // 이벤트 띠 슬라이드
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 회전이 도는 동안에도 안전하게 몇 프레임 넘긴다.
|
||||||
|
Future<void> _settle(WidgetTester tester) async {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 궤도 꼭대기에 바깥 궤도 행성을 세우는 표본. 목록 index 가 궤도 각도를 정하므로
|
||||||
|
/// (`index * 2pi / 5.4`) index 4 가 화면 위쪽 끝이다.
|
||||||
|
class _TopOrbitRepository implements GalaxyRepository {
|
||||||
|
const _TopOrbitRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setPrimary(String fandomId) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ZapResult> zap({
|
||||||
|
required String fandomId,
|
||||||
|
required int count,
|
||||||
|
required String clientSeq,
|
||||||
|
}) async => const ZapResult(txId: '', balanceAfter: -1);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GalaxySnapshot> load() async => const GalaxySnapshot(
|
||||||
|
myPop: 0,
|
||||||
|
joined: false,
|
||||||
|
planets: [
|
||||||
|
PlanetNode(id: 'mine', name: '내 행성', rank: 1, fpi: 100, members: 1, online: 1, isMine: true),
|
||||||
|
PlanetNode(id: 'a', name: 'A', rank: 2, fpi: 90, members: 1, online: 1),
|
||||||
|
PlanetNode(id: 'b', name: 'B', rank: 3, fpi: 80, members: 1, online: 1),
|
||||||
|
PlanetNode(id: 'c', name: 'C', rank: 4, fpi: 70, members: 1, online: 1),
|
||||||
|
PlanetNode(id: 'top', name: 'TOP', rank: 6, fpi: 60, members: 1, online: 1),
|
||||||
|
PlanetNode(id: 'e', name: 'E', rank: 5, fpi: 50, members: 1, online: 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 내 행성이 1위가 아닌 표본 — Ranking View 전환이 눈에 보이게 한다.
|
||||||
|
class _RankedRepository extends _TopOrbitRepository {
|
||||||
|
const _RankedRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<GalaxySnapshot> load() async => const GalaxySnapshot(
|
||||||
|
myPop: 0,
|
||||||
|
joined: false,
|
||||||
|
planets: [
|
||||||
|
PlanetNode(id: 'top', name: 'TOP', rank: 1, fpi: 900, members: 99, online: 1, stage: 4),
|
||||||
|
PlanetNode(id: 'a', name: 'A', rank: 2, fpi: 500, members: 5, online: 1),
|
||||||
|
PlanetNode(id: 'mine', name: '내 행성', rank: 3, fpi: 100, members: 7, online: 1, isMine: true),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 대표 팬덤 변경 호출을 기록하는 표본. [error] 가 있으면 서버 거부를 흉내 낸다.
|
||||||
|
class _PrimaryRepository extends SampleGalaxyRepository {
|
||||||
|
_PrimaryRepository({this.error});
|
||||||
|
final BackendException? error;
|
||||||
|
final changes = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setPrimary(String fandomId) async {
|
||||||
|
if (error case final e?) throw e;
|
||||||
|
changes.add(fandomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('GALAXY 모아보기 (§2.56 · §2.58) — 기본 행성 변경', () {
|
||||||
|
Future<void> pumpHome(WidgetTester tester, GalaxyRepository repo) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: GalaxyHomeScreen(
|
||||||
|
repository: repo,
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('대표가 아닌 가입 행성을 누르면 변경 모달 → fn-primary-fandom-set', (tester) async {
|
||||||
|
final repo = _PrimaryRepository();
|
||||||
|
await pumpHome(tester, repo);
|
||||||
|
await tester.tap(find.bySemanticsLabel('NewJeans'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('선택한 행성을 기본 행성으로 변경할까요?'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('변경'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.changes, ['newjeans']);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('30일 대기 중이면 서버 사유(E-FDM-04)를 알린다', (tester) async {
|
||||||
|
final repo = _PrimaryRepository(
|
||||||
|
error: const BackendException('E-FDM-04', '기본 행성은 변경 후 30일이 지나야 다시 바꿀 수 있습니다.'),
|
||||||
|
);
|
||||||
|
await pumpHome(tester, repo);
|
||||||
|
await tester.tap(find.bySemanticsLabel('NewJeans'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('변경'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('30일이 지나야'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('갤럭시 맵 (F2 · 354:220228)', () {
|
||||||
|
testWidgets('내 행성 HUD 가 Rank·FPI·접속자수를 띄운다', (tester) async {
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
// 명세 — HUD 데이터 : Rank, FPI 총점, 접속자수(맴버수)
|
||||||
|
expect(find.text('RANK'), findsOneWidget);
|
||||||
|
expect(find.text('FPI'), findsOneWidget);
|
||||||
|
expect(find.textContaining('접속 1,204명'), findsOneWidget);
|
||||||
|
expect(find.textContaining('멤버 12,840명'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Ranking View 를 켜면 FPI 1위로 중심이 옮겨 가고 끄면 내 행성으로 돌아온다', (tester) async {
|
||||||
|
await _pump(tester, _galaxy(repository: const _RankedRepository()));
|
||||||
|
// 진입은 내 Primary 행성 중심(B-21) — HUD 가 내 행성을 가리킨다.
|
||||||
|
expect(find.textContaining('멤버 7명'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('Ranking View'));
|
||||||
|
await _settle(tester);
|
||||||
|
expect(find.textContaining('멤버 99명'), findsOneWidget);
|
||||||
|
expect(find.textContaining('멤버 7명'), findsNothing);
|
||||||
|
await tester.tap(find.byTooltip('Ranking View'));
|
||||||
|
await _settle(tester);
|
||||||
|
expect(find.textContaining('멤버 7명'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('줌인·줌아웃과 터치 이동이 가능하다', (tester) async {
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
// 명세 — 3d갤럭시 : 브라우징 줌인 줌아웃 / 터치 이동
|
||||||
|
expect(find.byType(InteractiveViewer), findsOneWidget);
|
||||||
|
final v = tester.widget<InteractiveViewer>(find.byType(InteractiveViewer));
|
||||||
|
expect(v.minScale, lessThan(1));
|
||||||
|
expect(v.maxScale, greaterThan(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검토회신서 3절 — *「선택 행성 정보가 행성과 연계된 HUD 가 아니라 화면
|
||||||
|
// 최상단 고정 패널 형태」*. 고정 패널로 되돌아가면 여기서 깨진다.
|
||||||
|
testWidgets('HUD 는 화면이 아니라 행성에 붙어 움직인다', (tester) async {
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
final before = tester.getCenter(find.text('RANK'));
|
||||||
|
await tester.drag(find.byType(InteractiveViewer), const Offset(-90, -70));
|
||||||
|
await _settle(tester);
|
||||||
|
final after = tester.getCenter(find.text('RANK'));
|
||||||
|
expect(
|
||||||
|
(after - before).distance,
|
||||||
|
greaterThan(20),
|
||||||
|
reason: '화면 고정 패널이면 맵을 끌어도 제자리에 있다',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 검토회신서 3절 — *「행성 선택 시 중심 전환과 HUD 데이터의 즉시 연동」*.
|
||||||
|
testWidgets('행성을 고르면 HUD 가 즉시 바뀌고 중심 전환은 감속하며 간다', (tester) async {
|
||||||
|
// 기본 테스트 화면(800×600)에서는 궤도가 하단 이벤트 띠와 겹쳐 탭이
|
||||||
|
// 띠에 먹힌다. 세로를 넉넉히 잡아 겹침을 없앤다.
|
||||||
|
tester.view.physicalSize = const Size(1200, 2000);
|
||||||
|
tester.view.devicePixelRatio = 2.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
await tester.tap(find.byKey(const ValueKey('newjeans')));
|
||||||
|
await tester.pump();
|
||||||
|
// 즉시 연동 — 고른 행성의 FPI 총점이 바로 HUD 에 올라온다.
|
||||||
|
expect(find.text('271,905'), findsOneWidget);
|
||||||
|
|
||||||
|
// HUD 카드는 행성 위에 가운데를 맞춰 선다. 게이지의 x 가 곧 행성의 x 다.
|
||||||
|
final gauge = find.byType(LinearProgressIndicator);
|
||||||
|
final mid = tester.getSize(find.byType(Scaffold)).width / 2;
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
final early = (tester.getCenter(gauge).dx - mid).abs();
|
||||||
|
await tester.pump(GalaxyMotion.recenter);
|
||||||
|
final settled = (tester.getCenter(gauge).dx - mid).abs();
|
||||||
|
|
||||||
|
expect(settled, lessThan(12), reason: '전환이 끝나면 고른 행성이 한가운데다');
|
||||||
|
expect(early, greaterThan(settled + 20),
|
||||||
|
reason: '한 프레임에 순간이동하면 「행성 간 이동감」이 없다');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 리뷰 M3 — HUD 위/아래 뒤집힘이 비행 중에 일어나면 카드가 행성을 가로질러
|
||||||
|
// 순간 이동한다. 선택 시점에 정한 방향을 전환이 끝날 때까지 붙들어야 한다.
|
||||||
|
//
|
||||||
|
// 표본 6행성은 어느 것도 화면 위쪽 끝에서 출발하지 않아 뒤집힘이 나지 않는다.
|
||||||
|
// 궤도 꼭대기(목록 index 4 → 각도 ≈ -pi/2)에 바깥 궤도 행성을 놓아 재현한다.
|
||||||
|
testWidgets('중심 전환 중에 HUD 가 행성 위아래로 뒤집히지 않는다', (tester) async {
|
||||||
|
// 세로 800 이 분기점이다 — 출발점에는 카드가 들어갈 자리가 없고(아래),
|
||||||
|
// 도착점인 화면 한가운데에는 있다(위).
|
||||||
|
tester.view.physicalSize = const Size(800, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await _pump(tester, _galaxy(repository: const _TopOrbitRepository()));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('top')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(const ValueKey('hud-top')), findsOneWidget);
|
||||||
|
|
||||||
|
final planet = find.byKey(const ValueKey('top'));
|
||||||
|
final card = find.byType(LinearProgressIndicator);
|
||||||
|
int? side;
|
||||||
|
// 전환이 끝나기 전까지(620ms) 방향이 한 번도 바뀌면 안 된다.
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
final now =
|
||||||
|
tester.getCenter(card).dy < tester.getCenter(planet).dy ? -1 : 1;
|
||||||
|
side ??= now;
|
||||||
|
expect(now, side, reason: '비행 ${i * 16}ms 에서 HUD 가 반대편으로 넘어갔다');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 명세 — *HUD 터치시 FPI 상세 페이지 이동*. 목 모드에서 저장소가 누른 행성
|
||||||
|
// ID 를 버리면 여섯 행성이 전부 내 행성(BTS) 상세로 열린다.
|
||||||
|
testWidgets('남의 행성 HUD 를 누르면 그 행성 상세가 열린다', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
// 기본 팩토리(samplePlanetRepository)를 그대로 쓴다 — 실제 앱 경로와 같다.
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
await tester.tap(find.byKey(const ValueKey('newjeans')));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const ValueKey('hud-newjeans')));
|
||||||
|
// 갤럭시는 회전이 멈추지 않으므로 pumpAndSettle 을 쓸 수 없다.
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(find.textContaining('TOTAL FPI'), findsOneWidget, reason: '상세로 넘어가지 않았다');
|
||||||
|
expect(find.text('NewJeans'), findsWidgets);
|
||||||
|
expect(find.textContaining('271,905'), findsWidgets);
|
||||||
|
// 내 행성 상세가 열리면 이 값들이 보인다. 하나라도 보이면 ID 를 버린 것이다.
|
||||||
|
expect(find.text('BTS'), findsNothing, reason: '내 행성 상세가 열렸다');
|
||||||
|
expect(find.textContaining('284,310'), findsNothing, reason: '내 행성 상세가 열렸다');
|
||||||
|
// 남의 행성에는 내 기여도가 없다 — 대조표 3-5.
|
||||||
|
expect(find.textContaining(RegExp(r'상위 \d+%')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 리뷰 M2 — 행성이 뷰포트 밖이면 HUD 를 아예 그리지 않는다. 카드만 가장자리에
|
||||||
|
// clamp 해 두면 어느 행성을 가리키는지 알 수 없고 연결선은 화면 밖에 그려진다.
|
||||||
|
testWidgets('행성이 화면 밖으로 나가면 HUD 도 사라진다', (tester) async {
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
expect(find.text('RANK'), findsOneWidget);
|
||||||
|
|
||||||
|
// 맵을 끝까지 밀어 내 행성을 화면 밖으로 보낸다.
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await tester.drag(
|
||||||
|
find.byType(InteractiveViewer),
|
||||||
|
const Offset(-400, -400),
|
||||||
|
);
|
||||||
|
await _settle(tester);
|
||||||
|
}
|
||||||
|
expect(find.text('RANK'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('GALAXY 는 좌측 하단, 돋보기는 우측 하단', (tester) async {
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
expect(find.text('GALAXY'), findsOneWidget);
|
||||||
|
final label = tester.getCenter(find.text('GALAXY'));
|
||||||
|
final glass = tester.getCenter(find.byIcon(Icons.search));
|
||||||
|
final size = tester.getSize(find.byType(Scaffold));
|
||||||
|
expect(label.dx, lessThan(size.width / 2), reason: 'GALAXY 는 좌측');
|
||||||
|
expect(glass.dx, greaterThan(size.width / 2), reason: '돋보기는 우측');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('돋보기를 누르면 검색 화면으로 간다', (tester) async {
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
await tester.tap(find.byIcon(Icons.search));
|
||||||
|
await _settle(tester);
|
||||||
|
expect(find.byType(GalaxySearchScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Zap (F2 · 명세 「Zap 버튼」)', () {
|
||||||
|
testWidgets('가입한 사용자에게는 노출된다', (tester) async {
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
expect(find.byType(ZapButton), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('가입·생성하지 않은 사용자에게는 노출되지 않는다', (tester) async {
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
_galaxy(repository: const SampleGalaxyRepository(joined: false)),
|
||||||
|
);
|
||||||
|
expect(find.byType(ZapButton), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('롱프레스로 옵션이 열린다', (tester) async {
|
||||||
|
await _pump(tester, _galaxy());
|
||||||
|
// 명세 — 2초간 롱프레싱할 경우 Zap 옵션 버튼으로 전환
|
||||||
|
final hold = await tester.startGesture(tester.getCenter(find.byType(ZapButton)));
|
||||||
|
await tester.pump(ZapButton.longPress);
|
||||||
|
await hold.up();
|
||||||
|
await _settle(tester);
|
||||||
|
for (final label in ['기여', '아이템', '닫기']) {
|
||||||
|
expect(find.text(label), findsOneWidget, reason: '옵션 「$label」 누락');
|
||||||
|
}
|
||||||
|
// 「자동(오토 도네이션)」은 두지 않는다 — 별첨 1 ① 시나리오 4 가 적었으나
|
||||||
|
// 별첨 B 가 상위 기준이고 거기에 없는 기능이다(공통 기준 2항).
|
||||||
|
expect(find.text('자동'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('POP 이 모자라면 누를 수 없다', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: ZapButton(zapPop: 10, myPop: 3, onZap: () {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('POP이 모자랍니다'), findsOneWidget);
|
||||||
|
final btn = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||||
|
expect(btn.onPressed, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('갤럭시 검색 (F2 · 353:217832)', () {
|
||||||
|
testWidgets('처음에는 최근 검색어가 없다고 알린다', (tester) async {
|
||||||
|
await _pump(tester, const GalaxySearchScreen());
|
||||||
|
expect(find.text('최근 검색어'), findsOneWidget);
|
||||||
|
expect(find.text('최근 검색어가 없습니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('검색하면 팬덤이 걸리고 최근 검색어에 남는다', (tester) async {
|
||||||
|
await _pump(tester, const GalaxySearchScreen());
|
||||||
|
await tester.enterText(find.byType(TextField), 'BTS');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('멤버 12,840명'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), '');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('전체 삭제'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('없는 이름이면 사유를 알린다', (tester) async {
|
||||||
|
await _pump(tester, const GalaxySearchScreen());
|
||||||
|
await tester.enterText(find.byType(TextField), 'zzzz');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('찾는 팬덤이 없습니다'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('F2 화면 문구에 내부 경로가 새지 않는다', (tester) async {
|
||||||
|
for (final screen in <Widget>[
|
||||||
|
_galaxy(),
|
||||||
|
const GalaxySearchScreen(),
|
||||||
|
]) {
|
||||||
|
await _pump(tester, screen);
|
||||||
|
final texts = tester
|
||||||
|
.widgetList<Text>(find.byType(Text))
|
||||||
|
.map((w) => w.data ?? '')
|
||||||
|
.join('\n');
|
||||||
|
for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) {
|
||||||
|
expect(texts.contains(leak), isFalse, reason: '「$leak」 노출');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/core/ui/relative_time.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/planet/fpi_ranking_screen.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/planet/lounge_post_screens.dart';
|
||||||
|
import 'package:fancake/features/planet/moderation.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_detail_screen.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
Future<void> _pump(WidgetTester tester, Widget screen) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세로가 긴 화면을 한 번에 담는다.
|
||||||
|
///
|
||||||
|
/// TabBarView 안에는 스크롤 가능한 것이 여럿이라 `scrollUntilVisible` 이 엉뚱한
|
||||||
|
/// 쪽을 잡는다. 실기기에 가까운 세로 길이를 주는 편이 확실하다.
|
||||||
|
Future<void> _pumpTall(
|
||||||
|
WidgetTester tester,
|
||||||
|
Widget screen, {
|
||||||
|
double height = 2400,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = Size(1080, height);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await _pump(tester, screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 서버 산출 「내 기여 상위 %」가 아직 없는 상태의 저장소.
|
||||||
|
class _NoTopPercentRepository extends SamplePlanetRepository {
|
||||||
|
@override
|
||||||
|
Future<void> join() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) => _sample.history(cursor: cursor);
|
||||||
|
@override
|
||||||
|
Future<HeroDropReceipt> claimHeroDrop(String dropId) => _sample.claimHeroDrop(dropId);
|
||||||
|
const _NoTopPercentRepository();
|
||||||
|
|
||||||
|
static const _sample = SamplePlanetRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PlanetDetail> load({required bool nebula}) async {
|
||||||
|
final detail = await _sample.load(nebula: nebula);
|
||||||
|
return PlanetDetail(
|
||||||
|
fandomId: detail.fandomId,
|
||||||
|
name: detail.name,
|
||||||
|
isNebula: detail.isNebula,
|
||||||
|
totalFpi: detail.totalFpi,
|
||||||
|
members: detail.members,
|
||||||
|
posts: detail.posts,
|
||||||
|
scores: [
|
||||||
|
for (final s in detail.scores)
|
||||||
|
FpiScore(
|
||||||
|
metric: s.metric,
|
||||||
|
value: s.value,
|
||||||
|
rank: s.rank,
|
||||||
|
trend: s.trend,
|
||||||
|
myTopPercent: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
mockNote: '「내 기여 상위 %」 집계 함수가 아직 없습니다',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<LoungePost>> morePosts({required int offset, int limit = 5}) =>
|
||||||
|
_sample.morePosts(offset: offset, limit: limit);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30}) =>
|
||||||
|
_sample.planetRanking(metric: metric, limit: limit);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FpiRanking> userRanking({
|
||||||
|
required FpiMetric metric,
|
||||||
|
required String fandomId,
|
||||||
|
int limit = 30,
|
||||||
|
}) => _sample.userRanking(metric: metric, fandomId: fandomId, limit: limit);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30}) =>
|
||||||
|
_sample.countryRanking(metric: metric, limit: limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가입 전 상태로 시작하고, [join] 이 불리면 가입중으로 바뀌는 표본.
|
||||||
|
class _Joinable extends SamplePlanetRepository {
|
||||||
|
_Joinable();
|
||||||
|
int joins = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> join() async => joins++;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PlanetDetail> load({required bool nebula}) async {
|
||||||
|
final d = await super.load(nebula: nebula);
|
||||||
|
return PlanetDetail(
|
||||||
|
fandomId: d.fandomId, name: d.name, stage: d.stage, isNebula: d.isNebula,
|
||||||
|
totalFpi: d.totalFpi, scores: d.scores, members: d.members, posts: d.posts,
|
||||||
|
joined: joins > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
_fpiRankingTests();
|
||||||
|
testWidgets('가입하지 않은 행성은 「가입하기」를 눌러 fn-fandom-join 으로 가입한다', (tester) async {
|
||||||
|
final repo = _Joinable();
|
||||||
|
await _pumpTall(tester, PlanetDetailScreen(repository: repo, eclipse: const NoEclipseRepository()));
|
||||||
|
expect(find.text('가입하기'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('가입하기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.joins, 1);
|
||||||
|
expect(find.text('가입중'), findsOneWidget);
|
||||||
|
// 가입중 알약은 다시 눌러도 호출하지 않는다.
|
||||||
|
await tester.tap(find.text('가입중'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.joins, 1);
|
||||||
|
});
|
||||||
|
group('행성 상세 · FPI (F3 · 343:213428)', () {
|
||||||
|
testWidgets('TOTAL FPI 값과 갱신 주기를 띄운다', (tester) async {
|
||||||
|
await _pumpTall(tester, const PlanetDetailScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
));
|
||||||
|
expect(find.text('TOTAL FPI : 284,310'), findsOneWidget);
|
||||||
|
// 원장 B-15 검증 기준 — 화면에 마지막 갱신 시각 노출(서버 시각이 없으면 --:--).
|
||||||
|
expect(find.text('마지막 갱신 --:--'), findsOneWidget);
|
||||||
|
expect(find.textContaining('30초'), findsNothing);
|
||||||
|
// 개설자 이름은 서버에 없으면 그리지 않는다 — 시안 표본 이름을 박지 않는다.
|
||||||
|
expect(find.text('BTS_Maker'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('5지표를 답변서 확정 표기로 띄운다', (tester) async {
|
||||||
|
// 시안(373:441117)은 1243 높이의 스크롤 화면이라 지표 다섯을 한 창에 담으려면
|
||||||
|
// 실기기보다 긴 창이 필요하다. 스크롤하면 위 카드가 해제되어 다섯을 동시에 못 본다.
|
||||||
|
await _pumpTall(tester, const PlanetDetailScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
), height: 4200);
|
||||||
|
// 답변서 v1.1 Q3 이 정본이다. Figma 의 BMP·STEPS·REFERAL 이 아니다.
|
||||||
|
for (final label in [
|
||||||
|
'BPM',
|
||||||
|
'HEALTH',
|
||||||
|
'REFERRAL',
|
||||||
|
'COMMUNITY',
|
||||||
|
'CONTRIBUTION',
|
||||||
|
]) {
|
||||||
|
expect(find.text(label), findsWidgets, reason: '지표 「$label」 누락');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('지표마다 전체 순위와 내 기여 상위를 보여 준다', (tester) async {
|
||||||
|
await _pumpTall(tester, const PlanetDetailScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
), height: 4200);
|
||||||
|
expect(find.text('전체 1위'), findsOneWidget);
|
||||||
|
expect(find.text('상위 17%'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('내 기여 상위 집계 전이면 숫자 대신 사유를 띄운다 — 대조표 3-5', (tester) async {
|
||||||
|
await _pumpTall(
|
||||||
|
tester,
|
||||||
|
const PlanetDetailScreen(
|
||||||
|
repository: _NoTopPercentRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
height: 4200,
|
||||||
|
);
|
||||||
|
// 없는 값을 100%(최하위)로 지어내지 않는다.
|
||||||
|
expect(find.text('상위 100%'), findsNothing);
|
||||||
|
expect(find.text('집계 준비 중'), findsWidgets);
|
||||||
|
// 사유는 배지로도 뜬다 — 숨기지 않는다.
|
||||||
|
expect(find.textContaining('MOCK · '), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async {
|
||||||
|
// 스펙 B — 「집계 문서를 갤럭시·행성 상세 HUD 에서 실시간으로 표시한다」.
|
||||||
|
// 침식은 TOTAL FPI 를 깎는 경로이므로(원장 B-55) 그 카드 위에 온다.
|
||||||
|
await _pumpTall(tester, PlanetDetailScreen(
|
||||||
|
repository: const SamplePlanetRepository(),
|
||||||
|
eclipse: SampleEclipseRepository(),
|
||||||
|
));
|
||||||
|
expect(find.textContaining('이클립스 발동'), findsOneWidget);
|
||||||
|
expect(find.textContaining('침식 42%'), findsOneWidget);
|
||||||
|
// **POP 단위가 아니다** — 방어는 POP 을 소모하지 않는 공동 Shake 참여다
|
||||||
|
// (Firestore 설계서 v1.1 §9-#5).
|
||||||
|
expect(find.textContaining('유효 참여 5,800 / 10,000명'), findsOneWidget);
|
||||||
|
expect(find.textContaining('POP'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('이클립스가 없으면 침식 배너 자리가 비지 않는다', (tester) async {
|
||||||
|
await _pumpTall(tester, const PlanetDetailScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
));
|
||||||
|
expect(find.textContaining('이클립스 발동'), findsNothing);
|
||||||
|
// 배너가 없어도 본문은 그대로다 — 빈 자리가 남지 않는다.
|
||||||
|
expect(find.textContaining('TOTAL FPI'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('팬덤 라운지 (F3 · 251:51073)', () {
|
||||||
|
testWidgets('최초 5개를 최신순으로 띄운다', (tester) async {
|
||||||
|
await _pumpTall(tester, const PlanetDetailScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
));
|
||||||
|
await tester.tap(find.text('팬덤 라운지'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 명세 — 최초 5개, 스크롤 시 5개씩 호출.
|
||||||
|
expect(find.textContaining('오늘 공방 다녀왔어요'), findsOneWidget);
|
||||||
|
expect(find.byType(FloatingActionButton), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('스크롤하면 히어로가 접히고 탭은 위에 남는다 — 발주사 지적(글이 보이게)', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(750, 1624);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
const PlanetDetailScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('팬덤 라운지'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final tabTop = tester.getTopLeft(find.text('팬덤 라운지')).dy;
|
||||||
|
await tester.drag(
|
||||||
|
find.textContaining('오늘 공방 다녀왔어요'),
|
||||||
|
const Offset(0, -500),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 탭 줄이 앱 바 바로 아래로 올라와 고정되고, 글이 화면 위쪽까지 올라온다.
|
||||||
|
expect(tester.getTopLeft(find.text('팬덤 라운지')).dy, lessThan(tabTop - 300));
|
||||||
|
expect(find.text('팬덤 라운지').hitTestable(), findsOneWidget);
|
||||||
|
expect(find.textContaining('이번 앨범 최애 트랙').hitTestable(), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('이 회원의 글 모두 숨기기 — 확인하면 그 작성자 글이 목록에서 빠진다', (tester) async {
|
||||||
|
await _pumpTall(
|
||||||
|
tester,
|
||||||
|
const PlanetDetailScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('팬덤 라운지'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('아미보이'), findsOneWidget);
|
||||||
|
await tester.tap(
|
||||||
|
find.image(const AssetImage('assets/figma/icon-post-more.png')).first,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('이 회원의 글 모두 숨기기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('아미보이'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('글 쓰기는 본문이 있어야 완료된다', (tester) async {
|
||||||
|
// 시안 244:21746 · 설계서 §2.6 posts — 제목 칸 없이 본문 한 칸.
|
||||||
|
await _pump(tester, const LoungeComposeScreen());
|
||||||
|
final register = find.widgetWithText(TextButton, '완료');
|
||||||
|
expect(tester.widget<TextButton>(register).onPressed, isNull);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), ' ');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 공백만으로는 아직 안 된다.
|
||||||
|
expect(tester.widget<TextButton>(register).onPressed, isNull);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), '본문입니다');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<TextButton>(register).onPressed, isNotNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('신고 · 차단 (F3 · 252:162674 · 252:163306)', () {
|
||||||
|
testWidgets('사유를 고르기 전에는 신고할 수 없다', (tester) async {
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
const ReportScreen(
|
||||||
|
target: ModerationTarget(authorName: '아미보이', title: '테스트 글'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 신고 정보 영역 — 작성자 닉네임 · 게시물 제목
|
||||||
|
expect(find.text('아미보이'), findsOneWidget);
|
||||||
|
expect(find.text('테스트 글'), findsOneWidget);
|
||||||
|
final btn = tester.widget<TextButton>(find.widgetWithText(TextButton, '신고'));
|
||||||
|
expect(btn.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('사유는 단수 선택이고 고르면 확인을 되묻는다', (tester) async {
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
const ReportScreen(
|
||||||
|
target: ModerationTarget(authorName: '아미보이', title: '테스트 글'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('부적절한 콘텐츠'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '신고'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 화면 중앙 오버레이 — 취소 / 확인
|
||||||
|
expect(find.textContaining('신고할까요?'), findsOneWidget);
|
||||||
|
expect(find.text('취소'), findsOneWidget);
|
||||||
|
expect(find.text('확인'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('신고 사유는 어드민 등록 항목을 받는다', (tester) async {
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
const ReportScreen(
|
||||||
|
target: ModerationTarget(authorName: 'x', title: 'y'),
|
||||||
|
reasons: ['운영자가 정한 사유'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('운영자가 정한 사유'), findsOneWidget);
|
||||||
|
expect(find.text('부적절한 콘텐츠'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('성운 상세 (F3 · 373:442708)', () {
|
||||||
|
testWidgets('성운도 같은 화면으로 열린다', (tester) async {
|
||||||
|
await _pump(tester, const PlanetDetailScreen(
|
||||||
|
nebula: true,
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
));
|
||||||
|
expect(find.text('New_BTS'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('상대 시각 표기', (tester) async {
|
||||||
|
final now = DateTime(2026, 8, 31, 15);
|
||||||
|
expect(relativeTime(now.subtract(const Duration(seconds: 20)), now: now), '방금 전');
|
||||||
|
expect(relativeTime(now.subtract(const Duration(minutes: 10)), now: now), '10분 전');
|
||||||
|
expect(relativeTime(now.subtract(const Duration(hours: 3)), now: now), '3시간 전');
|
||||||
|
expect(relativeTime(now.subtract(const Duration(days: 2)), now: now), '2일 전');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('F3 화면 문구에 내부 경로가 새지 않는다', (tester) async {
|
||||||
|
await _pump(tester, const PlanetDetailScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
));
|
||||||
|
final texts = tester
|
||||||
|
.widgetList<Text>(find.byType(Text))
|
||||||
|
.map((w) => w.data ?? '')
|
||||||
|
.join('\n');
|
||||||
|
for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) {
|
||||||
|
expect(texts.contains(leak), isFalse, reason: '「$leak」 노출');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// 지표별 랭킹 2단계 — F3 · Figma `546:14589`(행성) → `546:14646`(사용자).
|
||||||
|
///
|
||||||
|
/// 발주사 검토회신서 4절 회귀 가드다. 지적은 두 가지였다 —
|
||||||
|
/// ① 지표 카드와 Ranking 사이 수치·순위 불일치 ② 지표를 바꿔도 같은 Ranking.
|
||||||
|
/// 둘 다 여기서 깨지면 바로 보이게 고정한다.
|
||||||
|
void _fpiRankingTests() {
|
||||||
|
const repo = SamplePlanetRepository();
|
||||||
|
|
||||||
|
group('표본은 행성별로 갈린다 — 어느 HUD 를 눌러도 같은 상세가 열리면 안 된다', () {
|
||||||
|
// 갤럭시 HUD 는 남의 행성도 연다. 표본이 내 행성 하나로 고정돼 있으면
|
||||||
|
// 여섯 행성이 전부 BTS 상세로 열려 발주사 확인에서 바로 드러난다.
|
||||||
|
test('행성마다 이름·TOTAL FPI·멤버 수가 다르다', () async {
|
||||||
|
final details = <PlanetDetail>[
|
||||||
|
for (final id in [
|
||||||
|
'bts',
|
||||||
|
'newjeans',
|
||||||
|
'seventeen',
|
||||||
|
'ive',
|
||||||
|
'aespa',
|
||||||
|
'lesserafim',
|
||||||
|
])
|
||||||
|
await SamplePlanetRepository(fandomId: id).load(nebula: false),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(details.map((d) => d.fandomId).toSet(), hasLength(6));
|
||||||
|
expect(details.map((d) => d.name).toSet(), hasLength(6));
|
||||||
|
expect(details.map((d) => d.totalFpi).toSet(), hasLength(6));
|
||||||
|
expect(details.map((d) => d.members).toSet(), hasLength(6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('상세의 TOTAL FPI 와 멤버 수가 갤럭시 맵 표본과 같다', () async {
|
||||||
|
// 같은 행성인데 HUD 와 상세가 다른 수치를 띄우면 그것부터 지적받는다.
|
||||||
|
final snapshot = await const SampleGalaxyRepository().load();
|
||||||
|
for (final node in snapshot.planets) {
|
||||||
|
final detail =
|
||||||
|
await SamplePlanetRepository(fandomId: node.id).load(nebula: false);
|
||||||
|
expect(detail.totalFpi, node.fpi, reason: '${node.id} TOTAL FPI 불일치');
|
||||||
|
expect(detail.members, node.members, reason: '${node.id} 멤버 수 불일치');
|
||||||
|
expect(detail.name, node.name, reason: '${node.id} 이름 불일치');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('전체 순위는 그 행성의 순위다 — 내 행성 순위를 돌려주지 않는다', () async {
|
||||||
|
final detail =
|
||||||
|
await SamplePlanetRepository(fandomId: 'lesserafim').load(nebula: false);
|
||||||
|
for (final score in detail.scores) {
|
||||||
|
final rows = (await const SamplePlanetRepository()
|
||||||
|
.planetRanking(metric: score.metric))
|
||||||
|
.rows;
|
||||||
|
final row = rows.firstWhere((r) => r.id == 'lesserafim');
|
||||||
|
expect(score.rank, row.rank, reason: '${score.metric.label} 순위 불일치');
|
||||||
|
expect(score.value, row.value, reason: '${score.metric.label} 수치 불일치');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('남의 행성에서는 내 기여 상위 %를 지어내지 않는다 — 대조표 3-5', () async {
|
||||||
|
final mine = await const SamplePlanetRepository().load(nebula: false);
|
||||||
|
expect(mine.scores.every((s) => s.myTopPercent != null), isTrue);
|
||||||
|
|
||||||
|
final theirs =
|
||||||
|
await SamplePlanetRepository(fandomId: 'aespa').load(nebula: false);
|
||||||
|
expect(theirs.scores.every((s) => s.myTopPercent == null), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('남의 행성 사용자 랭킹에는 「나」 행이 없다', () async {
|
||||||
|
const repo = SamplePlanetRepository();
|
||||||
|
expect(
|
||||||
|
(await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts')).mine,
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'ive')).mine,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ID 를 주지 않으면 내 행성이다 — 기존 호출부가 그대로 돈다', () async {
|
||||||
|
final byDefault = await const SamplePlanetRepository().load(nebula: false);
|
||||||
|
final explicit =
|
||||||
|
await SamplePlanetRepository(fandomId: 'bts').load(nebula: false);
|
||||||
|
expect(byDefault.fandomId, 'bts');
|
||||||
|
expect(byDefault.totalFpi, explicit.totalFpi);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('지표별 랭킹 — 발주사 검토회신서 4절 회귀 가드', () {
|
||||||
|
test('지표를 바꾸면 랭킹 순서가 달라진다', () async {
|
||||||
|
final byMetric = <FpiMetric, List<String>>{
|
||||||
|
for (final m in FpiMetric.values)
|
||||||
|
m: (await repo.planetRanking(metric: m)).rows
|
||||||
|
.map((r) => r.name)
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
// 다섯 축의 1위가 모두 같으면 「같은 Ranking 반복」 상태로 돌아간 것이다.
|
||||||
|
final tops = byMetric.values.map((rows) => rows.first).toSet();
|
||||||
|
expect(tops.length, greaterThan(1), reason: '지표마다 1위가 같습니다');
|
||||||
|
// 순서 자체도 축마다 달라야 한다.
|
||||||
|
expect(byMetric.values.map((r) => r.join()).toSet(), hasLength(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('지표 카드의 값·순위가 행성 랭킹의 내 행성 행과 같다', () async {
|
||||||
|
final detail = await repo.load(nebula: false);
|
||||||
|
for (final score in detail.scores) {
|
||||||
|
final mine = (await repo.planetRanking(metric: score.metric)).mine;
|
||||||
|
expect(mine, isNotNull, reason: '${score.metric.label} 내 행성 행 없음');
|
||||||
|
expect(mine!.value, score.value, reason: '${score.metric.label} 수치 불일치');
|
||||||
|
expect(mine.rank, score.rank, reason: '${score.metric.label} 순위 불일치');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TOTAL FPI 는 5축 단순 합산이다 — 별첨 B · 설계서 §4.1', () async {
|
||||||
|
final detail = await repo.load(nebula: false);
|
||||||
|
expect(
|
||||||
|
detail.totalFpi,
|
||||||
|
detail.scores.fold<int>(0, (a, s) => a + s.value),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('사용자 랭킹도 지표마다 다르다', () async {
|
||||||
|
final tops = <String>{
|
||||||
|
for (final m in FpiMetric.values)
|
||||||
|
(await repo.userRanking(metric: m, fandomId: 'bts')).rows.first.name,
|
||||||
|
};
|
||||||
|
expect(tops.length, greaterThan(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('행성 지표 랭킹 화면 (Figma 546:14589)', () {
|
||||||
|
Future<void> pump(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: FpiRankingScreen(metric: FpiMetric.bpm, repository: repo),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('제목은 지표명 + RANKING 이다', (tester) async {
|
||||||
|
await pump(tester);
|
||||||
|
expect(find.text('BPM RANKING'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('소속 행성 순위를 맨 위에 고정한다', (tester) async {
|
||||||
|
await pump(tester);
|
||||||
|
expect(find.text('내 행성의 BPM 랭킹'), findsOneWidget);
|
||||||
|
// 표본에서 BPM 1위는 NewJeans, 내 행성 BTS 는 2위다(내 줄 카드).
|
||||||
|
expect(find.text('BTS'), findsOneWidget);
|
||||||
|
expect(find.text('NewJeans'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('행성 행을 누르면 사용자 랭킹으로 간다 (546:14639)', (tester) async {
|
||||||
|
await pump(tester);
|
||||||
|
await tester.tap(find.text('BTS (나)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(FpiUserRankingScreen), findsOneWidget);
|
||||||
|
expect(find.text('나의 BPM 랭킹'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('사용자 지표 랭킹 화면 (Figma 546:14646)', () {
|
||||||
|
Future<void> pump(WidgetTester tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: FpiUserRankingScreen(
|
||||||
|
metric: FpiMetric.bpm,
|
||||||
|
fandomId: 'bts',
|
||||||
|
planetName: 'BTS',
|
||||||
|
repository: repo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('내 순위를 맨 위에 고정한다', (tester) async {
|
||||||
|
await pump(tester);
|
||||||
|
expect(find.text('나의 BPM 랭킹'), findsOneWidget);
|
||||||
|
// 내 줄 카드와 시상대(3위) 양쪽에 「나」가 있다.
|
||||||
|
expect(find.text('나'), findsOneWidget);
|
||||||
|
expect(find.text('나 (나)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('필터는 실시간과 국가별 두 축이다', (tester) async {
|
||||||
|
await pump(tester);
|
||||||
|
await tester.tap(find.text('실시간 랭킹'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('국가별'), findsWidgets);
|
||||||
|
expect(find.text('취소'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('국가별로 바꾸면 제목과 나의 랭킹 영역이 국가 기준이 된다', (tester) async {
|
||||||
|
await pump(tester);
|
||||||
|
await tester.tap(find.text('실시간 랭킹'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('국가별').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 전 국가 합산이므로 행성명이 없다.
|
||||||
|
expect(find.text('BTS'), findsNothing);
|
||||||
|
expect(find.text('국가별 랭킹'), findsOneWidget);
|
||||||
|
// 「나의 랭킹 영역」(546:14713)은 여기서 내 국가 행이다.
|
||||||
|
expect(find.text('나의 BPM 랭킹'), findsNothing);
|
||||||
|
expect(find.text('내 국가의 BPM 랭킹'), findsOneWidget);
|
||||||
|
// 목록에서도 내 국가 행에 표시가 붙는다.
|
||||||
|
expect(find.text('대한민국 (나)'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/core/monetization/rewarded_ad.dart';
|
||||||
|
import 'package:fancake/features/quest/place_picker_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_ad_screens.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_create_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_list_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_reward_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// F4 퀘스트 — 화면 대조 원장 10화면.
|
||||||
|
///
|
||||||
|
/// Figma 명세가 상태별 버튼 문구와 동작을 못박아 두었으므로 **문구까지 검증**한다.
|
||||||
|
/// 문구가 바뀌면 화면 대조에서 어긋난다.
|
||||||
|
|
||||||
|
/// 화면 검증은 표본 저장소로 한다. 저장소 선택은 `core/data/repositories.dart` 의
|
||||||
|
/// 몫이고, 화면은 주입받은 것을 그린다.
|
||||||
|
const _sample = SampleQuestRepository();
|
||||||
|
|
||||||
|
Future<void> pumpWide(WidgetTester tester, Widget child) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: child));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// 표본 저장소는 목 모드에서 「보상완료」를 기억한다(서버의 questProgress 자리).
|
||||||
|
// 회차마다 지워 두지 않으면 앞 테스트가 받은 보상이 다음 테스트에 남는다.
|
||||||
|
setUp(SampleQuestRepository.resetClaims);
|
||||||
|
|
||||||
|
group('보상형 광고 (B-03 · 별첨 1 ⑥)', () {
|
||||||
|
Future<void> watchAd(WidgetTester tester, QuestRepository repo) async {
|
||||||
|
await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('광고 보고 추가 보상 받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('안내값 — 운영 Quest 는 기본 × (배수 − 1), 사용자 생성은 고정값 (B-03 · C-02)', () {
|
||||||
|
const card = QuestCard(
|
||||||
|
id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin,
|
||||||
|
stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 30, participants: 0,
|
||||||
|
);
|
||||||
|
expect(card.adBonusPop, 30);
|
||||||
|
expect(
|
||||||
|
QuestCard(
|
||||||
|
id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin,
|
||||||
|
stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 30,
|
||||||
|
participants: 0, adMultiplier: 3,
|
||||||
|
).adBonusPop,
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
const QuestCard(
|
||||||
|
id: 'u', title: 't', categoryName: 'c', author: QuestAuthor.user,
|
||||||
|
stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 500, participants: 0,
|
||||||
|
).adBonusPop,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('끝까지 본 광고만 서버에 청구하고 지급액을 띄운다', (tester) async {
|
||||||
|
final repo = _AdRepo();
|
||||||
|
await watchAd(tester, repo);
|
||||||
|
expect(repo.claimed, ['imp-1']);
|
||||||
|
expect(find.text('광고 추가 보상 +20 POP'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('불러오지 못하면 기본 보상만 받는다', (tester) async {
|
||||||
|
final repo = _AdRepo(
|
||||||
|
loadError: const BackendException('E-AD-01', '광고를 불러오지 못했어요.'),
|
||||||
|
);
|
||||||
|
await watchAd(tester, repo);
|
||||||
|
expect(repo.claimed, isEmpty);
|
||||||
|
expect(find.text('+10 받았어요!'), findsOneWidget);
|
||||||
|
expect(find.textContaining('광고 추가 보상'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('중간에 닫으면 청구하지 않는다', (tester) async {
|
||||||
|
final repo = _AdRepo(impression: null);
|
||||||
|
await watchAd(tester, repo);
|
||||||
|
expect(repo.claimed, isEmpty);
|
||||||
|
expect(find.text('+10 받았어요!'), findsOneWidget);
|
||||||
|
expect(find.textContaining('광고 추가 보상'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('일일 한도(E-AD-02)를 알리고 기본 보상은 그대로 띄운다', (tester) async {
|
||||||
|
final repo = _AdRepo(
|
||||||
|
claimError: const BackendException('E-AD-02', '오늘 광고 보상 한도를 모두 사용했습니다.'),
|
||||||
|
);
|
||||||
|
await watchAd(tester, repo);
|
||||||
|
expect(find.text('오늘 광고 보상 한도를 모두 사용했습니다.'), findsOneWidget);
|
||||||
|
expect(find.text('+10 받았어요!'), findsOneWidget);
|
||||||
|
expect(find.textContaining('광고 추가 보상'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('진행 상태 (F4 · 518:88441)', () {
|
||||||
|
test('명세가 정한 다섯 상태의 버튼 문구를 그대로 쓴다', () {
|
||||||
|
expect(QuestStage.mine.buttonLabel, '자세히');
|
||||||
|
expect(QuestStage.notStarted.buttonLabel, '퀘스트 시작하기');
|
||||||
|
expect(QuestStage.inProgress.buttonLabel, '진행중');
|
||||||
|
expect(QuestStage.completed.buttonLabel, '보상받기');
|
||||||
|
expect(QuestStage.rewarded.buttonLabel, '보상 완료');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('보상완료만 누를 수 없다', () {
|
||||||
|
for (final stage in QuestStage.values) {
|
||||||
|
expect(stage.tappable, stage != QuestStage.rewarded);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('탭 필터 (F4 · 518:88377)', () {
|
||||||
|
const card = QuestCard(
|
||||||
|
id: 'x', title: 't', categoryName: 'c', author: QuestAuthor.admin,
|
||||||
|
stage: QuestStage.inProgress, goal: 10, progress: 5,
|
||||||
|
rewardPop: 10, participants: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('전체는 모두 받는다', () {
|
||||||
|
for (final stage in QuestStage.values) {
|
||||||
|
expect(QuestTab.all.accepts(_stage(card, stage)), isTrue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('완료 탭은 진행 완료와 보상 완료를 함께 받는다', () {
|
||||||
|
expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isTrue);
|
||||||
|
expect(QuestTab.done.accepts(_stage(card, QuestStage.rewarded)), isTrue);
|
||||||
|
expect(QuestTab.done.accepts(_stage(card, QuestStage.inProgress)), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('내 퀘스트 탭은 내가 만든 것만 받는다', () {
|
||||||
|
expect(QuestTab.mine.accepts(_stage(card, QuestStage.mine)), isTrue);
|
||||||
|
expect(QuestTab.mine.accepts(_stage(card, QuestStage.notStarted)), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('목록 화면 (F4 · 259:46109 · 264:46867)', () {
|
||||||
|
testWidgets('네 탭이 모두 보이고 디폴트는 전체다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestListScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
for (final tab in QuestTab.values) {
|
||||||
|
// 「진행중」은 탭 이름이자 카드 버튼 문구다. 탭만 본다.
|
||||||
|
expect(
|
||||||
|
find.descendant(of: find.byType(QuestTabStrip), matching: find.text(tab.label)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 시안(264:46867)대로 칩이 아니라 밑줄 탭이다. 선택 상태는 스트립이 들고 있다.
|
||||||
|
final strip = tester.widget<QuestTabStrip>(find.byType(QuestTabStrip));
|
||||||
|
expect(strip.selected, QuestTab.all);
|
||||||
|
final chip = (
|
||||||
|
selected: strip.selected == QuestTab.all,
|
||||||
|
);
|
||||||
|
expect(chip.selected, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('완료된 관리자 퀘스트는 목록에서 감춘다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestListScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
// 표본의 「오늘의 출석」은 관리자 퀘스트이고 보상완료 상태다.
|
||||||
|
expect(find.text('오늘의 출석'), findsNothing);
|
||||||
|
expect(find.text('만보기 챌린지'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('탭을 바꾸면 목록이 걸러진다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestListScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(of: find.byType(QuestTabStrip), matching: find.text(QuestTab.mine.label)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('데뷔일 챌린지 함께해요'), findsOneWidget);
|
||||||
|
expect(find.text('만보기 챌린지'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('헤더에 보유 POP 이 뜬다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestListScreen(repository: _sample, myPop: 12540));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('12,540'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('새 퀘스트 버튼이 생성 화면을 연다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestListScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('새 퀘스트'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(QuestCreateScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('상세 화면 (F4 · 264:48835)', () {
|
||||||
|
testWidgets('목표·진행·보상을 함께 보여 준다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'q1', repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('만보기 챌린지'), findsOneWidget);
|
||||||
|
expect(find.text('6,400 / 10,000'), findsOneWidget);
|
||||||
|
expect(find.text('+50'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('진행중에서 누르면 중단을 되묻고 취소하면 그대로다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'q1', repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '진행중'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('퀘스트를 중단할까요?'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('취소'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.widgetWithText(FilledButton, '진행중'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('중단하면 시작 전으로 돌아간다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'q1', repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '진행중'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '중단'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.widgetWithText(FilledButton, '퀘스트 시작하기'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('관리자 퀘스트 보상받기는 광고 추가 보상을 먼저 묻는다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'q2', repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 별첨 B B-28 — 배수형 철회. 추가분은 고정형 운영값(C-02 초기 10 기여 POP).
|
||||||
|
expect(find.text('+10 POP'), findsOneWidget);
|
||||||
|
expect(find.textContaining('배'), findsNothing);
|
||||||
|
// 별첨1 ⑥ No-Force — 보지 않아도 기본 보상을 받는다.
|
||||||
|
expect(find.text('기본 보상 받기'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('광고를 건너뛰면 기본 보상만 받는다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'q2', repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('기본 보상 받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(QuestRewardScreen), findsOneWidget);
|
||||||
|
expect(find.text('+10 받았어요!'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('광고를 봐도 화면에 뜨는 금액은 서버가 지급한 값이다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'q2', repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('광고 보고 추가 보상 받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 광고 시청 전 대기(494:14332) — 광고를 불러오는 동안 띄운다(표본은 2초).
|
||||||
|
expect(find.byType(QuestAdWarpScreen), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// 시청 후 대기(482:15084)는 지급이 끝나면 걷힌다.
|
||||||
|
expect(find.byType(QuestAdWarpScreen), findsNothing);
|
||||||
|
expect(find.byType(QuestRewardScreen), findsOneWidget);
|
||||||
|
// 별첨 B **B-28** 이 배수형(×2/×3)을 철회했다. 기본 보상과 광고 추가 보상은
|
||||||
|
// 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다(`fnAdReward.rewarded`).
|
||||||
|
expect(find.text('+10 받았어요!'), findsOneWidget);
|
||||||
|
expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('회신 대기 유형은 참여 버튼이 잠기고 MOCK 으로 알린다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'q6', repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
final button = tester.widget<FilledButton>(
|
||||||
|
find.widgetWithText(FilledButton, '발주사 확인 중'),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
expect(find.textContaining('Q-QC-04'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('보상을 받고 나오면 보상완료가 되고 더 못 누른다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'q2', repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('기본 보상 받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final button = tester.widget<FilledButton>(
|
||||||
|
find.widgetWithText(FilledButton, '보상 완료'),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('생성 화면 (F4 · 254:164081)', () {
|
||||||
|
testWidgets('완료 버튼은 값이 다 차기 전까지 비활성이다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
final done = tester.widget<TextButton>(
|
||||||
|
find.widgetWithText(TextButton, '완료'),
|
||||||
|
);
|
||||||
|
expect(done.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('미리보기가 입력을 실시간으로 따라간다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('옵션을 선택, 미리 보기를 확인하세요'), findsOneWidget);
|
||||||
|
|
||||||
|
// 명세 523:18173 — 입력 필드는 카테고리를 고른 뒤에 나타난다.
|
||||||
|
expect(find.widgetWithText(TextField, '퀘스트명'), findsNothing);
|
||||||
|
await tester.tap(find.text('커뮤니티'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, '퀘스트명'), '아침 산책',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('아침 산책'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('위치 분류를 고르면 장소 선택이 나타난다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('목표 위치'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('특정 위치'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('목표 위치'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('위치 분류에서는 장소를 고르기 전까지 완료가 열리지 않는다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('특정 위치'));
|
||||||
|
await tester.pump();
|
||||||
|
await _fill(tester);
|
||||||
|
|
||||||
|
var done = tester.widget<TextButton>(
|
||||||
|
find.widgetWithText(TextButton, '완료'),
|
||||||
|
);
|
||||||
|
expect(done.onPressed, isNull);
|
||||||
|
|
||||||
|
// 시안 서체·입력 테마를 적용하면 폼이 길어져 기본 창에서는 이 행이 접힌다.
|
||||||
|
await tester.ensureVisible(find.textContaining('목표 위치'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.textContaining('목표 위치'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(PlacePickerScreen), findsOneWidget);
|
||||||
|
await tester.tap(find.text('홍대 팝업스토어'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('선택 완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
done = tester.widget<TextButton>(find.widgetWithText(TextButton, '완료'));
|
||||||
|
expect(done.onPressed, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('완료를 누르면 만들기 전에 되묻는다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('커뮤니티'));
|
||||||
|
await tester.pump();
|
||||||
|
await _fill(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('이 내용으로 만들까요?'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('사용자 생성 — 활동별 입력 · 링크 · 검토 상태 (C-01 · C-05 · C-08)', () {
|
||||||
|
testWidgets('활동을 고르면 그 활동에 필요한 입력만 나타난다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('행성 활동'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.widgetWithText(TextField, '퀘스트명'), findsNothing);
|
||||||
|
await tester.tap(find.text('투표 참여'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.widgetWithText(TextField, '링크 주소'), findsOneWidget);
|
||||||
|
expect(find.textContaining('목표 위치'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('링크 조회가 실패하면 제목을 받아야 완료가 열린다', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('행성 활동'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('투표 참여'));
|
||||||
|
await tester.pump();
|
||||||
|
await _fill(tester);
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, '링크 주소'), 'https://vote.example.com/1');
|
||||||
|
await tester.tap(find.byTooltip('링크 확인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
var done = tester.widget<TextButton>(find.widgetWithText(TextButton, '완료'));
|
||||||
|
expect(done.onPressed, isNull);
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, '링크 제목'), '연말 시상식 투표');
|
||||||
|
await tester.pump();
|
||||||
|
done = tester.widget<TextButton>(find.widgetWithText(TextButton, '완료'));
|
||||||
|
expect(done.onPressed, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('검토 중 제출 건은 안내를 띄우고 버튼을 잠근다 · 24h 지연 안내', (tester) async {
|
||||||
|
await pumpWide(tester, const QuestDetailScreen(questId: 'v1', repository: _ReviewQuests()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('작성자 설명'), findsOneWidget);
|
||||||
|
final delayed = find.text('검토가 늦어지고 있어요. 운영팀이 확인하고 있습니다.');
|
||||||
|
await tester.scrollUntilVisible(delayed, 120, scrollable: find.byType(Scrollable).first);
|
||||||
|
expect(delayed, findsOneWidget);
|
||||||
|
expect(find.text('검토 중이에요. 승인되면 보상이 지급돼요.'), findsOneWidget);
|
||||||
|
final button = tester.widget<FilledButton>(find.widgetWithText(FilledButton, '검토 중'));
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('위치 선택 (F4 · 371:356705)', () {
|
||||||
|
testWidgets('고르기 전에는 확정 버튼이 비활성이다', (tester) async {
|
||||||
|
await pumpWide(tester, const PlacePickerScreen());
|
||||||
|
final button = tester.widget<FilledButton>(
|
||||||
|
find.widgetWithText(FilledButton, '선택 완료'),
|
||||||
|
);
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('검색으로 목록이 줄어든다', (tester) async {
|
||||||
|
await pumpWide(tester, const PlacePickerScreen());
|
||||||
|
await tester.enterText(find.byType(TextField), '홍대');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('홍대 팝업스토어'), findsOneWidget);
|
||||||
|
expect(find.text('고척 스카이돔'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('없는 장소를 찾으면 빈 상태를 알린다', (tester) async {
|
||||||
|
await pumpWide(tester, const PlacePickerScreen());
|
||||||
|
await tester.enterText(find.byType(TextField), '없는장소');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('검색 결과가 없습니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('F4 화면 문구에 내부 경로가 새지 않는다', (tester) async {
|
||||||
|
for (final screen in <Widget>[
|
||||||
|
const QuestListScreen(repository: _sample),
|
||||||
|
const QuestDetailScreen(questId: 'q1', repository: _sample),
|
||||||
|
const QuestCreateScreen(repository: _sample),
|
||||||
|
const PlacePickerScreen(),
|
||||||
|
]) {
|
||||||
|
await pumpWide(tester, screen);
|
||||||
|
await tester.pump();
|
||||||
|
for (final text in tester.widgetList<Text>(find.byType(Text))) {
|
||||||
|
final data = text.data ?? '';
|
||||||
|
expect(data, isNot(contains('lib/')));
|
||||||
|
expect(data, isNot(contains('app/')));
|
||||||
|
expect(data, isNot(contains('.dart')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 광고 경계만 바꾼 표본 — B-03 「광고 실패 시 기본 보상 지급 · 추가 보상 없음」.
|
||||||
|
class _AdRepo extends SampleQuestRepository {
|
||||||
|
_AdRepo({this.loadError, this.impression = 'imp-1', this.claimError});
|
||||||
|
|
||||||
|
final BackendException? loadError;
|
||||||
|
final String? impression;
|
||||||
|
final BackendException? claimError;
|
||||||
|
final claimed = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RewardedAdHandle> loadRewardedAd() async {
|
||||||
|
if (loadError case final error?) throw error;
|
||||||
|
return _Ad(impression);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> claimAdReward(String impressionId, QuestReward base) async {
|
||||||
|
claimed.add(impressionId);
|
||||||
|
if (claimError case final error?) throw error;
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Ad implements RewardedAdHandle {
|
||||||
|
_Ad(this.impression);
|
||||||
|
|
||||||
|
final String? impression;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> show() async => impression;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
QuestCard _stage(QuestCard card, QuestStage stage) => QuestCard(
|
||||||
|
id: card.id, title: card.title, categoryName: card.categoryName,
|
||||||
|
author: card.author, stage: stage, goal: card.goal, progress: card.progress,
|
||||||
|
rewardPop: card.rewardPop, participants: card.participants,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _fill(WidgetTester tester) async {
|
||||||
|
// 활동별 입력이 붙어 폼이 길다 — 목록 아래 칸은 스크롤해야 만들어진다.
|
||||||
|
for (final (label, value) in [('퀘스트명', '아침 산책'), ('참여 인원', '10'), ('완료 보상 POP', '30')]) {
|
||||||
|
final field = find.widgetWithText(TextField, label);
|
||||||
|
await tester.scrollUntilVisible(field, 120, scrollable: find.byType(Scrollable).first);
|
||||||
|
await tester.enterText(field, value);
|
||||||
|
}
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검토 대기 제출 건이 있는 사용자 생성 Vote Quest.
|
||||||
|
class _ReviewQuests extends SampleQuestRepository {
|
||||||
|
const _ReviewQuests();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<QuestDetail> detail(String questId) async => QuestDetail(
|
||||||
|
card: const QuestCard(
|
||||||
|
id: 'v1', title: '연말 시상식 투표', categoryName: '투표 참여', type: QuestType.vote,
|
||||||
|
author: QuestAuthor.user, stage: QuestStage.inProgress, goal: 1, progress: 0,
|
||||||
|
rewardPop: 5, participants: 1, capacity: 10,
|
||||||
|
),
|
||||||
|
imageLabel: '',
|
||||||
|
description: '인증샷을 올려 주세요',
|
||||||
|
link: const QuestLink(url: 'https://vote.example.com', status: 'preview_failed', title: '시상식 투표'),
|
||||||
|
endsAt: DateTime.now().add(const Duration(hours: 5)),
|
||||||
|
submission: QuestSubmission(
|
||||||
|
id: 'v1_me', status: 'submitted',
|
||||||
|
submittedAt: DateTime.now().subtract(const Duration(hours: 25)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
import 'package:fancake/core/ui/relative_time.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_create_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_data.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_list_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_ranking_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_search_screen.dart';
|
||||||
|
import 'package:fancake/features/campaign/widgets/campaign_period_sheet.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// F5 캠페인 — 화면 대조 원장 8화면.
|
||||||
|
|
||||||
|
Future<void> pumpWide(WidgetTester tester, Widget child) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2600);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: child));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
CampaignCardData _card({
|
||||||
|
int likes = 0, int comments = 0, int views = 0, int shares = 0,
|
||||||
|
int goal = 100, int raised = 50,
|
||||||
|
}) => CampaignCardData(
|
||||||
|
id: 'x', title: 't', body: 'b', authorName: 'a',
|
||||||
|
createdAt: DateTime(2026, 9, 1), status: CampaignStatus.funding,
|
||||||
|
goalPop: goal, raisedPop: raised, supporters: 1,
|
||||||
|
likes: likes, comments: comments, views: views, shares: shares,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('내 캠페인 철회 (B-58)', () {
|
||||||
|
testWidgets('검수 중이면 등록비 반환을 알리고 철회하면 철회 상태로 바뀐다', (tester) async {
|
||||||
|
final repo = _Mine(CampaignStatus.review);
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1', repository: repo, myPop: 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('운영 검수 중인 캠페인입니다.'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('캠페인 철회'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('돌려드립니다'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('철회하기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.withdrawn, ['c1']);
|
||||||
|
expect(find.text('개설자가 철회한 캠페인입니다.'), findsOneWidget);
|
||||||
|
expect(find.text('캠페인 철회'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('공개 후 첫 후원 전이면 비환불을 알린다', (tester) async {
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
CampaignDetailScreen(campaignId: 'c1', repository: _Mine(CampaignStatus.funding), myPop: 0),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('캠페인 철회'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('돌려드리지 않습니다'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('첫 후원 뒤에는 철회 버튼이 없다', (tester) async {
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
CampaignDetailScreen(
|
||||||
|
campaignId: 'c1',
|
||||||
|
repository: _Mine(CampaignStatus.funding, supporters: 1),
|
||||||
|
myPop: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('캠페인 철회'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('운영 취소는 반환 안내를 보인다', () {
|
||||||
|
expect(CampaignStatus.cancelled.notice, contains('돌려드렸습니다'));
|
||||||
|
expect(CampaignStatus.funding.notice, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('상대 시각 · 반응 수치 (F5 · 523:18399)', () {
|
||||||
|
final now = DateTime(2026, 9, 1, 12);
|
||||||
|
|
||||||
|
test('일주일이 넘으면 날짜로 바뀐다', () {
|
||||||
|
expect(relativeTime(now.subtract(const Duration(days: 6)), now: now), '6일 전');
|
||||||
|
expect(
|
||||||
|
relativeTime(DateTime(2026, 1, 1), now: now),
|
||||||
|
'2026.01.01',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('반응 수치는 K 단위로 줄인다', () {
|
||||||
|
expect(formatReaction(999), '999');
|
||||||
|
expect(formatReaction(1200), '1.2K');
|
||||||
|
expect(formatReaction(3000), '3K');
|
||||||
|
expect(formatReaction(9999), '10K');
|
||||||
|
expect(formatReaction(12400), '12K');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('달성률과 인기 정렬', () {
|
||||||
|
test('목표가 0이면 달성률도 0이다', () {
|
||||||
|
expect(_card(goal: 0, raised: 100).achievedPercent, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('달성률을 반올림한다', () {
|
||||||
|
expect(_card(goal: 3, raised: 1).achievedPercent, 33);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('인기순은 좋아요 → 댓글 → 조회수 → 공유수 순으로 본다', () {
|
||||||
|
final a = _card(likes: 10, comments: 1);
|
||||||
|
final b = _card(likes: 10, comments: 5);
|
||||||
|
// 좋아요가 같으면 댓글이 많은 쪽이 앞선다.
|
||||||
|
expect(a.comparePopularity(b) > 0, isTrue);
|
||||||
|
|
||||||
|
final c = _card(likes: 10, comments: 5, views: 1);
|
||||||
|
final d = _card(likes: 10, comments: 5, views: 9);
|
||||||
|
expect(c.comparePopularity(d) > 0, isTrue);
|
||||||
|
|
||||||
|
final e = _card(likes: 20);
|
||||||
|
expect(e.comparePopularity(a) < 0, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('목록 (F5 · 272:241184)', () {
|
||||||
|
testWidgets('필터 세 개가 있고 디폴트는 최신순이다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignListScreen());
|
||||||
|
await tester.pump();
|
||||||
|
for (final sort in CampaignSort.values) {
|
||||||
|
expect(find.widgetWithText(CampaignSortChip, sort.label), findsOneWidget);
|
||||||
|
}
|
||||||
|
final chip = tester.widget<CampaignSortChip>(
|
||||||
|
find.widgetWithText(CampaignSortChip, '최신순'),
|
||||||
|
);
|
||||||
|
expect(chip.selected, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('최신순은 최근 캠페인이 위에 온다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignListScreen());
|
||||||
|
await tester.pump();
|
||||||
|
final titles = tester
|
||||||
|
.widgetList<Text>(find.byType(Text))
|
||||||
|
.map((w) => w.data ?? '')
|
||||||
|
.where((s) => s.contains('광고') || s.contains('커피차'))
|
||||||
|
.toList();
|
||||||
|
expect(titles.first, contains('LED'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('기여중 필터는 내가 기여한 것만 남긴다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignListScreen());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(CampaignSortChip, '기여중'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('강남역'), findsOneWidget);
|
||||||
|
expect(find.textContaining('커피차'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('본문은 두 줄에서 잘린다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignListScreen());
|
||||||
|
await tester.pump();
|
||||||
|
final body = tester.widget<Text>(
|
||||||
|
find.textContaining('도심을 BTS의 색으로'),
|
||||||
|
);
|
||||||
|
expect(body.maxLines, 2);
|
||||||
|
expect(body.overflow, TextOverflow.ellipsis);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('새 캠페인 버튼이 생성 화면을 연다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignListScreen());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('새 캠페인'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(CampaignCreateScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('검색 (F5 · 272:241681 · 272:241724)', () {
|
||||||
|
testWidgets('처음에는 최근 검색어가 보인다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignSearchScreen());
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('최근 검색어'), findsOneWidget);
|
||||||
|
expect(find.text('강남역'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('검색어 칩의 ✕ 는 그 검색어만 지운다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignSearchScreen());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byTooltip('강남역 삭제'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('강남역'), findsNothing);
|
||||||
|
expect(find.text('LED'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('전체 삭제하면 빈 안내가 뜬다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignSearchScreen());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('전체 삭제'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('최신 검색어가 없습니다'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('검색하면 결과 화면으로 바뀐다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignSearchScreen());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(find.byType(TextField), '커피차');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('커피차'), findsWidgets);
|
||||||
|
expect(find.text('최근 검색어'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('없는 말을 찾으면 빈 결과를 알린다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignSearchScreen());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(find.byType(TextField), '없는캠페인');
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('검색 결과가 없습니다'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('검색어가 최근 목록 맨 앞에 쌓인다', (tester) async {
|
||||||
|
final repo = SampleCampaignRepository();
|
||||||
|
await pumpWide(tester, CampaignSearchScreen(repository: repo));
|
||||||
|
await tester.pump();
|
||||||
|
await repo.search('커피차');
|
||||||
|
final recent = await repo.recentQueries();
|
||||||
|
expect(recent.first, '커피차');
|
||||||
|
// 중복 제거 — 이미 있던 「강남역」을 다시 찾아도 하나다.
|
||||||
|
await repo.search('강남역');
|
||||||
|
final again = await repo.recentQueries();
|
||||||
|
expect(again.where((q) => q == '강남역').length, 1);
|
||||||
|
expect(again.first, '강남역');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('상세 (F5 · 272:245738)', () {
|
||||||
|
testWidgets('디폴트 탭은 캠페인 정보다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('캠페인 정보'), findsOneWidget);
|
||||||
|
expect(find.textContaining('응원 게시판'), findsOneWidget);
|
||||||
|
expect(find.text('92%'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('사진 개수만큼 인디케이터가 생긴다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.bySemanticsLabel('대표 이미지'), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('campaign-media')), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('좋아요는 누르면 늘고 다시 누르면 줄어든다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('820'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('좋아요'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('821'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('좋아요'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('820'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('내 캠페인에는 기여하기 버튼이 없다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c3'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.widgetWithText(FilledButton, '기여하기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('타인 캠페인에는 기여하기 버튼이 있다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.widgetWithText(FilledButton, '기여하기'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('기여 시트는 값을 정하기 전까지 완료가 잠겨 있다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '기여하기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('기여 POP 설정'), findsOneWidget);
|
||||||
|
final done = tester.widget<FilledButton>(
|
||||||
|
find.widgetWithText(FilledButton, '완료'),
|
||||||
|
);
|
||||||
|
expect(done.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('보유 POP 을 넘겨 끌 수 없다', (tester) async {
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
CampaignDetailScreen(campaignId: 'c1', myPop: 300),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '기여하기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final slider = tester.widget<Slider>(find.byType(Slider));
|
||||||
|
expect(slider.max, 300);
|
||||||
|
expect(find.text('나의 보유 POP : 300'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('응원 게시판 탭으로 넘어가면 댓글이 보인다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.textContaining('응원 게시판'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('꼭 성공했으면 좋겠어요!'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('공유 시트에 네 경로가 뜬다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byTooltip('공유'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final target in const ['인스타그램', '틱톡', 'X', '링크 복사']) {
|
||||||
|
expect(find.byTooltip(target), findsOneWidget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('「실시간 랭킹」에서 랭킹 화면으로 간다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final list = find.byKey(const ValueKey('campaign-info'));
|
||||||
|
// 끝까지 굴린다. 하단 고정 버튼이 마지막 줄을 덮지 않는지도 함께 본다.
|
||||||
|
await tester.drag(list, const Offset(0, -1200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('실시간 랭킹'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(CampaignRankingScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('랭킹 (F5 · 333:70971)', () {
|
||||||
|
testWidgets('내 순위를 맨 위에 고정한다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('나의 기여 랭킹'), findsOneWidget);
|
||||||
|
expect(find.text('나'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('필터 바텀시트에 세 구간이 있다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('실시간 랭킹'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final w in RankWindow.values) {
|
||||||
|
expect(find.text(w.label), findsWidgets);
|
||||||
|
}
|
||||||
|
expect(find.text('취소'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('구간을 바꾸면 라벨이 따라 바뀐다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignRankingScreen(campaignId: 'c1'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('실시간 랭킹'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('주간').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('주간 랭킹'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('새 캠페인 (F5 · 287:16991)', () {
|
||||||
|
testWidgets('검수를 거친다는 사실을 먼저 알린다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignCreateScreen());
|
||||||
|
expect(find.textContaining('운영 검수를 지난 뒤 모금이 열립니다'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('진행 기간은 사본 캘린더 시트로 고른다(333:72497 · 333:72719)', (tester) async {
|
||||||
|
DateTimeRange? picked;
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
Builder(
|
||||||
|
builder: (context) => TextButton(
|
||||||
|
onPressed: () async => picked = await showCampaignPeriodSheet(
|
||||||
|
context,
|
||||||
|
today: DateTime(2026, 4, 1),
|
||||||
|
),
|
||||||
|
child: const Text('열기'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('열기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('2026년 4월'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('9'));
|
||||||
|
await tester.pump();
|
||||||
|
// 끝을 고르기 전에는 「기간 선택」이 잠겨 있다.
|
||||||
|
expect(
|
||||||
|
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('23'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('4월 9일 ~ 4월 23일'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
picked,
|
||||||
|
DateTimeRange(start: DateTime(2026, 4, 9), end: DateTime(2026, 4, 23)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('오늘 이전 날짜와 1년 뒤 달은 고를 수 없다', (tester) async {
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
Scaffold(body: CampaignPeriodSheet(today: DateTime(2026, 4, 5))),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('3').first);
|
||||||
|
await tester.pump();
|
||||||
|
expect(
|
||||||
|
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
// 12번 넘기면 2027년 4월(오늘+365일이 든 달)에서 멈춘다.
|
||||||
|
for (var i = 0; i < 14; i++) {
|
||||||
|
await tester.tap(find.bySemanticsLabel('다음 달'));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
expect(find.text('2027년 4월'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Catalog 표본 가격은 앱바 MOCK 알약으로 알린다(G-08)', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignCreateScreen());
|
||||||
|
expect(
|
||||||
|
find.bySemanticsLabel('MOCK · 상품 목록은 Catalog 연결 전 표본입니다'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('값이 다 차기 전까지 완료가 잠겨 있다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignCreateScreen());
|
||||||
|
var done = tester.widget<TextButton>(
|
||||||
|
find.widgetWithText(TextButton, '완료'),
|
||||||
|
);
|
||||||
|
expect(done.onPressed, isNull);
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, '캠페인 제목을 입력해주세요.'), '커피차',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, '캠페인 설명을 작성해주세요.'), '스태프 응원',
|
||||||
|
);
|
||||||
|
// 원장 B-32 · B-59 — 목표는 입력이 아니라 상품 선택. C-14 — 권장 기간 자동 입력.
|
||||||
|
expect(find.widgetWithText(TextField, '목표 POP'), findsNothing);
|
||||||
|
await tester.tap(find.text('커피차 서포트'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('500,000 POP'), findsOneWidget);
|
||||||
|
expect(find.text('날짜 선택'), findsNothing);
|
||||||
|
|
||||||
|
done = tester.widget<TextButton>(find.widgetWithText(TextButton, '완료'));
|
||||||
|
expect(done.onPressed, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('올리기 전에 되묻는다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignCreateScreen());
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, '캠페인 제목을 입력해주세요.'), '커피차',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, '캠페인 설명을 작성해주세요.'), '스태프 응원',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('커피차 서포트'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('이 내용으로 올릴까요?'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.textContaining('500,000 POP · 14일'),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// 등록비를 올리기 전에 알리고, 올린 뒤에는 심사 대기임을 알린다.
|
||||||
|
expect(find.textContaining('등록비 100 POP'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '올리기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('검수 대기 중입니다'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('결과보고 (FR-CMP-06 · B-35)', () {
|
||||||
|
testWidgets('게시된 결과보고의 사진 · 본문 · 예산 · 영수증을 보여 준다', (tester) async {
|
||||||
|
await pumpWide(
|
||||||
|
tester,
|
||||||
|
CampaignDetailScreen(campaignId: 'c3', repository: _Reported()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(
|
||||||
|
find.byKey(const ValueKey('campaign-info')),
|
||||||
|
const Offset(0, -2000),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const ValueKey('campaign-report')), findsOneWidget);
|
||||||
|
expect(find.text('광고 집행 완료'), findsOneWidget);
|
||||||
|
expect(find.text('광고 집행'), findsOneWidget);
|
||||||
|
expect(find.text('500 POP'), findsOneWidget);
|
||||||
|
expect(find.text('영수증'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('성공한 내 캠페인에도 작성 버튼은 없다 — 운영이 올린다', (tester) async {
|
||||||
|
await pumpWide(tester, CampaignDetailScreen(campaignId: 'c3'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(
|
||||||
|
find.byKey(const ValueKey('campaign-info')),
|
||||||
|
const Offset(0, -2000),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('결과보고'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('F5 화면 문구에 내부 경로가 새지 않는다', (tester) async {
|
||||||
|
for (final screen in <Widget>[
|
||||||
|
CampaignListScreen(),
|
||||||
|
CampaignSearchScreen(),
|
||||||
|
CampaignDetailScreen(campaignId: 'c1'),
|
||||||
|
CampaignRankingScreen(campaignId: 'c1'),
|
||||||
|
CampaignCreateScreen(),
|
||||||
|
]) {
|
||||||
|
await pumpWide(tester, screen);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final text in tester.widgetList<Text>(find.byType(Text))) {
|
||||||
|
final data = text.data ?? '';
|
||||||
|
expect(data, isNot(contains('lib/')));
|
||||||
|
expect(data, isNot(contains('.dart')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 내 캠페인 상세 — 상태 · 후원 수를 바꿔 철회 버튼을 본다(B-58).
|
||||||
|
class _Mine extends SampleCampaignRepository {
|
||||||
|
_Mine(this.status, {this.supporters = 0});
|
||||||
|
|
||||||
|
final CampaignStatus status;
|
||||||
|
final int supporters;
|
||||||
|
final withdrawn = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CampaignDetail> detail(String campaignId) async {
|
||||||
|
final base = await super.detail(campaignId);
|
||||||
|
final c = base.card;
|
||||||
|
return CampaignDetail(
|
||||||
|
card: CampaignCardData(
|
||||||
|
id: c.id, title: c.title, body: c.body, authorName: c.authorName,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
status: withdrawn.isEmpty ? status : CampaignStatus.withdrawn,
|
||||||
|
goalPop: c.goalPop, raisedPop: supporters == 0 ? 0 : 10,
|
||||||
|
supporters: supporters, mine: true,
|
||||||
|
likes: 0, comments: 0, views: 0, shares: 0,
|
||||||
|
),
|
||||||
|
mediaLabels: base.mediaLabels,
|
||||||
|
endsAt: base.endsAt,
|
||||||
|
topContributors: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> withdraw(String campaignId) async => withdrawn.add(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 결과보고가 게시된 상세. 사진 URL 은 비워 둔다 — 위젯 테스트는 네트워크를 타지 않는다.
|
||||||
|
class _Reported extends SampleCampaignRepository {
|
||||||
|
@override
|
||||||
|
Future<CampaignDetail> detail(String campaignId) async {
|
||||||
|
final base = await super.detail(campaignId);
|
||||||
|
return CampaignDetail(
|
||||||
|
card: base.card,
|
||||||
|
mediaLabels: base.mediaLabels,
|
||||||
|
endsAt: base.endsAt,
|
||||||
|
topContributors: base.topContributors,
|
||||||
|
report: CampaignReport(
|
||||||
|
body: '광고 집행 완료',
|
||||||
|
photoUrls: const [],
|
||||||
|
receiptUrls: const [],
|
||||||
|
budget: const [(label: '광고 집행', amount: 450), (label: '제작비', amount: 50)],
|
||||||
|
createdAt: DateTime(2026, 9, 20),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fancake/core/ui/planet_25d.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 모든 위젯 테스트 앞에서 한 번 실행된다.
|
||||||
|
/// 2.5D 행성의 무한 반복 애니메이션은 `pumpAndSettle` 을 끝나지 않게 하므로 끈다.
|
||||||
|
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||||
|
Planet25D.animationsEnabled = false;
|
||||||
|
// 테스트 단말 언어는 기본 en_US 다. 앱은 단말 언어를 따르므로(B-76) 한국어 문구를
|
||||||
|
// 확인하는 기존 테스트가 한국어 단말을 전제로 돌도록 고정한다. 언어 전환은 i18n_test 가 본다.
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized()
|
||||||
|
.platformDispatcher
|
||||||
|
.localesTestValue = const [
|
||||||
|
Locale('ko', 'KR'),
|
||||||
|
];
|
||||||
|
await testMain();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/planet_visual_state.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_motion.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('상태별 표류는 점진적으로 변하고 사용자 드래그는 유지된다', () {
|
||||||
|
final clock = GalaxyClock();
|
||||||
|
addTearDown(clock.dispose);
|
||||||
|
for (final scale in [0.25, 0.5, 1.5, 1.0]) {
|
||||||
|
final before = clock.velocity;
|
||||||
|
clock.idleSpeedScale = scale;
|
||||||
|
clock.advance(1 / 60);
|
||||||
|
final target = GalaxyMotion.idleSpin * scale;
|
||||||
|
expect(
|
||||||
|
clock.velocity,
|
||||||
|
inExclusiveRange(
|
||||||
|
before < target ? before : target,
|
||||||
|
before > target ? before : target,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (var i = 0; i < 600; i++) {
|
||||||
|
clock.advance(1 / 60);
|
||||||
|
}
|
||||||
|
expect(clock.velocity, closeTo(target, 1e-6));
|
||||||
|
}
|
||||||
|
clock.idleSpeedScale = 0.25;
|
||||||
|
clock.drag(10);
|
||||||
|
clock.advance(1 / 60);
|
||||||
|
expect(clock.velocity, closeTo(10 * 60 * GalaxyMotion.dragSpinGain, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('선택 행성의 서버 상태만 주변 궤도에 적용하고 Reduce Motion은 정지한다', (tester) async {
|
||||||
|
final streams = <String, StreamController<PlanetVisualState>>{};
|
||||||
|
Stream<PlanetVisualState> watch(String id) => streams
|
||||||
|
.putIfAbsent(id, () => StreamController<PlanetVisualState>.broadcast())
|
||||||
|
.stream;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: GalaxyScreen(
|
||||||
|
repository: const SampleGalaxyRepository(),
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
watchPlanet: watch,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
final clock = tester
|
||||||
|
.widgetList<AnimatedBuilder>(find.byType(AnimatedBuilder))
|
||||||
|
.map((w) => w.listenable)
|
||||||
|
.whereType<GalaxyClock>()
|
||||||
|
.first;
|
||||||
|
GalaxyField field() => tester.widget<GalaxyField>(find.byType(GalaxyField));
|
||||||
|
final mine = field().planets.firstWhere((p) => p.isMine);
|
||||||
|
for (final entry in [
|
||||||
|
(const PlanetVisualState(isHeroTime: true), 1.5),
|
||||||
|
(const PlanetVisualState(isHeroTime: true, isEclipsed: true), 0.5),
|
||||||
|
(const PlanetVisualState(isHeroTime: true, isHibernating: true), 0.25),
|
||||||
|
(const PlanetVisualState(), 1.0),
|
||||||
|
]) {
|
||||||
|
streams[mine.id]!.add(entry.$1);
|
||||||
|
await tester.pump();
|
||||||
|
expect(clock.idleSpeedScale, entry.$2);
|
||||||
|
}
|
||||||
|
final other = field().planets.firstWhere((p) => !p.isMine);
|
||||||
|
field().onSelect(other);
|
||||||
|
await tester.pump();
|
||||||
|
streams[mine.id]!.add(const PlanetVisualState(isHibernating: true));
|
||||||
|
streams[other.id]!.add(const PlanetVisualState(isHeroTime: true));
|
||||||
|
await tester.pump();
|
||||||
|
expect(clock.idleSpeedScale, 1.5);
|
||||||
|
tester.platformDispatcher.accessibilityFeaturesTestValue =
|
||||||
|
const FakeAccessibilityFeatures(disableAnimations: true);
|
||||||
|
addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
|
||||||
|
await tester.pump();
|
||||||
|
final spin = clock.spin;
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(clock.spin, spin);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
for (final stream in streams.values) {
|
||||||
|
expect(stream.hasListener, isFalse);
|
||||||
|
await stream.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/core/data/planet_visual_state.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_25d.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_screen.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
import 'perf_paint_test.dart' show medianPaintMicros, paintBudgetMicros;
|
||||||
|
|
||||||
|
/// 프로토타입 궤도 간격 450 × Mass Volume 1.5 × Orbit Exp 1.0.
|
||||||
|
const _spacing = 675.0;
|
||||||
|
|
||||||
|
GalaxySnapshot _snap(List<PlanetNode> planets) =>
|
||||||
|
GalaxySnapshot(planets: planets, myPop: 0);
|
||||||
|
|
||||||
|
const _base = [
|
||||||
|
PlanetNode(
|
||||||
|
id: 'a',
|
||||||
|
name: 'Alpha',
|
||||||
|
rank: 1,
|
||||||
|
fpi: 5000,
|
||||||
|
members: 10,
|
||||||
|
online: 1204,
|
||||||
|
isMine: true,
|
||||||
|
),
|
||||||
|
PlanetNode(
|
||||||
|
id: 'b',
|
||||||
|
name: 'Beta',
|
||||||
|
rank: 2,
|
||||||
|
fpi: 4000,
|
||||||
|
members: 10,
|
||||||
|
online: 30,
|
||||||
|
),
|
||||||
|
PlanetNode(
|
||||||
|
id: 'c',
|
||||||
|
name: 'Gamma',
|
||||||
|
rank: 3,
|
||||||
|
fpi: 1000,
|
||||||
|
members: 10,
|
||||||
|
online: 20,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late int opened;
|
||||||
|
|
||||||
|
Future<void> pumpMap(
|
||||||
|
WidgetTester tester,
|
||||||
|
GalaxySnapshot data, {
|
||||||
|
double size = 140,
|
||||||
|
}) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: GalaxyMiniMap(data: data, onOpen: () => opened++, size: size),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
GalaxyMiniMapPainter painterOf(WidgetTester tester) => tester
|
||||||
|
.widgetList<CustomPaint>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(GalaxyMiniMap),
|
||||||
|
matching: find.byType(CustomPaint),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((w) => w.painter)
|
||||||
|
.whereType<GalaxyMiniMapPainter>()
|
||||||
|
.single;
|
||||||
|
|
||||||
|
setUp(() => opened = 0);
|
||||||
|
|
||||||
|
testWidgets('실데이터의 내 행성 HUD 숫자를 그리고 궤도는 순위로 정한다', (tester) async {
|
||||||
|
await pumpMap(tester, _snap(_base));
|
||||||
|
final label = tester.getSemantics(find.byType(GalaxyMiniMap)).label;
|
||||||
|
expect(label, contains('ALPHA · RANK #1 · FPI: 5000 · LIVE: 1,204'));
|
||||||
|
final p = painterOf(tester);
|
||||||
|
expect(p.orbitOf('a'), 0);
|
||||||
|
expect(p.orbitOf('b'), _spacing);
|
||||||
|
expect(p.orbitOf('c'), 2 * _spacing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('행성 색은 팬덤 colorKey 를 따르고, 없거나 모르는 키는 순번 색으로 둔다', (tester) async {
|
||||||
|
await pumpMap(
|
||||||
|
tester,
|
||||||
|
_snap([
|
||||||
|
for (final (i, p) in _base.indexed)
|
||||||
|
PlanetNode(
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
rank: p.rank,
|
||||||
|
fpi: p.fpi,
|
||||||
|
members: p.members,
|
||||||
|
online: p.online,
|
||||||
|
isMine: p.isMine,
|
||||||
|
colorKey: const ['gold', 'violet', null][i],
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
final p = painterOf(tester);
|
||||||
|
expect(p.colorOf('a'), planetColorOf('gold'));
|
||||||
|
// 모르는 키(서버 기본값 violet)·미제공은 FPI 순번 프로토타입 색(2번째 · 3번째).
|
||||||
|
expect(p.colorOf('b'), const Color(0xFF8B5CF6));
|
||||||
|
expect(p.colorOf('c'), const Color(0xFF10B981));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('누르면 열리고, 가로로 끌면 장면이 돈다(±180° 한계)', (tester) async {
|
||||||
|
await pumpMap(tester, _snap(_base));
|
||||||
|
await tester.tap(find.byType(GalaxyMiniMap));
|
||||||
|
expect(opened, 1);
|
||||||
|
|
||||||
|
await tester.drag(find.byType(GalaxyMiniMap), const Offset(30, 0));
|
||||||
|
await tester.pump();
|
||||||
|
final turned = painterOf(tester).rotation;
|
||||||
|
expect(turned, greaterThan(0));
|
||||||
|
|
||||||
|
await tester.drag(find.byType(GalaxyMiniMap), const Offset(-2000, 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(painterOf(tester).rotation, closeTo(-math.pi, 1e-9));
|
||||||
|
expect(opened, 1, reason: '끌기는 열기가 아니다');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('순위가 바뀌면 궤도와 HUD 숫자가 따라 바뀐다', (tester) async {
|
||||||
|
await pumpMap(tester, _snap(_base));
|
||||||
|
await pumpMap(
|
||||||
|
tester,
|
||||||
|
_snap(const [
|
||||||
|
PlanetNode(
|
||||||
|
id: 'c',
|
||||||
|
name: 'Gamma',
|
||||||
|
rank: 1,
|
||||||
|
fpi: 6000,
|
||||||
|
members: 10,
|
||||||
|
online: 20,
|
||||||
|
),
|
||||||
|
PlanetNode(
|
||||||
|
id: 'a',
|
||||||
|
name: 'Alpha',
|
||||||
|
rank: 2,
|
||||||
|
fpi: 5100,
|
||||||
|
members: 10,
|
||||||
|
online: 1300,
|
||||||
|
isMine: true,
|
||||||
|
),
|
||||||
|
PlanetNode(
|
||||||
|
id: 'b',
|
||||||
|
name: 'Beta',
|
||||||
|
rank: 3,
|
||||||
|
fpi: 4000,
|
||||||
|
members: 10,
|
||||||
|
online: 30,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
final p = painterOf(tester);
|
||||||
|
expect(p.orbitOf('c'), 0);
|
||||||
|
expect(p.orbitOf('a'), _spacing);
|
||||||
|
expect(
|
||||||
|
tester.getSemantics(find.byType(GalaxyMiniMap)).label,
|
||||||
|
contains('ALPHA · RANK #2 · FPI: 5100 · LIVE: 1,300'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('움직임이 켜져 있으면 새 궤도로 미끄러져 간다(프로토타입 lerp)', (tester) async {
|
||||||
|
Planet25D.animationsEnabled = true;
|
||||||
|
addTearDown(() => Planet25D.animationsEnabled = false);
|
||||||
|
await pumpMap(tester, _snap(_base));
|
||||||
|
// 한 프레임 dt 는 1/20 초로 잘리므로 프레임 단위로 흘린다.
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
final mid = painterOf(tester).orbitOf('c')!;
|
||||||
|
expect(mid, greaterThan(0));
|
||||||
|
expect(mid, lessThan(2 * _spacing));
|
||||||
|
for (var i = 0; i < 600; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
expect(painterOf(tester).orbitOf('c'), closeTo(2 * _spacing, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('140 에서는 내 행성 간략 카드만 — 두 줄 모두 9pt 이상', (tester) async {
|
||||||
|
await pumpMap(tester, _snap(_base));
|
||||||
|
final p = painterOf(tester);
|
||||||
|
expect(p.compactText, [
|
||||||
|
('ALPHA', GalaxyMiniMapPainter.compactNamePt),
|
||||||
|
('#1 · FPI 5,000 · LIVE 1,204', GalaxyMiniMapPainter.compactLinePt),
|
||||||
|
]);
|
||||||
|
expect(p.compactText.first.$2, greaterThanOrEqualTo(10));
|
||||||
|
for (final (_, pt) in p.compactText) {
|
||||||
|
expect(pt, greaterThanOrEqualTo(9));
|
||||||
|
}
|
||||||
|
final w = p.hudWidthOf('a')!;
|
||||||
|
expect(w, greaterThan(0));
|
||||||
|
expect(w, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor));
|
||||||
|
expect(p.hudWidthOf('b'), 0, reason: '다른 행성 카드는 뺀다');
|
||||||
|
expect(p.hudWidthOf('c'), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('320 에서는 모든 행성에 프로토타입 카드를 그린다', (tester) async {
|
||||||
|
await pumpMap(tester, _snap(_base), size: 320);
|
||||||
|
final p = painterOf(tester);
|
||||||
|
for (final id in ['a', 'b', 'c']) {
|
||||||
|
expect(p.hudWidthOf(id), greaterThan(0), reason: id);
|
||||||
|
}
|
||||||
|
expect(p.compactText, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('FPI 상위 10개 + 순위 밖 내 행성만 그린다', (tester) async {
|
||||||
|
await pumpMap(
|
||||||
|
tester,
|
||||||
|
_snap([
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
PlanetNode(
|
||||||
|
id: 'p$i',
|
||||||
|
name: 'P$i',
|
||||||
|
rank: i + 1,
|
||||||
|
fpi: 1000 - i,
|
||||||
|
members: 1,
|
||||||
|
online: 1,
|
||||||
|
isMine: i == 11,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
final p = painterOf(tester);
|
||||||
|
expect(p.orbitOf('p9'), isNotNull);
|
||||||
|
expect(p.orbitOf('p10'), isNull);
|
||||||
|
expect(p.orbitOf('p11'), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('보조 기술은 버튼 하나로 읽고 눌러 연다', (tester) async {
|
||||||
|
final handle = tester.ensureSemantics();
|
||||||
|
await pumpMap(tester, _snap(_base));
|
||||||
|
final node = tester.getSemantics(find.byType(GalaxyMiniMap));
|
||||||
|
expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
|
||||||
|
node.owner!.performAction(node.id, SemanticsAction.tap);
|
||||||
|
expect(opened, 1);
|
||||||
|
handle.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('게이지 조절 UI 는 없다(C-51)', (tester) async {
|
||||||
|
await pumpMap(tester, _snap(_base));
|
||||||
|
expect(find.byType(Slider), findsNothing);
|
||||||
|
expect(find.byType(RangeSlider), findsNothing);
|
||||||
|
for (final gauge in [
|
||||||
|
'Mass Volume',
|
||||||
|
'Orbit Exp.',
|
||||||
|
'HUD Size',
|
||||||
|
'HUD Dist.',
|
||||||
|
'Camera Height',
|
||||||
|
'View Rotation',
|
||||||
|
'Action Flare',
|
||||||
|
]) {
|
||||||
|
expect(find.text(gauge), findsNothing);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('행성 12개 한 프레임 그리기가 예산 안에 든다', () {
|
||||||
|
final painter = GalaxyMiniMapPainter()
|
||||||
|
..update([
|
||||||
|
for (var i = 0; i < 12; i++)
|
||||||
|
PlanetNode(
|
||||||
|
id: 'p$i',
|
||||||
|
name: 'PLANET $i',
|
||||||
|
rank: i + 1,
|
||||||
|
fpi: 300000 - i * 20000,
|
||||||
|
members: 100,
|
||||||
|
online: 10,
|
||||||
|
isMine: i == 2,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
..settle();
|
||||||
|
addTearDown(painter.dispose);
|
||||||
|
final micros = medianPaintMicros(painter, const Size(140, 140));
|
||||||
|
expect(micros, lessThan(paintBudgetMicros));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('HOME 미니맵은 같은 저장소의 Galaxy로 진입한다', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
const galaxy = SampleGalaxyRepository();
|
||||||
|
final states = StreamController<PlanetVisualState>.broadcast();
|
||||||
|
addTearDown(states.close);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: HomeScreen(
|
||||||
|
repository: const SampleHomeRepository(),
|
||||||
|
questRepository: const SampleQuestRepository(),
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
galaxyRepository: galaxy,
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
watchPlanet: (_) => states.stream,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
final mini = find.byType(GalaxyMiniMap);
|
||||||
|
expect(mini, findsOneWidget);
|
||||||
|
// Q-DS-06 권고안 A(회신 전 임시) — 행성 영역 우측 상단 140 정사각.
|
||||||
|
expect(tester.getSize(mini), const Size(140, 140));
|
||||||
|
expect(
|
||||||
|
tester.getTopRight(mini).dx,
|
||||||
|
closeTo(tester.getSize(find.byType(HomeScreen)).width - 16, 1),
|
||||||
|
);
|
||||||
|
await tester.tap(mini);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(
|
||||||
|
tester.widget<GalaxyScreen>(find.byType(GalaxyScreen)).repository,
|
||||||
|
same(galaxy),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
expect(states.hasListener, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import 'package:fancake/features/home/home_screen.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/zap_feedback.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_repository_firestore.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
class _DelayedGalaxy extends SampleGalaxyRepository {
|
||||||
|
final replies = <Completer<ZapResult>>[];
|
||||||
|
final keys = <String>[];
|
||||||
|
final counts = <int>[];
|
||||||
|
final fandoms = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ZapResult> zap({
|
||||||
|
required String fandomId,
|
||||||
|
required int count,
|
||||||
|
required String clientSeq,
|
||||||
|
}) {
|
||||||
|
keys.add(clientSeq);
|
||||||
|
counts.add(count);
|
||||||
|
fandoms.add(fandomId);
|
||||||
|
final reply = Completer<ZapResult>();
|
||||||
|
replies.add(reply);
|
||||||
|
return reply.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _show(WidgetTester tester, GalaxyRepository repository) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: GalaxyScreen(
|
||||||
|
repository: repository,
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LedgerReply extends Backend {
|
||||||
|
_LedgerReply(FakeFirebaseFirestore db)
|
||||||
|
: super(firestore: db, auth: MockFirebaseAuth());
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(
|
||||||
|
String name, [
|
||||||
|
Map<String, dynamic> payload = const {},
|
||||||
|
]) async => {
|
||||||
|
'txId': 'receipt',
|
||||||
|
'balanceAfter': 963,
|
||||||
|
'lotRefs': [
|
||||||
|
{'lotId': 'a', 'delta': -12},
|
||||||
|
{'lotId': 'b', 'delta': -25},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('HOME ZAP 연타·응답유실·확정 결과가 실제 행성에 연결된다', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final repository = _DelayedGalaxy();
|
||||||
|
final home = await const SampleHomeRepository().load();
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: HomeScreen(
|
||||||
|
repository: const SampleHomeRepository(),
|
||||||
|
questRepository: const SampleQuestRepository(),
|
||||||
|
eclipse: const NoEclipseRepository(), galaxyRepository: repository,
|
||||||
|
)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(find.byType(ZapButton), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 350));
|
||||||
|
expect(repository.counts, [2]);
|
||||||
|
expect(repository.fandoms, [home.fandomId]);
|
||||||
|
expect(find.textContaining('기여 완료'), findsNothing);
|
||||||
|
final fx = tester.widget<ZapFeedback>(find.byType(ZapFeedback));
|
||||||
|
expect(fx.source.dy, greaterThan(fx.target.dy));
|
||||||
|
expect(fx.target.dx, greaterThan(0));
|
||||||
|
repository.replies.single.completeError(const BackendException('unavailable', 'private error'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('같은 기여 다시 확인'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(repository.keys.last, repository.keys.first);
|
||||||
|
expect(repository.counts, [2, 2]);
|
||||||
|
repository.replies.last.complete(const ZapResult(txId: 'home-confirmed', balanceAfter: 900, contributedPop: 37));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('37 POP 기여 완료'), findsOneWidget);
|
||||||
|
expect(tester.widget<ZapButton>(find.byType(ZapButton)).myPop, 900);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
testWidgets('Reduce Motion은 갤럭시 자전도 정지하고 해제 시 복구한다', (tester) async {
|
||||||
|
Future<void> show(bool reduced) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: MediaQueryData(disableAnimations: reduced),
|
||||||
|
child: GalaxyScreen(
|
||||||
|
repository: const SampleGalaxyRepository(),
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await show(true);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
expect(tester.binding.transientCallbackCount, 0);
|
||||||
|
await show(false);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
expect(tester.binding.transientCallbackCount, greaterThan(0));
|
||||||
|
await show(true);
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(tester.binding.transientCallbackCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('기여 결과는 현재 단가가 아니라 서버가 확정한 차감 lot 합계다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('config/policy').set({'zapPop': 10});
|
||||||
|
final result = await FirestoreGalaxyRepository(
|
||||||
|
_LedgerReply(db),
|
||||||
|
).zap(fandomId: 'bts', count: 1, clientSeq: 'retry');
|
||||||
|
expect(result.contributedPop, 37);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('실제 기여량을 표시하고 같은 거래 응답의 성공 연출은 반복하지 않는다', (tester) async {
|
||||||
|
final repository = _DelayedGalaxy();
|
||||||
|
await _show(tester, repository);
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
const receipt = ZapResult(
|
||||||
|
txId: 'receipt',
|
||||||
|
balanceAfter: 963,
|
||||||
|
contributedPop: 37,
|
||||||
|
);
|
||||||
|
repository.replies.last.complete(receipt);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('37 POP 기여 완료'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
expect(find.text('37 POP 기여 완료'), findsNothing);
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
repository.replies.last.complete(receipt);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('37 POP 기여 완료'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ZAP 옵션은 2초 전에 열리지 않고 길게 누르면 차감하지 않는다', (tester) async {
|
||||||
|
var taps = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: ZapButton(zapPop: 10, myPop: 100, onZap: () => taps++),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final hold = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byType(ZapButton)),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 1999));
|
||||||
|
expect(find.text('아이템'), findsNothing);
|
||||||
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
|
expect(find.text('아이템'), findsOneWidget);
|
||||||
|
await hold.up();
|
||||||
|
expect(taps, 0);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('전송 중 성공을 보여주지 않고 전송 중 연타로 중복 차감하지 않는다', (tester) async {
|
||||||
|
final repository = _DelayedGalaxy();
|
||||||
|
await _show(tester, repository);
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
expect(repository.replies, hasLength(1));
|
||||||
|
expect(find.textContaining('기여 반영 중'), findsWidgets);
|
||||||
|
expect(find.textContaining('기여 완료'), findsNothing);
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
expect(repository.replies, hasLength(1));
|
||||||
|
repository.replies.single.complete(
|
||||||
|
const ZapResult(txId: 'confirmed', balanceAfter: 12530),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(find.textContaining('기여 완료'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('응답 유실 재확인은 같은 멱등키를 쓰고 실패에는 성공 FX가 없다', (tester) async {
|
||||||
|
final repository = _DelayedGalaxy();
|
||||||
|
await _show(tester, repository);
|
||||||
|
await tester.tap(find.byType(ZapButton));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
repository.replies.single.completeError(
|
||||||
|
const BackendException('unavailable', '연결 실패'),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('기여 완료'), findsNothing);
|
||||||
|
await tester.tap(find.text('다시 확인'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(repository.keys, hasLength(2));
|
||||||
|
expect(repository.keys[1], repository.keys[0]);
|
||||||
|
repository.replies.last.complete(
|
||||||
|
const ZapResult(txId: 'confirmed', balanceAfter: 12530),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(find.textContaining('기여 완료'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:sensors_plus/sensors_plus.dart';
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_repository_firestore.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/core/data/planet_visual_state.dart';
|
||||||
|
import 'package:fancake/features/planet/hero_participation_sheet.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
|
||||||
|
class _Claims extends SamplePlanetRepository {
|
||||||
|
final ids = <String>[];
|
||||||
|
final replies = <Completer<HeroDropReceipt>>[];
|
||||||
|
@override
|
||||||
|
Future<HeroDropReceipt> claimHeroDrop(String id) {
|
||||||
|
ids.add(id);
|
||||||
|
final reply = Completer<HeroDropReceipt>();
|
||||||
|
replies.add(reply);
|
||||||
|
return reply.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class _HeroBackend extends Backend {
|
||||||
|
_HeroBackend() : super(firestore: FakeFirebaseFirestore(),
|
||||||
|
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: 'viewer')));
|
||||||
|
Map<String, dynamic> response = {'claimedPop': 5, 'txId': 'receipt'};
|
||||||
|
Map<String, dynamic>? payload;
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(String name, [Map<String, dynamic> data = const {}]) async {
|
||||||
|
expect(name, 'fnHeroDropClaim');
|
||||||
|
payload = data;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
for (final code in ['E-POP-02', 'E-POP-03', 'E-AUTH-03', 'E-PERM-01']) {
|
||||||
|
testWidgets('서버 확정 거절 $code는 성공이나 무한 재시도로 표시하지 않는다', (tester) async {
|
||||||
|
final repo = _Claims();
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Scaffold(body: HeroParticipationSheet(
|
||||||
|
fandomId: 'target', repository: repo, accelerometer: const Stream.empty(),
|
||||||
|
watch: (_) => Stream.value(PlanetVisualState(isHeroTime: true,
|
||||||
|
heroEventIds: const {'gift'}, heroEndsAt: DateTime.now().add(const Duration(minutes: 1)))),
|
||||||
|
))));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('흔들기 대신 버튼으로 참여'));
|
||||||
|
repo.replies.single.completeError(BackendException(code, 'internal details'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('같은 선물 다시 확인'), findsNothing);
|
||||||
|
expect(find.textContaining('POP 수령 확인'), findsNothing);
|
||||||
|
expect(find.text('internal details'), findsNothing);
|
||||||
|
expect(tester.widget<FilledButton>(find.byType(FilledButton)).onPressed, isNull);
|
||||||
|
expect(repo.ids, ['gift']);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('수령 요청에 서버 판정용 팬덤·이벤트만 보내며 잘못된 응답은 성공으로 읽지 않는다', () async {
|
||||||
|
final backend = _HeroBackend();
|
||||||
|
final repo = FirestorePlanetRepository(backend, fandomId: 'target');
|
||||||
|
expect((await repo.claimHeroDrop('gift')).amount, 5);
|
||||||
|
expect(backend.payload, {'fandomId': 'target', 'dropId': 'gift'});
|
||||||
|
backend.response = {'claimedPop': 0, 'txId': 'receipt'};
|
||||||
|
await expectLater(repo.claimHeroDrop('gift'), throwsStateError);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('지급 전 성공 없음·연타 중복 없음·종료 후에도 같은 선물 재확인', (tester) async {
|
||||||
|
final repo = _Claims();
|
||||||
|
final sensor = StreamController<AccelerometerEvent>();
|
||||||
|
addTearDown(sensor.close);
|
||||||
|
final states = StreamController<PlanetVisualState>.broadcast();
|
||||||
|
addTearDown(states.close);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Scaffold(body: HeroParticipationSheet(
|
||||||
|
fandomId: 'test-fandom', watch: (_) => states.stream, repository: repo,
|
||||||
|
accelerometer: sensor.stream,
|
||||||
|
))));
|
||||||
|
states.add(PlanetVisualState(isHeroTime: true, heroEventIds: const {'gift'},
|
||||||
|
heroEndsAt: DateTime.now().add(const Duration(minutes: 1))));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
sensor.add(AccelerometerEvent(25, 0, 0, DateTime.now()));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('지급 확인 중…'));
|
||||||
|
expect(repo.ids, ['gift']);
|
||||||
|
expect(find.textContaining('POP 수령 확인'), findsNothing);
|
||||||
|
repo.replies.first.completeError(StateError('response lost'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
states.add(const PlanetVisualState(isHeroTime: false));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('같은 선물 다시 확인'));
|
||||||
|
expect(repo.ids, ['gift', 'gift']);
|
||||||
|
repo.replies.last.complete(const HeroDropReceipt(txId: 'confirmed', amount: 5));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('5 POP 수령 확인'), findsOneWidget);
|
||||||
|
expect(tester.widget<FilledButton>(find.byType(FilledButton)).onPressed, isNull);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import 'package:fancake/features/home/home_screen.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/planet/hero_participation_sheet.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/zap_button.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_detail_screen.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/core/data/planet_visual_state.dart';
|
||||||
|
import 'package:fancake/core/ui/hero_time_status.dart';
|
||||||
|
|
||||||
|
class _NoSpendGalaxy extends SampleGalaxyRepository {
|
||||||
|
int spends = 0;
|
||||||
|
@override
|
||||||
|
Future<ZapResult> zap({required String fandomId, required int count, required String clientSeq}) {
|
||||||
|
spends++;
|
||||||
|
return super.zap(fandomId: fandomId, count: count, clientSeq: clientSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void main() {
|
||||||
|
testWidgets('HOME HERO 참여는 현재 팬덤의 서버 저장소로 진입한다', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
String? opened;
|
||||||
|
final home = await const SampleHomeRepository().load();
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: HomeScreen(
|
||||||
|
repository: const SampleHomeRepository(),
|
||||||
|
questRepository: const SampleQuestRepository(),
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
planetRepository: (id) { opened = id; return SamplePlanetRepository(fandomId: id); },
|
||||||
|
watchPlanet: (_) => Stream.value(PlanetVisualState(stage: 1, isHeroTime: true,
|
||||||
|
heroEventIds: const {'gift'}, heroEndsAt: DateTime.now().add(const Duration(minutes: 5)))),
|
||||||
|
)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.tap(find.text('ZAP+ · 선물 참여'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(opened, home.fandomId);
|
||||||
|
expect(tester.widget<HeroParticipationSheet>(find.byType(HeroParticipationSheet)).fandomId, home.fandomId);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
testWidgets('HERO ZAP+ 진입은 POP을 차감하지 않고 종료시 일반 ZAP으로 돌아간다', (tester) async {
|
||||||
|
final galaxy = _NoSpendGalaxy();
|
||||||
|
final states = StreamController<PlanetVisualState>.broadcast();
|
||||||
|
addTearDown(states.close);
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: GalaxyScreen(
|
||||||
|
repository: galaxy, planetRepository: samplePlanetRepository,
|
||||||
|
eclipse: const NoEclipseRepository(), watchPlanet: (_) => states.stream,
|
||||||
|
)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(tester.widget<ZapButton>(find.byType(ZapButton)).isHeroTime, isFalse);
|
||||||
|
states.add(PlanetVisualState(stage: 1, isHeroTime: true, heroEventIds: const {'gift'},
|
||||||
|
heroEndsAt: DateTime.now().add(const Duration(minutes: 1))));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('ZAP+ · HERO TIME'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(find.byType(HeroParticipationSheet), findsOneWidget);
|
||||||
|
expect(galaxy.spends, 0);
|
||||||
|
Navigator.of(tester.element(find.byType(HeroParticipationSheet))).pop();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
states.add(const PlanetVisualState(stage: 1));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.widget<ZapButton>(find.byType(ZapButton)).isHeroTime, isFalse);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
testWidgets('HERO 참여는 개인 POP 잔액 0이어도 열 수 있다', (tester) async {
|
||||||
|
var opened = 0;
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Scaffold(body: ZapButton(
|
||||||
|
zapPop: 10, myPop: 0, isHeroTime: true, onZap: () => opened++,
|
||||||
|
))));
|
||||||
|
await tester.tap(find.text('ZAP+ · HERO TIME'));
|
||||||
|
expect(opened, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Galaxy 전광판은 해당 팬덤의 진행 중 행성 상세로 진입한다', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
String? opened;
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: GalaxyScreen(
|
||||||
|
repository: const SampleGalaxyRepository(),
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
planetRepository: (id) { opened = id; return SamplePlanetRepository(fandomId: id); },
|
||||||
|
watchPlanet: (id) => id != 'bts' ? const Stream.empty() : Stream.value(
|
||||||
|
PlanetVisualState(stage: 4, isHeroTime: true, heroNicknames: const ['공개팬'],
|
||||||
|
heroEndsAt: DateTime.now().add(const Duration(minutes: 5))),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('BTS · HERO TIME 진행 중 →'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('BTS · HERO TIME 진행 중 →'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(opened, 'bts');
|
||||||
|
expect(find.byType(PlanetDetailScreen), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('서버 종료시각을 표시하고 만료·종료는 숨기며 구독을 해제한다', (tester) async {
|
||||||
|
final states = StreamController<PlanetVisualState>.broadcast();
|
||||||
|
addTearDown(states.close);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Scaffold(body: HeroTimeStatus(
|
||||||
|
fandomId: 'bts', watch: (_) => states.stream,
|
||||||
|
))));
|
||||||
|
expect(find.textContaining('HERO TIME'), findsNothing);
|
||||||
|
states.add(PlanetVisualState(isHeroTime: true, heroNicknames: const ['공개팬'], heroClaimCount: 3,
|
||||||
|
heroEndsAt: DateTime.now().add(const Duration(minutes: 5))));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('HERO TIME · 5:00 남음'), findsOneWidget);
|
||||||
|
expect(find.text('by 공개팬'), findsOneWidget);
|
||||||
|
expect(find.text('팬들의 선물 수령 3건'), findsOneWidget);
|
||||||
|
states.add(PlanetVisualState(isHeroTime: true,
|
||||||
|
heroEndsAt: DateTime.now().subtract(const Duration(seconds: 1))));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('HERO TIME'), findsNothing);
|
||||||
|
states.add(const PlanetVisualState());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('HERO TIME'), findsNothing);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(states.hasListener, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:fancake/core/ui/section_header.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_list_screen.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/home/widgets/briefing_list.dart';
|
||||||
|
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
|
||||||
|
class _LiveGrowthRepo extends SampleHomeRepository {
|
||||||
|
final changes = StreamController<PlanetGrowth>.broadcast();
|
||||||
|
@override
|
||||||
|
Stream<PlanetGrowth> watchGrowth(String fandomId) => changes.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 값을 못 가져오는 경우를 재현한다.
|
||||||
|
class _FailingRepo extends SampleHomeRepository {
|
||||||
|
@override
|
||||||
|
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeSnapshot> load() async => throw StateError('boom');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 5 요소(B-75)를 갖춘 브리핑 한 줄. 검증마다 제목과 구획만 바꿔 쓴다.
|
||||||
|
BriefingItem _brief(
|
||||||
|
String headline, {
|
||||||
|
int rank = 1,
|
||||||
|
BriefingSection section = BriefingSection.news,
|
||||||
|
}) => BriefingItem(
|
||||||
|
rank: rank,
|
||||||
|
section: section,
|
||||||
|
headline: headline,
|
||||||
|
sourceName: '연합뉴스',
|
||||||
|
sourceAt: DateTime(2026, 9, 14, 8),
|
||||||
|
sourceUrl: 'https://example.com/a',
|
||||||
|
articleUrl: 'https://example.com/a',
|
||||||
|
aiSummary: '요약입니다.',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 브리핑만 갈아 끼운 표본. 「0 건이면 블록 전체 숨김」을 그 한 값으로 본다.
|
||||||
|
class _BriefingRepo extends SampleHomeRepository {
|
||||||
|
@override
|
||||||
|
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
|
||||||
|
|
||||||
|
const _BriefingRepo(this.briefings);
|
||||||
|
|
||||||
|
final List<BriefingItem> briefings;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeSnapshot> load() async {
|
||||||
|
final base = await const SampleHomeRepository().load();
|
||||||
|
return HomeSnapshot(
|
||||||
|
popBalance: base.popBalance,
|
||||||
|
liveTicker: base.liveTicker,
|
||||||
|
growth: base.growth,
|
||||||
|
quests: base.quests,
|
||||||
|
briefings: briefings,
|
||||||
|
campaign: base.campaign,
|
||||||
|
insides: base.insides,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pump(WidgetTester tester, {HomeRepository? repo}) async {
|
||||||
|
// 홈 히어로가 시안대로 첫 화면의 78% 를 차지하므로 기본 800×600 창에서는 아래
|
||||||
|
// 구획이 지연 빌드되지 않는다. 실기기에 가까운 세로 길이를 준다(F3 테스트와 같다).
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: HomeScreen(
|
||||||
|
repository: repo ?? const SampleHomeRepository(),
|
||||||
|
questRepository: const SampleQuestRepository(),
|
||||||
|
eclipse: const NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('성장 조건을 실시간 갱신하고 화면 종료 시 구독을 해제한다', (tester) async {
|
||||||
|
final repo = _LiveGrowthRepo();
|
||||||
|
addTearDown(repo.changes.close);
|
||||||
|
await _pump(tester, repo: repo);
|
||||||
|
expect(repo.changes.hasListener, isTrue);
|
||||||
|
repo.changes.add(const PlanetGrowth(
|
||||||
|
stage: 1, fandomName: 'BTS', contributorName: '함께한팬',
|
||||||
|
contributedPop: 10, growthPercent: 100, currentPop: 10000,
|
||||||
|
requiredPop: 10000, currentSfpi: 750, requiredSfpi: 1000,
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('POP 조건 충족 · 10,000 / 10,000'), findsOneWidget);
|
||||||
|
expect(find.text('sFPI 조건 미충족 · 750 / 1,000'), findsOneWidget);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump();
|
||||||
|
expect(repo.changes.hasListener, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Figma 홈의 구획 제목이 순서대로 나온다', (tester) async {
|
||||||
|
await _pump(tester);
|
||||||
|
// 화면 대조 원장 F1 · Figma `252:159401` 이하의 구획명 그대로여야 한다.
|
||||||
|
// 기본 뷰포트(800×600)보다 화면이 길어 아래쪽은 스크롤해야 잡힌다.
|
||||||
|
for (final title in [
|
||||||
|
'TODAY’S QUEST',
|
||||||
|
'AI Daily Briefing',
|
||||||
|
'HOT ARCHIVE',
|
||||||
|
'인기 K-INSIDE',
|
||||||
|
]) {
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text(title),
|
||||||
|
220,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
expect(find.text(title), findsOneWidget, reason: '구획 「$title」 누락');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('POP 진행률과 두 성장 조건을 구분해서 표시한다', (tester) async {
|
||||||
|
await _pump(tester);
|
||||||
|
expect(find.text('67%'), findsOneWidget);
|
||||||
|
expect(find.text('POP 진행률'), findsOneWidget);
|
||||||
|
expect(find.text('POP 조건 미충족 · 6,270 / 10,000'), findsOneWidget);
|
||||||
|
expect(find.text('sFPI 조건 확인 중 · — / —'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('POP 잔액은 천 단위로 끊어 띄운다', (tester) async {
|
||||||
|
await _pump(tester);
|
||||||
|
expect(find.text('12,540'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('퀘스트 카드는 보상과 진행 상태를 보여 준다', (tester) async {
|
||||||
|
await _pump(tester);
|
||||||
|
// 시안(가로 카드)은 제목 옆에 「+보상」, 아래에 상태 한 줄.
|
||||||
|
expect(find.text('+10'), findsOneWidget);
|
||||||
|
expect(find.text('출석 완료'), findsOneWidget);
|
||||||
|
expect(find.text('0/1,500 걸음'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('현상금 표기에 천 단위 구분이 들어간다', (tester) async {
|
||||||
|
await _pump(tester);
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('500 P'),
|
||||||
|
220,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
expect(find.text('500 P'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('아직 없는 화면으로 가려 하면 언제 열리는지 알린다', (tester) async {
|
||||||
|
await _pump(tester);
|
||||||
|
// 첫 「더 보기」는 퀘스트라 실제로 이동한다. 아직 없는 구간까지 굴려서 누른다.
|
||||||
|
// 브리핑 헤더는 「공유」다(`266:90529`). 아직 없는 목록인 인기 K-INSIDE 로 본다.
|
||||||
|
final briefing = find.byWidgetPredicate(
|
||||||
|
(w) => w is SectionHeader && w.title == '인기 K-INSIDE',
|
||||||
|
);
|
||||||
|
await tester.scrollUntilVisible(briefing, 220,
|
||||||
|
scrollable: find.byType(Scrollable).first);
|
||||||
|
await tester.ensureVisible(briefing);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기')));
|
||||||
|
await tester.pump();
|
||||||
|
// 조용히 무시하면 검수 때 고장으로 보인다.
|
||||||
|
expect(find.textContaining('구현 확인물 2차'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async {
|
||||||
|
await _pump(tester);
|
||||||
|
await tester.tap(find.text('더 보기').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(QuestListScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('값을 못 가져오면 다시 시도할 수 있다', (tester) async {
|
||||||
|
await _pump(tester, repo: _FailingRepo());
|
||||||
|
expect(find.text('홈을 불러오지 못했습니다'), findsOneWidget);
|
||||||
|
expect(find.text('다시 시도'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('화면 문구에 내부 경로가 새지 않는다', (tester) async {
|
||||||
|
await _pump(tester);
|
||||||
|
final texts = tester
|
||||||
|
.widgetList<Text>(find.byType(Text))
|
||||||
|
.map((w) => w.data ?? '')
|
||||||
|
.join('\n');
|
||||||
|
for (final leak in ['lib/', 'app/', '.dart', 'Exception', 'StateError']) {
|
||||||
|
expect(texts.contains(leak), isFalse, reason: '화면에 「$leak」 노출');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 정책 원장 B-47 — *News·SNS 모두 0건 시 **Briefing 블록 전체 숨김**(빈 상태*
|
||||||
|
/// *문구도 노출하지 않음)*. 서버가 당일 게시분을 0 건으로 주는 상황이다.
|
||||||
|
testWidgets('브리핑이 0 건이면 구획 제목까지 숨긴다', (tester) async {
|
||||||
|
await _pump(tester, repo: const _BriefingRepo([]));
|
||||||
|
expect(find.text('AI Daily Briefing'), findsNothing);
|
||||||
|
// 빈 상태 문구도 두지 않는다 — 아래 구획은 그대로 있어야 한다.
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('HOT ARCHIVE'),
|
||||||
|
220,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
expect(find.text('HOT ARCHIVE'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('브리핑 헤더의 「공유」는 브리핑 공유 카드를 연다(F8 · 별첨 1 ③-5)', (tester) async {
|
||||||
|
await _pump(tester, repo: _BriefingRepo([_brief('공유할 한 줄')]));
|
||||||
|
final header = find.byWidgetPredicate(
|
||||||
|
(w) => w is SectionHeader && w.title == 'AI Daily Briefing',
|
||||||
|
);
|
||||||
|
await tester.scrollUntilVisible(find.text('공유할 한 줄'), 220,
|
||||||
|
scrollable: find.byType(Scrollable).first);
|
||||||
|
await tester.tap(find.descendant(of: header, matching: find.text('공유')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('AI DAILY BRIEFING'), findsOneWidget);
|
||||||
|
expect(find.text('공유할 한 줄'), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('한 건이라도 있으면 구획을 그린다', (tester) async {
|
||||||
|
await _pump(tester, repo: _BriefingRepo([_brief('오늘 게시된 한 줄')]));
|
||||||
|
// 헤더가 아니라 줄 자체를 기준으로 스크롤한다 — 헤더가 보이는 시점에 줄은
|
||||||
|
// 아직 화면 아래에 있어 지연 빌드가 끝나지 않는다.
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('오늘 게시된 한 줄'),
|
||||||
|
220,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
expect(find.text('AI Daily Briefing'), findsOneWidget);
|
||||||
|
expect(find.text('오늘 게시된 한 줄'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 정책 원장 B-47 — *News/SNS 각각 0건이면 해당 섹션 숨김*.
|
||||||
|
testWidgets('SNS 가 0 건이면 SNS 구획만 숨는다', (tester) async {
|
||||||
|
await _pump(tester, repo: _BriefingRepo([
|
||||||
|
_brief('뉴스 한 줄', section: BriefingSection.news),
|
||||||
|
]));
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('뉴스 한 줄'),
|
||||||
|
220,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
expect(find.text('NEWS'), findsOneWidget);
|
||||||
|
expect(find.text('SNS'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('News 가 0 건이면 News 구획만 숨는다', (tester) async {
|
||||||
|
await _pump(tester, repo: _BriefingRepo([
|
||||||
|
_brief('SNS 한 줄', section: BriefingSection.sns),
|
||||||
|
]));
|
||||||
|
await tester.scrollUntilVisible(
|
||||||
|
find.text('SNS 한 줄'),
|
||||||
|
220,
|
||||||
|
scrollable: find.byType(Scrollable).first,
|
||||||
|
);
|
||||||
|
expect(find.text('SNS'), findsOneWidget);
|
||||||
|
expect(find.text('NEWS'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('줄을 누르면 원문보기로 나간다', (tester) async {
|
||||||
|
final opened = <String>[];
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: Scaffold(
|
||||||
|
body: BriefingList(
|
||||||
|
items: [_brief('원문이 있는 줄')],
|
||||||
|
onOpen: (item) async => opened.add(item.articleUrl),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.tap(find.text('원문이 있는 줄'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(opened, ['https://example.com/a']);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:fancake/core/i18n/app_locale.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/home/nebula_create_screen.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_data.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/my/my_data.dart';
|
||||||
|
import 'package:fancake/features/my/my_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_list_screen.dart';
|
||||||
|
import 'package:fancake/features/station/station_screen.dart';
|
||||||
|
import 'package:fancake/l10n/app_localizations.dart'
|
||||||
|
show lookupAppLocalizations;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 이전한 화면을 ko 가 아닌 언어로 띄워 문구가 바뀌고, 긴 번역에도 레이아웃이
|
||||||
|
/// 넘치지 않는지(RenderFlex overflow 등) 본다. 폭은 작은 단말(360×800) 기준.
|
||||||
|
void main() {
|
||||||
|
tearDown(() => appL10n = koL10n);
|
||||||
|
|
||||||
|
final screens = <String, (Widget, String Function(AppLocalizations))>{
|
||||||
|
'MY': (MyScreen(repository: SampleMyRepository()), (l) => l.myTabActivity),
|
||||||
|
'QUEST': (
|
||||||
|
const QuestListScreen(repository: SampleQuestRepository()),
|
||||||
|
(l) => l.questDailyHelp,
|
||||||
|
),
|
||||||
|
'STATION': (const StationScreen(), (l) => l.stationSuggest),
|
||||||
|
'성운 생성': (const NebulaCreateScreen(), (l) => l.nebulaHeadline),
|
||||||
|
'캠페인 상세': (
|
||||||
|
CampaignDetailScreen(campaignId: 'c1', myPop: 1000),
|
||||||
|
(l) => l.campaignFundingStatus,
|
||||||
|
),
|
||||||
|
'INSIDE 상세': (
|
||||||
|
InsideDetailScreen(
|
||||||
|
question: InsideQuestion(
|
||||||
|
id: 'q-0',
|
||||||
|
title: 'Title',
|
||||||
|
body: 'Body',
|
||||||
|
authorName: 'fan',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
bountyPop: 500,
|
||||||
|
),
|
||||||
|
answers: sampleInsideAnswers,
|
||||||
|
),
|
||||||
|
(l) => l.insideAnswerHint,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final locale in const [Locale('en'), Locale('ja')]) {
|
||||||
|
for (final MapEntry(key: name, value: (screen, marker))
|
||||||
|
in screens.entries) {
|
||||||
|
testWidgets('$name 화면이 ${locale.languageCode} 로 넘침 없이 그려진다', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
locale: locale,
|
||||||
|
supportedLocales: appLocales,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
RemoteL10nDelegate({}),
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: screen,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(
|
||||||
|
find.text(marker(lookupAppLocalizations(locale)), findRichText: true),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:fancake/core/i18n/app_locale.dart';
|
||||||
|
import 'package:fancake/features/my/my_data.dart';
|
||||||
|
import 'package:fancake/features/my/settings_screen.dart';
|
||||||
|
import 'package:fancake/l10n/app_localizations.dart'
|
||||||
|
show lookupAppLocalizations;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../tool/gen_l10n_remote.dart';
|
||||||
|
|
||||||
|
Map<String, dynamic> _arb(String lang) =>
|
||||||
|
jsonDecode(File('lib/l10n/app_$lang.arb').readAsStringSync())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
Set<String> _keys(Map<String, dynamic> arb) =>
|
||||||
|
arb.keys.where((k) => !k.startsWith('@')).toSet();
|
||||||
|
|
||||||
|
Widget _app(LocaleController c, Widget home) => ListenableBuilder(
|
||||||
|
listenable: c,
|
||||||
|
builder: (_, _) => LocaleScope(
|
||||||
|
controller: c,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: c.locale,
|
||||||
|
supportedLocales: appLocales,
|
||||||
|
localizationsDelegates: [
|
||||||
|
RemoteL10nDelegate(c.remote),
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: home,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
group('지원 언어 (원장 B-76)', () {
|
||||||
|
test('supportedLocales 가 정확히 ko · en · ja · zh-CN · es 다', () {
|
||||||
|
expect(appLocales.map(localeTag).toList(), [
|
||||||
|
'ko',
|
||||||
|
'en',
|
||||||
|
'ja',
|
||||||
|
'zh-CN',
|
||||||
|
'es',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('단말 언어로 기본값을 고르고 zh-TW·zh-Hant 는 zh-CN · 미지원 언어는 en 으로 폴백한다', () {
|
||||||
|
expect(resolveAppLocale(const [Locale('ja', 'JP')]), const Locale('ja'));
|
||||||
|
expect(
|
||||||
|
resolveAppLocale(const [Locale('zh', 'TW')]),
|
||||||
|
const Locale('zh', 'CN'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveAppLocale(const [
|
||||||
|
Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
|
||||||
|
]),
|
||||||
|
const Locale('zh', 'CN'),
|
||||||
|
);
|
||||||
|
expect(resolveAppLocale(const [Locale('fr', 'FR')]), const Locale('en'));
|
||||||
|
expect(
|
||||||
|
resolveAppLocale(const [Locale('fr'), Locale('es', 'MX')]),
|
||||||
|
const Locale('es'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('5개 언어 arb 가 같은 키를 전부 가진다(QA 8-2 문자열 누락 0)', () {
|
||||||
|
final ko = _keys(_arb('ko'));
|
||||||
|
for (final lang in const ['en', 'ja', 'zh', 'es']) {
|
||||||
|
final other = _keys(_arb(lang));
|
||||||
|
expect(ko.difference(other), isEmpty, reason: '$lang 누락');
|
||||||
|
expect(other.difference(ko), isEmpty, reason: '$lang 초과');
|
||||||
|
for (final k in other) {
|
||||||
|
expect(
|
||||||
|
(_arb(lang)[k] as String).trim(),
|
||||||
|
isNotEmpty,
|
||||||
|
reason: '$lang.$k',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('원격 덮어쓰기 층이 arb 의 모든 키를 덮는다(생성물이 최신)', () {
|
||||||
|
final generated = File(
|
||||||
|
'lib/l10n/remote_app_localizations.dart',
|
||||||
|
).readAsStringSync();
|
||||||
|
final expected = generateRemoteL10n(_arb('ko'));
|
||||||
|
for (final key in _keys(_arb('ko'))) {
|
||||||
|
expect(expected, contains(' $key'));
|
||||||
|
expect(generated, contains(' $key'), reason: '재생성 필요: $key');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('언어 전환 · 원격 문자열 (FR-I18N-01)', () {
|
||||||
|
testWidgets('설정의 언어 시트에서 English 를 고르면 화면 문자열이 바뀌고 기기에 남는다', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
String? saved;
|
||||||
|
final c = LocaleController(
|
||||||
|
initial: fallbackLocale,
|
||||||
|
onSaved: (tag) async => saved = tag,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_app(c, SettingsScreen(repository: SampleMyRepository())),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('설정'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.scrollUntilVisible(find.text('한국어'), 200);
|
||||||
|
await tester.tap(find.text('한국어'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('English'));
|
||||||
|
await tester.tap(find.text('선택'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(c.locale, const Locale('en'));
|
||||||
|
expect(find.text('Settings'), findsOneWidget);
|
||||||
|
expect(saved, 'en');
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getString(LocaleController.prefsKey), 'en');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('원격 문서의 키는 번들 문자열을 덮고, 없는 키는 번들을 쓴다', (tester) async {
|
||||||
|
final remote = StreamController<Map<String, Object?>>();
|
||||||
|
addTearDown(remote.close);
|
||||||
|
final c = LocaleController(
|
||||||
|
initial: const Locale('ja'),
|
||||||
|
remoteSource: (tag) {
|
||||||
|
expect(tag, 'ja');
|
||||||
|
return remote.stream;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_app(
|
||||||
|
c,
|
||||||
|
Builder(
|
||||||
|
builder: (context) => Column(
|
||||||
|
children: [
|
||||||
|
Text(context.l10n.settingsTitle),
|
||||||
|
Text(context.l10n.commonNotYet('X')),
|
||||||
|
Text(context.l10n.commonCancel),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('設定'), findsOneWidget);
|
||||||
|
|
||||||
|
remote.add({
|
||||||
|
'settingsTitle': '設定(更新)',
|
||||||
|
'commonNotYet': '{where} まもなく',
|
||||||
|
'ignored': 3,
|
||||||
|
});
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('設定(更新)'), findsOneWidget);
|
||||||
|
expect(find.text('X まもなく'), findsOneWidget);
|
||||||
|
expect(find.text('キャンセル'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('원격 문자열은 인자 값 안의 자리표시자를 다시 치환하지 않는다', () {
|
||||||
|
final t = RemoteL10nDelegate(const {
|
||||||
|
'linkTitle': '{name} 연동',
|
||||||
|
'settingsLinkNotReady': '{name}: {items}',
|
||||||
|
});
|
||||||
|
return t.load(const Locale('ko')).then((l10n) {
|
||||||
|
expect(l10n.linkTitle('{name}'), '{name} 연동');
|
||||||
|
expect(l10n.settingsLinkNotReady('{items}', 'A'), '{items}: A');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('언어를 복원하면 이전 언어의 원격 문자열을 버린다', () async {
|
||||||
|
final remote = StreamController<Map<String, Object?>>.broadcast();
|
||||||
|
addTearDown(remote.close);
|
||||||
|
final c = LocaleController(
|
||||||
|
initial: const Locale('ja'),
|
||||||
|
remoteSource: (_) => remote.stream,
|
||||||
|
);
|
||||||
|
remote.add({'settingsTitle': '設定(更新)'});
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(c.remote, isNotEmpty);
|
||||||
|
SharedPreferences.setMockInitialValues({LocaleController.prefsKey: 'es'});
|
||||||
|
await c.restore();
|
||||||
|
expect(c.locale, const Locale('es'));
|
||||||
|
expect(c.remote, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('로그인 때 이 기기에서 고른 언어가 없으면 프로필 언어를 따른다', () async {
|
||||||
|
final c = LocaleController(initial: const Locale('en'));
|
||||||
|
await c.adoptProfile('ja');
|
||||||
|
expect(c.locale, const Locale('ja'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('이 기기에서 고른 언어가 있으면 프로필 언어보다 우선한다', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({LocaleController.prefsKey: 'es'});
|
||||||
|
final c = LocaleController(initial: const Locale('es'));
|
||||||
|
await c.adoptProfile('ja');
|
||||||
|
expect(c.locale, const Locale('es'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('저장된 언어를 복원한다', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
LocaleController.prefsKey: 'zh-CN',
|
||||||
|
});
|
||||||
|
final c = LocaleController(initial: fallbackLocale);
|
||||||
|
await c.restore();
|
||||||
|
expect(c.locale, const Locale('zh', 'CN'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('대리자가 없는 트리는 ko 로 폴백한다', () {
|
||||||
|
expect(koL10n.settingsTitle, '설정');
|
||||||
|
expect(
|
||||||
|
lookupAppLocalizations(const Locale('zh', 'CN')).settingsTitle,
|
||||||
|
'设置',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
|
import 'package:fancake/core/ui/app_icons.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_data.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_screen.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_write_screen.dart';
|
||||||
|
import 'package:fancake/features/station/station_data.dart';
|
||||||
|
import 'package:fancake/features/station/station_screen.dart';
|
||||||
|
import 'package:fancake/features/station/station_suggest_screen.dart';
|
||||||
|
import 'package:fancake/features/translation/translation_data.dart';
|
||||||
|
import 'package:fancake/features/translation/translation_comments.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
Future<void> _pump(WidgetTester tester, Widget screen) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 항상 실패하는 저장소. 화면의 오류 경로를 태운다.
|
||||||
|
class _FailingInsideRepository extends SampleInsideRepository {
|
||||||
|
@override
|
||||||
|
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest}) async =>
|
||||||
|
throw StateError('boom');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<InsideQuestion>> more({
|
||||||
|
required InsideSort sort,
|
||||||
|
int limit = 5,
|
||||||
|
}) async => throw StateError('boom');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 현상금 Q&A 경로를 기록하는 저장소. [failWith] 가 있으면 쓰기 호출이 그걸 던진다.
|
||||||
|
class _QnaRepository extends SampleInsideRepository {
|
||||||
|
_QnaRepository({this.failWith});
|
||||||
|
|
||||||
|
final BackendException? failWith;
|
||||||
|
final created = <(String, String, int)>[];
|
||||||
|
final seqs = <String>[];
|
||||||
|
final accepted = <String>[];
|
||||||
|
final sent = <String>[];
|
||||||
|
var list = <InsideAnswer>[
|
||||||
|
InsideAnswer(
|
||||||
|
id: 'a1',
|
||||||
|
authorName: '남',
|
||||||
|
body: '남의 답',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
),
|
||||||
|
InsideAnswer(
|
||||||
|
id: 'a2',
|
||||||
|
authorName: '나',
|
||||||
|
body: '내 답',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
isMine: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> create({
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
required int bountyPop,
|
||||||
|
required String clientSeq,
|
||||||
|
}) async {
|
||||||
|
seqs.add(clientSeq);
|
||||||
|
if (failWith != null) throw failWith!;
|
||||||
|
created.add((title, body, bountyPop));
|
||||||
|
return 'q-new';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<InsideAnswer>> answers(String questionId) async => list;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> answer(String questionId, String body) async {
|
||||||
|
sent.add(body);
|
||||||
|
list = [
|
||||||
|
InsideAnswer(
|
||||||
|
id: 'a3',
|
||||||
|
authorName: '나',
|
||||||
|
body: body,
|
||||||
|
createdAt: DateTime(2026, 9, 2),
|
||||||
|
isMine: true,
|
||||||
|
),
|
||||||
|
...list,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> accept(String questionId, String answerId) async {
|
||||||
|
if (failWith != null) throw failWith!;
|
||||||
|
accepted.add(answerId);
|
||||||
|
list = [
|
||||||
|
for (final a in list)
|
||||||
|
a.id == answerId
|
||||||
|
? InsideAnswer(
|
||||||
|
id: a.id,
|
||||||
|
authorName: a.authorName,
|
||||||
|
body: a.body,
|
||||||
|
createdAt: a.createdAt,
|
||||||
|
isAccepted: true,
|
||||||
|
)
|
||||||
|
: a,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyStationRepository implements StationRepository {
|
||||||
|
@override
|
||||||
|
Future<StationSnapshot> load() async =>
|
||||||
|
const StationSnapshot(links: [], canSuggest: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('INSIDE 목록 (F3 · 196:2673 · 199:43083 · 199:43964)', () {
|
||||||
|
testWidgets('세 정렬 칩을 Figma 화면 이름 그대로 띄운다', (tester) async {
|
||||||
|
await _pump(tester, const InsideScreen());
|
||||||
|
// Figma 가 화면을 셋으로 나눠 둔 그 축이다. 라벨이 바뀌면 대조가 끊긴다.
|
||||||
|
for (final label in ['최신순', '인기순', 'POP순']) {
|
||||||
|
expect(find.text(label), findsOneWidget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('명세대로 최초 5건만 띄운다', (tester) async {
|
||||||
|
await _pump(tester, const InsideScreen());
|
||||||
|
// 2.70-1 — *최신순 소팅 최초 5개, 스크롤 시 5개씩 호출*
|
||||||
|
expect(find.byType(InsideQuestionCard), findsNWidgets(5));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('기본 정렬은 최신순이다', (tester) async {
|
||||||
|
await _pump(tester, const InsideScreen());
|
||||||
|
final cards = tester
|
||||||
|
.widgetList<InsideQuestionCard>(find.byType(InsideQuestionCard))
|
||||||
|
.toList();
|
||||||
|
for (var i = 1; i < cards.length; i++) {
|
||||||
|
expect(
|
||||||
|
cards[i - 1].question.createdAt.isAfter(cards[i].question.createdAt),
|
||||||
|
isTrue,
|
||||||
|
reason: '${cards[i - 1].question.id} 가 ${cards[i].question.id} 보다 뒤다',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('POP순을 고르면 현상금 내림차순으로 다시 그린다', (tester) async {
|
||||||
|
await _pump(tester, const InsideScreen());
|
||||||
|
await tester.tap(find.text('POP순'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final cards = tester
|
||||||
|
.widgetList<InsideQuestionCard>(find.byType(InsideQuestionCard))
|
||||||
|
.toList();
|
||||||
|
expect(cards.first.question.bountyPop, 800);
|
||||||
|
for (var i = 1; i < cards.length; i++) {
|
||||||
|
expect(
|
||||||
|
cards[i - 1].question.bountyPop >= cards[i].question.bountyPop,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('현상금이 0이면 POP 칩을 그리지 않는다', (tester) async {
|
||||||
|
// 0 P 칩이 붙으면 없는 상금을 광고하는 셈이다.
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
Scaffold(
|
||||||
|
body: InsideQuestionCard(
|
||||||
|
question: InsideQuestion(
|
||||||
|
id: 'z',
|
||||||
|
title: '제목',
|
||||||
|
body: '본문',
|
||||||
|
authorName: '누군가',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('0 P'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('불러오기가 실패하면 목록 대신 다시 시도를 준다', (tester) async {
|
||||||
|
await _pump(tester, InsideScreen(repository: _FailingInsideRepository()));
|
||||||
|
expect(find.text('인사이드를 불러오지 못했습니다'), findsOneWidget);
|
||||||
|
expect(find.text('다시 시도'), findsOneWidget);
|
||||||
|
expect(find.byType(InsideQuestionCard), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('작성 플로팅 버튼이 있다', (tester) async {
|
||||||
|
// 2.70-3 — *우측 하단 고정 · 해당 팬덤에 가입된 유저에게만 노출*
|
||||||
|
await _pump(tester, const InsideScreen());
|
||||||
|
expect(find.text('인사이드 작성'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('INSIDE 상세 (F3 · 207:53130 본인 · 201:49678 타인)', () {
|
||||||
|
InsideQuestion question({bool isMine = false}) => InsideQuestion(
|
||||||
|
id: 'q',
|
||||||
|
title: '제목',
|
||||||
|
body: '본문',
|
||||||
|
authorName: '별빛수집가',
|
||||||
|
createdAt: DateTime(2026, 9, 1, 20),
|
||||||
|
bountyPop: 500,
|
||||||
|
answerCount: 3,
|
||||||
|
isMine: isMine,
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('타인 글은 신고·차단을 준다', (tester) async {
|
||||||
|
await _pump(tester, InsideDetailScreen(question: question()));
|
||||||
|
await tester.tap(find.byTooltip('더보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('신고하기'), findsOneWidget);
|
||||||
|
expect(find.text('차단하기'), findsOneWidget);
|
||||||
|
expect(find.text('삭제하기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('번역 저장소가 있으면 별도 영역 없이 댓글 입력에 지구본(번역 댓글)을 붙인다', (tester) async {
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
InsideDetailScreen(
|
||||||
|
question: question(),
|
||||||
|
translation: SampleTranslationRepository(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final bar = tester.widget<CommentInputBar>(find.byType(CommentInputBar));
|
||||||
|
expect(bar.rootPath, 'questions/q');
|
||||||
|
expect(bar.translation, isNotNull);
|
||||||
|
expect(find.bySemanticsLabel('번역 댓글 작성'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('본인 글은 수정·삭제를 준다', (tester) async {
|
||||||
|
await _pump(tester, InsideDetailScreen(question: question(isMine: true)));
|
||||||
|
await tester.tap(find.byTooltip('더보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('수정하기'), findsOneWidget);
|
||||||
|
expect(find.text('삭제하기'), findsOneWidget);
|
||||||
|
// 본인 글을 본인이 신고하는 경로가 열려 있으면 안 된다.
|
||||||
|
expect(find.text('신고하기'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('INSIDE 작성 (F3 · 199:44733)', () {
|
||||||
|
testWidgets('제목과 내용이 비면 등록되지 않는다', (tester) async {
|
||||||
|
await _pump(tester, const InsideWriteScreen());
|
||||||
|
await tester.tap(find.text('완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('제목을 입력해 주세요.'), findsOneWidget);
|
||||||
|
expect(find.text('내용을 입력해 주세요.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('현상금이 보유 POP 을 넘으면 막는다', (tester) async {
|
||||||
|
await _pump(tester, const InsideWriteScreen(myPop: 1000));
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(0), '제목');
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(1), '내용');
|
||||||
|
await tester.enterText(find.byType(TextField).at(2), '2000');
|
||||||
|
await tester.tap(find.text('완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('보유 POP 을 넘을 수 없습니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('현상금은 사본 안내대로 100 POP 이상이어야 한다', (tester) async {
|
||||||
|
await _pump(tester, const InsideWriteScreen(myPop: 1000));
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(0), '제목');
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(1), '내용');
|
||||||
|
await tester.enterText(find.byType(TextField).at(2), '99');
|
||||||
|
await tester.tap(find.text('완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('현상금은 100 POP 이상이어야 합니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('INSIDE 현상금 하한 운영값', () {
|
||||||
|
testWidgets('하한은 저장소(config/policy.qnaMinBountyPop)를 따른다', (tester) async {
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
InsideWriteScreen(repository: _MinBounty(), myPop: 1000),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(0), '제목');
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(1), '내용');
|
||||||
|
await tester.enterText(find.byType(TextField).at(2), '150');
|
||||||
|
await tester.tap(find.text('완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('현상금은 200 POP 이상이어야 합니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('INSIDE 현상금 Q&A (별첨 1 ④-2)', () {
|
||||||
|
InsideQuestion mine() => InsideQuestion(
|
||||||
|
id: 'q',
|
||||||
|
title: '제목',
|
||||||
|
body: '본문',
|
||||||
|
authorName: '나',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
bountyPop: 300,
|
||||||
|
isMine: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> fill(WidgetTester tester, String bounty) async {
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(0), ' 제목 ');
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(1), '내용');
|
||||||
|
await tester.enterText(find.byType(TextField).at(2), bounty);
|
||||||
|
await tester.tap(find.text('완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('현상금을 비우면 등록되지 않는다', (tester) async {
|
||||||
|
final repo = _QnaRepository();
|
||||||
|
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
|
||||||
|
await fill(tester, '');
|
||||||
|
expect(find.text('현상금을 입력해 주세요.'), findsOneWidget);
|
||||||
|
expect(repo.created, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('완료하면 fn-question-create 로 예치 요청을 보낸다', (tester) async {
|
||||||
|
final repo = _QnaRepository();
|
||||||
|
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
|
||||||
|
await fill(tester, '300');
|
||||||
|
expect(repo.created, [('제목', '내용', 300)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('잔액 부족(E-POP-01)은 한국어 안내로 보인다', (tester) async {
|
||||||
|
final repo = _QnaRepository(
|
||||||
|
failWith: const BackendException('E-POP-01', 'POP 잔액이 부족합니다.'),
|
||||||
|
);
|
||||||
|
await _pump(tester, InsideWriteScreen(repository: repo, myPop: 1000));
|
||||||
|
await fill(tester, '300');
|
||||||
|
expect(find.text('POP 이 모자라 현상금을 걸지 못했습니다.'), findsOneWidget);
|
||||||
|
// 입력을 바꿔 다시 보내도 같은 화면에서는 같은 번호다 — 이중 예치 방지.
|
||||||
|
await tester.enterText(find.byType(TextField).at(2), '400');
|
||||||
|
await tester.tap(find.text('완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.seqs, hasLength(2));
|
||||||
|
expect(repo.seqs.toSet(), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('환불 중단 상태는 중립 문구로 보인다', (tester) async {
|
||||||
|
final q = InsideQuestion(
|
||||||
|
id: 'q',
|
||||||
|
title: '제목',
|
||||||
|
body: '본문',
|
||||||
|
authorName: '나',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
isMine: true,
|
||||||
|
refund: InsideRefund.fromStatus('refund_failed'),
|
||||||
|
);
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
InsideDetailScreen(question: q, repository: _QnaRepository()),
|
||||||
|
);
|
||||||
|
expect(find.text('채택 기한이 지나 현상금 환불을 확인하고 있습니다.'), findsOneWidget);
|
||||||
|
expect(find.text('채택하기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('질문자는 남의 답변만 채택할 수 있고, 채택 뒤에는 버튼이 사라진다', (tester) async {
|
||||||
|
final repo = _QnaRepository();
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
InsideDetailScreen(question: mine(), repository: repo),
|
||||||
|
);
|
||||||
|
expect(find.text('남의 답'), findsOneWidget);
|
||||||
|
// 자기 답변에는 채택하기가 없다(E-QNA-01).
|
||||||
|
expect(find.text('채택하기'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('채택하기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('채택'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repo.accepted, ['a1']);
|
||||||
|
expect(find.text('채택 완료'), findsOneWidget);
|
||||||
|
expect(find.text('채택하기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('타인 질문에는 채택하기가 없다', (tester) async {
|
||||||
|
final q = InsideQuestion(
|
||||||
|
id: 'q',
|
||||||
|
title: '제목',
|
||||||
|
body: '본문',
|
||||||
|
authorName: '남',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
);
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
InsideDetailScreen(question: q, repository: _QnaRepository()),
|
||||||
|
);
|
||||||
|
expect(find.text('채택하기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('기한이 지난 채택은 E-QNA-02 안내를 띄운다', (tester) async {
|
||||||
|
final repo = _QnaRepository(
|
||||||
|
failWith: const BackendException(
|
||||||
|
'E-QNA-02',
|
||||||
|
'채택 기한이 지나 현상금이 환불될 예정입니다.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
InsideDetailScreen(question: mine(), repository: repo),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('채택하기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('채택'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('채택 기한이 지나 현상금이 환불될 예정입니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('댓글을 보내면 저장 뒤 목록에 보인다', (tester) async {
|
||||||
|
final repo = _QnaRepository();
|
||||||
|
await _pump(
|
||||||
|
tester,
|
||||||
|
InsideDetailScreen(question: mine(), repository: repo),
|
||||||
|
);
|
||||||
|
await tester.enterText(find.byType(TextField), '새 답변');
|
||||||
|
await tester.tap(
|
||||||
|
find.byWidgetPredicate((w) => w is Image && w.image == AppIcons.send),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.sent, ['새 답변']);
|
||||||
|
expect(find.text('새 답변'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('서버 코드를 화면 문구로 옮긴다', () {
|
||||||
|
expect(
|
||||||
|
insideErrorMessage(const BackendException('E-QNA-01', 'x')),
|
||||||
|
'자기 답변은 채택할 수 없습니다.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
insideErrorMessage(const BackendException('E-QNA-03', 'x')),
|
||||||
|
'이미 채택한 질문입니다. 채택은 바꿀 수 없습니다.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
insideErrorMessage(const BackendException('E-SYS-01', '서버 문구')),
|
||||||
|
'서버 문구',
|
||||||
|
);
|
||||||
|
expect(insideErrorMessage(StateError('x')), '연결을 확인하고 다시 시도해 주세요.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('STATION 허브 (S-S-HUB · Figma 시안 없음)', () {
|
||||||
|
testWidgets('분류 칩과 링크를 띄운다', (tester) async {
|
||||||
|
await _pump(tester, const StationScreen());
|
||||||
|
expect(find.text('전체'), findsOneWidget);
|
||||||
|
for (final c in StationCategory.values) {
|
||||||
|
expect(find.text(c.label), findsWidgets);
|
||||||
|
}
|
||||||
|
expect(find.byType(StationLinkTile), findsNWidgets(4));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('분류를 고르면 그 분류만 남는다', (tester) async {
|
||||||
|
await _pump(tester, const StationScreen());
|
||||||
|
await tester.tap(find.widgetWithText(ChoiceChip, '예매'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(StationLinkTile), findsOneWidget);
|
||||||
|
expect(find.text('팬미팅 예매'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('링크가 없으면 가입 안내를 띄우고 제안 버튼을 감춘다', (tester) async {
|
||||||
|
await _pump(tester, StationScreen(repository: _EmptyStationRepository()));
|
||||||
|
expect(find.textContaining('아직 등록된 링크가 없습니다'), findsOneWidget);
|
||||||
|
expect(find.text('판매처 제안'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('STATION 제안 (S-S-SUGGEST)', () {
|
||||||
|
testWidgets('https 가 아닌 주소를 막는다', (tester) async {
|
||||||
|
await _pump(tester, const StationSuggestScreen());
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(0), '어떤 판매처');
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).at(1),
|
||||||
|
'http://shop.example',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('제출'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('https 로 시작하는 주소를 입력해 주세요.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('https 주소는 통과한다', (tester) async {
|
||||||
|
await _pump(tester, const StationSuggestScreen());
|
||||||
|
await tester.enterText(find.byType(TextFormField).at(0), '어떤 판매처');
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextFormField).at(1),
|
||||||
|
'https://shop.example',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('제출'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('https 로 시작하는 주소를 입력해 주세요.'), findsNothing);
|
||||||
|
expect(find.textContaining('서버 연동 후 열립니다'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MinBounty extends SampleInsideRepository {
|
||||||
|
@override
|
||||||
|
Future<int> minBounty() async => 200;
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart' show FieldValue;
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/features/my/my_data.dart';
|
||||||
|
import 'package:fancake/features/my/photo_picker.dart';
|
||||||
|
import 'package:fancake/features/planet/lounge_post_screens.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_repository_firestore.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 팬덤 라운지 쓰기 경로 — 글 · 좋아요 · 댓글 · 신고 · 차단이 서버 문서로 남는가.
|
||||||
|
///
|
||||||
|
/// 규칙 판정(본인 · 팬덤 회원 · 집계 필드 위조)은 `firebase/test` 의 규칙
|
||||||
|
/// 테스트가 본다. 여기서는 앱이 **규칙이 받는 모양으로** 쓰는지를 본다.
|
||||||
|
|
||||||
|
const _me = 'u-me';
|
||||||
|
|
||||||
|
Future<(FakeFirebaseFirestore, FirestorePlanetRepository)> _repo() async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
// displayName 은 공급자 실명 자리 — 라운지 이름은 닉네임에서만 나온다(M-7).
|
||||||
|
await db.doc('users/$_me').set({
|
||||||
|
'displayName': '김실명',
|
||||||
|
'nickname': '나',
|
||||||
|
'fandomId': 'bts',
|
||||||
|
});
|
||||||
|
final backend = Backend(
|
||||||
|
firestore: db,
|
||||||
|
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _me)),
|
||||||
|
);
|
||||||
|
return (db, FirestorePlanetRepository(backend, fandomId: 'bts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 1×1 PNG. 화면이 미리보기를 그려야 해서 실제로 디코딩되는 바이트를 쓴다.
|
||||||
|
final _png = base64Decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||||
|
);
|
||||||
|
|
||||||
|
PickedPhoto _photo([int? size]) => PickedPhoto(
|
||||||
|
bytes: size == null ? _png : Uint8List(size),
|
||||||
|
contentType: 'image/png',
|
||||||
|
name: 'a.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('라운지 쓰기 — Firestore', () {
|
||||||
|
test('글은 규칙이 받는 필드로만 남고 목록에 바로 뜬다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
final post = await repo.createPost('첫 글');
|
||||||
|
final data = (await db.doc('posts/${post.id}').get()).data()!;
|
||||||
|
expect(data.keys.toSet(), {
|
||||||
|
'fandomId', 'authorUid', 'authorName', 'body', 'imageUrls',
|
||||||
|
'likeCount', 'commentCount', 'visibility', 'createdAt', 'updatedAt',
|
||||||
|
});
|
||||||
|
expect(data['authorName'], '나');
|
||||||
|
expect(data['visibility'], 'visible');
|
||||||
|
expect(data['likeCount'], 0);
|
||||||
|
expect(post.isMine, isTrue);
|
||||||
|
|
||||||
|
final list = await repo.morePosts(offset: 0);
|
||||||
|
expect(list.map((p) => p.id), [post.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('닉네임이 없으면 작성자 이름은 실명이 아니라 「팬」이다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
await db.doc('users/$_me').update({'nickname': FieldValue.delete()});
|
||||||
|
final post = await repo.createPost('글');
|
||||||
|
expect(post.authorName, '팬');
|
||||||
|
final c = await repo.addComment(post.id, '댓글');
|
||||||
|
expect(c.authorName, '팬');
|
||||||
|
final data = (await db.doc('posts/${post.id}').get()).data()!;
|
||||||
|
expect(data['authorName'], '팬');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('사진 6장 · 10MB 초과는 업로드 전에 거른다(B-44)', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
await expectLater(
|
||||||
|
repo.createPost('x', photos: List.filled(6, _photo())),
|
||||||
|
throwsA(isA<PhotoRejected>()),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
repo.createPost('x', photos: [_photo(10 * 1024 * 1024 + 1)]),
|
||||||
|
throwsA(isA<PhotoRejected>()),
|
||||||
|
);
|
||||||
|
expect((await db.collection('posts').get()).docs, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('수정 · 삭제는 본문과 문서에 반영된다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
final post = await repo.createPost('before');
|
||||||
|
final edited = await repo.updatePost(post, 'after');
|
||||||
|
expect(edited.body, 'after');
|
||||||
|
expect((await db.doc('posts/${post.id}').get()).data()!['body'], 'after');
|
||||||
|
await repo.deletePost(edited);
|
||||||
|
expect((await db.doc('posts/${post.id}').get()).exists, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('좋아요는 likes/{uid} 한 건을 켜고 끄며 수는 쓰지 않는다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
final post = await repo.createPost('글');
|
||||||
|
final liked = await repo.toggleLike(post);
|
||||||
|
expect(liked.liked, isTrue);
|
||||||
|
expect(liked.likes, 1);
|
||||||
|
expect((await db.doc('posts/${post.id}/likes/$_me').get()).exists, isTrue);
|
||||||
|
// 집계는 서버 트리거 몫 — 앱은 likeCount 를 건드리지 않는다.
|
||||||
|
expect((await db.doc('posts/${post.id}').get()).data()!['likeCount'], 0);
|
||||||
|
expect((await repo.morePosts(offset: 0)).single.liked, isTrue);
|
||||||
|
|
||||||
|
final unliked = await repo.toggleLike(liked);
|
||||||
|
expect(unliked.liked, isFalse);
|
||||||
|
expect((await db.doc('posts/${post.id}/likes/$_me').get()).exists, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('댓글은 쓰고 읽고 내 것만 지운다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
final post = await repo.createPost('글');
|
||||||
|
final c = await repo.addComment(post.id, '댓글');
|
||||||
|
expect(c.isMine, isTrue);
|
||||||
|
await db.collection('posts/${post.id}/comments').add({
|
||||||
|
'authorUid': 'other', 'authorName': '남', 'body': '숨김',
|
||||||
|
'visibility': 'hidden', 'createdAt': DateTime(2026),
|
||||||
|
});
|
||||||
|
final list = await repo.comments(post.id);
|
||||||
|
expect(list.map((c) => c.body), ['댓글']);
|
||||||
|
await repo.deleteComment(post.id, c.id);
|
||||||
|
expect(await repo.comments(post.id), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('신고는 reports 에 대상 + 신고자 ID 로 한 건 쌓인다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
final post = await repo.createPost('글');
|
||||||
|
await repo.reportPost(post, '부적절한 콘텐츠');
|
||||||
|
final report =
|
||||||
|
(await db.doc('reports/post-${post.id}-$_me').get()).data()!;
|
||||||
|
expect(report['reporterUid'], _me);
|
||||||
|
expect(report['targetType'], 'post');
|
||||||
|
expect(report['targetPath'], 'posts/${post.id}');
|
||||||
|
// 화면 문구가 아니라 규칙이 받는 코드로 적는다.
|
||||||
|
expect(report['reasonCode'], 'inappropriate');
|
||||||
|
await expectLater(
|
||||||
|
repo.reportPost(post, '목록에 없는 사유'),
|
||||||
|
throwsA(isA<BackendException>()),
|
||||||
|
);
|
||||||
|
expect(report['status'], 'pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('차단한 작성자의 글 · 댓글은 목록에서 빠진다', () async {
|
||||||
|
final (db, repo) = await _repo();
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await db.collection('posts').add({
|
||||||
|
'fandomId': 'bts', 'visibility': 'visible',
|
||||||
|
'authorUid': 'troll', 'authorName': '트롤',
|
||||||
|
'body': '$i', 'createdAt': DateTime(2026, 9, 2, i),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final good = await db.collection('posts').add({
|
||||||
|
'fandomId': 'bts', 'visibility': 'visible',
|
||||||
|
'authorUid': 'fan', 'authorName': '팬', 'body': '정상',
|
||||||
|
'createdAt': DateTime(2026, 9, 1),
|
||||||
|
});
|
||||||
|
await db.collection('posts/${good.id}/comments').add({
|
||||||
|
'authorUid': 'troll', 'authorName': '트롤', 'body': '악플',
|
||||||
|
'visibility': 'visible', 'createdAt': DateTime(2026),
|
||||||
|
});
|
||||||
|
|
||||||
|
final first = await repo.morePosts(offset: 0);
|
||||||
|
await repo.blockAuthor(first.first);
|
||||||
|
expect((await db.doc('users/$_me/blocks/troll').get()).exists, isTrue);
|
||||||
|
|
||||||
|
// 한 장(5개)이 통째로 차단 글이어도 다음 장까지 읽어 정상 글을 낸다.
|
||||||
|
final after = await repo.morePosts(offset: 0);
|
||||||
|
expect(after.map((p) => p.body), ['정상']);
|
||||||
|
expect(await repo.comments(good.id), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('운영 사칭 닉네임은 대소문자와 무관하게 막는다(규칙과 같은 목록)', () {
|
||||||
|
for (final n in ['FANUZU팬', 'Admin99', 'xOfficialx', '운영팀장', '관리자']) {
|
||||||
|
expect(nicknameReserved(n), isTrue, reason: n);
|
||||||
|
}
|
||||||
|
expect(nicknameReserved('평범한팬'), isFalse);
|
||||||
|
for (final n in [' 팬 ', '\u3000팬', '팬\u200B', 'Fan', ' FAN', 'ファン', '粉丝', 'ad\u200Dmin', 'FANU\uFEFFZU']) {
|
||||||
|
expect(nicknameReserved(n), isTrue, reason: n);
|
||||||
|
}
|
||||||
|
expect(nicknameReserved('팬팬'), isFalse);
|
||||||
|
expect(nicknameReserved('Fans'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('라운지 쓰기 — 화면', () {
|
||||||
|
Future<void> pump(WidgetTester tester, Widget screen) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(home: screen));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('6번째 사진은 붙지 않고 사유를 띄운다', (tester) async {
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
LoungeComposeScreen(picker: (PhotoSource _) async => _photo()),
|
||||||
|
);
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await tester.tap(find.bySemanticsLabel('앨범'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
expect(find.bySemanticsLabel('사진 5 빼기'), findsOneWidget);
|
||||||
|
expect(find.bySemanticsLabel('사진 6 빼기'), findsNothing);
|
||||||
|
expect(find.text('사진은 5장까지 올릴 수 있습니다.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('저장 실패면 화면에 남아 사유를 띄운다', (tester) async {
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
LoungeComposeScreen(
|
||||||
|
onSubmit: (_) async => throw const PhotoRejected('용량 초과'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.enterText(find.byType(TextField), '본문');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(TextButton, '완료'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('용량 초과'), findsOneWidget);
|
||||||
|
expect(find.byType(LoungeComposeScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('댓글을 보내면 목록에 붙고 수가 늘어 알린다', (tester) async {
|
||||||
|
LoungePost? changed;
|
||||||
|
final post = LoungePost(
|
||||||
|
id: 'widget-${DateTime.now().microsecondsSinceEpoch}',
|
||||||
|
authorName: '팬',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
title: '',
|
||||||
|
body: '본문',
|
||||||
|
likes: 0,
|
||||||
|
comments: 0,
|
||||||
|
);
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
LoungePostScreen(post: post, onChanged: (p) => changed = p),
|
||||||
|
);
|
||||||
|
expect(find.text('첫 댓글을 남겨주세요.'), findsOneWidget);
|
||||||
|
await tester.enterText(find.byType(TextField), '반가워요');
|
||||||
|
await tester.tap(find.byTooltip('보내기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('반가워요'), findsOneWidget);
|
||||||
|
expect(changed?.comments, 1);
|
||||||
|
|
||||||
|
await tester.tap(find.bySemanticsLabel('좋아요'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(changed?.liked, isTrue);
|
||||||
|
expect(changed?.likes, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/core/monetization/store.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart' show ItemProduct;
|
||||||
|
import 'package:fancake/features/home/home_repository_firestore.dart' show kstDayKey;
|
||||||
|
import 'package:fancake/features/home/widgets/item_sheet.dart';
|
||||||
|
import 'package:fancake/features/my/my_data.dart';
|
||||||
|
import 'package:fancake/features/my/my_screen.dart';
|
||||||
|
import 'package:fancake/features/my/my_repository_firestore.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||||
|
|
||||||
|
/// 결제 경계 — 별첨 1 ⑥ 「영수증 서버검증 통과」 · 원장 B-02 · B-72.
|
||||||
|
///
|
||||||
|
/// 스토어와 서버는 대역이다. 보는 것은 두 가지다 — **서버가 받아들인 뒤에만 스토어에
|
||||||
|
/// 구매 완료를 알리는가**, 그리고 MY 결제 내역이 서버 문서를 화면 상태로 옳게 옮기는가.
|
||||||
|
|
||||||
|
const _uid = 'u-pay';
|
||||||
|
|
||||||
|
class _Backend extends Backend {
|
||||||
|
_Backend(FakeFirebaseFirestore db, {this.failWith})
|
||||||
|
: super(
|
||||||
|
firestore: db,
|
||||||
|
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final BackendException? failWith;
|
||||||
|
final calls = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(
|
||||||
|
String name, [
|
||||||
|
Map<String, dynamic> payload = const {},
|
||||||
|
]) async {
|
||||||
|
calls.add({'name': name, ...payload});
|
||||||
|
if (failWith case final error?) throw error;
|
||||||
|
return const {'granted': 100};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Focused extends SampleMyRepository {
|
||||||
|
bool claimed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FocusStatus?> focus() async =>
|
||||||
|
FocusStatus(expiresAt: DateTime(2026, 10, 28), claimedToday: claimed);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> claimFocusDaily() async {
|
||||||
|
claimed = true;
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeIap extends Fake implements InAppPurchase {
|
||||||
|
final updates = StreamController<List<PurchaseDetails>>();
|
||||||
|
final completed = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<PurchaseDetails>> get purchaseStream => updates.stream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> completePurchase(PurchaseDetails purchase) async =>
|
||||||
|
completed.add(purchase.productID);
|
||||||
|
}
|
||||||
|
|
||||||
|
PurchaseDetails _purchase(PurchaseStatus status) => PurchaseDetails(
|
||||||
|
purchaseID: '2000000123',
|
||||||
|
productID: 'focus.monthly',
|
||||||
|
verificationData: PurchaseVerificationData(
|
||||||
|
localVerificationData: 'local',
|
||||||
|
serverVerificationData: 'receipt-1',
|
||||||
|
source: 'app_store',
|
||||||
|
),
|
||||||
|
transactionDate: '0',
|
||||||
|
status: status,
|
||||||
|
)..pendingCompletePurchase = true;
|
||||||
|
|
||||||
|
Future<(_FakeIap, _Backend, List<String>)> _run(
|
||||||
|
PurchaseStatus status, {
|
||||||
|
BackendException? failWith,
|
||||||
|
}) async {
|
||||||
|
final iap = _FakeIap();
|
||||||
|
final backend = _Backend(FakeFirebaseFirestore(), failWith: failWith);
|
||||||
|
final store = Store(backend, iap: iap);
|
||||||
|
final messages = <String>[];
|
||||||
|
store.messages.listen(messages.add);
|
||||||
|
store.start();
|
||||||
|
iap.updates.add([_purchase(status)]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
await store.dispose();
|
||||||
|
return (iap, backend, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Store — 검증 뒤에만 구매 완료', () {
|
||||||
|
test('서버가 받아들이면 영수증을 보낸 뒤 구매를 완료한다', () async {
|
||||||
|
final (iap, backend, messages) = await _run(PurchaseStatus.purchased);
|
||||||
|
expect(backend.calls.single['name'], 'fnIapVerify');
|
||||||
|
expect(backend.calls.single['receipt'], 'receipt-1');
|
||||||
|
expect(backend.calls.single['productId'], 'focus.monthly');
|
||||||
|
// iOS 영수증에서 이 거래를 고르라고 거래 ID 를 함께 보낸다.
|
||||||
|
expect(backend.calls.single['transactionId'], '2000000123');
|
||||||
|
expect(iap.completed, ['focus.monthly']);
|
||||||
|
expect(messages, ['구매가 반영되었습니다.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('서버가 거부하면 완료하지 않고 사유를 알린다', () async {
|
||||||
|
final (iap, _, messages) = await _run(
|
||||||
|
PurchaseStatus.purchased,
|
||||||
|
failWith: const BackendException('E-PAY-02', '결제 검증 설정이 아직 준비되지 않았습니다.'),
|
||||||
|
);
|
||||||
|
// 완료하지 않은 구매는 다음 실행에서 다시 검증된다(Android 는 끝내 미확인이면 자동 환불).
|
||||||
|
expect(iap.completed, isEmpty);
|
||||||
|
expect(messages, ['결제 검증 설정이 아직 준비되지 않았습니다.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('재시도해도 같은 거부(E-PAY-01)는 거래를 닫고 한 번만 알린다(Android 외)', () async {
|
||||||
|
final (iap, _, messages) = await _run(
|
||||||
|
PurchaseStatus.purchased,
|
||||||
|
failWith: const BackendException('E-PAY-01', '판매하지 않는 상품입니다.'),
|
||||||
|
);
|
||||||
|
// 테스트 호스트는 Android 가 아니다. Android 는 확인하지 않고 두어 스토어 자동 환불을 받는다.
|
||||||
|
expect(iap.completed, ['focus.monthly']);
|
||||||
|
expect(messages, ['판매하지 않는 상품입니다.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('승인 대기(pending)는 서버에 보내지 않고 기다린다', () async {
|
||||||
|
final (iap, backend, messages) = await _run(PurchaseStatus.pending);
|
||||||
|
expect(backend.calls, isEmpty);
|
||||||
|
expect(iap.completed, isEmpty);
|
||||||
|
expect(messages.single, contains('승인'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('복원 재전송은 조용히 검증만 한다', () async {
|
||||||
|
final (iap, backend, messages) = await _run(PurchaseStatus.restored);
|
||||||
|
expect(backend.calls, hasLength(1));
|
||||||
|
expect(iap.completed, ['focus.monthly']);
|
||||||
|
expect(messages, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Focus 일일 보상 — B-04', () {
|
||||||
|
test('만료 전 구독만 상태를 돌려주고 오늘 받았는지 서버 기록으로 판단한다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
final repo = FirestoreMyRepository(_Backend(db));
|
||||||
|
expect(await repo.focus(), isNull);
|
||||||
|
|
||||||
|
await db.doc('users/$_uid/subscription/current').set({
|
||||||
|
'state': 'active', 'expiresAt': DateTime.now().add(const Duration(days: 3)),
|
||||||
|
'focusClaimedDay': kstDayKey(),
|
||||||
|
});
|
||||||
|
expect((await repo.focus())!.claimedToday, isTrue);
|
||||||
|
|
||||||
|
await db.doc('users/$_uid/subscription/current').update({
|
||||||
|
'expiresAt': DateTime.now().subtract(const Duration(minutes: 1)),
|
||||||
|
});
|
||||||
|
expect(await repo.focus(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('MY 에 구독 카드가 뜨고 받으면 「오늘 받음」으로 잠긴다', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: MyScreen(repository: _Focused())));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '오늘 보상 받기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('+40 기여 POP'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilledButton, '오늘 받음'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('구독하지 않으면 카드를 그리지 않는다', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: MyScreen(repository: SampleMyRepository())));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('광고 없는 집중모드 이용 중'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('소속 행성이 없으면 Hero Gift 결제 창을 열기 전에 막는다', (tester) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => TextButton(
|
||||||
|
onPressed: () => showItemSheet(
|
||||||
|
context,
|
||||||
|
items: const [
|
||||||
|
ItemProduct(
|
||||||
|
productId: 'herogift.basic', name: '베이직 드랍', description: '',
|
||||||
|
dropPop: 500, buyerPop: 500, priceWon: 1200, owned: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
purchase: true,
|
||||||
|
),
|
||||||
|
child: const Text('open'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.tap(find.text('open'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('베이직 드랍'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('행성에 가입한 뒤 영웅의 선물을 보낼 수 있어요.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('MY 결제 내역 — users/{uid}/purchases', () {
|
||||||
|
test('최신순으로 읽고 서버 상태를 화면 다섯 상태로 옮긴다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('users/$_uid/purchases/android_a').set({
|
||||||
|
'itemName': '베이직 드랍', 'priceAmount': 1200, 'method': 'Google Play',
|
||||||
|
'state': 'granted', 'purchasedAt': DateTime(2026, 9, 1),
|
||||||
|
});
|
||||||
|
await db.doc('users/$_uid/purchases/android_b').set({
|
||||||
|
'productId': 'focus.monthly', 'state': 'clawback-pending',
|
||||||
|
'purchasedAt': DateTime(2026, 9, 20),
|
||||||
|
});
|
||||||
|
final repo = FirestoreMyRepository(_Backend(db));
|
||||||
|
|
||||||
|
final rows = await repo.payments();
|
||||||
|
expect(rows.map((p) => p.id), ['android_b', 'android_a']);
|
||||||
|
expect(rows.last.itemName, '베이직 드랍');
|
||||||
|
expect(rows.last.price, 1200);
|
||||||
|
expect(rows.last.state, PaymentState.paid);
|
||||||
|
// 이름이 없으면 상품 ID, 회수 대기는 스토어 환불이 끝난 건이다.
|
||||||
|
expect(rows.first.itemName, 'focus.monthly');
|
||||||
|
expect(rows.first.state, PaymentState.refunded);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('환불 요청은 상태 · 사유 · 시각만 쓴다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('users/$_uid/purchases/android_a').set({
|
||||||
|
'state': 'granted', 'amountPop': 100, 'purchasedAt': DateTime(2026, 9, 1),
|
||||||
|
});
|
||||||
|
final repo = FirestoreMyRepository(_Backend(db));
|
||||||
|
|
||||||
|
await repo.requestRefund('android_a', '실수로 결제했습니다');
|
||||||
|
final doc = (await db.doc('users/$_uid/purchases/android_a').get()).data()!;
|
||||||
|
expect(doc['state'], 'refund-requested');
|
||||||
|
expect(doc['refundReason'], '실수로 결제했습니다');
|
||||||
|
expect(doc['amountPop'], 100);
|
||||||
|
expect((await repo.payment('android_a')).state, PaymentState.refundRequested);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:fancake/features/planet/widgets/fpi_hexagon.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 그리기 비용 측정 — 09-03 GALAXY 2.5D 시연의 프레임 예산을 지키는지 본다.
|
||||||
|
///
|
||||||
|
/// **왜 이걸 재는가.** 갤럭시 맵은 매 프레임 별밭 220개와 궤도·행성을 다시 그린다.
|
||||||
|
/// 한 프레임의 예산은 60fps 기준 16.7ms 이고, 그 안에 레이아웃·합성·래스터화가 전부
|
||||||
|
/// 들어가야 한다. 그리기 하나가 예산을 다 먹으면 맵이 끊긴다.
|
||||||
|
///
|
||||||
|
/// 여기서 재는 것은 **[CustomPainter.paint] 가 표시 목록을 만드는 데 드는 CPU 시간**이다.
|
||||||
|
/// GPU 래스터화 시간은 기기에서만 잴 수 있으므로 이 수치가 곧 프레임 시간은 아니다.
|
||||||
|
/// 다만 이 값이 예산을 넘으면 어떤 기기에서도 60fps 가 나오지 않는다 — 하한 검증이다.
|
||||||
|
|
||||||
|
/// 표시 목록 생성 시간의 중앙값(마이크로초).
|
||||||
|
int medianPaintMicros(
|
||||||
|
CustomPainter painter,
|
||||||
|
Size size, {
|
||||||
|
int warmup = 20,
|
||||||
|
int samples = 60,
|
||||||
|
}) {
|
||||||
|
// 첫 몇 회는 JIT 예열이라 버린다. 넣으면 중앙값이 실제보다 나쁘게 나온다.
|
||||||
|
for (var i = 0; i < warmup; i++) {
|
||||||
|
_paintOnce(painter, size);
|
||||||
|
}
|
||||||
|
final measured = <int>[
|
||||||
|
for (var i = 0; i < samples; i++) _paintOnce(painter, size),
|
||||||
|
]..sort();
|
||||||
|
return measured[measured.length ~/ 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
int _paintOnce(CustomPainter painter, Size size) {
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
final canvas = Canvas(recorder);
|
||||||
|
final watch = Stopwatch()..start();
|
||||||
|
painter.paint(canvas, size);
|
||||||
|
watch.stop();
|
||||||
|
recorder.endRecording().dispose();
|
||||||
|
return watch.elapsedMicroseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 60fps 프레임 예산. 그리기 하나가 이 절반을 넘으면 여유가 없다고 본다.
|
||||||
|
const int frameBudgetMicros = 16700;
|
||||||
|
const int paintBudgetMicros = frameBudgetMicros ~/ 2;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('갤럭시 맵 한 프레임 그리기가 예산 안에 든다', () {
|
||||||
|
const planets = [
|
||||||
|
PlanetNode(id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 421, isMine: true),
|
||||||
|
PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 231000, members: 9800, online: 388),
|
||||||
|
PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 198400, members: 8600, online: 254),
|
||||||
|
PlanetNode(id: 'ive', name: 'IVE', rank: 4, fpi: 176200, members: 7400, online: 199),
|
||||||
|
PlanetNode(id: 'aespa', name: 'aespa', rank: 5, fpi: 155900, members: 6900, online: 174),
|
||||||
|
PlanetNode(id: 'lesserafim', name: 'LE SSERAFIM', rank: 6, fpi: 141300, members: 6100, online: 143),
|
||||||
|
PlanetNode(id: 'txt', name: 'TXT', rank: 7, fpi: 128700, members: 5500, online: 121),
|
||||||
|
PlanetNode(id: 'straykids', name: 'Stray Kids', rank: 8, fpi: 119400, members: 5200, online: 108),
|
||||||
|
PlanetNode(id: 'enhypen', name: 'ENHYPEN', rank: 9, fpi: 104800, members: 4700, online: 96),
|
||||||
|
PlanetNode(id: 'zerobaseone', name: 'ZEROBASEONE', rank: 10, fpi: 98200, members: 4100, online: 87),
|
||||||
|
PlanetNode(id: 'riize', name: 'RIIZE', rank: 11, fpi: 90500, members: 3800, online: 74),
|
||||||
|
PlanetNode(id: 'boynextdoor', name: 'BOYNEXTDOOR', rank: 12, fpi: 82100, members: 3400, online: 61),
|
||||||
|
];
|
||||||
|
|
||||||
|
final micros = medianPaintMicros(
|
||||||
|
galaxyBackdropPainterForTest(planets: planets),
|
||||||
|
const Size(GalaxyField.extent, GalaxyField.extent),
|
||||||
|
);
|
||||||
|
// ignore: avoid_print — 성능 리포트가 이 값을 그대로 인용한다.
|
||||||
|
print('[perf] galaxy-backdrop median paint: ${micros}us');
|
||||||
|
expect(micros, lessThan(paintBudgetMicros),
|
||||||
|
reason: '갤럭시 맵 그리기가 프레임 예산의 절반을 넘었습니다');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('행성 수를 배로 늘려도 예산 안에 있다', () {
|
||||||
|
final planets = [
|
||||||
|
for (var i = 0; i < 24; i++)
|
||||||
|
PlanetNode(
|
||||||
|
id: 'f$i', name: 'F$i', rank: i + 1, fpi: 300000 - i * 9000,
|
||||||
|
members: 12000 - i * 300, online: 400 - i * 12, isMine: i == 0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final micros = medianPaintMicros(
|
||||||
|
galaxyBackdropPainterForTest(planets: planets),
|
||||||
|
const Size(GalaxyField.extent, GalaxyField.extent),
|
||||||
|
);
|
||||||
|
print('[perf] galaxy-backdrop(24) median paint: ${micros}us');
|
||||||
|
expect(micros, lessThan(paintBudgetMicros));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FPI 육각형 그래프 그리기 비용', () {
|
||||||
|
const scores = [
|
||||||
|
FpiScore(metric: FpiMetric.bpm, value: 82400, rank: 1, trend: 1240, myTopPercent: 3),
|
||||||
|
FpiScore(metric: FpiMetric.health, value: 61200, rank: 2, trend: -320, myTopPercent: 12),
|
||||||
|
FpiScore(metric: FpiMetric.referral, value: 38900, rank: 4, trend: 640, myTopPercent: 21),
|
||||||
|
FpiScore(metric: FpiMetric.community, value: 55100, rank: 2, trend: 210, myTopPercent: 8),
|
||||||
|
FpiScore(metric: FpiMetric.contribution, value: 46710, rank: 3, trend: 980, myTopPercent: 5),
|
||||||
|
];
|
||||||
|
final micros = medianPaintMicros(
|
||||||
|
fpiHexagonPainterForTest(scores: scores, max: 82400),
|
||||||
|
const Size(320, 320),
|
||||||
|
);
|
||||||
|
print('[perf] fpi-hexagon median paint: ${micros}us');
|
||||||
|
expect(micros, lessThan(paintBudgetMicros));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('갤럭시 맵이 두 프레임 안에 선다', (tester) async {
|
||||||
|
// 앱 시작 시간은 여기서 재지 않는다. 테스트 하네스가 바인딩을 세우는 비용이
|
||||||
|
// 섞여 기기 실측과 자릿수가 다르다 — 시작 시간은 기기에서만 잰다.
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: GalaxyScreen(
|
||||||
|
repository: SampleGalaxyRepository(),
|
||||||
|
planetRepository: samplePlanetRepository,
|
||||||
|
eclipse: SampleEclipseRepository(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
|
||||||
|
expect(find.byType(GalaxyField), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:fancake/core/data/planet_visual_state.dart';
|
||||||
|
import 'package:fancake/core/ui/live_planet.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_25d.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final reduced in [false, true]) {
|
||||||
|
testWidgets('진화 완료 기록 버튼: 첫 조회/Level 제외, Reduce Motion=$reduced', (tester) async {
|
||||||
|
var opened = 0;
|
||||||
|
Future<void> show(int stage, int level) => tester.pumpWidget(MaterialApp(
|
||||||
|
home: MediaQuery(data: MediaQueryData(disableAnimations: reduced),
|
||||||
|
child: Scaffold(body: Center(child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png', size: 240,
|
||||||
|
stage: stage, level: level, onOpenHistory: () => opened++,
|
||||||
|
)))),
|
||||||
|
));
|
||||||
|
await show(1, 1);
|
||||||
|
await tester.pump(const Duration(seconds: 5));
|
||||||
|
expect(find.text('PASSPORT 기록 보기'), findsNothing);
|
||||||
|
await show(1, 2);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
expect(find.text('PASSPORT 기록 보기'), findsNothing);
|
||||||
|
await show(2, 3);
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
expect(find.text('PASSPORT 기록 보기'), findsNothing);
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
expect(find.text('PASSPORT 기록 보기'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('PASSPORT 기록 보기'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(opened, 1);
|
||||||
|
expect(find.text('PASSPORT 기록 보기'), findsNothing);
|
||||||
|
await show(2, 3);
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
expect(find.text('PASSPORT 기록 보기'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('팬덤 서버 스트림은 유효 Stage/Level 변화만 전달한다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('fandoms/bts').set({'stage': 1, 'level': 2});
|
||||||
|
final repos = Repositories(
|
||||||
|
live: true,
|
||||||
|
backend: Backend(firestore: db, auth: MockFirebaseAuth()),
|
||||||
|
);
|
||||||
|
final states = <PlanetVisualState>[];
|
||||||
|
final sub = repos.watchPlanet('bts').listen(states.add);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(states.last.stage, 1);
|
||||||
|
expect(states.last.level, 2);
|
||||||
|
await db.doc('fandoms/bts').update({'stage': 2, 'level': 10});
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(states.last.stage, 2);
|
||||||
|
expect(states.last.level, 10);
|
||||||
|
await db.doc('fandoms/bts').update({'stage': 9, 'level': -1});
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(states.last.stage, isNull);
|
||||||
|
expect(states.last.level, isNull);
|
||||||
|
await sub.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Eclipse 성공은 행성 조명만 회복하고 Stage/Level은 유지한다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('fandoms/bts').set({'stage': 3, 'level': 20});
|
||||||
|
final event = db.doc('fandoms/bts/eclipseRounds/one');
|
||||||
|
await event.set({
|
||||||
|
'status': 'active',
|
||||||
|
'startAt': Timestamp.now(),
|
||||||
|
'defended': false,
|
||||||
|
});
|
||||||
|
final repos = Repositories(
|
||||||
|
live: true,
|
||||||
|
backend: Backend(firestore: db, auth: MockFirebaseAuth()),
|
||||||
|
);
|
||||||
|
final states = <PlanetVisualState>[];
|
||||||
|
final sub = repos.watchPlanet('bts').listen(states.add);
|
||||||
|
addTearDown(sub.cancel);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last.isEclipsed, true);
|
||||||
|
expect(states.last.stage, 3);
|
||||||
|
await event.update({'defended': true});
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last.isEclipsed, false);
|
||||||
|
expect(states.last.stage, 3);
|
||||||
|
expect(states.last.level, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Eclipse 밝기는 점진 복귀하며 행성 표면과 단계는 유지한다', (tester) async {
|
||||||
|
Future<void> show(bool danger) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 200,
|
||||||
|
stage: 3,
|
||||||
|
isEclipsed: danger,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final opacity = find.byKey(const ValueKey('planet-surface-light'));
|
||||||
|
await show(true);
|
||||||
|
expect(tester.widget<Opacity>(opacity).opacity, 0.5);
|
||||||
|
await show(false);
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(tester.widget<Opacity>(opacity).opacity, greaterThan(0.5));
|
||||||
|
expect(tester.widget<Opacity>(opacity).opacity, lessThan(1));
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(tester.widget<Opacity>(opacity).opacity, 1);
|
||||||
|
expect(tester.widget<Planet25D>(find.byType(Planet25D)).stage, 3);
|
||||||
|
expect(find.text('이상 반응'), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HERO TIME은 실제 남은 Pool과 서버 종료시각을 따르고 Stage는 유지한다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('fandoms/bts').set({'stage': 3, 'level': 20});
|
||||||
|
final drop = db.doc('fandoms/bts/heroDrops/one');
|
||||||
|
await drop.set({
|
||||||
|
'heroNickname': '공개팬',
|
||||||
|
'claimedCount': 3,
|
||||||
|
'triggeredBy': 'private-uid',
|
||||||
|
'email': 'private@example.com',
|
||||||
|
'remainingPop': 100,
|
||||||
|
'endsAt': Timestamp.fromDate(
|
||||||
|
DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
final repos = Repositories(
|
||||||
|
live: true,
|
||||||
|
backend: Backend(firestore: db, auth: MockFirebaseAuth()),
|
||||||
|
);
|
||||||
|
final states = <PlanetVisualState>[];
|
||||||
|
final sub = repos.watchPlanet('bts').listen(states.add);
|
||||||
|
addTearDown(sub.cancel);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last.isHeroTime, true);
|
||||||
|
expect(states.last.heroEndsAt, isNotNull);
|
||||||
|
expect(states.last.heroNicknames, ['공개팬']);
|
||||||
|
expect(states.last.heroClaimCount, 3);
|
||||||
|
// 닉네임 없는 영웅은 서버가 「팬」으로 싣는다 — 이름 줄에는 띄우지 않는다(M-7 이전과 같음).
|
||||||
|
await db.doc('fandoms/bts/heroDrops/nameless').set({
|
||||||
|
'heroNickname': '팬',
|
||||||
|
'claimedCount': 0,
|
||||||
|
'remainingPop': 50,
|
||||||
|
'endsAt': Timestamp.fromDate(
|
||||||
|
DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last.heroNicknames, ['공개팬']);
|
||||||
|
await db.doc('fandoms/bts/heroDrops/nameless').delete();
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last.stage, 3);
|
||||||
|
await drop.update({'remainingPop': 0});
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last.isHeroTime, false);
|
||||||
|
expect(states.last.heroEndsAt, isNull);
|
||||||
|
await drop.update({
|
||||||
|
'remainingPop': 100,
|
||||||
|
'endsAt': Timestamp.fromDate(
|
||||||
|
DateTime.now().subtract(const Duration(seconds: 1)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last.isHeroTime, false);
|
||||||
|
expect(states.last.level, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마지막 HERO TIME 종료시각에 추가 스냅샷 없이 축제를 해제한다', () async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('fandoms/bts').set({'stage': 3});
|
||||||
|
await db.doc('fandoms/bts/heroDrops/one').set({
|
||||||
|
'remainingPop': 100,
|
||||||
|
'endsAt': Timestamp.fromDate(
|
||||||
|
DateTime.now().add(const Duration(seconds: 1)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
final repos = Repositories(
|
||||||
|
live: true,
|
||||||
|
backend: Backend(firestore: db, auth: MockFirebaseAuth()),
|
||||||
|
);
|
||||||
|
final states = <PlanetVisualState>[];
|
||||||
|
final sub = repos.watchPlanet('bts').listen(states.add);
|
||||||
|
addTearDown(sub.cancel);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
expect(states.last.isHeroTime, true);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||||
|
expect(states.last.isHeroTime, false);
|
||||||
|
expect(states.last.stage, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('첫 실시간 값은 캐시와 달라도 진화로 연출하지 않는다', (tester) async {
|
||||||
|
final states = StreamController<PlanetVisualState>();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: LivePlanet25D(
|
||||||
|
fandomId: 'bts',
|
||||||
|
watch: (_) => states.stream,
|
||||||
|
stage: 0,
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 240,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
states.add(const PlanetVisualState(stage: 2, level: 10));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.widget<Planet25D>(find.byType(Planet25D)).stage, 2);
|
||||||
|
expect(find.text('이상 반응'), findsNothing);
|
||||||
|
states.add(const PlanetVisualState(stage: 3, level: 20));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('이상 반응'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.runAsync(states.close);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('서버 Stage 상승은 5구간 진화로 공개하고 재수신에는 반복하지 않는다', (tester) async {
|
||||||
|
Future<void> show(int stage) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 240,
|
||||||
|
stage: stage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await show(1);
|
||||||
|
expect(find.text('에너지 집중'), findsNothing);
|
||||||
|
await show(2);
|
||||||
|
expect(find.text('이상 반응'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
expect(find.text('에너지 집중'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(milliseconds: 650));
|
||||||
|
expect(find.text('전환'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(find.text('Stage 2 · 성장'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(milliseconds: 800));
|
||||||
|
expect(find.text('행성 진화 완료'), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await show(2);
|
||||||
|
expect(find.text('이상 반응'), findsNothing);
|
||||||
|
await show(1); // 정정된 낮은 값 이후 같은 최고 Stage도 재축하하지 않는다.
|
||||||
|
await show(2);
|
||||||
|
expect(find.text('이상 반응'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Level 상승은 Stage Evolution과 구분된 짧은 결과를 표시한다', (tester) async {
|
||||||
|
Future<void> show(int level) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 240,
|
||||||
|
stage: 2,
|
||||||
|
level: level,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await show(5);
|
||||||
|
await show(6);
|
||||||
|
expect(find.text('Lv.6'), findsOneWidget);
|
||||||
|
expect(find.text('이상 반응'), findsNothing);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Lv.6'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Reduce Motion에서도 확정된 새 Stage를 즉시 알린다', (tester) async {
|
||||||
|
Future<void> show(int stage) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(disableAnimations: true),
|
||||||
|
child: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 240,
|
||||||
|
stage: stage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await show(0);
|
||||||
|
await show(1);
|
||||||
|
expect(find.text('이상 반응'), findsNothing);
|
||||||
|
expect(find.text('Stage 1 · 시작'), findsOneWidget);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_25d.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_fx_scope.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/zap_feedback.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('진화 중 ZAP 효과만 억제하고 확정 기여는 표시하며 종료 후 복귀한다', (tester) async {
|
||||||
|
Future<void> show(int stage, String? tx, {bool planet = true}) =>
|
||||||
|
tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: PlanetFxScope(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (planet)
|
||||||
|
Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 200,
|
||||||
|
stage: stage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: ZapFeedback(
|
||||||
|
touchSerial: 0,
|
||||||
|
pending: false,
|
||||||
|
result: tx == null
|
||||||
|
? null
|
||||||
|
: ZapResult(
|
||||||
|
txId: tx,
|
||||||
|
balanceAfter: 900,
|
||||||
|
contributedPop: 37,
|
||||||
|
),
|
||||||
|
source: const Offset(100, 300),
|
||||||
|
target: const Offset(100, 100),
|
||||||
|
radius: 60,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final paints = find.descendant(
|
||||||
|
of: find.byType(ZapFeedback),
|
||||||
|
matching: find.byType(CustomPaint),
|
||||||
|
);
|
||||||
|
await show(1, null);
|
||||||
|
await show(2, 'one');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('이상 반응'), findsOneWidget);
|
||||||
|
expect(find.text('37 POP 기여 완료'), findsOneWidget);
|
||||||
|
expect(paints, findsNothing);
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pump();
|
||||||
|
await show(2, 'two');
|
||||||
|
expect(paints, findsOneWidget);
|
||||||
|
expect(find.text('37 POP 기여 완료'), findsOneWidget);
|
||||||
|
await show(3, 'three');
|
||||||
|
await tester.pump();
|
||||||
|
expect(paints, findsNothing);
|
||||||
|
await show(3, 'three', planet: false);
|
||||||
|
await tester.pump();
|
||||||
|
expect(paints, findsOneWidget, reason: '연출 소유자가 제거되면 억제가 남지 않아야 한다');
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import 'package:fancake/core/data/planet_visual_state.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_25d.dart';
|
||||||
|
import 'package:fancake/features/home/planet_create_screen.dart';
|
||||||
|
import 'package:fancake/features/galaxy/galaxy_data.dart';
|
||||||
|
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// C-50 — 생성 화면이 고른 colorKey 를 같은 팔레트로 본체에 입힌다.
|
||||||
|
testWidgets('행성 본체는 서버 colorKey 의 생성 팔레트 색을 입는다', (tester) async {
|
||||||
|
expect(identical(PlanetCreateScreen.colors, planetColors), isTrue);
|
||||||
|
expect(PlanetVisualState.fromDocument({'colorKey': 'gold'}).colorKey, 'gold');
|
||||||
|
expect(planetColorOf('gold'), const Color(0xFFFFC500));
|
||||||
|
expect(planetColorOf('violet'), isNull, reason: '서버 기본값은 원래 일러스트 그대로');
|
||||||
|
|
||||||
|
Future<Iterable<ColorFiltered>> filters(Color? tint) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Center(child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png', size: 200, stage: 1, tint: tint, animate: false,
|
||||||
|
))));
|
||||||
|
return tester.widgetList<ColorFiltered>(find.byType(ColorFiltered));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await filters(null), isEmpty);
|
||||||
|
final tinted = await filters(planetColorOf('gold'));
|
||||||
|
expect(tinted.single.colorFilter,
|
||||||
|
const ColorFilter.mode(Color(0xFFFFC500), BlendMode.modulate));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('행성 본체와 로고는 시간이 지나도 평면 회전하지 않는다', (tester) async {
|
||||||
|
final previous = Planet25D.animationsEnabled;
|
||||||
|
Planet25D.animationsEnabled = true;
|
||||||
|
addTearDown(() => Planet25D.animationsEnabled = previous);
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: Center(child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png', size: 200, stage: 1,
|
||||||
|
))));
|
||||||
|
final body = find.byWidgetPredicate((w) => w is Image &&
|
||||||
|
w.image is AssetImage && (w.image as AssetImage).assetName == 'assets/figma/planet-home.png');
|
||||||
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
|
for (var quarter = 0; quarter < 4; quarter++) {
|
||||||
|
await tester.pump(const Duration(seconds: 12));
|
||||||
|
final box = tester.renderObject<RenderBox>(body);
|
||||||
|
final left = box.localToGlobal(Offset(0, box.size.height / 2));
|
||||||
|
final right = box.localToGlobal(Offset(box.size.width, box.size.height / 2));
|
||||||
|
expect(right.dx - left.dx, greaterThan(0));
|
||||||
|
expect(right.dy, closeTo(left.dy, 0.01));
|
||||||
|
}
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('뒤 고리와 잔해는 본체 안쪽에서 차폐하고 앞 고리만 통과한다', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 200,
|
||||||
|
stage: 4,
|
||||||
|
animate: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final clips = tester
|
||||||
|
.widgetList<ClipPath>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(Planet25D),
|
||||||
|
matching: find.byType(ClipPath),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where((w) => w.clipper != null)
|
||||||
|
.toList();
|
||||||
|
expect(clips.length, 2);
|
||||||
|
const size = Size(340, 340);
|
||||||
|
final rear = clips.first.clipper!.getClip(size);
|
||||||
|
final front = clips.last.clipper!.getClip(size);
|
||||||
|
expect(rear.contains(const Offset(170, 145)), false, reason: '본체 뒤는 투과 금지');
|
||||||
|
expect(
|
||||||
|
rear.contains(const Offset(55, 150)),
|
||||||
|
true,
|
||||||
|
reason: '본체 밖의 뒤 고리는 표시',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
front.contains(const Offset(170, 195)),
|
||||||
|
true,
|
||||||
|
reason: '앞 고리는 본체 위에 합성',
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('갤럭시의 Stage 0도 고체 행성 대신 원본 성운을 렌더한다', (tester) async {
|
||||||
|
const nebula = PlanetNode(
|
||||||
|
id: 'nebula',
|
||||||
|
name: '성운',
|
||||||
|
stage: 0,
|
||||||
|
rank: 1,
|
||||||
|
fpi: 0,
|
||||||
|
members: 1,
|
||||||
|
online: 1,
|
||||||
|
isMine: true,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: GalaxyField(
|
||||||
|
planets: const [nebula],
|
||||||
|
selected: nebula,
|
||||||
|
onSelect: (_) {},
|
||||||
|
spin: 0,
|
||||||
|
pulse: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byType(Planet25D), findsOneWidget);
|
||||||
|
final images = tester
|
||||||
|
.widgetList<Image>(find.byType(Image))
|
||||||
|
.map((image) => image.image)
|
||||||
|
.whereType<AssetImage>()
|
||||||
|
.map((asset) => asset.assetName);
|
||||||
|
expect(images.contains('assets/fx/nebula-figma.png'), true);
|
||||||
|
expect(images.contains('assets/figma/planet-home.png'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Stage 0 uses the Figma nebula instead of a solid planet; stages expose their names',
|
||||||
|
(tester) async {
|
||||||
|
final semantics = tester.ensureSemantics();
|
||||||
|
const names = ['성운', '시작', '성장', '함께', '동행', '빛', '영원'];
|
||||||
|
for (var stage = 0; stage <= 6; stage++) {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 200,
|
||||||
|
stage: stage,
|
||||||
|
animate: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final images = tester
|
||||||
|
.widgetList<Image>(find.byType(Image))
|
||||||
|
.map((image) => image.image)
|
||||||
|
.whereType<AssetImage>()
|
||||||
|
.map((asset) => asset.assetName);
|
||||||
|
expect(images.contains('assets/figma/planet-home.png'), stage != 0);
|
||||||
|
expect(images.contains('assets/fx/nebula-figma.png'), stage == 0);
|
||||||
|
expect(
|
||||||
|
find.bySemanticsLabel('Stage $stage · ${names[stage]}'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
}
|
||||||
|
semantics.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Reduce Motion stops the ambient ticker, including runtime changes',
|
||||||
|
(tester) async {
|
||||||
|
Planet25D.animationsEnabled = true;
|
||||||
|
addTearDown(() => Planet25D.animationsEnabled = false);
|
||||||
|
Future<void> show(bool reduced) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: MediaQueryData(disableAnimations: reduced),
|
||||||
|
child: const Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await show(true);
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(tester.binding.transientCallbackCount, 0);
|
||||||
|
await show(false);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(tester.binding.transientCallbackCount, greaterThan(0));
|
||||||
|
await show(true);
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(tester.binding.transientCallbackCount, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/core/ui/planet_25d.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('HERO 종료는 잔여 입자 회수 후 사라지고 재조회에 반복하지 않는다', (tester) async {
|
||||||
|
Future<void> show(Set<String> ids, {int stage = 2, bool reduced = false}) =>
|
||||||
|
tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: MediaQueryData(disableAnimations: reduced),
|
||||||
|
child: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 200,
|
||||||
|
stage: stage,
|
||||||
|
heroEventIds: ids,
|
||||||
|
isHeroTime: ids.isNotEmpty,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await show({'one'});
|
||||||
|
await show({});
|
||||||
|
expect(find.text('HERO TIME 종료'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
expect(find.text('HERO TIME 종료'), findsNothing);
|
||||||
|
await show({});
|
||||||
|
expect(find.text('HERO TIME 종료'), findsNothing);
|
||||||
|
await show({'two'});
|
||||||
|
await show({}, stage: 3);
|
||||||
|
expect(find.text('이상 반응'), findsOneWidget);
|
||||||
|
expect(find.text('HERO TIME 종료'), findsNothing);
|
||||||
|
await tester.pump(const Duration(milliseconds: 3300));
|
||||||
|
expect(find.text('HERO TIME 종료'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
await show({'three'}, stage: 3, reduced: true);
|
||||||
|
await show({}, stage: 3, reduced: true);
|
||||||
|
expect(find.text('HERO TIME 종료'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
testWidgets('HERO 최초 상태는 기준점이며 새 ID만 발동하고 진화 뒤에 이어진다', (tester) async {
|
||||||
|
Future<void> show(int stage, Set<String>? ids) => tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: Planet25D(
|
||||||
|
image: 'assets/figma/planet-home.png',
|
||||||
|
size: 200,
|
||||||
|
stage: stage,
|
||||||
|
isHeroTime: ids?.isNotEmpty ?? false,
|
||||||
|
heroEventIds: ids,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await show(1, null);
|
||||||
|
await show(1, {'old'});
|
||||||
|
expect(find.text('행성이 깨어납니다'), findsNothing);
|
||||||
|
await show(1, {'old', 'new'});
|
||||||
|
expect(find.text('행성이 깨어납니다'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(milliseconds: 1500));
|
||||||
|
expect(find.text('HERO TIME'), findsOneWidget);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
await show(1, {'old', 'new'});
|
||||||
|
expect(find.text('HERO TIME'), findsNothing);
|
||||||
|
await show(2, {'old', 'new', 'next'});
|
||||||
|
expect(find.text('이상 반응'), findsOneWidget);
|
||||||
|
expect(find.text('행성이 깨어납니다'), findsNothing);
|
||||||
|
await tester.pump(const Duration(milliseconds: 3300));
|
||||||
|
expect(find.text('행성이 깨어납니다'), findsOneWidget);
|
||||||
|
await show(2, {});
|
||||||
|
expect(find.text('행성이 깨어납니다'), findsNothing);
|
||||||
|
await show(2, {'next'});
|
||||||
|
expect(find.text('행성이 깨어납니다'), findsNothing, reason: '재수신은 재발동하지 않는다');
|
||||||
|
await show(3, {'next', 'expires'});
|
||||||
|
await show(3, {'next'});
|
||||||
|
await tester.pump(const Duration(milliseconds: 3300));
|
||||||
|
expect(
|
||||||
|
find.text('행성이 깨어납니다'),
|
||||||
|
findsNothing,
|
||||||
|
reason: '대기하던 신규 이벤트가 끝났으면 다른 진행 중 이벤트가 있어도 재생하지 않는다',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/core/data/planet_visual_state.dart';
|
||||||
|
import 'package:fancake/core/ui/live_planet.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('휴면은 FPI를 단말에서 추측하지 않고 명시적인 서버 상태만 읽는다', () {
|
||||||
|
expect(PlanetVisualState.fromDocument({'fpi': 0}).isHibernating, isFalse);
|
||||||
|
expect(PlanetVisualState.fromDocument({'isHibernating': 'true'}).isHibernating, isFalse);
|
||||||
|
expect(PlanetVisualState.fromDocument({'isHibernating': true}).isHibernating, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('서버 휴면에서 어두워지고 복귀 시 점진적으로 광량을 회복한다', (tester) async {
|
||||||
|
final states = StreamController<PlanetVisualState>.broadcast();
|
||||||
|
addTearDown(states.close);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: Scaffold(body: LivePlanet25D(
|
||||||
|
fandomId: 'one', watch: (_) => states.stream,
|
||||||
|
image: 'assets/figma/planet-home.png', size: 200,
|
||||||
|
))));
|
||||||
|
states.add(const PlanetVisualState(stage: 1, isHibernating: true));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
double light() => tester.widget<Opacity>(find.byKey(const ValueKey('planet-surface-light'))).opacity;
|
||||||
|
expect(light(), 0.35);
|
||||||
|
expect(find.bySemanticsLabel('Stage 1 · 시작 · 휴면 중'), findsOneWidget);
|
||||||
|
states.add(const PlanetVisualState(stage: 1));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(light(), greaterThan(0.35));
|
||||||
|
expect(light(), lessThan(1));
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(light(), 1);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
expect(states.hasListener, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:fancake/core/data/planet_visual_state.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_repository_firestore.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_detail_screen.dart';
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'eclipse_stubs.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_history_screen.dart';
|
||||||
|
|
||||||
|
class _Backend extends Backend {
|
||||||
|
_Backend()
|
||||||
|
: super(
|
||||||
|
firestore: FakeFirebaseFirestore(),
|
||||||
|
auth: MockFirebaseAuth(
|
||||||
|
mockUser: MockUser(uid: 'reader'),
|
||||||
|
signedIn: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Map<String, dynamic>? payload;
|
||||||
|
Map<String, dynamic> response = {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'id': 'one',
|
||||||
|
'kind': 'evolution',
|
||||||
|
'occurredAt': '2026-09-22T00:00:00Z',
|
||||||
|
'fromStage': 1,
|
||||||
|
'toStage': 2,
|
||||||
|
'participationConfirmed': 'true',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'nextCursor': null,
|
||||||
|
};
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(
|
||||||
|
String name, [
|
||||||
|
Map<String, dynamic> data = const {},
|
||||||
|
]) async {
|
||||||
|
expect(name, 'fnPlanetHistory');
|
||||||
|
payload = data;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonalHistory extends SamplePlanetRepository {
|
||||||
|
final scopes = <bool>[];
|
||||||
|
@override
|
||||||
|
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
|
||||||
|
scopes.add(personal);
|
||||||
|
return PlanetHistoryPage(items: [PlanetHistoryEntry(
|
||||||
|
id: 'one', kind: personal ? PlanetHistoryKind.zap : PlanetHistoryKind.evolution,
|
||||||
|
occurredAt: DateTime.utc(2026, 9, 22), fromStage: personal ? null : 1,
|
||||||
|
toStage: personal ? null : 2, amount: personal ? 37 : null,
|
||||||
|
)], nextCursor: personal ? null : 'older');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _History extends SamplePlanetRepository {
|
||||||
|
final cursors = <String?>[];
|
||||||
|
@override
|
||||||
|
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
|
||||||
|
cursors.add(cursor);
|
||||||
|
if (cursors.length == 2) throw StateError('private details');
|
||||||
|
return PlanetHistoryPage(
|
||||||
|
items: [
|
||||||
|
PlanetHistoryEntry(
|
||||||
|
id: cursor ?? 'first',
|
||||||
|
kind: PlanetHistoryKind.evolution,
|
||||||
|
occurredAt: DateTime.utc(2026, 9, 22),
|
||||||
|
fromStage: 1,
|
||||||
|
toStage: 2,
|
||||||
|
participationConfirmed: cursor == null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
nextCursor: cursor == null ? 'next' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('내 활동 전환은 이전 목록과 커서를 지우고 실제 성장량을 표시한다', (tester) async {
|
||||||
|
final repository = _PersonalHistory();
|
||||||
|
await tester.pumpWidget(MaterialApp(home: PlanetHistoryScreen(
|
||||||
|
repository: repository, fandomName: '팬덤',
|
||||||
|
)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('내 활동'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repository.scopes, [false, true]);
|
||||||
|
expect(find.text('Stage 1 → 2 진화'), findsNothing);
|
||||||
|
expect(find.text('ZAP 기여'), findsOneWidget);
|
||||||
|
expect(find.textContaining('성장에 37 POP 반영'), findsOneWidget);
|
||||||
|
expect(find.textContaining('이 진화에 함께했습니다'), findsNothing);
|
||||||
|
expect(find.text('이전 기록 더 보기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('서버 Stage 상승 완료에서 현재 행성의 역사 화면을 연다', (tester) async {
|
||||||
|
const repository = SamplePlanetRepository();
|
||||||
|
final states = StreamController<PlanetVisualState>.broadcast();
|
||||||
|
addTearDown(states.close);
|
||||||
|
await tester.pumpWidget(MaterialApp(home: PlanetDetailScreen(
|
||||||
|
repository: repository, eclipse: const NoEclipseRepository(),
|
||||||
|
watchPlanet: (_) => states.stream,
|
||||||
|
)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
states.add(const PlanetVisualState(stage: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
states.add(const PlanetVisualState(stage: 2));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.tap(find.text('PASSPORT 기록 보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<PlanetHistoryScreen>(find.byType(PlanetHistoryScreen)).repository, same(repository));
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('실제 저장소는 대상 팬덤·커서만 요청하고 불완전한 응답을 빈 기록으로 숨기지 않는다', () async {
|
||||||
|
final backend = _Backend();
|
||||||
|
final repository = FirestorePlanetRepository(backend, fandomId: 'target');
|
||||||
|
final page = await repository.history(cursor: 'previous');
|
||||||
|
expect(backend.payload, {'fandomId': 'target', 'cursor': 'previous'});
|
||||||
|
expect(page.items.single.participationConfirmed, isFalse);
|
||||||
|
backend.response = {
|
||||||
|
'items': [
|
||||||
|
{'id': 'bad'},
|
||||||
|
],
|
||||||
|
'nextCursor': null,
|
||||||
|
};
|
||||||
|
await expectLater(repository.history(), throwsStateError);
|
||||||
|
backend.response = {'items': [{'id': 'zap', 'kind': 'zap',
|
||||||
|
'occurredAt': '2026-09-22T00:00:00Z', 'amount': 37, 'participationConfirmed': true}], 'nextCursor': null};
|
||||||
|
final personal = await repository.history(personal: true);
|
||||||
|
expect(backend.payload, {'fandomId': 'target', 'scope': 'personal'});
|
||||||
|
expect(personal.items.single.amount, 37);
|
||||||
|
expect(personal.items.single.participationConfirmed, isFalse);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('행성 상세에서 같은 저장소의 팬덤 역사를 연다', (tester) async {
|
||||||
|
const repository = SamplePlanetRepository();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: PlanetDetailScreen(
|
||||||
|
repository: repository,
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byTooltip('더 보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('PASSPORT · 팬덤 역사'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<PlanetHistoryScreen>(find.byType(PlanetHistoryScreen))
|
||||||
|
.repository,
|
||||||
|
same(repository),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('확정된 개인 참여만 표시하고 다음 페이지 실패는 같은 커서로 재시도한다', (tester) async {
|
||||||
|
final repository = _History();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: PlanetHistoryScreen(repository: repository, fandomName: '팬덤'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('이 진화에 함께했습니다'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('이전 기록 더 보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('기록을 불러오지 못했습니다.'), findsOneWidget);
|
||||||
|
expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsNothing);
|
||||||
|
expect(find.textContaining('private details'), findsNothing);
|
||||||
|
await tester.tap(find.text('다시 시도'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repository.cursors, [null, 'next', 'next']);
|
||||||
|
expect(find.text('Stage 1 → 2 진화'), findsNWidgets(2));
|
||||||
|
expect(find.textContaining('이 진화에 함께했습니다'), findsOneWidget);
|
||||||
|
expect(find.text('이전 기록 더 보기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('조회 성공의 빈 기록을 오류와 구분한다', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: PlanetHistoryScreen(
|
||||||
|
repository: SamplePlanetRepository(),
|
||||||
|
fandomName: '팬덤',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsOneWidget);
|
||||||
|
expect(find.text('다시 시도'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_evidence.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_repository_firestore.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// 퀘스트 서버 연동 계층 — 화면 대조 원장 F4 의 데이터 경로.
|
||||||
|
///
|
||||||
|
/// **무엇을 읽어 무엇으로 옮기는지**와 **어느 함수를 부르는지**를 본다. 규칙이 틀리면
|
||||||
|
/// 화면은 조용히 0 을 그리거나 엉뚱한 함수를 불러 지급이 일어나므로, 눈으로는 잡히지
|
||||||
|
/// 않는다. 지급 자체의 판정은 서버 쪽 `firebase/functions/test/quest_e2e.test.ts` 가 본다.
|
||||||
|
|
||||||
|
const _uid = 'u-quest';
|
||||||
|
|
||||||
|
/// Cloud Functions 호출을 기록하는 경계. 실제 함수는 에뮬레이터에서 따로 검증한다.
|
||||||
|
class _RecordingBackend extends Backend {
|
||||||
|
_RecordingBackend(FakeFirebaseFirestore db, {Map<String, dynamic>? reply})
|
||||||
|
: _reply =
|
||||||
|
reply ?? const {'txId': 'tx-1', 'balanceAfter': 60, 'rewarded': 10},
|
||||||
|
super(
|
||||||
|
firestore: db,
|
||||||
|
auth: MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: _uid, displayName: '테스터'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, dynamic> _reply;
|
||||||
|
final List<({String name, Map<String, dynamic> payload})> calls = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(
|
||||||
|
String name, [
|
||||||
|
Map<String, dynamic> payload = const {},
|
||||||
|
]) async {
|
||||||
|
calls.add((name: name, payload: payload));
|
||||||
|
return _reply;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _today() => DateTime.now()
|
||||||
|
.toUtc()
|
||||||
|
.add(const Duration(hours: 9))
|
||||||
|
.toIso8601String()
|
||||||
|
.substring(0, 10);
|
||||||
|
|
||||||
|
Future<FakeFirebaseFirestore> _seed() async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'});
|
||||||
|
// 걸음 목표는 운영값이다 — 앱에 하드코딩하지 않는다(별첨 B 「운영값 관리 원칙」).
|
||||||
|
await db.doc('config/policy').set({
|
||||||
|
'stepsGoal': 10000,
|
||||||
|
'stepsPop': 50,
|
||||||
|
'attendPop': 10,
|
||||||
|
});
|
||||||
|
await db.doc('quests/attend').set({
|
||||||
|
'type': 'attend',
|
||||||
|
'title': '오늘의 출석',
|
||||||
|
'rewardPop': 50,
|
||||||
|
'dailyLimit': 1,
|
||||||
|
});
|
||||||
|
await db.doc('quests/steps').set({
|
||||||
|
'type': 'walk',
|
||||||
|
'title': '만보기',
|
||||||
|
'rewardPop': 50,
|
||||||
|
'dailyLimit': 1,
|
||||||
|
});
|
||||||
|
await db.doc('quests/place').set({
|
||||||
|
'type': 'location',
|
||||||
|
'title': '팝업 방문',
|
||||||
|
'rewardPop': 100,
|
||||||
|
'dailyLimit': 1,
|
||||||
|
'placeId': 'hongdae',
|
||||||
|
});
|
||||||
|
await db.doc('quests/stream').set({
|
||||||
|
'type': 'streaming',
|
||||||
|
'title': '신곡 완청',
|
||||||
|
'rewardPop': 10,
|
||||||
|
'dailyLimit': 1,
|
||||||
|
});
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
QuestCard _card(List<QuestCard> cards, String id) =>
|
||||||
|
cards.firstWhere((c) => c.id == id);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
// 걸음 기준선은 단말에 남는다. 회차마다 빈 저장소에서 시작해야 값이 겹치지 않는다.
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('퀘스트 목록 — Firestore 구현', () {
|
||||||
|
test('정의 문서를 카드로 옮기고 유형을 별칭까지 읽는다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final cards = await FirestoreQuestRepository(
|
||||||
|
_RecordingBackend(db),
|
||||||
|
).load();
|
||||||
|
|
||||||
|
expect(cards.map((c) => c.id).toSet(), {
|
||||||
|
'attend',
|
||||||
|
'steps',
|
||||||
|
'place',
|
||||||
|
'stream',
|
||||||
|
});
|
||||||
|
expect(_card(cards, 'steps').type, QuestType.steps);
|
||||||
|
expect(_card(cards, 'place').type, QuestType.place);
|
||||||
|
// 출석은 문서의 50 이 아니라 서버가 지급하는 config/policy.attendPop 이다.
|
||||||
|
expect(_card(cards, 'attend').rewardPop, 10);
|
||||||
|
// 걸음 목표는 `config/policy` 에서 온다. 나머지 유형은 일일 한도가 목표다.
|
||||||
|
expect(_card(cards, 'steps').goal, 10000);
|
||||||
|
expect(_card(cards, 'attend').goal, 1);
|
||||||
|
expect(_card(cards, 'attend').stage, QuestStage.notStarted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('당일 한도를 채우면 보상완료로 본다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('users/$_uid/questProgress/attend').set({
|
||||||
|
'count': 1,
|
||||||
|
'dayKey': _today(),
|
||||||
|
});
|
||||||
|
final cards = await FirestoreQuestRepository(
|
||||||
|
_RecordingBackend(db),
|
||||||
|
).load();
|
||||||
|
expect(_card(cards, 'attend').stage, QuestStage.rewarded);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('어제 진행분은 오늘로 세지 않는다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('users/$_uid/questProgress/attend').set({
|
||||||
|
'count': 1,
|
||||||
|
'dayKey': '2026-01-01',
|
||||||
|
});
|
||||||
|
final cards = await FirestoreQuestRepository(
|
||||||
|
_RecordingBackend(db),
|
||||||
|
).load();
|
||||||
|
expect(_card(cards, 'attend').stage, QuestStage.notStarted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('닫힌 퀘스트는 감추고 기간 필드가 없는 문서는 그대로 보여 준다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('quests/closed').set({
|
||||||
|
'type': 'attend',
|
||||||
|
'title': '지난 이벤트',
|
||||||
|
'rewardPop': 10,
|
||||||
|
'active': false,
|
||||||
|
});
|
||||||
|
final cards = await FirestoreQuestRepository(
|
||||||
|
_RecordingBackend(db),
|
||||||
|
).load();
|
||||||
|
expect(cards.map((c) => c.id), isNot(contains('closed')));
|
||||||
|
// 시연 데이터에도 Admin 문서에도 없는 필드를 근거로 감추지 않는다.
|
||||||
|
expect(cards.map((c) => c.id), contains('attend'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('내가 만든 퀘스트는 내 퀘스트 상태로 온다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('quests/mine').set({
|
||||||
|
'type': 'attend',
|
||||||
|
'title': '내가 만든 것',
|
||||||
|
'rewardPop': 10,
|
||||||
|
'authorUid': _uid,
|
||||||
|
});
|
||||||
|
final cards = await FirestoreQuestRepository(
|
||||||
|
_RecordingBackend(db),
|
||||||
|
).load();
|
||||||
|
expect(_card(cards, 'mine').stage, QuestStage.mine);
|
||||||
|
expect(_card(cards, 'mine').author, QuestAuthor.user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('퀘스트 참여 — 지급은 전부 Cloud Functions 를 지난다', () {
|
||||||
|
test('출석은 fnQuestAttend 를 부르고 서버 값을 그대로 옮긴다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(backend);
|
||||||
|
final cards = await repo.load();
|
||||||
|
|
||||||
|
final reward = await repo.verify(_card(cards, 'attend'));
|
||||||
|
expect(backend.calls.single.name, 'fnQuestAttend');
|
||||||
|
expect(reward.rewardedPop, 10);
|
||||||
|
expect(reward.balanceAfter, 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('걸음은 하루 기준선 대비 오늘치를 보낸다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(
|
||||||
|
backend,
|
||||||
|
// 부팅 이후 누적값이 들어온다. 그대로 보내면 며칠 켜 둔 단말이 공짜로 통과한다.
|
||||||
|
stepCount: () => Stream.fromIterable([40_000, 45_000, 50_400]),
|
||||||
|
);
|
||||||
|
final cards = await repo.load();
|
||||||
|
final card = _card(cards, 'steps');
|
||||||
|
|
||||||
|
expect(await repo.progressStream(card).toList(), [0, 5_000, 10_400]);
|
||||||
|
await repo.verify(card);
|
||||||
|
expect(backend.calls.single.name, 'fnQuestVerifySteps');
|
||||||
|
expect(backend.calls.single.payload['steps'], 10_400);
|
||||||
|
// 진행 문서와 일일 한도를 퀘스트 단위로 세도록 문서 ID 를 함께 보낸다.
|
||||||
|
expect(backend.calls.single.payload['questId'], 'steps');
|
||||||
|
// 집계 축은 소속 팬덤이다 — `activityLogs.fandomId`(설계서 §2.1).
|
||||||
|
expect(backend.calls.single.payload['fandomId'], 'bts');
|
||||||
|
expect(backend.calls.single.payload['occurredAt'], isA<String>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('앱을 껐다 켜도 오늘치가 0 으로 돌아가지 않는다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
// 첫 실행 — 기준선 40,000 을 잡고 5,000 걷는다.
|
||||||
|
final first = FirestoreQuestRepository(
|
||||||
|
_RecordingBackend(db),
|
||||||
|
stepCount: () => Stream.fromIterable([40_000, 45_000]),
|
||||||
|
);
|
||||||
|
final card = _card(await first.load(), 'steps');
|
||||||
|
expect(await first.progressStream(card).toList(), [0, 5_000]);
|
||||||
|
|
||||||
|
// 앱을 다시 열었다. 저장소도 기준선도 새로 만들지만 단말에 남은 값을 읽는다.
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final second = FirestoreQuestRepository(
|
||||||
|
backend,
|
||||||
|
stepCount: () => Stream.fromIterable([46_200]),
|
||||||
|
);
|
||||||
|
expect(await second.progressStream(card).toList(), [6_200]);
|
||||||
|
await second.verify(card);
|
||||||
|
expect(backend.calls.single.payload['steps'], 6_200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('출석도 퀘스트 문서 ID 를 함께 보낸다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(backend);
|
||||||
|
final cards = await repo.load();
|
||||||
|
|
||||||
|
await repo.verify(_card(cards, 'attend'));
|
||||||
|
expect(backend.calls.single.payload['questId'], 'attend');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('걸음 수를 아직 세지 못했으면 서버를 부르지 않는다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(backend);
|
||||||
|
final cards = await repo.load();
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
repo.verify(_card(cards, 'steps')),
|
||||||
|
throwsA(
|
||||||
|
isA<BackendException>().having((e) => e.code, 'code', 'E-QST-01'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(backend.calls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('위치는 좌표와 모의 위치 여부를 함께 보낸다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(
|
||||||
|
backend,
|
||||||
|
placeFix: () async => const PlaceFix(
|
||||||
|
lat: 37.5563,
|
||||||
|
lng: 126.9236,
|
||||||
|
accuracyM: 12,
|
||||||
|
mocked: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final cards = await repo.load();
|
||||||
|
await repo.verify(_card(cards, 'place'));
|
||||||
|
|
||||||
|
final call = backend.calls.single;
|
||||||
|
expect(call.name, 'fnQuestVerifyPlace');
|
||||||
|
expect(call.payload['placeId'], 'hongdae');
|
||||||
|
expect(call.payload['questId'], 'place');
|
||||||
|
expect(call.payload['lat'], 37.5563);
|
||||||
|
// 판정은 서버가 한다. 앱은 모의 위치 여부를 감추지도, 스스로 막지도 않는다.
|
||||||
|
expect(call.payload['mocked'], isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('장소가 등록되지 않은 위치 퀘스트는 서버를 부르지 않는다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('quests/place').set({
|
||||||
|
'type': 'location',
|
||||||
|
'title': '장소 미등록',
|
||||||
|
'rewardPop': 100,
|
||||||
|
'dailyLimit': 1,
|
||||||
|
});
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(backend);
|
||||||
|
final cards = await repo.load();
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
repo.verify(_card(cards, 'place')),
|
||||||
|
throwsA(
|
||||||
|
isA<BackendException>().having((e) => e.code, 'code', 'E-QST-03'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(backend.calls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('회신 대기 유형은 어느 함수도 부르지 않는다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(backend);
|
||||||
|
final cards = await repo.load();
|
||||||
|
final card = _card(cards, 'stream');
|
||||||
|
|
||||||
|
expect(card.type.verifiable, isFalse);
|
||||||
|
await expectLater(repo.verify(card), throwsA(isA<BackendException>()));
|
||||||
|
expect(backend.calls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('생성은 fn-quest-create 로 — 종료일은 날짜 + 그 시각의 UTC 오프셋(C-06)', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db, reply: const {'questId': 'q-new'});
|
||||||
|
final repo = FirestoreQuestRepository(backend);
|
||||||
|
final id = await repo.create(
|
||||||
|
QuestDraft(
|
||||||
|
clientSeq: 's1',
|
||||||
|
activityKey: 'vote',
|
||||||
|
title: '투표',
|
||||||
|
rewardPop: 5,
|
||||||
|
participants: 10,
|
||||||
|
endDate: DateTime(2026, 10, 3),
|
||||||
|
url: 'https://x.com/a',
|
||||||
|
manualTitle: '제목',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(id, 'q-new');
|
||||||
|
final call = backend.calls.single;
|
||||||
|
expect(call.name, 'fnQuestCreate');
|
||||||
|
expect(call.payload, containsPair('endDate', '2026-10-03'));
|
||||||
|
expect(call.payload, containsPair('capacity', 10));
|
||||||
|
expect(call.payload, containsPair('manualTitle', '제목'));
|
||||||
|
expect(
|
||||||
|
call.payload['utcOffsetMinutes'],
|
||||||
|
DateTime(2026, 10, 3, 23, 59, 59).timeZoneOffset.inMinutes,
|
||||||
|
);
|
||||||
|
// 앱은 quests 에 직접 쓰지 않는다.
|
||||||
|
expect((await db.collection('quests').get()).docs, hasLength(4));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'분류 마스터 — 노출 · ugcAllowed · 순서를 따르고 만들 활동이 없는 분류는 감춘다(C-01)',
|
||||||
|
() async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('config/policy').update({
|
||||||
|
'ugcFeeMin': 20,
|
||||||
|
'ugcMaxDays': 14,
|
||||||
|
});
|
||||||
|
await db.doc('config/questCatalog').set({
|
||||||
|
'categories': [
|
||||||
|
{'key': 'location', 'label': '특정 위치', 'order': 2, 'visible': true},
|
||||||
|
{'key': 'planet', 'label': '행성 활동', 'order': 1, 'visible': true},
|
||||||
|
{'key': 'health', 'label': '건강', 'order': 3, 'visible': true},
|
||||||
|
{'key': 'community', 'label': '커뮤니티', 'order': 4, 'visible': false},
|
||||||
|
],
|
||||||
|
'activities': [
|
||||||
|
{
|
||||||
|
'key': 'sns',
|
||||||
|
'categoryKey': 'planet',
|
||||||
|
'order': 2,
|
||||||
|
'visible': true,
|
||||||
|
'ugcAllowed': true,
|
||||||
|
'inputSchema': [
|
||||||
|
{'field': 'link', 'required': true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'vote',
|
||||||
|
'categoryKey': 'planet',
|
||||||
|
'order': 1,
|
||||||
|
'visible': true,
|
||||||
|
'ugcAllowed': true,
|
||||||
|
'inputSchema': [
|
||||||
|
{'field': 'link', 'required': true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'youtube',
|
||||||
|
'categoryKey': 'planet',
|
||||||
|
'order': 3,
|
||||||
|
'visible': true,
|
||||||
|
'ugcAllowed': false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'walk',
|
||||||
|
'categoryKey': 'health',
|
||||||
|
'order': 1,
|
||||||
|
'visible': true,
|
||||||
|
'ugcAllowed': false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'location',
|
||||||
|
'categoryKey': 'location',
|
||||||
|
'order': 1,
|
||||||
|
'visible': true,
|
||||||
|
'ugcAllowed': true,
|
||||||
|
'inputSchema': [
|
||||||
|
{'field': 'place', 'required': true},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'qr',
|
||||||
|
'categoryKey': 'location',
|
||||||
|
'order': 2,
|
||||||
|
'visible': true,
|
||||||
|
'ugcAllowed': false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'community',
|
||||||
|
'categoryKey': 'community',
|
||||||
|
'order': 1,
|
||||||
|
'visible': true,
|
||||||
|
'ugcAllowed': true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
final catalog = await FirestoreQuestRepository(
|
||||||
|
_RecordingBackend(db),
|
||||||
|
).catalog();
|
||||||
|
expect(catalog.categories.map((c) => c.id), ['planet', 'location']);
|
||||||
|
expect(catalog.categories.first.activities.map((a) => a.key), [
|
||||||
|
'vote',
|
||||||
|
'sns',
|
||||||
|
]);
|
||||||
|
expect(catalog.categories.first.activities.first.fields, {'link'});
|
||||||
|
expect(catalog.categories.last.activities.single.fields, {'place'});
|
||||||
|
expect(catalog.feeOf(50), 20);
|
||||||
|
// Q-UQ-02 권고 A안 — 보상 없는 Quest 는 수수료 0.
|
||||||
|
expect(catalog.feeOf(0), 0);
|
||||||
|
expect(catalog.maxDays, 14);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('제출 · 검토 · 수정은 각 함수로만 간다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _RecordingBackend(db);
|
||||||
|
final repo = FirestoreQuestRepository(backend);
|
||||||
|
await repo.submit('q1', url: 'https://x.com/p', text: '');
|
||||||
|
await repo.review(
|
||||||
|
'q1_u',
|
||||||
|
approve: false,
|
||||||
|
reason: '불명확',
|
||||||
|
resubmitAllowed: true,
|
||||||
|
);
|
||||||
|
await repo.edit('q1', title: 't', description: 'd');
|
||||||
|
expect(backend.calls.map((c) => c.name), [
|
||||||
|
'fnQuestSubmit',
|
||||||
|
'fnQuestReview',
|
||||||
|
'fnQuestEdit',
|
||||||
|
]);
|
||||||
|
expect(backend.calls.first.payload, {
|
||||||
|
'questId': 'q1',
|
||||||
|
'url': 'https://x.com/p',
|
||||||
|
});
|
||||||
|
expect(backend.calls[1].payload, {
|
||||||
|
'submissionId': 'q1_u',
|
||||||
|
'action': 'reject',
|
||||||
|
'reason': '불명확',
|
||||||
|
'resubmitAllowed': true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마감이 지난 남의 사용자 생성 Quest 는 목록에서 빠진다(G-04)', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('quests/ugc-old').set({
|
||||||
|
'type': 'vote',
|
||||||
|
'title': '지난 투표',
|
||||||
|
'authorUid': 'someone',
|
||||||
|
'status': 'published',
|
||||||
|
'endsAt': DateTime.now().subtract(const Duration(minutes: 1)),
|
||||||
|
});
|
||||||
|
await db.doc('quests/ugc-live').set({
|
||||||
|
'type': 'vote',
|
||||||
|
'title': '진행 투표',
|
||||||
|
'authorUid': 'someone',
|
||||||
|
'status': 'published',
|
||||||
|
'endsAt': DateTime.now().add(const Duration(hours: 3)),
|
||||||
|
'capacity': 5,
|
||||||
|
});
|
||||||
|
final cards = await FirestoreQuestRepository(
|
||||||
|
_RecordingBackend(db),
|
||||||
|
).load(limit: 20);
|
||||||
|
expect(cards.map((c) => c.id), isNot(contains('ugc-old')));
|
||||||
|
final live = _card(cards, 'ugc-live');
|
||||||
|
expect(live.type, QuestType.vote);
|
||||||
|
expect(live.author, QuestAuthor.user);
|
||||||
|
expect(live.capacity, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:fancake/core/data/shared_stream.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
/// 실시간 구독 공유 — 같은 문서를 여러 화면이 볼 때 원본 구독은 하나만 연다.
|
|
||||||
void main() {
|
|
||||||
group('구독 공유 — 여러 화면이 원본 하나를 나눠 본다', () {
|
|
||||||
test('여러 화면이 같은 원본 하나를 나눠 쓴다', () async {
|
|
||||||
// 화면마다 snapshots() 를 열면 한 팬덤에 리스너가 화면 수만큼 붙는다.
|
|
||||||
var opened = 0;
|
|
||||||
final controller = StreamController<int>.broadcast();
|
|
||||||
addTearDown(controller.close);
|
|
||||||
final shared = SharedStream<int>(() {
|
|
||||||
opened += 1;
|
|
||||||
return controller.stream;
|
|
||||||
});
|
|
||||||
|
|
||||||
final a = <int>[];
|
|
||||||
final b = <int>[];
|
|
||||||
final subA = shared.stream.listen(a.add);
|
|
||||||
final subB = shared.stream.listen(b.add);
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
|
|
||||||
controller.add(7);
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
|
|
||||||
// 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다.
|
|
||||||
expect(opened, 1);
|
|
||||||
expect(a, [7]);
|
|
||||||
expect(b, [7]);
|
|
||||||
|
|
||||||
await subA.cancel();
|
|
||||||
await subB.cancel();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async {
|
|
||||||
final controller = StreamController<int>.broadcast();
|
|
||||||
addTearDown(controller.close);
|
|
||||||
final shared = SharedStream<int>(() => controller.stream);
|
|
||||||
|
|
||||||
final first = <int>[];
|
|
||||||
final subA = shared.stream.listen(first.add);
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
controller.add(42);
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
|
|
||||||
// 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다.
|
|
||||||
final late = <int>[];
|
|
||||||
final subB = shared.stream.listen(late.add);
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
expect(late, [42]);
|
|
||||||
|
|
||||||
await subA.cancel();
|
|
||||||
await subB.cancel();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('마지막 화면이 떠나면 원본을 닫는다', () async {
|
|
||||||
var cancelled = false;
|
|
||||||
final controller = StreamController<int>(
|
|
||||||
onCancel: () => cancelled = true,
|
|
||||||
);
|
|
||||||
addTearDown(controller.close);
|
|
||||||
final shared = SharedStream<int>(() => controller.stream);
|
|
||||||
|
|
||||||
final sub = shared.stream.listen((_) {});
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
expect(cancelled, isFalse);
|
|
||||||
|
|
||||||
await sub.cancel();
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
// 남겨 두면 화면이 없는 동안에도 읽기 비용이 계속 든다.
|
|
||||||
expect(cancelled, isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,14 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
/// iOS 스토어 제출 구성 검사.
|
/// 스토어 제출 준비물 검사.
|
||||||
///
|
///
|
||||||
/// 여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다. 문제는 업로드·심사·로그인
|
/// **여기 있는 것들은 사라져도 앱이 멀쩡히 빌드된다.** 문제는 심사에 넣거나
|
||||||
/// 시점에야 드러나고, 그때는 원인이 코드처럼 보인다. 파일을 문자열로 읽어
|
/// 사용자가 권한을 누르는 순간에야 드러나고, 그때는 원인이 코드처럼 보인다.
|
||||||
/// 키가 있는지만 본다 — 값의 해석은 빌드가 한다.
|
/// 실제로 이 프로젝트에서 최소 지원 OS 가 계약값과 다른 채로 나가고 있었다.
|
||||||
|
///
|
||||||
const _teamId = 'V28G543VK5'; // Fancake Inc.
|
/// 파일을 문자열로 읽는 이유는 plist·gradle 파서를 붙이지 않기 위해서다.
|
||||||
|
/// 키가 있는지만 보면 되고, 값의 해석은 빌드가 한다.
|
||||||
|
|
||||||
String _read(String path) {
|
String _read(String path) {
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
@@ -17,47 +18,62 @@ String _read(String path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('iOS — 서명', () {
|
group('iOS — 권한 사용 문구', () {
|
||||||
test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로 서명한다', () {
|
late final String plist = _read('ios/Runner/Info.plist');
|
||||||
final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
|
|
||||||
final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
|
test('권한을 요청하는 기능마다 문구가 있다', () {
|
||||||
.allMatches(pbx)
|
// 문구 없이 요청하면 iOS 가 앱을 즉시 종료한다. 크래시로 보인다.
|
||||||
.map((m) => m.group(1))
|
for (final key in const [
|
||||||
.toSet();
|
'NSPhotoLibraryUsageDescription', // 프로필 사진
|
||||||
expect(teams, {_teamId});
|
'NSCameraUsageDescription', // 프로필 · 방문 인증 사진
|
||||||
expect(_read('ios/ExportOptions.plist'), contains(_teamId));
|
'NSLocationWhenInUseUsageDescription', // 위치 퀘스트
|
||||||
|
'NSMotionUsageDescription', // 만보기
|
||||||
|
'NSHealthShareUsageDescription', // 걸음 수
|
||||||
|
'NSUserTrackingUsageDescription', // 광고
|
||||||
|
]) {
|
||||||
|
expect(plist, contains(key), reason: key);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Sign in with Apple Capability 가 서명에 연결되어 있다', () {
|
test('문구가 용도를 말한다', () {
|
||||||
expect(
|
// 「접근이 필요합니다」처럼 용도 없는 문장은 심사 반려 사유다.
|
||||||
_read('ios/Runner/Runner.entitlements'),
|
expect(plist, contains('프로필 사진을 고르기 위해'));
|
||||||
contains('com.apple.developer.applesignin'),
|
expect(plist, contains('퀘스트 보상을 드리기 위해'));
|
||||||
);
|
expect(plist, contains('다른 건강 정보는 읽지 않습니다'));
|
||||||
expect(
|
|
||||||
'CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;'
|
|
||||||
.allMatches(_read('ios/Runner.xcodeproj/project.pbxproj'))
|
|
||||||
.length,
|
|
||||||
3, // Debug · Release · Profile
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('iOS — 제출 선언과 Firebase 설정', () {
|
group('iOS — 제출 선언', () {
|
||||||
late final String plist = _read('ios/Runner/Info.plist');
|
late final String plist = _read('ios/Runner/Info.plist');
|
||||||
|
late final String entitlements = _read('ios/Runner/Runner.entitlements');
|
||||||
|
|
||||||
test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () {
|
test('암호화 사용 신고가 있어 업로드가 멈추지 않는다', () {
|
||||||
expect(plist, contains('ITSAppUsesNonExemptEncryption'));
|
expect(plist, contains('ITSAppUsesNonExemptEncryption'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Google 로그인 콜백 스킴이 설정 파일 값으로 채워진다', () {
|
test('Google 로그인 콜백 스킴 자리가 있다', () {
|
||||||
|
expect(plist, contains('CFBundleURLTypes'));
|
||||||
|
// 값은 Firebase 설정 파일에서 뽑아 넣는다. 손으로 적으면 오타로 조용히 실패한다.
|
||||||
expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)'));
|
expect(plist, contains(r'$(GOOGLE_REVERSED_CLIENT_ID)'));
|
||||||
for (final c in const ['Debug', 'Release']) {
|
});
|
||||||
expect(
|
|
||||||
_read('ios/Flutter/$c.xcconfig'),
|
test('Sign in with Apple Capability 가 있다', () {
|
||||||
contains('#include? "../config/Google.xcconfig"'),
|
// 정책 §P-01 — 소셜 로그인을 제공하면 Apple 심사 필수 조건이다.
|
||||||
reason: c,
|
expect(entitlements, contains('com.apple.developer.applesignin'));
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
test('Xcode 프로젝트와 내보내기 설정이 발주사 팀으로만 서명한다', () {
|
||||||
|
// 다른 팀으로 서명하면 업로드가 그 팀의 App Store Connect 로 간다(10/2 실제 발생).
|
||||||
|
const teamId = 'V28G543VK5'; // Fancake Inc.
|
||||||
|
final pbx = _read('ios/Runner.xcodeproj/project.pbxproj');
|
||||||
|
final teams = RegExp(r'DEVELOPMENT_TEAM = (\w+);')
|
||||||
|
.allMatches(pbx)
|
||||||
|
.map((m) => m.group(1))
|
||||||
|
.toSet();
|
||||||
|
expect(teams, {teamId});
|
||||||
|
final options = _read('ios/ExportOptions.plist');
|
||||||
|
expect(options, contains('app-store-connect'));
|
||||||
|
expect(options, contains(teamId));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () {
|
test('GoogleService-Info.plist 가 앱 번들 리소스로 들어간다', () {
|
||||||
@@ -69,18 +85,76 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('설정·서명 자산을 git 이 추적하지 않는다', () {
|
group('Android — 계약값과 배포 경계', () {
|
||||||
final result = Process.runSync('git', [
|
late final String gradle = _read('android/app/build.gradle.kts');
|
||||||
'ls-files',
|
|
||||||
'--',
|
test('최소 지원 OS 가 계약값 API 29 다', () {
|
||||||
'ios/Runner/GoogleService-Info.plist',
|
// 별첨 1 v1.0 이 Android 10 으로 정했다. Flutter 기본값 24 로 두면
|
||||||
'ios/config/Google.xcconfig',
|
// 검증하지 않은 기기까지 설치가 열린다.
|
||||||
'android/app/google-services.json',
|
expect(gradle, contains('minSdk = 29'));
|
||||||
'*.p12',
|
});
|
||||||
'*.p8',
|
|
||||||
'*.mobileprovision',
|
test('평문 예외가 개발 소스 세트에만 있다', () {
|
||||||
]);
|
for (final variant in const ['debug', 'profile']) {
|
||||||
expect(result.exitCode, 0, reason: 'git 을 실행하지 못했습니다');
|
expect(
|
||||||
expect((result.stdout as String).trim(), isEmpty);
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp;
|
||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/core/data/invite_code.dart';
|
||||||
|
import 'package:fancake/core/data/server_clock.dart';
|
||||||
|
import 'package:fancake/features/home/home_data.dart';
|
||||||
|
import 'package:fancake/features/home/home_repository_firestore.dart';
|
||||||
|
import 'package:fancake/features/home/widgets/briefing_share.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/quest/quest_repository_firestore.dart';
|
||||||
|
import 'package:fancake/features/quest/widgets/time_attack_countdown.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// 긴급 돌발 타임어택(별첨 1 ③-4 · FR-QST-06) · 브리핑 공유(③-5 · 「홈_브리핑 공유」).
|
||||||
|
///
|
||||||
|
/// 지급 판정은 서버 `firebase/functions/test/timeattack.test.ts` 가 본다. 여기서는
|
||||||
|
/// 앱이 **무엇을 읽어 무엇을 부르는지**와 마감 · 공유 문구를 본다.
|
||||||
|
|
||||||
|
const _uid = 'u-ta';
|
||||||
|
|
||||||
|
class _Backend extends Backend {
|
||||||
|
_Backend(FakeFirebaseFirestore db, {this.serverAhead = Duration.zero, this.duplicate = false})
|
||||||
|
: super(
|
||||||
|
firestore: db,
|
||||||
|
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Duration serverAhead;
|
||||||
|
final bool duplicate;
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(
|
||||||
|
String name, [
|
||||||
|
Map<String, dynamic> payload = const {},
|
||||||
|
]) async {
|
||||||
|
if (name == 'fnServerTime') {
|
||||||
|
calls.add(name);
|
||||||
|
return {'now': DateTime.now().add(serverAhead).millisecondsSinceEpoch};
|
||||||
|
}
|
||||||
|
calls.add('$name:${payload['questId']}');
|
||||||
|
return {'txId': 'tx', 'balanceAfter': 30, 'rewarded': 30, 'duplicate': duplicate};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fnInviteCodeGet` 만 흉내 — 부른 횟수를 센다. [fail] 이면 서버 오류.
|
||||||
|
class _InviteBackend extends Backend {
|
||||||
|
_InviteBackend({this.fail = false})
|
||||||
|
: super(
|
||||||
|
firestore: FakeFirebaseFirestore(),
|
||||||
|
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
|
||||||
|
);
|
||||||
|
final bool fail;
|
||||||
|
int calls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> call(String name, [Map<String, dynamic> payload = const {}]) async {
|
||||||
|
calls++;
|
||||||
|
if (fail) throw const BackendException('E-SYS-01', 'down');
|
||||||
|
return {'code': 'ABCDEFGH23'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FakeFirebaseFirestore> _seed({Duration left = const Duration(minutes: 5)}) async {
|
||||||
|
final db = FakeFirebaseFirestore();
|
||||||
|
await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'});
|
||||||
|
await db.doc('quests/ta').set({
|
||||||
|
'type': 'timeattack', 'title': '돌발', 'fandomId': 'bts', 'rewardPop': 30,
|
||||||
|
'dailyLimit': 1, 'active': true,
|
||||||
|
'activeFrom': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))),
|
||||||
|
'activeTo': Timestamp.fromDate(DateTime.now().add(left)),
|
||||||
|
});
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
BriefingItem _brief(int rank, String headline) => BriefingItem(
|
||||||
|
rank: rank,
|
||||||
|
headline: headline,
|
||||||
|
section: BriefingSection.news,
|
||||||
|
sourceName: '출처',
|
||||||
|
sourceAt: DateTime(2026, 9, 28),
|
||||||
|
sourceUrl: 'https://example.com',
|
||||||
|
articleUrl: 'https://example.com/a',
|
||||||
|
aiSummary: '요약',
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _app(Widget child) => MaterialApp(home: child);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
ServerClock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('타임어택 — 앱 경로', () {
|
||||||
|
test('카드에 마감을 싣고 참여는 fnQuestTimeAttack 으로 보낸다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _Backend(db);
|
||||||
|
final repo = FirestoreQuestRepository(backend);
|
||||||
|
final card = (await repo.load()).single;
|
||||||
|
expect(card.type, QuestType.timeAttack);
|
||||||
|
expect(card.endsAt, isNotNull);
|
||||||
|
expect(card.rewardPop, 30);
|
||||||
|
final reward = await repo.verify(card);
|
||||||
|
expect(reward.duplicate, isFalse);
|
||||||
|
// 서버 시계는 타임어택이 있을 때 세션당 한 번만 잰다.
|
||||||
|
expect(backend.calls, ['fnServerTime', 'fnQuestTimeAttack:ta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('재응답(duplicate)은 새 지급이 아니라고 표시한다', () async {
|
||||||
|
final repo = FirestoreQuestRepository(_Backend(await _seed(), duplicate: true));
|
||||||
|
final card = (await repo.load()).single;
|
||||||
|
expect((await repo.verify(card)).duplicate, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('노출 · 마감은 서버 시각으로 판정한다(단말 시계가 늦어도)', () async {
|
||||||
|
// 서버가 10분 앞서 있다 → 단말 기준 5분 남은 창은 이미 닫혔다.
|
||||||
|
final db = await _seed();
|
||||||
|
final backend = _Backend(db, serverAhead: const Duration(minutes: 10));
|
||||||
|
expect(await FirestoreQuestRepository(backend).load(), isEmpty);
|
||||||
|
expect(ServerClock.offset.inMinutes, inInclusiveRange(9, 10));
|
||||||
|
final home = await FirestoreHomeRepository(backend).load();
|
||||||
|
expect(home.quests.where((q) => q.id == 'ta'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('어제 받은 타임어택도 보상 완료로 남는다(1인 1회)', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('users/$_uid/questProgress/ta').set({'dayKey': '2000-01-01', 'count': 1});
|
||||||
|
final card = (await FirestoreQuestRepository(_Backend(db)).load()).single;
|
||||||
|
expect(card.stage, QuestStage.rewarded);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('홈은 열린 타임어택을 맨 앞에 두고 마감된 것은 뺀다', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('quests/ta-old').set({
|
||||||
|
'type': 'timeattack', 'title': '지난 돌발', 'fandomId': 'bts', 'active': true,
|
||||||
|
'activeTo': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))),
|
||||||
|
});
|
||||||
|
await db.doc('quests/ta-off').set({
|
||||||
|
'type': 'timeattack', 'title': '내려진 돌발', 'fandomId': 'bts', 'active': false,
|
||||||
|
'activeTo': Timestamp.fromDate(DateTime.now().add(const Duration(minutes: 5))),
|
||||||
|
});
|
||||||
|
await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'active': true});
|
||||||
|
final home = await FirestoreHomeRepository(_Backend(db)).load();
|
||||||
|
expect(home.quests.map((q) => q.id), ['ta', 'attend']);
|
||||||
|
expect(home.quests.first.endsAt, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('성운 목표는 config/policy 운영값 · 탄생 권한은 창립자만(Q-PN-01)', () async {
|
||||||
|
final db = await _seed();
|
||||||
|
await db.doc('config/policy').set({'nebulaMinMembers': 12, 'nebulaMinPop': 1500});
|
||||||
|
await db.doc('fandoms/bts').set({
|
||||||
|
'name': 'BTS', 'stage': 0, 'memberCount': 12, 'growthPop': 1500, 'founderUid': _uid,
|
||||||
|
});
|
||||||
|
var growth = (await FirestoreHomeRepository(_Backend(db)).load()).growth;
|
||||||
|
expect((growth.requiredMembers, growth.requiredPop), (12, 1500));
|
||||||
|
expect(growth.nebulaReady && growth.isFounder, isTrue);
|
||||||
|
await db.doc('fandoms/bts').update({'founderUid': 'someone-else'});
|
||||||
|
growth = (await FirestoreHomeRepository(_Backend(db)).load()).growth;
|
||||||
|
expect(growth.nebulaReady, isTrue);
|
||||||
|
expect(growth.isFounder, isFalse);
|
||||||
|
expect(await FirestoreHomeRepository(_Backend(db)).nebulaTargets(),
|
||||||
|
(pop: 1500, members: 12, days: 7));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('남은 시간 표기', () {
|
||||||
|
expect(formatLeft(const Duration(minutes: 9, seconds: 5)), '09:05');
|
||||||
|
expect(formatLeft(const Duration(hours: 1, minutes: 2, seconds: 3)), '1:02:03');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('마감되면 상세 버튼이 잠긴다', (tester) async {
|
||||||
|
final db = await tester.runAsync(() => _seed(left: const Duration(seconds: 2)));
|
||||||
|
final repo = FirestoreQuestRepository(_Backend(db!));
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(_app(QuestDetailScreen(questId: 'ta', repository: repo)));
|
||||||
|
for (var i = 0; i < 50 && find.text('남은 시간').evaluate().isEmpty; i++) {
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
expect(find.text('남은 시간'), findsOneWidget);
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(const Duration(seconds: 3)));
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(find.text('마감'), findsNWidgets(2));
|
||||||
|
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('브리핑 공유', () {
|
||||||
|
test('초대 코드는 세션당 한 번 받고, 실패하면 ref 없이 나간다', () async {
|
||||||
|
addTearDown(InviteCode.reset);
|
||||||
|
InviteCode.reset();
|
||||||
|
final down = _InviteBackend(fail: true);
|
||||||
|
await InviteCode.ensure(down);
|
||||||
|
expect(InviteCode.current, isNull);
|
||||||
|
expect(briefingShareLink('bts', '2026-09-28', base: 'https://s.example'),
|
||||||
|
'https://s.example/b/bts/2026-09-28');
|
||||||
|
final up = _InviteBackend();
|
||||||
|
await Future.wait([InviteCode.ensure(up), InviteCode.ensure(up)]);
|
||||||
|
await InviteCode.ensure(up);
|
||||||
|
expect(up.calls, 1);
|
||||||
|
expect(briefingShareLink('bts', '2026-09-28', base: 'https://s.example'),
|
||||||
|
'https://s.example/b/bts/2026-09-28?ref=ABCDEFGH23');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('링크는 설정된 웹 브릿지 주소가 있을 때만 만든다', () {
|
||||||
|
expect(briefingShareLink('bts', '2026-09-28', base: ''), isNull);
|
||||||
|
expect(
|
||||||
|
briefingShareLink('bts', '2026-09-28', base: 'https://share.example/'),
|
||||||
|
'https://share.example/b/bts/2026-09-28',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('문구는 헤드라인 최대 3건 · 링크 · 동적 해시태그다', () {
|
||||||
|
final text = briefingShareText(
|
||||||
|
items: [for (var i = 1; i <= 4; i++) _brief(i, '뉴스 $i')],
|
||||||
|
fandomName: 'BTS',
|
||||||
|
link: 'https://share.example/b/bts/2026-09-28',
|
||||||
|
);
|
||||||
|
expect(text, contains('3. 뉴스 3'));
|
||||||
|
expect(text, isNot(contains('뉴스 4')));
|
||||||
|
expect(text, contains('https://share.example/b/bts/2026-09-28'));
|
||||||
|
expect(text, contains('#FANUZU #BTS #AIBriefing'));
|
||||||
|
expect(briefingShareText(items: [_brief(1, 'a')]), isNot(contains('http')));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('SNS 버튼은 카드 이미지와 문구를 공유 시트로 보낸다', (tester) async {
|
||||||
|
ShareParams? sent;
|
||||||
|
await tester.pumpWidget(_app(Scaffold(
|
||||||
|
body: BriefingShareDialog(
|
||||||
|
items: [_brief(1, '빌보드 수상')],
|
||||||
|
fandomName: 'BTS',
|
||||||
|
share: (params) async => sent = params,
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
expect(find.text('AI DAILY BRIEFING'), findsOneWidget);
|
||||||
|
expect(find.text('빌보드 수상'), findsOneWidget);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
await tester.tap(find.byTooltip('X'));
|
||||||
|
for (var i = 0; i < 50 && sent == null; i++) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(sent?.text, contains('1. 빌보드 수상'));
|
||||||
|
expect(sent?.files, hasLength(1));
|
||||||
|
// 웹 브릿지 주소가 없으면 링크가 없으니 「문구 복사」로 부른다.
|
||||||
|
expect(find.byTooltip('문구 복사'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('링크 복사'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||||
|
import 'package:fancake/core/data/backend.dart';
|
||||||
|
import 'package:fancake/core/i18n/app_locale.dart';
|
||||||
|
import 'package:fancake/core/ui/app_icons.dart';
|
||||||
|
import 'package:fancake/features/translation/translation_data.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_data.dart';
|
||||||
|
import 'package:fancake/features/inside/inside_detail_screen.dart';
|
||||||
|
import 'package:fancake/features/planet/lounge_post_screens.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
|
import 'package:fancake/features/translation/translation_comments.dart';
|
||||||
|
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// 참여형 번역 — FR-I18N-02 · 원장 B-63 · E-TRN-01.
|
||||||
|
/// 화면에서는 댓글 영역의 「번역 댓글」이다(Figma `201:50554` · `540:19136`).
|
||||||
|
void main() {
|
||||||
|
const target = 'questions/q1';
|
||||||
|
final question = InsideQuestion(
|
||||||
|
id: 'q1',
|
||||||
|
authorName: '다이브짱',
|
||||||
|
title: '공방 신청 꿀팁',
|
||||||
|
body: '본문',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
);
|
||||||
|
final answers = [
|
||||||
|
InsideAnswer(
|
||||||
|
id: 'a1',
|
||||||
|
authorName: 'pro_따봉',
|
||||||
|
body: '세상이 이런 꿀팁이',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> pumpScreen(WidgetTester tester, Widget screen) async {
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('ko'),
|
||||||
|
supportedLocales: appLocales,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
RemoteL10nDelegate({}),
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: screen,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pump(WidgetTester tester, TranslationRepository repo) =>
|
||||||
|
pumpScreen(
|
||||||
|
tester,
|
||||||
|
InsideDetailScreen(
|
||||||
|
question: question,
|
||||||
|
answers: answers,
|
||||||
|
translation: repo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> send(WidgetTester tester, String body) async {
|
||||||
|
await tester.enterText(find.byType(TextField), body);
|
||||||
|
await tester.tap(find.byTooltip('보내기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('지구본으로 쓴 번역 댓글은 질문 본문의 번역으로 댓글 목록에 뜨고, 본인 번역은 마킹이 거절된다', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repo = SampleTranslationRepository(uid: 'me');
|
||||||
|
await pump(tester, repo);
|
||||||
|
expect(find.byType(TranslationCommentCard), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(CommentInputBar),
|
||||||
|
matching: find.bySemanticsLabel('번역 댓글 작성'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('번역을 입력하세요'), findsOneWidget);
|
||||||
|
await send(tester, ' Great tip ');
|
||||||
|
|
||||||
|
final card = tester.widget<TranslationCommentCard>(
|
||||||
|
find.byType(TranslationCommentCard),
|
||||||
|
);
|
||||||
|
expect(card.entry.targetPath, target);
|
||||||
|
expect(card.reply, isFalse);
|
||||||
|
expect(find.text('번역함'), findsOneWidget);
|
||||||
|
expect(find.text('한국어'), findsOneWidget);
|
||||||
|
expect(find.text('Great tip'), findsOneWidget);
|
||||||
|
expect(find.textContaining('24시간'), findsOneWidget);
|
||||||
|
// 등록 뒤 입력은 일반 댓글로 돌아간다.
|
||||||
|
expect(find.text('댓글을 입력해 보세요.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('유효 0'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('본인 번역은 마킹할 수 없습니다.'), findsOneWidget);
|
||||||
|
expect(find.text('유효 0'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('댓글의 대댓글 버튼으로 고르면 그 댓글의 번역이 대댓글 자리에 붙는다', (tester) async {
|
||||||
|
final repo = SampleTranslationRepository(uid: 'me');
|
||||||
|
await pump(tester, repo);
|
||||||
|
|
||||||
|
// 대댓글 버튼(시안의 말풍선)은 이 댓글을 번역 대상으로 고른다.
|
||||||
|
await tester.tap(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) =>
|
||||||
|
w is ImageIcon && w.image == AppIcons.messageCircle && w.size == 16,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('pro_따봉 님 댓글 번역'), findsOneWidget);
|
||||||
|
await send(tester, 'Is there really such a great tip');
|
||||||
|
|
||||||
|
final card = tester.widget<TranslationCommentCard>(
|
||||||
|
find.byType(TranslationCommentCard),
|
||||||
|
);
|
||||||
|
expect(card.entry.targetPath, '$target/answers/a1');
|
||||||
|
expect(card.reply, isTrue);
|
||||||
|
expect(find.text('pro_따봉 님 댓글 번역'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('다른 멤버의 번역은 한 번만 마킹할 수 있다', (tester) async {
|
||||||
|
final repo = SampleTranslationRepository(
|
||||||
|
uid: 'me',
|
||||||
|
seed: [
|
||||||
|
SampleTranslation(
|
||||||
|
id: 't1',
|
||||||
|
targetPath: target,
|
||||||
|
locale: 'en',
|
||||||
|
authorUid: 'alice',
|
||||||
|
authorName: '호떡이',
|
||||||
|
body: '번역문',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await pump(tester, repo);
|
||||||
|
expect(find.text('호떡이'), findsOneWidget);
|
||||||
|
expect(find.text('English'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('유효 0'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('유효 1'), findsOneWidget);
|
||||||
|
expect(find.text('마킹했습니다.'), findsOneWidget);
|
||||||
|
|
||||||
|
// 마킹 뒤에는 두 버튼이 잠긴다.
|
||||||
|
final invalid = tester.widget<OutlinedButton>(
|
||||||
|
find.widgetWithText(OutlinedButton, '무효 0'),
|
||||||
|
);
|
||||||
|
expect(invalid.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('라운지 글 상세 — 댓글의 지구본으로 그 댓글을 번역한다(Q-TR-06 B안)', (tester) async {
|
||||||
|
final repo = SampleTranslationRepository(uid: 'me');
|
||||||
|
final post = LoungePost(
|
||||||
|
id: 'tr-${DateTime.now().microsecondsSinceEpoch}',
|
||||||
|
authorName: '팬',
|
||||||
|
createdAt: DateTime(2026, 9, 1),
|
||||||
|
title: '',
|
||||||
|
body: '본문',
|
||||||
|
likes: 0,
|
||||||
|
comments: 0,
|
||||||
|
);
|
||||||
|
await pumpScreen(tester, LoungePostScreen(post: post, translation: repo));
|
||||||
|
await send(tester, '반가워요');
|
||||||
|
expect(find.text('반가워요'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('번역 댓글 작성'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('님 댓글 번역'), findsOneWidget);
|
||||||
|
await send(tester, 'Nice to meet you');
|
||||||
|
|
||||||
|
final card = tester.widget<TranslationCommentCard>(
|
||||||
|
find.byType(TranslationCommentCard),
|
||||||
|
);
|
||||||
|
expect(card.entry.targetPath, startsWith('posts/${post.id}/comments/'));
|
||||||
|
expect(card.reply, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('캠페인 응원 게시판 — 기본은 일반 댓글, 지구본을 켜면 캠페인 본문의 번역 댓글', (tester) async {
|
||||||
|
final repo = SampleTranslationRepository(uid: 'me');
|
||||||
|
await pumpScreen(
|
||||||
|
tester,
|
||||||
|
CampaignDetailScreen(campaignId: 'c1', myPop: 0, translation: repo),
|
||||||
|
);
|
||||||
|
expect(find.byType(CommentInputBar), findsNothing);
|
||||||
|
await tester.tap(find.textContaining('응원 게시판'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('댓글을 입력해 보세요.'), findsOneWidget);
|
||||||
|
await send(tester, '화이팅');
|
||||||
|
expect(find.text('화이팅'), findsOneWidget);
|
||||||
|
expect(find.byType(TranslationCommentCard), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(CommentInputBar),
|
||||||
|
matching: find.bySemanticsLabel('번역 댓글 작성'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('번역을 입력하세요'), findsOneWidget);
|
||||||
|
await send(tester, 'Go go');
|
||||||
|
|
||||||
|
final card = tester.widget<TranslationCommentCard>(
|
||||||
|
find.byType(TranslationCommentCard),
|
||||||
|
);
|
||||||
|
expect(card.entry.targetPath, 'campaigns/c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('마킹이 성공하면 스트림을 기다리지 않고 수와 잠금이 바로 바뀐다', (tester) async {
|
||||||
|
final repo = _SilentMarkRepository([
|
||||||
|
SampleTranslation(
|
||||||
|
id: 't1',
|
||||||
|
targetPath: target,
|
||||||
|
locale: 'en',
|
||||||
|
authorUid: 'alice',
|
||||||
|
body: '번역문',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await pump(tester, repo);
|
||||||
|
// 작성자 이름이 비어 있으면 「팬」.
|
||||||
|
expect(find.text('팬'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('무효 0'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(repo.marks, 1);
|
||||||
|
expect(find.text('무효 1'), findsOneWidget);
|
||||||
|
final valid = tester.widget<OutlinedButton>(
|
||||||
|
find.widgetWithText(OutlinedButton, '유효 0'),
|
||||||
|
);
|
||||||
|
expect(valid.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('정산이 끝난 번역은 마킹 버튼을 숨긴다', (tester) async {
|
||||||
|
final repo = _FixedRepository([
|
||||||
|
const TranslationEntry(
|
||||||
|
id: 'done',
|
||||||
|
authorUid: 'alice',
|
||||||
|
authorName: '호떡이',
|
||||||
|
body: 'Settled',
|
||||||
|
locale: 'en',
|
||||||
|
targetPath: target,
|
||||||
|
status: 'confirmed',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await pump(tester, repo);
|
||||||
|
expect(find.text('Settled'), findsOneWidget);
|
||||||
|
expect(find.byType(OutlinedButton), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('한 페이지(50건)가 차면 「더 보기」로 다음 페이지를 붙인다', (tester) async {
|
||||||
|
final repo = SampleTranslationRepository(
|
||||||
|
uid: 'me',
|
||||||
|
seed: [
|
||||||
|
for (var i = 0; i < translationPageSize + 1; i++)
|
||||||
|
SampleTranslation(
|
||||||
|
id: 'p$i',
|
||||||
|
targetPath: target,
|
||||||
|
locale: 'en',
|
||||||
|
authorUid: 'u$i',
|
||||||
|
body: 'row $i',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await pump(tester, repo);
|
||||||
|
expect(find.byType(TranslationCommentCard), findsNWidgets(50));
|
||||||
|
await tester.ensureVisible(find.text('더 보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('더 보기'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(TranslationCommentCard), findsNWidgets(51));
|
||||||
|
expect(find.text('더 보기'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('글 경로로 본문과 댓글의 번역을 모두 읽는다', () async {
|
||||||
|
final repo = SampleTranslationRepository(uid: 'me');
|
||||||
|
await repo.submit(target, 'en', 'Hi');
|
||||||
|
await repo.submit('$target/answers/a1', 'en', 'Yo');
|
||||||
|
await repo.submit('questions/q2', 'en', 'Other');
|
||||||
|
final rows = await repo.watch(target).first;
|
||||||
|
// 최신순.
|
||||||
|
expect(rows.map((e) => e.targetPath), ['$target/answers/a1', target]);
|
||||||
|
expect(translationRootOf('posts/p1/comments/c1'), 'posts/p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('같은 콘텐츠·언어 재등록은 duplicate 로 거절한다', () async {
|
||||||
|
final repo = SampleTranslationRepository(uid: 'me');
|
||||||
|
await repo.submit(target, 'en', 'Hi');
|
||||||
|
await expectLater(
|
||||||
|
repo.submit(target, 'en', 'Hi again'),
|
||||||
|
throwsA(
|
||||||
|
isA<TranslationException>().having(
|
||||||
|
(e) => e.kind,
|
||||||
|
'kind',
|
||||||
|
TranslationFailure.duplicate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Firestore 저장소', () {
|
||||||
|
late FakeFirebaseFirestore db;
|
||||||
|
late FirestoreTranslationRepository repo;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = FakeFirebaseFirestore();
|
||||||
|
final auth = MockFirebaseAuth(
|
||||||
|
signedIn: true,
|
||||||
|
mockUser: MockUser(uid: 'bob'),
|
||||||
|
);
|
||||||
|
repo = FirestoreTranslationRepository(Backend(firestore: db, auth: auth));
|
||||||
|
await db.doc('translationEntries/e1').set({
|
||||||
|
// 수는 fn-translation-mark 가 번역 문서에 올린 값이다.
|
||||||
|
'validCount': 0,
|
||||||
|
'invalidCount': 1,
|
||||||
|
'authorUid': 'alice',
|
||||||
|
'targetPath': target,
|
||||||
|
'rootPath': target,
|
||||||
|
'authorName': '호떡이',
|
||||||
|
'locale': 'en',
|
||||||
|
'body': 'Hello',
|
||||||
|
'status': 'pending',
|
||||||
|
'createdAt': DateTime(2026, 9, 28),
|
||||||
|
});
|
||||||
|
// 작성자 자기 마킹은 규칙이 막지만, 들어와 있어도 세지 않는다(fn-translation-settle 과 같음).
|
||||||
|
await db.doc('translationEntries/e1/markings/alice').set({'valid': true});
|
||||||
|
await db.doc('translationEntries/e1/markings/carol').set({
|
||||||
|
'valid': false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('수는 번역 문서에서 읽고 내 마킹만 한 건 읽는다', () async {
|
||||||
|
final rows = await repo.watch(target).first;
|
||||||
|
expect(rows.single.authorName, '호떡이');
|
||||||
|
expect(rows.single.targetPath, target);
|
||||||
|
expect(rows.single.validCount, 0);
|
||||||
|
expect(rows.single.invalidCount, 1);
|
||||||
|
expect(rows.single.myMark, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('이미 마킹한 번역은 서버에 닿기 전에 거절한다', () async {
|
||||||
|
await db.doc('translationEntries/e1/markings/bob').set({'valid': true});
|
||||||
|
final entry = (await repo.watch(target).first).single;
|
||||||
|
expect(entry.myMark, isTrue);
|
||||||
|
await expectLater(
|
||||||
|
repo.mark(entry, valid: false),
|
||||||
|
throwsA(
|
||||||
|
isA<TranslationException>().having(
|
||||||
|
(e) => e.kind,
|
||||||
|
'kind',
|
||||||
|
TranslationFailure.alreadyMarked,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('서버 오류 코드 E-TRN-01~05 를 화면 실패 종류로 옮긴다', () {
|
||||||
|
expect(
|
||||||
|
translationFailureOf('E-TRN-01', forMark: true),
|
||||||
|
TranslationFailure.selfMark,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
translationFailureOf('E-TRN-02', forMark: false),
|
||||||
|
TranslationFailure.dailyCap,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
translationFailureOf('E-TRN-03', forMark: false),
|
||||||
|
TranslationFailure.invalidTarget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
translationFailureOf('E-TRN-04', forMark: true),
|
||||||
|
TranslationFailure.notEligible,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
translationFailureOf('E-TRN-05', forMark: true),
|
||||||
|
TranslationFailure.alreadyMarked,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
translationFailureOf('E-TRN-05', forMark: false),
|
||||||
|
TranslationFailure.duplicate,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
translationFailureOf('E-SYS-01', forMark: false),
|
||||||
|
TranslationFailure.other,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('본인 번역 마킹은 서버에 닿기 전에 거절한다(E-TRN-01)', () async {
|
||||||
|
const own = TranslationEntry(
|
||||||
|
id: 'e1',
|
||||||
|
authorUid: 'bob',
|
||||||
|
body: 'x',
|
||||||
|
locale: 'en',
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
repo.mark(own, valid: true),
|
||||||
|
throwsA(
|
||||||
|
isA<TranslationException>().having(
|
||||||
|
(e) => e.kind,
|
||||||
|
'kind',
|
||||||
|
TranslationFailure.selfMark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await db.doc('translationEntries/e1/markings/bob').get()).exists,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마킹은 성공하지만 스트림은 다시 오지 않는 저장소 — 낙관적 갱신을 본다.
|
||||||
|
class _SilentMarkRepository extends SampleTranslationRepository {
|
||||||
|
_SilentMarkRepository(List<SampleTranslation> seed)
|
||||||
|
: super(uid: 'me', seed: seed);
|
||||||
|
|
||||||
|
int marks = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> mark(TranslationEntry entry, {required bool valid}) async =>
|
||||||
|
marks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 고정 목록을 돌려주는 저장소.
|
||||||
|
class _FixedRepository extends SampleTranslationRepository {
|
||||||
|
_FixedRepository(this.rows) : super(uid: 'me');
|
||||||
|
|
||||||
|
final List<TranslationEntry> rows;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<TranslationEntry>> watch(
|
||||||
|
String rootPath, {
|
||||||
|
int limit = translationPageSize,
|
||||||
|
}) => Stream.value(rows);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:integration_test/integration_test_driver_extended.dart';
|
||||||
|
|
||||||
|
/// 구현 확인물 캡처 드라이버 — 화면마다 PNG 하나를 `build/captures/` 에 떨어뜨린다.
|
||||||
|
///
|
||||||
|
/// 드라이버는 호스트 VM 에서 도는 별개 프로그램이라 `--dart-define` 이 닿지 않는다.
|
||||||
|
/// 그래서 경로를 인자로 받지 않고 빌드 산출물 폴더에 모은 뒤 밖에서 옮긴다.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
|
||||||
|
/// adb shell input keyevent KEYCODE_WAKEUP # ← 이거 빼먹으면 앱이 죽는다
|
||||||
|
/// fvm flutter drive --driver=test_driver/capture_driver.dart \
|
||||||
|
/// --target=integration_test/capture_f2_test.dart -d emulator-5554
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// **화면을 먼저 깨워야 한다.** 잠긴 에뮬레이터에서는 창에 surface 가 없어
|
||||||
|
/// `takeScreenshot` 이 `PixelCopy … Window doesn't have a backing surface!` 로
|
||||||
|
/// **앱을 죽인다.** 드라이버는 그때 `DriverError: Service has disappeared` 만
|
||||||
|
/// 남기므로 원인이 화면 잠금이라는 게 로그에 드러나지 않는다 — logcat 을 봐야 나온다.
|
||||||
|
Future<void> main() => integrationDriver(
|
||||||
|
onScreenshot: (String name, List<int> bytes, [Map<String, Object?>? _]) async {
|
||||||
|
final file = File('build/captures/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes);
|
||||||
|
stdout.writeln('[capture] ${file.path}');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:integration_test/integration_test_driver.dart';
|
||||||
|
|
||||||
|
/// 프레임 타이밍 결과를 파일로 떨어뜨리는 드라이버.
|
||||||
|
///
|
||||||
|
/// 기본 드라이버는 요약을 콘솔에만 남긴다. 성능 리포트가 수치를 인용해야 하므로
|
||||||
|
/// 원본 JSON 을 남겨 근거를 되짚을 수 있게 한다.
|
||||||
|
Future<void> main() => integrationDriver(
|
||||||
|
responseDataCallback: (data) async {
|
||||||
|
if (data == null) return;
|
||||||
|
final dir = Directory('build/perf')..createSync(recursive: true);
|
||||||
|
for (final entry in data.entries) {
|
||||||
|
final file = File('${dir.path}/${entry.key}.json');
|
||||||
|
file.writeAsStringSync(
|
||||||
|
const JsonEncoder.withIndent(' ').convert(entry.value),
|
||||||
|
);
|
||||||
|
stdout.writeln('[perf] saved ${file.path}');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user