사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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` 라 밖에서 직접 부르면
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user