import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp; import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/data/invite_code.dart'; import 'package:fancake/core/data/server_clock.dart'; import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_repository_firestore.dart'; import 'package:fancake/features/home/widgets/briefing_share.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_detail_screen.dart'; import 'package:fancake/features/quest/quest_repository_firestore.dart'; import 'package:fancake/features/quest/widgets/time_attack_countdown.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:share_plus/share_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// 긴급 돌발 타임어택(별첨 1 ③-4 · FR-QST-06) · 브리핑 공유(③-5 · 「홈_브리핑 공유」). /// /// 지급 판정은 서버 `firebase/functions/test/timeattack.test.ts` 가 본다. 여기서는 /// 앱이 **무엇을 읽어 무엇을 부르는지**와 마감 · 공유 문구를 본다. const _uid = 'u-ta'; class _Backend extends Backend { _Backend(FakeFirebaseFirestore db, {this.serverAhead = Duration.zero, this.duplicate = false}) : super( firestore: db, auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)), ); final Duration serverAhead; final bool duplicate; final calls = []; @override Future> call( String name, [ Map payload = const {}, ]) async { if (name == 'fnServerTime') { calls.add(name); return {'now': DateTime.now().add(serverAhead).millisecondsSinceEpoch}; } calls.add('$name:${payload['questId']}'); return {'txId': 'tx', 'balanceAfter': 30, 'rewarded': 30, 'duplicate': duplicate}; } } /// `fnInviteCodeGet` 만 흉내 — 부른 횟수를 센다. [fail] 이면 서버 오류. class _InviteBackend extends Backend { _InviteBackend({this.fail = false}) : super( firestore: FakeFirebaseFirestore(), auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)), ); final bool fail; int calls = 0; @override Future> call(String name, [Map payload = const {}]) async { calls++; if (fail) throw const BackendException('E-SYS-01', 'down'); return {'code': 'ABCDEFGH23'}; } } Future _seed({Duration left = const Duration(minutes: 5)}) async { final db = FakeFirebaseFirestore(); await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'}); await db.doc('quests/ta').set({ 'type': 'timeattack', 'title': '돌발', 'fandomId': 'bts', 'rewardPop': 30, 'dailyLimit': 1, 'active': true, 'activeFrom': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))), 'activeTo': Timestamp.fromDate(DateTime.now().add(left)), }); return db; } BriefingItem _brief(int rank, String headline) => BriefingItem( rank: rank, headline: headline, section: BriefingSection.news, sourceName: '출처', sourceAt: DateTime(2026, 9, 28), sourceUrl: 'https://example.com', articleUrl: 'https://example.com/a', aiSummary: '요약', ); Widget _app(Widget child) => MaterialApp(home: child); void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { SharedPreferences.setMockInitialValues({}); ServerClock.reset(); }); group('타임어택 — 앱 경로', () { test('카드에 마감을 싣고 참여는 fnQuestTimeAttack 으로 보낸다', () async { final db = await _seed(); final backend = _Backend(db); final repo = FirestoreQuestRepository(backend); final card = (await repo.load()).single; expect(card.type, QuestType.timeAttack); expect(card.endsAt, isNotNull); expect(card.rewardPop, 30); final reward = await repo.verify(card); expect(reward.duplicate, isFalse); // 서버 시계는 타임어택이 있을 때 세션당 한 번만 잰다. expect(backend.calls, ['fnServerTime', 'fnQuestTimeAttack:ta']); }); test('재응답(duplicate)은 새 지급이 아니라고 표시한다', () async { final repo = FirestoreQuestRepository(_Backend(await _seed(), duplicate: true)); final card = (await repo.load()).single; expect((await repo.verify(card)).duplicate, isTrue); }); test('노출 · 마감은 서버 시각으로 판정한다(단말 시계가 늦어도)', () async { // 서버가 10분 앞서 있다 → 단말 기준 5분 남은 창은 이미 닫혔다. final db = await _seed(); final backend = _Backend(db, serverAhead: const Duration(minutes: 10)); expect(await FirestoreQuestRepository(backend).load(), isEmpty); expect(ServerClock.offset.inMinutes, inInclusiveRange(9, 10)); final home = await FirestoreHomeRepository(backend).load(); expect(home.quests.where((q) => q.id == 'ta'), isEmpty); }); test('어제 받은 타임어택도 보상 완료로 남는다(1인 1회)', () async { final db = await _seed(); await db.doc('users/$_uid/questProgress/ta').set({'dayKey': '2000-01-01', 'count': 1}); final card = (await FirestoreQuestRepository(_Backend(db)).load()).single; expect(card.stage, QuestStage.rewarded); }); test('홈은 열린 타임어택을 맨 앞에 두고 마감된 것은 뺀다', () async { final db = await _seed(); await db.doc('quests/ta-old').set({ 'type': 'timeattack', 'title': '지난 돌발', 'fandomId': 'bts', 'active': true, 'activeTo': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))), }); await db.doc('quests/ta-off').set({ 'type': 'timeattack', 'title': '내려진 돌발', 'fandomId': 'bts', 'active': false, 'activeTo': Timestamp.fromDate(DateTime.now().add(const Duration(minutes: 5))), }); await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'active': true}); final home = await FirestoreHomeRepository(_Backend(db)).load(); expect(home.quests.map((q) => q.id), ['ta', 'attend']); expect(home.quests.first.endsAt, isNotNull); }); test('성운 목표는 config/policy 운영값 · 탄생 권한은 창립자만(Q-PN-01)', () async { final db = await _seed(); await db.doc('config/policy').set({'nebulaMinMembers': 12, 'nebulaMinPop': 1500}); await db.doc('fandoms/bts').set({ 'name': 'BTS', 'stage': 0, 'memberCount': 12, 'growthPop': 1500, 'founderUid': _uid, }); var growth = (await FirestoreHomeRepository(_Backend(db)).load()).growth; expect((growth.requiredMembers, growth.requiredPop), (12, 1500)); expect(growth.nebulaReady && growth.isFounder, isTrue); await db.doc('fandoms/bts').update({'founderUid': 'someone-else'}); growth = (await FirestoreHomeRepository(_Backend(db)).load()).growth; expect(growth.nebulaReady, isTrue); expect(growth.isFounder, isFalse); expect(await FirestoreHomeRepository(_Backend(db)).nebulaTargets(), (pop: 1500, members: 12, days: 7)); }); test('남은 시간 표기', () { expect(formatLeft(const Duration(minutes: 9, seconds: 5)), '09:05'); expect(formatLeft(const Duration(hours: 1, minutes: 2, seconds: 3)), '1:02:03'); }); testWidgets('마감되면 상세 버튼이 잠긴다', (tester) async { final db = await tester.runAsync(() => _seed(left: const Duration(seconds: 2))); final repo = FirestoreQuestRepository(_Backend(db!)); tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await tester.pumpWidget(_app(QuestDetailScreen(questId: 'ta', repository: repo))); for (var i = 0; i < 50 && find.text('남은 시간').evaluate().isEmpty; i++) { await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); await tester.pump(); } expect(find.text('남은 시간'), findsOneWidget); await tester.runAsync(() => Future.delayed(const Duration(seconds: 3))); await tester.pump(const Duration(seconds: 1)); expect(find.text('마감'), findsNWidgets(2)); final button = tester.widget(find.byType(FilledButton)); expect(button.onPressed, isNull); }); }); group('브리핑 공유', () { test('초대 코드는 세션당 한 번 받고, 실패하면 ref 없이 나간다', () async { addTearDown(InviteCode.reset); InviteCode.reset(); final down = _InviteBackend(fail: true); await InviteCode.ensure(down); expect(InviteCode.current, isNull); expect(briefingShareLink('bts', '2026-09-28', base: 'https://s.example'), 'https://s.example/b/bts/2026-09-28'); final up = _InviteBackend(); await Future.wait([InviteCode.ensure(up), InviteCode.ensure(up)]); await InviteCode.ensure(up); expect(up.calls, 1); expect(briefingShareLink('bts', '2026-09-28', base: 'https://s.example'), 'https://s.example/b/bts/2026-09-28?ref=ABCDEFGH23'); }); test('링크는 설정된 웹 브릿지 주소가 있을 때만 만든다', () { expect(briefingShareLink('bts', '2026-09-28', base: ''), isNull); expect( briefingShareLink('bts', '2026-09-28', base: 'https://share.example/'), 'https://share.example/b/bts/2026-09-28', ); }); test('문구는 헤드라인 최대 3건 · 링크 · 동적 해시태그다', () { final text = briefingShareText( items: [for (var i = 1; i <= 4; i++) _brief(i, '뉴스 $i')], fandomName: 'BTS', link: 'https://share.example/b/bts/2026-09-28', ); expect(text, contains('3. 뉴스 3')); expect(text, isNot(contains('뉴스 4'))); expect(text, contains('https://share.example/b/bts/2026-09-28')); expect(text, contains('#FANUZU #BTS #AIBriefing')); expect(briefingShareText(items: [_brief(1, 'a')]), isNot(contains('http'))); }); testWidgets('SNS 버튼은 카드 이미지와 문구를 공유 시트로 보낸다', (tester) async { ShareParams? sent; await tester.pumpWidget(_app(Scaffold( body: BriefingShareDialog( items: [_brief(1, '빌보드 수상')], fandomName: 'BTS', share: (params) async => sent = params, ), ))); expect(find.text('AI DAILY BRIEFING'), findsOneWidget); expect(find.text('빌보드 수상'), findsOneWidget); await tester.runAsync(() async { await tester.tap(find.byTooltip('X')); for (var i = 0; i < 50 && sent == null; i++) { await Future.delayed(const Duration(milliseconds: 20)); } }); expect(sent?.text, contains('1. 빌보드 수상')); expect(sent?.files, hasLength(1)); // 웹 브릿지 주소가 없으면 링크가 없으니 「문구 복사」로 부른다. expect(find.byTooltip('문구 복사'), findsOneWidget); expect(find.byTooltip('링크 복사'), findsNothing); }); }); }