Files
fanuzu/app/test/home_screen_test.dart
T

285 lines
11 KiB
Dart
Raw 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/core/ui/section_header.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/quest/quest_data.dart';
import 'package:fancake/features/home/widgets/briefing_list.dart';
import 'eclipse_stubs.dart';
class _LiveGrowthRepo extends SampleHomeRepository {
final changes = StreamController<PlanetGrowth>.broadcast();
@override
Stream<PlanetGrowth> watchGrowth(String fandomId) => changes.stream;
}
/// 값을 못 가져오는 경우를 재현한다.
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: '요약입니다.',
);
/// 브리핑만 갈아 끼운 표본. 「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,
campaign: base.campaign,
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();
expect(find.text('POP 조건 충족 · 10,000 / 10,000'), findsOneWidget);
expect(find.text('sFPI 조건 미충족 · 750 / 1,000'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump();
expect(repo.changes.hasListener, isFalse);
});
testWidgets('Figma 홈의 구획 제목이 순서대로 나온다', (tester) async {
await _pump(tester);
// 화면 대조 원장 F1 · Figma `252:159401` 이하의 구획명 그대로여야 한다.
// 기본 뷰포트(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('POP 진행률과 두 성장 조건을 구분해서 표시한다', (tester) async {
await _pump(tester);
expect(find.text('67%'), findsOneWidget);
expect(find.text('POP 진행률'), findsOneWidget);
expect(find.text('POP 조건 미충족 · 6,270 / 10,000'), findsOneWidget);
expect(find.text('sFPI 조건 확인 중 · — / —'), findsOneWidget);
});
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('현상금 표기에 천 단위 구분이 들어간다', (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('아직 없는 화면으로 가려 하면 언제 열리는지 알린다', (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,
scrollable: find.byType(Scrollable).first);
await tester.ensureVisible(briefing);
await tester.pumpAndSettle();
await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기')));
await tester.pump();
// 조용히 무시하면 검수 때 고장으로 보인다.
expect(find.textContaining('구현 확인물 2차'), 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 SectionHeader && 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('줄을 누르면 원문보기로 나간다', (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, ['https://example.com/a']);
});
}