Files
fanuzu/app/test/home_screen_test.dart

451 lines
18 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 'package:fancake/core/router/app_router.dart' show scrollTabToTop;
import 'package:fancake/features/notification/notification_screen.dart';
import 'package:fancake/features/home/nebula_create_screen.dart' show isValidNebulaName;
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',
'팬덤행성 인큐베이터',
'AI Daily Briefing',
'HOT ARCHIVE',
'인기 K-INSIDE',
]) {
await tester.scrollUntilVisible(
find.text(title),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text(title), findsOneWidget, reason: '구획 「$title」 누락');
}
});
testWidgets('성장 카드는 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('AI Daily Briefing'), findsNothing);
// 빈 상태 문구도 두지 않는다 — 아래 구획은 그대로 있어야 한다.
await tester.scrollUntilVisible(
find.text('HOT ARCHIVE'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('HOT ARCHIVE'), findsOneWidget);
});
testWidgets('브리핑 헤더의 「공유」는 브리핑 공유 카드를 연다(F8 · 별첨 1 ③-5)', (tester) async {
await _pump(tester, repo: _BriefingRepo([_brief('공유할 한 줄')]));
final header = find.byWidgetPredicate(
(w) => w is HomeSectionHeader && w.title == 'AI Daily Briefing',
);
await tester.scrollUntilVisible(find.text('공유할 한 줄'), 220,
scrollable: find.byType(Scrollable).first);
await tester.tap(find.descendant(of: header, matching: find.text('공유')));
await tester.pumpAndSettle();
expect(find.text('AI DAILY BRIEFING'), findsOneWidget);
expect(find.text('공유할 한 줄'), findsNWidgets(2));
});
testWidgets('한 건이라도 있으면 구획을 그린다', (tester) async {
await _pump(tester, repo: _BriefingRepo([_brief('오늘 게시된 한 줄')]));
// 헤더가 아니라 줄 자체를 기준으로 스크롤한다 — 헤더가 보이는 시점에 줄은
// 아직 화면 아래에 있어 지연 빌드가 끝나지 않는다.
await tester.scrollUntilVisible(
find.text('오늘 게시된 한 줄'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('AI Daily Briefing'), findsOneWidget);
expect(find.text('오늘 게시된 한 줄'), findsOneWidget);
});
/// 정책 원장 B-47 — *News/SNS 각각 0건이면 해당 섹션 숨김*.
testWidgets('SNS 가 0 건이면 SNS 구획만 숨는다', (tester) async {
await _pump(tester, repo: _BriefingRepo([
_brief('뉴스 한 줄', section: BriefingSection.news),
]));
await tester.scrollUntilVisible(
find.text('뉴스 한 줄'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('NEWS'), findsOneWidget);
expect(find.text('SNS'), findsNothing);
});
testWidgets('News 가 0 건이면 News 구획만 숨는다', (tester) async {
await _pump(tester, repo: _BriefingRepo([
_brief('SNS 한 줄', section: BriefingSection.sns),
]));
await tester.scrollUntilVisible(
find.text('SNS 한 줄'),
220,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('SNS'), findsOneWidget);
expect(find.text('NEWS'), findsNothing);
});
testWidgets('B-3 8-4 — 카드 줄에 출처명 · 게시시각이 보인다(상세 시트를 열지 않아도)', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: BriefingList(items: [_brief('출처가 보이는 줄')])),
));
expect(find.text('연합뉴스 · ${briefingStamp(DateTime(2026, 9, 14, 8))}'), findsOneWidget);
expect(briefingStamp(DateTime(2026, 9, 4, 7, 5)), '2026.09.04 07:05');
expect(find.text('AI 요약'), 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']);
});
group('설명 프레임 대조(홈 일반)', () {
Future<bool> badgeShown(WidgetTester tester, List<AppNotification> items) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: HomeHeader(
key: UniqueKey(),
pop: 0,
notifications: SampleNotificationRepository(items: items),
),
),
),
);
await tester.pumpAndSettle();
return tester.widget<Badge>(find.byKey(const ValueKey('home-bell-badge'))).isLabelVisible;
}
AppNotification n(bool read) => AppNotification(
id: 'n$read', kind: NotificationKind.quest, title: 't', at: DateTime(2026), read: read,
);
testWidgets('2097:16385 — 안 읽은 알림이 있으면 알림 아이콘에 배지', (tester) async {
expect(await badgeShown(tester, [n(false), n(true)]), isTrue);
expect(await badgeShown(tester, [n(true)]), isFalse);
});
testWidgets('2097:16427 — 열린 탭을 다시 누르면 루트 화면이 맨 위로', (tester) async {
final key = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: key,
home: Scaffold(
body: ListView(children: [for (var i = 0; i < 50; i++) SizedBox(height: 100, child: Text('$i'))]),
),
),
);
await tester.drag(find.byType(ListView), const Offset(0, -1500));
await tester.pumpAndSettle();
expect(find.text('0'), findsNothing);
scrollTabToTop(key);
await tester.pumpAndSettle();
expect(find.text('0'), findsOneWidget);
});
test('516:87397 — 성운명 규칙은 서버와 같다', () {
for (final ok in ['방탄', 'BTS Army 7', '가' * 20]) {
expect(isValidNebulaName(ok), isTrue, reason: ok);
}
for (final bad in ['가', '가' * 21, ' 방탄', '방탄 ', '방 탄', '방탄!', '방탄😀']) {
expect(isValidNebulaName(bad), isFalse, reason: bad);
}
});
});
}