383 lines
15 KiB
Dart
383 lines
15 KiB
Dart
import 'dart:async';
|
||
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';
|
||
|
||
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';
|
||
|
||
class _LiveGrowthRepo extends SampleHomeRepository {
|
||
final changes = StreamController<PlanetGrowth>.broadcast();
|
||
@override
|
||
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
|
||
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: '요약입니다.',
|
||
sourceKind: 'official',
|
||
);
|
||
|
||
/// 브리핑만 갈아 끼운 표본. 「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,
|
||
campaigns: base.campaigns,
|
||
nebulas: base.nebulas,
|
||
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();
|
||
// 카드는 병목 축(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);
|
||
// Figma 「홈」 `2097:16428` `Frame 4241` 구획명 그대로여야 한다.
|
||
// 기본 뷰포트(800×600)보다 화면이 길어 아래쪽은 스크롤해야 잡힌다.
|
||
for (final title in [
|
||
'TODAY’S QUEST',
|
||
'팬덤행성 인큐베이터',
|
||
'아티스트 소식',
|
||
'HOT ARCHIVE',
|
||
'인기 K-INSIDE',
|
||
]) {
|
||
await tester.scrollUntilVisible(
|
||
find.text(title),
|
||
220,
|
||
scrollable: find.byType(Scrollable).first,
|
||
);
|
||
expect(find.text(title), findsOneWidget, reason: '구획 「$title」 누락');
|
||
}
|
||
});
|
||
|
||
testWidgets('성장 카드는 Figma 배치 — 성장률 · 수치 한 쌍, 말풍선을 누르면 두 조건 상세', (tester) async {
|
||
await _pump(tester);
|
||
// 표본은 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('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 {
|
||
await _pump(tester);
|
||
await tester.scrollUntilVisible(
|
||
find.text('500 P'),
|
||
220,
|
||
scrollable: find.byType(Scrollable).first,
|
||
);
|
||
expect(find.text('500 P'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('인기 K-INSIDE 카드를 누르면 인사이드 상세로 간다', (tester) async {
|
||
await _pump(tester);
|
||
final card = find.text('화양연화 세계관 해석해주실 분?');
|
||
await tester.scrollUntilVisible(card, 220,
|
||
scrollable: find.byType(Scrollable).first);
|
||
await tester.ensureVisible(card);
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(card);
|
||
await tester.pumpAndSettle();
|
||
expect(find.byType(InsideDetailScreen), 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('아티스트 소식'), 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 HomeSectionHeader && w.title == '아티스트 소식',
|
||
);
|
||
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('아티스트 소식'), 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('줄을 누르면 B-75 5 요소 상세가 뜨고, 원문 보기로 나간다', (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, isEmpty);
|
||
expect(find.text('AI 요약'), findsOneWidget);
|
||
expect(find.textContaining('공식 출처 · '), findsOneWidget);
|
||
await tester.tap(find.text('원문 보기'));
|
||
await tester.pumpAndSettle();
|
||
expect(opened, ['https://example.com/a']);
|
||
});
|
||
}
|