HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE

This commit is contained in:
Sn-Kinos
2026-10-05 16:29:41 +09:00
parent f84d696c21
commit 54d0b23260
45 changed files with 3448 additions and 1111 deletions
+122 -32
View File
@@ -1,5 +1,5 @@
import 'dart:async';
import 'package:fancake/core/ui/section_header.dart';
import 'package:fancake/features/home/widgets/home_sections.dart';
import 'package:fancake/features/quest/quest_list_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -7,8 +7,12 @@ 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/home/home_repository_firestore.dart' show adminQuestsLatest, nebulaPicks;
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/home/widgets/briefing_list.dart';
import 'package:fancake/features/home/widgets/quest_row.dart';
import 'package:fancake/features/inside/inside_detail_screen.dart';
import 'package:fancake/features/quest/widgets/quest_card_tile.dart' show QuestIconBox;
import 'eclipse_stubs.dart';
@@ -18,6 +22,36 @@ class _LiveGrowthRepo extends SampleHomeRepository {
Stream<PlanetGrowth> watchGrowth(String fandomId) => changes.stream;
}
class _LiveWalletRepo extends SampleHomeRepository {
final wallet = StreamController<int>.broadcast();
@override
Stream<int> watchPopBalance() => wallet.stream;
}
/// 퀘스트 4건 — 3줄까지만 그리는지 본다.
class _QuestRepo extends SampleHomeRepository {
const _QuestRepo();
@override
Future<HomeSnapshot> load() async {
final base = await const SampleHomeRepository().load();
return HomeSnapshot(
popBalance: base.popBalance,
fandomId: base.fandomId,
liveTicker: base.liveTicker,
growth: base.growth,
quests: [
...base.quests,
const QuestSummary(title: '좋아요', rewardPop: 5, state: QuestState.done),
const QuestSummary(title: '네 번째', rewardPop: 5, state: QuestState.inProgress),
],
briefings: base.briefings,
campaigns: base.campaigns,
nebulas: base.nebulas,
insides: base.insides,
);
}
}
/// 값을 못 가져오는 경우를 재현한다.
class _FailingRepo extends SampleHomeRepository {
@override
@@ -61,7 +95,8 @@ class _BriefingRepo extends SampleHomeRepository {
growth: base.growth,
quests: base.quests,
briefings: briefings,
campaign: base.campaign,
campaigns: base.campaigns,
nebulas: base.nebulas,
insides: base.insides,
);
}
@@ -98,20 +133,36 @@ void main() {
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);
// 카드는 병목 축(sFPI 75%)을 보인다.
expect(find.text('행성 성장률 75%'), findsOneWidget);
expect(find.text('750 / 1,000'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump();
expect(repo.changes.hasListener, isFalse);
});
testWidgets('헤더 POP 칩은 지갑 문서 변동을 따라간다(다른 화면의 예치·보상)', (tester) async {
final repo = _LiveWalletRepo();
addTearDown(repo.wallet.close);
await _pump(tester, repo: repo);
expect(find.text('12,540'), findsOneWidget);
expect(repo.wallet.hasListener, isTrue);
repo.wallet.add(12160);
await tester.pumpAndSettle();
expect(find.text('12,160'), findsOneWidget);
repo.wallet.add(12140);
await tester.pumpAndSettle();
expect(find.text('12,140'), findsOneWidget);
});
testWidgets('Figma 홈의 구획 제목이 순서대로 나온다', (tester) async {
await _pump(tester);
// 화면 대조 원장 F1 · Figma `252:159401` 이하의 구획명 그대로여야 한다.
// Figma 「홈」 `2097:16428` `Frame 4241` 구획명 그대로여야 한다.
// 기본 뷰포트(800×600)보다 화면이 길어 아래쪽은 스크롤해야 잡힌다.
for (final title in [
'TODAY’S QUEST',
'AI Daily Briefing',
'팬덤행성 인큐베이터',
'아티스트 소식',
'HOT ARCHIVE',
'인기 K-INSIDE',
]) {
@@ -124,25 +175,71 @@ void main() {
}
});
testWidgets('POP 진행률과 두 성장 조건을 구분해서 표시한다', (tester) async {
testWidgets('성장 카드는 Figma 배치 — 성장률 · 수치 한 쌍, 말풍선을 누르면 두 조건 상세', (tester) async {
await _pump(tester);
expect(find.text('67%'), findsOneWidget);
expect(find.text('POP 진행률'), findsOneWidget);
// 표본은 sFPI 값이 없어 POP 축(6,270 / 10,000)이 기준이다.
expect(find.text('행성 성장률 63%'), findsOneWidget);
expect(find.text('6,270 / 10,000'), findsOneWidget);
// 카드에 두 조건 줄을 늘어놓지 않는다.
expect(find.text('POP 조건 미충족 · 6,270 / 10,000'), findsNothing);
// 말풍선이 성장률 글자를 가리지 않는다.
final rate = tester.getRect(find.text('행성 성장률 63%'));
final bubble = tester.getRect(find.text('다음 진화까지 얼마 안남았어요!'));
expect(bubble.left, greaterThan(rate.right));
await tester.tap(find.text('다음 진화까지 얼마 안남았어요!'));
await tester.pumpAndSettle();
expect(find.text('POP 조건 미충족 · 6,270 / 10,000'), findsOneWidget);
expect(find.text('sFPI 조건 확인 중 · — / —'), findsOneWidget);
});
test('TODAY’S QUEST 는 관리자 퀘스트만 최신순 — 사용자 생성 퀘스트는 빠진다(2097:16397)', () {
final docs = [
{'id': 'old', 'createdAt': DateTime(2026, 9, 1)},
{'id': 'user', 'authorUid': 'u1', 'createdAt': DateTime(2026, 10, 4)},
{'id': 'new', 'createdAt': DateTime(2026, 10, 1)},
];
expect(adminQuestsLatest(docs, (d) => d).map((d) => d['id']), ['new', 'old']);
});
test('인큐베이터는 기여율 높은 순 최대 6개(명세 §2.114-4)', () {
final picks = nebulaPicks([
for (var i = 0; i < 8; i++) ('n$i', {'name': 'N$i', 'growthPop': i * 100}),
], 1000);
expect(picks, hasLength(6));
expect(picks.first.id, 'n7');
expect(picks.first.percent, 70);
});
test('성장률 기준은 두 조건 중 진행률이 낮은 쪽이다', () {
const g = PlanetGrowth(
stage: 2, fandomName: 'X', contributorName: '', contributedPop: 0,
growthPercent: 90, currentPop: 9000, requiredPop: 10000,
currentSfpi: 300, requiredSfpi: 1000,
);
expect(g.bottleneck!.ratio, closeTo(0.3, 1e-9));
expect(g.bottleneck!.current, 300);
expect(g.growthBubble, '성장 조건 확인');
});
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('TODAY’S QUEST 는 Figma 「홈」 대로 세로 줄이다 — 높이 90 · 아이콘 상자 50 · 최대 3줄', (tester) async {
await _pump(tester, repo: const _QuestRepo());
final rows = find.byType(QuestRow);
expect(rows, findsNWidgets(3));
final first = tester.getRect(rows.at(0));
final second = tester.getRect(rows.at(1));
expect(first.height, 90);
expect(second.top, first.bottom); // 간격 없이 쌓는다(`2097:16428`).
expect(second.left, first.left);
expect(tester.getSize(find.byType(QuestIconBox).first), const Size(50, 50));
// 보조 줄은 카테고리, 보상 숫자 · 진행 상태는 그리지 않는다(사본 「홈」).
expect(find.text('오늘의 출석체크'), findsOneWidget);
expect(find.text('+10'), findsNothing);
expect(find.text('네 번째'), findsNothing);
});
testWidgets('현상금 표기에 천 단위 구분이 들어간다', (tester) async {
@@ -155,23 +252,16 @@ void main() {
expect(find.text('500 P'), findsOneWidget);
});
testWidgets('아직 없는 화면으로 가려 하면 준비 중이라고 알린다', (tester) async {
testWidgets('인기 K-INSIDE 카드를 누르면 인사이드 상세로 간다', (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,
final card = find.text('화양연화 세계관 해석해주실 분?');
await tester.scrollUntilVisible(card, 220,
scrollable: find.byType(Scrollable).first);
await tester.ensureVisible(briefing);
await tester.ensureVisible(card);
await tester.pumpAndSettle();
await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기')));
await tester.pump();
// 조용히 무시하면 검수 때 고장으로 보인다.
expect(find.textContaining('화면은 준비 중입니다.'), findsOneWidget);
// 내부 일정(구현 확인물 · 날짜)은 사용자 화면에 내보내지 않는다.
expect(find.textContaining('구현 확인물'), findsNothing);
await tester.tap(card);
await tester.pumpAndSettle();
expect(find.byType(InsideDetailScreen), findsOneWidget);
});
testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async {
@@ -202,7 +292,7 @@ void main() {
/// *문구도 노출하지 않음)*. 서버가 당일 게시분을 0 건으로 주는 상황이다.
testWidgets('브리핑이 0 건이면 구획 제목까지 숨긴다', (tester) async {
await _pump(tester, repo: const _BriefingRepo([]));
expect(find.text('AI Daily Briefing'), findsNothing);
expect(find.text('아티스트 소식'), findsNothing);
// 빈 상태 문구도 두지 않는다 — 아래 구획은 그대로 있어야 한다.
await tester.scrollUntilVisible(
find.text('HOT ARCHIVE'),
@@ -215,7 +305,7 @@ void main() {
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',
(w) => w is HomeSectionHeader && w.title == '아티스트 소식',
);
await tester.scrollUntilVisible(find.text('공유할 한 줄'), 220,
scrollable: find.byType(Scrollable).first);
@@ -234,7 +324,7 @@ void main() {
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('AI Daily Briefing'), findsOneWidget);
expect(find.text('아티스트 소식'), findsOneWidget);
expect(find.text('오늘 게시된 한 줄'), findsOneWidget);
});