사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정

This commit is contained in:
Sn-Kinos
2026-10-05 00:44:39 +09:00
parent c8d29d032b
commit 8a0297eb92
66 changed files with 1812 additions and 959 deletions
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/app_dialog.dart';
/// 공통 확인 창 — Figma `2066:17799` · `554:24669`: 취소(테두리)·실행(분홍 채움)이
/// 한 줄에 반씩. 실기기(v0.5.0+7)에서 실행 버튼이 전체 폭 테마 때문에 아래로 밀리고
/// 취소가 오른쪽 위 글자 버튼으로 남던 배치를 막는다.
void main() {
Future<List<Object?>> open(
WidgetTester tester,
Future<Object?> Function(BuildContext) show,
) async {
final results = <Object?>[];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => TextButton(
onPressed: () async => results.add(await show(context)),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return results;
}
testWidgets('취소 · 실행이 같은 줄에 왼쪽 · 오른쪽 같은 폭으로 놓인다', (tester) async {
final results = await open(
tester,
(c) => showConfirmDialog(c, '기본 행성을 변경할까요?'),
);
final cancel = find.widgetWithText(OutlinedButton, '취소');
final change = find.widgetWithText(FilledButton, '변경');
expect(cancel, findsOneWidget);
expect(change, findsOneWidget);
final a = tester.getRect(cancel);
final b = tester.getRect(change);
expect(a.top, b.top);
expect(a.height, 48);
expect(a.width, b.width);
expect(a.right, lessThan(b.left));
// 버튼 줄은 문구 아래.
expect(tester.getRect(find.text('기본 행성을 변경할까요?')).bottom,
lessThan(a.top));
await tester.tap(change);
await tester.pumpAndSettle();
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(cancel);
await tester.pumpAndSettle();
expect(results, [true, false]);
});
testWidgets('단일 알림은 확인 하나가 카드 폭을 채운다', (tester) async {
await open(tester, (c) => showAlertDialog(c, '저장했어요'));
expect(find.byType(OutlinedButton), findsNothing);
final ok = tester.getRect(find.widgetWithText(FilledButton, '확인'));
final card = tester.getRect(
find.descendant(of: find.byType(AppDialog), matching: find.byType(Material)).first,
);
expect(ok.width, card.width - 32);
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(find.byType(AppDialog), findsNothing);
});
}
+12
View File
@@ -1,3 +1,5 @@
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/login_screen.dart' show authNotice;
import 'package:fancake/features/auth/social_providers.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
import 'package:flutter_test/flutter_test.dart';
@@ -92,6 +94,16 @@ void main() {
}
});
});
// 10/4 실기기 2-3 — Android Apple 로그인 다이얼로그에 「발주사 키」 · 설정 항목이 떴다.
test('설정 전 제공자 안내는 내부 설정 항목을 내보내지 않는다', () {
final notice = authNotice(
const AuthNotConfigured(AuthProviderKind.apple, ['Apple Android 웹 인증 설정']),
)!;
expect(notice, contains('Apple'));
expect(notice, isNot(contains('웹 인증 설정')));
expect(notice, isNot(contains('발주사')));
});
}
/// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면
+28 -1
View File
@@ -207,13 +207,25 @@ void main() {
'active': true, 'title': '오늘의 출석체크', 'rewardPop': 50, 'dailyLimit': 1,
});
await db.doc('users/$_uid/questProgress/attend')
.set({'count': 1, 'dayKey': '2026-09-01'});
.set({'count': 1, 'dayKey': kstDayKey()});
final snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.quests.single.state, QuestState.done);
expect(snapshot.quests.single.rewardPop, 50);
});
test('지난 날의 진행 행은 완료로 보지 않는다 — 목록 · 상세와 같은 판정(실기기 4-1)', () async {
final db = await _seed();
await db.doc('quests/attend').set({
'active': true, 'type': 'attend', 'title': '출석 체크', 'dailyLimit': 1,
});
await db.doc('users/$_uid/questProgress/attend')
.set({'count': 1, 'dayKey': '2026-09-21'});
final snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.quests.single.state, isNot(QuestState.done));
});
test('출석 보상은 서버 지급과 같은 config/policy.attendPop 을 쓴다', () async {
final db = await _seed();
await db.doc('quests/attend').set({
@@ -464,6 +476,21 @@ void main() {
expect(detail.scores.every((s) => s.myTopPercent == null), isTrue);
});
test('행성 값이 0 인 축은 남아 있는 내 기여 상위 값을 쓰지 않는다', () async {
// 10/4 실기기 — FPI 0 행성에 시드의 「상위 43%」가 그대로 떴다.
final db = await seedPlanet();
await db.doc('fandoms/bts').set({
'fpiAxes': {'bpm': 0},
}, SetOptions(merge: true));
final detail = await FirestorePlanetRepository(_backend(db), fandomId: 'bts')
.load(nebula: false);
final bpm = detail.scores.firstWhere((s) => s.metric == FpiMetric.bpm);
expect(bpm.myTopPercent, isNull);
// 값이 있는 축은 그대로 읽는다.
final health = detail.scores.firstWhere((s) => s.metric == FpiMetric.health);
expect(health.myTopPercent, 12);
});
test('스냅샷이 없으면 동향은 0 이다', () async {
final db = await seedPlanet();
await db.doc('fandoms/bts/fpiSnapshots/2026-09-01-00').delete();
+60 -8
View File
@@ -172,9 +172,15 @@ void main() {
// 검토회신서 3절 — *「선택 행성 정보가 행성과 연계된 HUD 가 아니라 화면
// 최상단 고정 패널 형태」*. 고정 패널로 되돌아가면 여기서 깨진다.
testWidgets('HUD 는 화면이 아니라 행성에 붙어 움직인다', (tester) async {
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 _settle(tester);
final before = tester.getCenter(find.text('RANK'));
await tester.drag(find.byType(InteractiveViewer), const Offset(-90, -70));
await tester.drag(find.byType(InteractiveViewer), const Offset(-200, 0));
await _settle(tester);
final after = tester.getCenter(find.text('RANK'));
expect(
@@ -275,17 +281,63 @@ void main() {
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);
// 끝까지 확대하며 두 손가락을 오른쪽 아래로 끌어 내 행성을 화면 밖으로 보낸다
// (한 손가락 팬은 없다 — 확대 제스처의 초점 이동만 맵을 옮긴다).
final viewer = find.byType(InteractiveViewer);
final origin = tester.getTopLeft(viewer);
final a = await tester.startGesture(origin + const Offset(4, 4));
final b = await tester.startGesture(
origin + const Offset(24, 24),
pointer: 7,
);
for (var i = 1; i <= 10; i++) {
await b.moveTo(origin + Offset(24.0 + i * 32, 24.0 + i * 32));
await a.moveTo(origin + Offset(4.0 + i * 20, 4.0 + i * 20));
await tester.pump();
}
await a.up();
await b.up();
await _settle(tester);
expect(find.text('RANK'), findsNothing);
});
// 10/4 실기기 — 가로로 끌면 궤도가 돌지 않고 장면이 통째로 밀려 빈 화면이 됐다.
testWidgets('가로로 끌면 장면은 제자리에서 궤도가 돌고, 손을 떼도 관성으로 더 돈다', (tester) async {
await _pump(tester, _galaxy());
final viewer = tester.widget<InteractiveViewer>(
find.byType(InteractiveViewer),
);
final view = viewer.transformationController!;
final before = view.value.clone();
final mine = tester.getCenter(find.text('RANK'));
for (var i = 0; i < 4; i++) {
await tester.fling(
find.byType(InteractiveViewer),
const Offset(-300, 0),
1500,
);
await tester.pump();
}
expect(view.value, before, reason: '끌기는 맵을 옮기지 않는다(팬 없음)');
expect(
(tester.getCenter(find.text('RANK')) - mine).distance,
lessThan(1),
reason: '가운데 내 행성은 화면 밖으로 밀려나지 않는다',
);
// 관성 — 손을 뗀 뒤에도 기본 표류보다 빠르게 돌다가 감속한다.
final state = tester.state(find.byType(GalaxyScreen));
final clock = (state as dynamic).clockForTest as GalaxyClock;
final v0 = clock.velocity;
expect(v0.abs(), greaterThan(GalaxyMotion.idleSpin));
await _settle(tester);
expect(
(clock.velocity - GalaxyMotion.idleSpin).abs(),
lessThan((v0 - GalaxyMotion.idleSpin).abs()),
);
});
testWidgets('GALAXY 는 좌측 하단, 돋보기는 우측 하단', (tester) async {
await _pump(tester, _galaxy());
expect(find.text('GALAXY'), findsOneWidget);
+10 -5
View File
@@ -61,7 +61,8 @@ class _NoTopPercentRepository extends SamplePlanetRepository {
FpiScore(
metric: s.metric,
value: s.value,
rank: s.rank,
// 집계 모집단 밖(서버 fpiRanks 없음)이면 순위가 0 으로 온다.
rank: 0,
trend: s.trend,
myTopPercent: null,
),
@@ -166,7 +167,7 @@ void main() {
expect(find.text('상위 17%'), findsWidgets);
});
testWidgets('내 기여 상위 집계 전이면 숫자 대신 사유를 띄운다 — 대조표 3-5', (tester) async {
testWidgets('집계 값이 없으면 숫자를 짓지 않고 내부 사유도 띄우지 않는다 — 대조표 3-5', (tester) async {
await _pumpTall(
tester,
const PlanetDetailScreen(
@@ -177,9 +178,13 @@ void main() {
);
// 없는 값을 100%(최하위)로 지어내지 않는다.
expect(find.text('상위 100%'), findsNothing);
expect(find.text('집계 준비 중'), findsWidgets);
// 사유는 배지로도 뜬다 — 숨기지 않는다.
expect(find.textContaining('MOCK · '), findsOneWidget);
expect(find.text('—'), findsWidgets);
// 개발용 사유(MOCK · 집계 함수)는 사용자 화면에 내보내지 않는다(10/4 실기기 3-1).
expect(find.textContaining('MOCK'), findsNothing);
expect(find.textContaining('집계 함수'), findsNothing);
// 순위가 없으면 「전체 0위」 대신 「순위 집계 전」.
expect(find.text('전체 0위'), findsNothing);
expect(find.text('순위 집계 전'), findsWidgets);
});
testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async {
+70 -5
View File
@@ -90,6 +90,29 @@ void main() {
expect(find.textContaining('광고 추가 보상'), findsNothing);
});
testWidgets('참여 조건 판정이 실패하면 광고 모달을 띄우지 않는다 (실기기 4-3)', (tester) async {
final repo = _AdRepo(
verifyError: const BackendException('E-QST-03', '기기의 위치 기능이 꺼져 있습니다.'),
);
await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
await tester.pumpAndSettle();
expect(find.text('광고 보고 추가 보상 받기'), findsNothing);
expect(find.text('기기의 위치 기능이 꺼져 있습니다.'), findsOneWidget);
expect(repo.claimed, isEmpty);
});
testWidgets('판정을 통과한 뒤에야 광고 모달을 띄운다', (tester) async {
final repo = _AdRepo();
await pumpWide(tester, QuestDetailScreen(questId: 'q2', repository: repo));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
await tester.pumpAndSettle();
expect(repo.verified, 1);
expect(find.text('광고 보고 추가 보상 받기'), findsOneWidget);
});
testWidgets('일일 한도(E-AD-02)를 알리고 기본 보상은 그대로 띄운다', (tester) async {
final repo = _AdRepo(
claimError: const BackendException('E-AD-02', '오늘 광고 보상 한도를 모두 사용했습니다.'),
@@ -101,6 +124,21 @@ void main() {
});
});
group('목록 헤더 · 실패 (2026-10-04 실기기)', () {
testWidgets('헤더 보유 POP 은 목록을 읽을 때 지갑 값으로 다시 맞춘다', (tester) async {
await pumpWide(tester, QuestListScreen(repository: _BalanceRepo(), myPop: 12290));
await tester.pumpAndSettle();
expect(find.text('12,260'), findsOneWidget);
expect(find.text('12,290'), findsNothing);
});
testWidgets('목록 조회가 실패해도 로딩이 끝난다', (tester) async {
await pumpWide(tester, QuestListScreen(repository: _FailingList()));
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
});
});
group('진행 상태 (F4 · 518:88441)', () {
test('명세가 정한 다섯 상태의 버튼 문구를 그대로 쓴다', () {
expect(QuestStage.mine.buttonLabel, '자세히');
@@ -233,7 +271,8 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, '보상받기'));
await tester.pumpAndSettle();
// 별첨 B B-28 — 배수형 철회. 추가분은 고정형 운영값(C-02 초기 10 기여 POP).
expect(find.text('+10 POP'), findsOneWidget);
// 원장 C-116 — 「+N 기여 POP」 표기.
expect(find.text('+10 기여 POP'), findsOneWidget);
expect(find.textContaining('배'), findsNothing);
// 별첨1 ⑥ No-Force — 보지 않아도 기본 보상을 받는다.
expect(find.text('기본 보상 받기'), findsOneWidget);
@@ -270,14 +309,18 @@ void main() {
expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget);
});
testWidgets('회신 대기 유형은 참여 버튼이 잠기고 MOCK 으로 알린다', (tester) async {
testWidgets('회신 대기 유형은 참여 버튼이 잠기고 사용자 문구로만 알린다', (tester) async {
await pumpWide(tester, const QuestDetailScreen(questId: 'q6', repository: _sample));
await tester.pump();
final button = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '발주사 확인 중'),
find.widgetWithText(FilledButton, '준비 중입니다.'),
);
expect(button.onPressed, isNull);
expect(find.textContaining('Q-QC-04'), findsOneWidget);
expect(find.text('이 퀘스트는 아직 참여할 수 없습니다.'), findsOneWidget);
// 내부 질의 번호 · 「발주사」 · MOCK 은 화면에 내보내지 않는다.
expect(find.textContaining('Q-QC-'), findsNothing);
expect(find.textContaining('발주사'), findsNothing);
expect(find.textContaining('MOCK'), findsNothing);
});
testWidgets('보상을 받고 나오면 보상완료가 되고 더 못 누른다', (tester) async {
@@ -464,14 +507,36 @@ void main() {
});
}
/// 지갑이 서버 값으로 바뀐 표본 — 생성 예치 · 수수료 뒤 헤더가 다시 맞는가.
class _BalanceRepo extends SampleQuestRepository {
@override
Future<int?> popBalance() async => 12260;
}
/// 목록이 실패하는 표본 — 로딩이 끝나지 않으면 탭 전체가 무한 로딩이다.
class _FailingList extends SampleQuestRepository {
@override
Future<List<QuestCard>> load({int limit = 10}) async =>
throw const BackendException('E-PERM-01', 'denied');
}
/// 광고 경계만 바꾼 표본 — B-03 「광고 실패 시 기본 보상 지급 · 추가 보상 없음」.
class _AdRepo extends SampleQuestRepository {
_AdRepo({this.loadError, this.impression = 'imp-1', this.claimError});
_AdRepo({this.loadError, this.impression = 'imp-1', this.claimError, this.verifyError});
final BackendException? loadError;
final String? impression;
final BackendException? claimError;
final BackendException? verifyError;
final claimed = <String>[];
var verified = 0;
@override
Future<QuestReward> verify(QuestCard card) async {
verified += 1;
if (verifyError case final error?) throw error;
return super.verify(card);
}
@override
Future<RewardedAdHandle> loadRewardedAd() async {
+5 -6
View File
@@ -7,6 +7,7 @@ import 'package:fancake/features/campaign/campaign_ranking_screen.dart';
import 'package:fancake/features/campaign/campaign_search_screen.dart';
import 'package:fancake/features/campaign/widgets/campaign_period_sheet.dart';
import 'package:flutter/material.dart';
import 'package:fancake/core/ui/app_dialog.dart';
import 'package:flutter_test/flutter_test.dart';
/// F5 캠페인 — 화면 대조 원장 8화면.
@@ -418,12 +419,10 @@ void main() {
expect(find.text('2027년 4월'), findsOneWidget);
});
testWidgets('Catalog 표본 가격은 앱바 MOCK 알약으로 알린다(G-08)', (tester) async {
testWidgets('앱바에 개발용 MOCK 표시를 띄우지 않는다', (tester) async {
await pumpWide(tester, CampaignCreateScreen());
expect(
find.bySemanticsLabel('MOCK · 상품 목록은 Catalog 연결 전 표본입니다'),
findsOneWidget,
);
expect(find.textContaining('MOCK'), findsNothing);
expect(find.bySemanticsLabel(RegExp('MOCK')), findsNothing);
});
testWidgets('값이 다 차기 전까지 완료가 잠겨 있다', (tester) async {
@@ -465,7 +464,7 @@ void main() {
expect(find.text('이 내용으로 올릴까요?'), findsOneWidget);
expect(
find.descendant(
of: find.byType(AlertDialog),
of: find.byType(AppDialog),
matching: find.textContaining('500,000 POP · 14일'),
),
findsOneWidget,
+68 -7
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/inside/inside_data.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/my/my_data.dart';
import 'package:fancake/features/my/my_lists_screen.dart';
@@ -15,8 +15,11 @@ 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:fancake/features/my/widgets/profile_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:share_plus/share_plus.dart';
/// F6 MY — 화면 대조 원장 21화면.
@@ -178,11 +181,45 @@ void main() {
await tester.pump();
await tester.tap(find.byType(CircleAvatar).first);
await tester.pumpAndSettle();
for (final target in const ['인스타그램', '틱톡', 'X', '링크 복사']) {
// 프로필 웹 주소가 없어 마지막 칸은 「문구 복사」다(없는 링크를 약속하지 않는다).
for (final target in const ['인스타그램', '틱톡', 'X', '문구 복사']) {
expect(find.byTooltip(target), findsOneWidget);
}
});
testWidgets('공유는 OS 공유 시트로 프로필 문구를 보낸다 — 내부 일정 문구 없음', (tester) async {
final profile = await SampleMyRepository().profile();
ShareParams? sent;
final copied = await shareProfile(
profile,
'인스타그램',
share: (params) async {
sent = params;
return const ShareResult('ok', ShareResultStatus.success);
},
);
expect(copied, isFalse);
expect(sent?.text, contains(profile.nickname));
expect(sent?.text, contains('#FANUZU'));
expect(sent?.text, isNot(contains('회차')));
// 마지막 칸은 공유 시트 대신 같은 문구를 복사한다.
String? clip;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
clip = (call.arguments as Map)['text'] as String?;
}
return null;
},
);
addTearDown(() => tester.binding.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null));
expect(await shareProfile(profile, '문구 복사'), isTrue);
expect(clip, profileShareText(profile));
});
testWidgets('티어 카드를 누르면 행성별 티어로 넘어간다', (tester) async {
await pumpWide(tester, MyScreen(repository: SampleMyRepository()));
await tester.pump();
@@ -844,11 +881,17 @@ void main() {
});
group('내 활동 (F6 · 370:228289 · 370:229665)', () {
testWidgets('내 인사이드에 내가 올린 글이 나온다', (tester) async {
await pumpWide(tester, const MyListsScreen(kind: MyListKind.inside));
await tester.pump();
expect(find.textContaining('화양연화'), findsOneWidget);
expect(find.text('500 P'), findsOneWidget);
testWidgets('내 인사이드는 저장소의 내 질문을 보여 준다 — 고정 표본 없음', (tester) async {
await pumpWide(
tester,
const MyListsScreen(kind: MyListKind.inside, insides: _MyInsides()),
);
await tester.pumpAndSettle();
expect(find.text('device-check-question'), findsOneWidget);
expect(find.text('100 P'), findsOneWidget);
// 10/4 실기기 8-3 — 내 글 대신 뜨던 표본(실제 아티스트명 포함).
expect(find.textContaining('화양연화'), findsNothing);
expect(find.textContaining('BTS'), findsNothing);
});
testWidgets('내 캠페인에는 내가 만든 것만 나온다', (tester) async {
@@ -1000,3 +1043,21 @@ class _PagedZapRepository extends SampleMyRepository {
return [_rows[i + 1]];
}
}
/// 내가 쓴 질문 한 건만 돌려주는 저장소 — 화면이 저장소를 읽는지 확인한다.
class _MyInsides extends SampleInsideRepository {
const _MyInsides();
@override
Future<List<InsideQuestion>> mine() async => [
InsideQuestion(
id: 'mine-1',
title: 'device-check-question',
body: '본문',
authorName: '팬',
createdAt: DateTime(2026, 10, 4),
bountyPop: 100,
isMine: true,
),
];
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_home_screen.dart';
/// 「내 행성」 칸 그림은 칸 순서가 아니라 팬덤 stage 로 고른다 — 실기기에서 둘째 칸의
/// FANUZU 데모 행성(stage 1)이 성운 그림으로 나왔다.
void main() {
PlanetNode node(int? stage) => PlanetNode(
id: 'x', name: 'x', rank: 1, fpi: 0, members: 0, online: 0, stage: stage,
);
test('정식 행성은 어느 칸이든 행성 그림, 성운만 성운 그림', () {
for (final i in [0, 1, 2]) {
expect(GalaxyHomeScreen.cardImageOf(node(1), i), isNot(contains('nebula')));
expect(GalaxyHomeScreen.cardImageOf(node(null), i), isNot(contains('nebula')));
expect(GalaxyHomeScreen.cardImageOf(node(0), i), contains('nebula'));
}
});
}
+128
View File
@@ -0,0 +1,128 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// 프로토타입 v6.2 와 나란히 볼 미니맵 캡처. `MINIMAP_OUT=<폴더>` 일 때만 PNG 를 쓴다.
/// MINIMAP_OUT=build/minimap fvm flutter test test/galaxy_mini_map_capture_test.dart
/// 표본은 프로토타입 `FPI_DATA` 5행성 — 같은 데이터로 두 장면을 비교한다.
const protoPlanets = [
PlanetNode(
id: '1',
name: 'NewJeans',
rank: 1,
fpi: 3200,
members: 1,
online: 4820,
isMine: true,
),
PlanetNode(
id: '2',
name: 'BTS',
rank: 2,
fpi: 2800,
members: 1,
online: 12540,
),
PlanetNode(
id: '3',
name: 'aespa',
rank: 3,
fpi: 2100,
members: 1,
online: 3910,
),
PlanetNode(
id: '4',
name: 'IVE',
rank: 4,
fpi: 1700,
members: 1,
online: 2840,
),
PlanetNode(
id: '5',
name: 'PLAVE',
rank: 5,
fpi: 1300,
members: 1,
online: 5120,
),
];
/// 내 행성이 3위인 경우(C-103 — 1위가 아니어도 내 행성이 가운데).
List<PlanetNode> mineAt(int rank) => [
for (final p in protoPlanets)
PlanetNode(
id: p.id,
name: p.name,
rank: p.rank,
fpi: p.fpi,
members: p.members,
online: p.online,
isMine: p.rank == rank,
),
];
/// 테스트 기본 글꼴(Ahem 네모) 대신 앱 글꼴로 글자 폭을 잰다.
Future<void> loadAppFonts() 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();
}
}
void main() {
final out = Platform.environment['MINIMAP_OUT'];
if (out == null) return;
setUpAll(() async {
Directory(out).createSync(recursive: true);
await loadAppFonts();
});
for (final (name, planets, size) in [
('minimap-140-mine1', protoPlanets, 140.0),
('minimap-140-mine3', mineAt(3), 140.0),
('minimap-360-mine1', protoPlanets, 360.0),
// Figma 「홈」(2097:16428) 실측 박스 약 93pt — C-105 140pt 와 비교용.
('after_93', protoPlanets, 93.0),
('after_93-mine3', mineAt(3), 93.0),
]) {
testWidgets(name, (tester) async {
final key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Center(
child: RepaintBoundary(
key: key,
child: GalaxyMiniMap(
data: GalaxySnapshot(planets: planets, myPop: 0),
onOpen: () {},
size: size,
),
),
),
),
);
await tester.pump();
await tester.runAsync(() async {
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
File('$out/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
});
});
}
}
+69 -10
View File
@@ -11,9 +11,11 @@ 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/home/widgets/home_hero.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'eclipse_stubs.dart';
import 'galaxy_mini_map_capture_test.dart' show loadAppFonts, mineAt;
import 'perf_paint_test.dart' show medianPaintMicros, paintBudgetMicros;
/// 프로토타입 궤도 간격 450 × Mass Volume 1.5 × Orbit Exp 1.0.
@@ -187,24 +189,68 @@ void main() {
expect(painterOf(tester).orbitOf('c'), closeTo(2 * _spacing, 5));
});
testWidgets('140 에서는 내 행성 간략 카드만 — 두 줄 모두 9pt 이상', (tester) async {
// 10/4 실기기 — 140pt 에서 HUD 글상자가 미니맵 대부분을 덮어 궤도·행성이 안 보였다.
testWidgets('140 에서는 내 행성 간략 카드만(C-105) — 장면을 덮지 않는 크기', (tester) async {
await tester.runAsync(loadAppFonts);
await pumpMap(tester, _snap(_base));
final p = painterOf(tester);
expect(p.compactText, [
('ALPHA', GalaxyMiniMapPainter.compactNamePt),
('#1 · FPI 5,000 · LIVE 1,204', GalaxyMiniMapPainter.compactLinePt),
('#1 · FPI 5,000', GalaxyMiniMapPainter.compactLinePt),
('LIVE 1,204', GalaxyMiniMapPainter.compactLinePt),
]);
expect(p.compactText.first.$2, greaterThanOrEqualTo(10));
for (final (_, pt) in p.compactText) {
expect(pt, greaterThanOrEqualTo(9));
expect(pt, greaterThanOrEqualTo(8));
}
final w = p.hudWidthOf('a')!;
expect(w, greaterThan(0));
expect(w, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor));
final card = p.compactSize;
expect(card.width, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor));
expect(
card.width * card.height,
lessThan(140 * 140 * 0.2),
reason: '카드는 미니맵 넓이의 1/5 미만',
);
expect(p.hudWidthOf('b'), 0, reason: '다른 행성 카드는 뺀다');
expect(p.hudWidthOf('c'), 0);
});
testWidgets('긴 행성 이름은 말줄임 — 순위·FPI·LIVE 숫자는 줄이지 않는다', (tester) async {
await tester.runAsync(loadAppFonts);
await pumpMap(
tester,
_snap(const [
PlanetNode(
id: 'a',
name: 'FANUZU 데모 행성 아주 긴 이름 #1',
rank: 1,
fpi: 123456,
members: 10,
online: 1204,
isMine: true,
),
]),
);
final p = painterOf(tester);
expect(p.compactText.skip(1).map((t) => t.$1), [
'#1 · FPI 123,456',
'LIVE 1,204',
]);
for (final (_, pt) in p.compactText.skip(1)) {
expect(pt, GalaxyMiniMapPainter.compactLinePt);
}
expect(
p.compactSize.width,
lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor + 0.01),
);
});
testWidgets('내 행성이 1위가 아니어도 첫 화면 가운데에 둔다(C-103)', (tester) async {
await pumpMap(tester, _snap(mineAt(3)));
final p = painterOf(tester);
expect(p.worldOf('3')!.distance, lessThan(1), reason: '내 행성 = 장면 원점');
expect(p.orbitOf('3'), 2 * _spacing, reason: '궤도는 FPI 순위 그대로');
expect(p.worldOf('1')!.distance, greaterThan(_spacing / 2));
});
testWidgets('320 에서는 모든 행성에 프로토타입 카드를 그린다', (tester) async {
await pumpMap(tester, _snap(_base), size: 320);
final p = painterOf(tester);
@@ -307,11 +353,24 @@ void main() {
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));
// C-105 약 140pt · C-113 자리 = Figma 「홈」(2097:16428) 히어로 우측 상단 · 오른쪽 여백 0.
expect(
tester.getSize(mini),
const Size.square(HomeHero.miniMapSize),
);
expect(
tester.getTopRight(mini).dx,
closeTo(tester.getSize(find.byType(HomeScreen)).width - 16, 1),
closeTo(tester.getSize(find.byType(HomeScreen)).width, 1),
);
expect(
tester.getTopLeft(mini).dy,
closeTo(
tester.getTopLeft(find.byType(HomeHero)).dy +
MediaQuery.paddingOf(tester.element(mini)).top -
48 + // 사본 y 는 상태 표시줄 48 을 포함한다(HomeHero 와 같은 환산).
HomeHero.miniMapTop,
1,
),
);
await tester.tap(mini);
await tester.pump();
+204
View File
@@ -0,0 +1,204 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/section_header.dart';
import 'package:fancake/features/campaign/campaign_create_screen.dart';
import 'package:fancake/features/campaign/campaign_list_screen.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_repository_firestore.dart';
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'eclipse_stubs.dart';
/// 실기기 판정(2026-10-04) HOME 결함 — HOT ARCHIVE 진입 · 브리핑 · 인기 K-INSIDE · 상태 표시줄.
const _uid = 'u-home';
class _Backend extends Backend {
_Backend(FakeFirebaseFirestore db)
: super(
firestore: db,
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
);
@override
Future<Map<String, dynamic>> call(
String name, [
Map<String, dynamic> payload = const {},
]) async => {'code': 'ABCDEFGH23'};
}
/// 모금 중 캠페인이 없는 HOME.
class _NoCampaignRepo extends SampleHomeRepository {
const _NoCampaignRepo();
@override
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
@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: base.briefings,
campaign: null,
insides: base.insides,
);
}
}
Future<void> _pump(WidgetTester tester, HomeRepository repo) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: MediaQuery(
// 노치 단말 — 상태 표시줄 47.
data: const MediaQueryData(
size: Size(540, 1200),
padding: EdgeInsets.only(top: 47),
),
child: HomeScreen(
repository: repo,
questRepository: const SampleQuestRepository(),
eclipse: const NoEclipseRepository(),
),
),
),
);
await tester.pumpAndSettle();
}
Finder get _hotArchive => find.byWidgetPredicate(
(w) => w is SectionHeader && w.title == 'HOT ARCHIVE',
);
void main() {
setUp(() => SharedPreferences.setMockInitialValues({}));
group('HOT ARCHIVE — 모금 중 캠페인 0건(명세 §2.114-6)', () {
testWidgets('구획과 「더 보기」가 남아 캠페인 목록으로 간다', (tester) async {
await _pump(tester, const _NoCampaignRepo());
await tester.scrollUntilVisible(
_hotArchive,
220,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(
find.descendant(of: _hotArchive, matching: find.text('더 보기')),
);
await tester.pumpAndSettle();
expect(find.byType(CampaignListScreen), findsOneWidget);
});
testWidgets('카드 자리의 「새 캠페인」이 개설 화면을 연다', (tester) async {
await _pump(tester, const _NoCampaignRepo());
final create = find.text('새 캠페인');
await tester.scrollUntilVisible(
create,
220,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(create);
await tester.pumpAndSettle();
expect(find.byType(CampaignCreateScreen), findsOneWidget);
});
});
testWidgets('스크롤하면 상태 표시줄 자리를 가린다(머리글 겹침 방지)', (tester) async {
await _pump(tester, const _NoCampaignRepo());
final scrim = find.byKey(const ValueKey('home-status-scrim'));
// 맨 위에서는 히어로 배경이 상태 표시줄 뒤까지 보여야 한다.
expect(scrim, findsNothing);
await tester.drag(find.byType(Scrollable).first, const Offset(0, -400));
await tester.pumpAndSettle();
expect(scrim, findsOneWidget);
expect(tester.getSize(scrim).height, 47);
await tester.drag(find.byType(Scrollable).first, const Offset(0, 2000));
await tester.pumpAndSettle();
expect(scrim, findsNothing);
});
group('FirestoreHomeRepository', () {
late FakeFirebaseFirestore db;
setUp(() async {
db = FakeFirebaseFirestore();
await db.doc('users/$_uid').set({'fandomId': 'fanuzu-demo'});
});
Future<void> brief(String id, String section, String status) =>
db.doc('briefings/$id').set({
'fandomId': 'fanuzu-demo',
'dayKey': kstDayKey(),
'status': status,
'section': section,
'rank': 1,
'headline': id,
'sourceName': '출처',
'sourceUrl': 'https://example.com/s',
'articleUrl': 'https://example.com/a',
'aiSummary': '요약',
'sourceAt': DateTime(2026, 10, 4),
});
test('브리핑 section 은 서버 값(News·SNS)만 그린다 — 시연 시드 소문자 결함', () async {
await brief('news', 'News', 'published');
await brief('sns', 'SNS', 'published');
await brief('draft', 'News', 'draft');
// 수정 전 시드(`tools/demo/fs.mjs`)가 쓰던 값 — 앱은 모르는 구획이라 버린다.
await brief('lower', 'news', 'published');
final home = await FirestoreHomeRepository(_Backend(db)).load();
expect(
home.briefings.map((b) => b.headline),
unorderedEquals(['news', 'sns']),
);
});
test('인기 K-INSIDE 는 미해결 먼저, 그 안에서 현상금 큰 순(명세 §2.114-7)', () async {
final at = DateTime(2026, 10, 4);
final rows = {
'new-0-answers': ('open', 100, at.add(const Duration(hours: 1))),
'solved-big': ('accepted', 900, at),
'open-500': ('open', 500, at),
'open-300': ('open', 300, at),
'open-200': ('open', 200, at),
};
for (final MapEntry(key: id, value: (status, bounty, createdAt))
in rows.entries) {
await db.doc('questions/$id').set({
'title': id,
'status': status,
'bountyPop': bounty,
'createdAt': createdAt,
});
}
final home = await FirestoreHomeRepository(_Backend(db)).load();
expect(home.insides.map((i) => i.title), [
'open-500',
'open-300',
'open-200',
]);
});
test('미해결이 3건보다 적으면 해결된 고액 질문으로 채운다', () {
expect(
popularInsides([
{'title': 'a', 'status': 'accepted'},
{'title': 'b', 'status': 'open'},
{'title': 'c', 'status': 'expired'},
]).map((q) => q['title']),
['b', 'a', 'c'],
);
});
});
}
+4 -2
View File
@@ -155,7 +155,7 @@ void main() {
expect(find.text('500 P'), findsOneWidget);
});
testWidgets('아직 없는 화면으로 가려 하면 언제 열리는지 알린다', (tester) async {
testWidgets('아직 없는 화면으로 가려 하면 준비 중이라고 알린다', (tester) async {
await _pump(tester);
// 첫 「더 보기」는 퀘스트라 실제로 이동한다. 아직 없는 구간까지 굴려서 누른다.
// 브리핑 헤더는 「공유」다(`266:90529`). 아직 없는 목록인 인기 K-INSIDE 로 본다.
@@ -169,7 +169,9 @@ void main() {
await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기')));
await tester.pump();
// 조용히 무시하면 검수 때 고장으로 보인다.
expect(find.textContaining('구현 확인물 2차'), findsOneWidget);
expect(find.textContaining('화면은 준비 중입니다.'), findsOneWidget);
// 내부 일정(구현 확인물 · 날짜)은 사용자 화면에 내보내지 않는다.
expect(find.textContaining('구현 확인물'), findsNothing);
});
testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async {
+17 -2
View File
@@ -172,11 +172,11 @@ void main() {
test('원격 문자열은 인자 값 안의 자리표시자를 다시 치환하지 않는다', () {
final t = RemoteL10nDelegate(const {
'linkTitle': '{name} 연동',
'settingsLinkNotReady': '{name}: {items}',
'myShareText': '{nickname}: {level}',
});
return t.load(const Locale('ko')).then((l10n) {
expect(l10n.linkTitle('{name}'), '{name} 연동');
expect(l10n.settingsLinkNotReady('{items}', 'A'), '{items}: A');
expect(l10n.myShareText('{level}', 'A', '1'), '{level}: A');
});
});
@@ -226,4 +226,19 @@ void main() {
);
});
});
// 10/4 실기기 — 「발주사」 · 「09-10 회차에 연결」 · 오탈자 「맴버」가 사용자 화면에 떴다.
test('사용자 문구에 내부 표현 · 일정 · 오탈자가 없다', () {
const banned = ['발주사', '発注元', 'client key', 'MOCK', '회차에 연결', '09-10', '2026-', 'Q-QC-', '맴버', '구현 확인물'];
for (final file in Directory('lib/l10n').listSync().whereType<File>()) {
if (!file.path.endsWith('.arb')) continue;
final arb = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
for (final MapEntry(:key, :value) in arb.entries) {
if (key.startsWith('@') || value is! String) continue;
for (final word in banned) {
expect(value.contains(word), isFalse, reason: '${file.path} $key: $value');
}
}
}
});
}
+20
View File
@@ -96,6 +96,26 @@ void main() {
});
group('퀘스트 목록 — Firestore 구현', () {
test('UGC 퀘스트의 제출 건 조회가 거부돼도 목록 전체는 뜬다 (2026-10-04 실기기)', () async {
// 제출 건 읽기를 막은 규칙 — 배포 전 규칙에서 없는 문서 get 이 permission-denied 였다.
final db = FakeFirebaseFirestore(securityRules: '''
service cloud.firestore {
match /databases/{database}/documents {
match /quests/{id} { allow read, write: if true; }
match /config/{id} { allow read, write: if true; }
match /users/{document=**} { allow read, write: if true; }
}
}''');
await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'dailyLimit': 1});
await db.doc('quests/ugc').set({
'type': 'community', 'title': '내가 만든 퀘스트', 'authorUid': 'someone',
'status': 'published', 'goal': 1,
});
final cards = await FirestoreQuestRepository(_RecordingBackend(db)).load();
expect(cards.map((c) => c.id).toSet(), {'attend', 'ugc'});
expect(_card(cards, 'ugc').stage, QuestStage.notStarted);
});
test('정의 문서를 카드로 옮기고 유형을 별칭까지 읽는다', () async {
final db = await _seed();
final cards = await FirestoreQuestRepository(
+53
View File
@@ -0,0 +1,53 @@
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_repository_firestore.dart' show questToday;
import 'package:fancake/features/quest/widgets/quest_card_tile.dart' show questImage;
import 'package:flutter_test/flutter_test.dart';
/// HOME 과 퀘스트 목록 · 상세가 같이 쓰는 오늘 판정(2026-10-04 실기기 4-1 · 4-9).
void main() {
// 2026-10-04 09:00 KST.
final now = DateTime.utc(2026, 10, 4);
const quest = {'dailyLimit': 1};
const policy = <String, dynamic>{};
test('지난 날의 진행 행은 오늘 0 회다 — 9/21 출석이 10/4 에 「보상 완료」로 남지 않는다', () {
final r = questToday(QuestType.attend, quest, {'dayKey': '2026-09-21', 'count': 1}, policy, now);
expect(r.count, 0);
expect(r.done, isFalse);
});
test('오늘(KST) 행이면 완료다', () {
final r = questToday(QuestType.attend, quest, {'dayKey': '2026-10-04', 'count': 1}, policy, now);
expect(r.done, isTrue);
});
test('날짜 경계는 KST — UTC 10/3 15:00 은 KST 10/4 0 시다', () {
final r = questToday(QuestType.attend, quest, {'dayKey': '2026-10-04', 'count': 1}, policy,
DateTime.utc(2026, 10, 3, 15));
expect(r.done, isTrue);
final before = questToday(QuestType.attend, quest, {'dayKey': '2026-10-04', 'count': 1}, policy,
DateTime.utc(2026, 10, 3, 14, 59));
expect(before.done, isFalse);
});
test('타임어택은 1인 1회라 날짜로 끊지 않는다', () {
final r = questToday(QuestType.timeAttack, quest, {'dayKey': '2026-09-21', 'count': 1}, policy, now);
expect(r.done, isTrue);
});
test('걸음은 구간을 다 받아야 완료다(B-26)', () {
const tiers = {'stepTiers': [{'steps': 1000}, {'steps': 5000}]};
final one = questToday(QuestType.steps, quest, {'dayKey': '2026-10-04', 'count': 1}, tiers, now);
expect(one.done, isFalse);
final two = questToday(QuestType.steps, quest, {'dayKey': '2026-10-04', 'count': 2}, tiers, now);
expect(two.done, isTrue);
});
test('유형 · 제목으로 못 고르는 퀘스트도 아이콘 칸이 비지 않는다', () {
const card = QuestCard(
id: 'daily-heart', title: '심박 기록 남기기', categoryName: 'c', author: QuestAuthor.admin,
stage: QuestStage.notStarted, goal: 1, progress: 0, rewardPop: 80, participants: 0,
);
expect(questImage(card), 'assets/figma/quest-img-bolt.png');
});
}