앱 단위·화면·통합 테스트

This commit is contained in:
Sn-Kinos
2026-10-04 01:43:10 +09:00
parent de44739e63
commit 4f6e1eac61
63 changed files with 14700 additions and 230 deletions
+284
View File
@@ -0,0 +1,284 @@
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']);
});
}