import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/core/ui/section_header.dart'; import 'package:fancake/features/campaign/campaign_create_screen.dart'; import 'package:fancake/features/campaign/campaign_list_screen.dart'; import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_repository_firestore.dart'; import 'package:fancake/features/home/home_screen.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'eclipse_stubs.dart'; /// 실기기 판정(2026-10-04) HOME 결함 — HOT ARCHIVE 진입 · 브리핑 · 인기 K-INSIDE · 상태 표시줄. const _uid = 'u-home'; class _Backend extends Backend { _Backend(FakeFirebaseFirestore db) : super( firestore: db, auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)), ); @override Future> call( String name, [ Map payload = const {}, ]) async => {'code': 'ABCDEFGH23'}; } /// 모금 중 캠페인이 없는 HOME. class _NoCampaignRepo extends SampleHomeRepository { const _NoCampaignRepo(); @override Stream watchGrowth(String fandomId) => const Stream.empty(); @override Future load() async { final base = await const SampleHomeRepository().load(); return HomeSnapshot( popBalance: base.popBalance, liveTicker: base.liveTicker, growth: base.growth, quests: base.quests, briefings: base.briefings, campaign: null, insides: base.insides, ); } } Future _pump(WidgetTester tester, HomeRepository repo) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: MediaQuery( // 노치 단말 — 상태 표시줄 47. data: const MediaQueryData( size: Size(540, 1200), padding: EdgeInsets.only(top: 47), ), child: HomeScreen( repository: repo, questRepository: const SampleQuestRepository(), eclipse: const NoEclipseRepository(), ), ), ), ); await tester.pumpAndSettle(); } Finder get _hotArchive => find.byWidgetPredicate( (w) => w is SectionHeader && w.title == 'HOT ARCHIVE', ); void main() { setUp(() => SharedPreferences.setMockInitialValues({})); group('HOT ARCHIVE — 모금 중 캠페인 0건(명세 §2.114-6)', () { testWidgets('구획과 「더 보기」가 남아 캠페인 목록으로 간다', (tester) async { await _pump(tester, const _NoCampaignRepo()); await tester.scrollUntilVisible( _hotArchive, 220, scrollable: find.byType(Scrollable).first, ); await tester.tap( find.descendant(of: _hotArchive, matching: find.text('더 보기')), ); await tester.pumpAndSettle(); expect(find.byType(CampaignListScreen), findsOneWidget); }); testWidgets('카드 자리의 「새 캠페인」이 개설 화면을 연다', (tester) async { await _pump(tester, const _NoCampaignRepo()); final create = find.text('새 캠페인'); await tester.scrollUntilVisible( create, 220, scrollable: find.byType(Scrollable).first, ); await tester.tap(create); await tester.pumpAndSettle(); expect(find.byType(CampaignCreateScreen), findsOneWidget); }); }); testWidgets('스크롤하면 상태 표시줄 자리를 가린다(머리글 겹침 방지)', (tester) async { await _pump(tester, const _NoCampaignRepo()); final scrim = find.byKey(const ValueKey('home-status-scrim')); // 맨 위에서는 히어로 배경이 상태 표시줄 뒤까지 보여야 한다. expect(scrim, findsNothing); await tester.drag(find.byType(Scrollable).first, const Offset(0, -400)); await tester.pumpAndSettle(); expect(scrim, findsOneWidget); expect(tester.getSize(scrim).height, 47); await tester.drag(find.byType(Scrollable).first, const Offset(0, 2000)); await tester.pumpAndSettle(); expect(scrim, findsNothing); }); group('FirestoreHomeRepository', () { late FakeFirebaseFirestore db; setUp(() async { db = FakeFirebaseFirestore(); await db.doc('users/$_uid').set({'fandomId': 'fanuzu-demo'}); }); Future brief(String id, String section, String status) => db.doc('briefings/$id').set({ 'fandomId': 'fanuzu-demo', 'dayKey': kstDayKey(), 'status': status, 'section': section, 'rank': 1, 'headline': id, 'sourceName': '출처', 'sourceUrl': 'https://example.com/s', 'articleUrl': 'https://example.com/a', 'aiSummary': '요약', 'sourceAt': DateTime(2026, 10, 4), }); test('브리핑 section 은 서버 값(News·SNS)만 그린다 — 시연 시드 소문자 결함', () async { await brief('news', 'News', 'published'); await brief('sns', 'SNS', 'published'); await brief('draft', 'News', 'draft'); // 수정 전 시드(`tools/demo/fs.mjs`)가 쓰던 값 — 앱은 모르는 구획이라 버린다. await brief('lower', 'news', 'published'); final home = await FirestoreHomeRepository(_Backend(db)).load(); expect( home.briefings.map((b) => b.headline), unorderedEquals(['news', 'sns']), ); }); test('인기 K-INSIDE 는 미해결 먼저, 그 안에서 현상금 큰 순(명세 §2.114-7)', () async { final at = DateTime(2026, 10, 4); final rows = { 'new-0-answers': ('open', 100, at.add(const Duration(hours: 1))), 'solved-big': ('accepted', 900, at), 'open-500': ('open', 500, at), 'open-300': ('open', 300, at), 'open-200': ('open', 200, at), }; for (final MapEntry(key: id, value: (status, bounty, createdAt)) in rows.entries) { await db.doc('questions/$id').set({ 'title': id, 'status': status, 'bountyPop': bounty, 'createdAt': createdAt, }); } final home = await FirestoreHomeRepository(_Backend(db)).load(); expect(home.insides.map((i) => i.title), [ 'open-500', 'open-300', 'open-200', ]); }); test('미해결이 3건보다 적으면 해결된 고액 질문으로 채운다', () { expect( popularInsides([ {'title': 'a', 'status': 'accepted'}, {'title': 'b', 'status': 'open'}, {'title': 'c', 'status': 'expired'}, ]).map((q) => q['title']), ['b', 'a', 'c'], ); }); }); }