import 'package:fancake/core/push/push_messaging.dart'; import 'package:fancake/core/theme/tokens.dart'; import 'package:fancake/core/ui/fx_intensity.dart'; import 'package:fancake/features/eclipse/widgets/live_proof.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/galaxy/widgets/zap_feedback.dart'; import 'package:fancake/features/galaxy/zap_controller.dart'; import 'package:fancake/features/notification/notification_screen.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_reward_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'eclipse_stubs.dart'; /// UX·FX v1.5 2차 약속분(10/8) — 진척현황 부록 B-3 1-2 · 4-2 · 4-3(퀘스트 완료 → 성공 햅틱 · 행성 반응), /// 1-4 · 8-6(Push ↔ 라이브 띠 중복 제어 · 알림 위계 · Push 탭 실제 화면 이동), 원장 C-78 단계 배정. void main() { group('C-78 단계 배정 — Push · 알림함 같은 표', () { test('위기 · 공동행동 · 성취 · 결과 · 그 밖', () { expect(pushTierOf('eclipse'), PushTier.crisis); expect(pushTierOf('eclipse', phase: 'start'), PushTier.crisis); expect(pushTierOf('eclipse', phase: 'defended'), PushTier.result); expect(pushTierOf('timeattack'), PushTier.collective); expect(pushTierOf('evolution'), PushTier.achievement); expect( pushTierOf('item_used'), PushTier.achievement, reason: 'HERO TIME 발동 = 성취', ); expect(pushTierOf('quest'), PushTier.result); expect(pushTierOf('campaign'), PushTier.result); expect(pushTierOf('briefing'), PushTier.other); expect(pushTierOf(''), PushTier.other); }); test('알림함 항목도 같은 단계 — 표본(eventType 없음)은 종류로 읽는다', () { AppNotification n( NotificationKind kind, { String? event, String phase = '', }) => AppNotification( id: 'x', kind: kind, title: 't', at: DateTime(2026), eventType: event, phase: phase, ); expect(n(NotificationKind.itemUsed).tier, PushTier.achievement); expect( n(NotificationKind.eclipse, event: 'eclipse', phase: 'defended').tier, PushTier.result, ); // 승계 알림처럼 eventType 이 빈 문서는 아이템 사용으로 떨어지지만 위계는 올리지 않는다. expect(n(NotificationKind.itemUsed, event: '').tier, PushTier.other); }); }); group('Push 탭 → 실제 화면 (v1.5 8장 Deep Link · 9장)', () { PushNotice n( String kind, { String? fandomId, String phase = '', String? targetPath, String? questId, String? campaignId, }) => PushNotice( title: 't', body: 'b', kind: kind, fandomId: fandomId, phase: phase, targetPath: targetPath, questId: questId, campaignId: campaignId, ); test('종류마다 해당 화면', () { expect(n('quest', targetPath: 'quests/q1').target, 'quests/q1'); expect(n('campaign', campaignId: 'c1').target, 'campaigns/c1'); expect( n('timeattack', questId: 'q2', fandomId: 'bts').target, 'quests/q2', ); expect(n('eclipse', fandomId: 'bts').target, 'eclipse/bts'); expect( n('eclipse', fandomId: 'bts', phase: 'defended').target, 'planets/bts', ); expect(n('evolution', fandomId: 'bts').target, 'planets/bts'); expect(n('item_used', fandomId: 'bts').target, 'planets/bts'); expect( n('briefing', fandomId: 'bts').target, isNull, reason: 'HOME 브리핑 블록이 그 화면', ); expect(n('eclipse').target, isNull, reason: '행성을 모르면 탭만'); }); test('같은 사건은 문구가 달라도 한 번 — 진화 토픽 Push + 참여자 알림함 Push', () { final topic = PushNotice( title: '진화', body: 'a', kind: 'evolution', fandomId: 'bts', ); const inbox = PushNotice( title: 'FANUZU', body: 'b', kind: 'evolution', fandomId: 'bts', ); expect(shouldShowInApp(inbox, topic), isFalse); expect( shouldShowInApp( const PushNotice( title: 'FANUZU', body: 'b', kind: 'evolution', fandomId: 'twice', ), topic, ), isTrue, ); }); test('라이브 띠가 이미 알리는 사건은 띄우지 않는다 (v1.5 §1.1 Push ↔ 인앱 티커)', () { final ticker = liveTickerKey('eclipse', 'bts'); expect( shouldShowInApp( n('eclipse', fandomId: 'bts', phase: 'start'), null, ticker: ticker, ), isFalse, ); expect( shouldShowInApp(n('eclipse', fandomId: 'twice'), null, ticker: ticker), isTrue, reason: '다른 행성', ); expect( shouldShowInApp( n('eclipse', fandomId: 'bts', phase: 'defended'), null, ticker: ticker, ), isTrue, reason: '방어 성공은 띠에 없다', ); expect( shouldShowInApp( n('item_used', fandomId: 'bts'), null, ticker: liveTickerKey('item_used', 'bts'), ), isFalse, reason: 'HERO TIME 전광판', ); }); test('띠 한 줄은 같은 사건의 Push 종류를 들고 있다', () { final start = DateTime.now().subtract(const Duration(seconds: 5)); TickerLine? line({eclipse, bool hero = false}) => tickerLine( eclipse: eclipse, heroTime: hero, heroNicknames: const [], events: const [], fandomName: 'BTS', now: DateTime.now(), fallback: 'f', ); expect(line(eclipse: eclipseFixture(startAt: start))!.push, 'eclipse'); expect(line(hero: true)!.push, 'item_used'); expect(line()!.push, isNull); }); }); testWidgets('알림함 위계 — 위기 · 공동행동 · 성취만 배지 획 색을 올린다 (v1.5 8장)', (tester) async { final now = DateTime.now(); AppNotification item( String id, NotificationKind kind, String event, { String phase = '', }) => AppNotification( id: id, kind: kind, title: id, at: now, eventType: event, phase: phase, ); await tester.pumpWidget( MaterialApp( home: NotificationScreen( repository: SampleNotificationRepository( items: [ item( 'crisis', NotificationKind.eclipse, 'eclipse', phase: 'start', ), item('achieve', NotificationKind.itemUsed, 'item_used'), item('result', NotificationKind.quest, 'quest'), ], ), ), ), ); await tester.pump(); Color ring(String title) { final row = find .ancestor(of: find.text(title), matching: find.byType(Row)) .last; final badge = tester.widget( find.descendant(of: row, matching: find.byType(Container)).first, ); return ((badge.decoration! as BoxDecoration).border! as Border).top.color; } expect(ring('crisis'), AppColors.neonMagenta); expect(ring('achieve'), AppColors.gold); expect(ring('result'), const Color(0xFF333333), reason: 'Figma 기본 획'); }); group('퀘스트 완료 → 성공 햅틱 · 행성 반응 (v1.5 4장)', () { test('Music/Location 은 Medium, 그 밖은 Small (v1.5 §1)', () { expect(questSuccessFx(QuestType.musicShake), FxIntensity.medium); expect(questSuccessFx(QuestType.place), FxIntensity.medium); expect(questSuccessFx(QuestType.steps), FxIntensity.small); expect(questSuccessFx(QuestType.attend), FxIntensity.small); }); testWidgets('보상 화면 「확인」이 HOME 행성 반응을 올린다', (tester) async { const card = QuestCard( id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin, stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 20, participants: 0, ); tester.view.physicalSize = const Size(1080, 2600); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget( const MaterialApp( home: QuestRewardScreen( detail: QuestDetail(card: card, imageLabel: '', description: ''), reward: QuestReward(rewardedPop: 20, balanceAfter: 0), ), ), ); final before = questPlanetReaction.value; await tester.tap(find.byType(FilledButton)); await tester.pump(); expect(questPlanetReaction.value, before + 1); }); testWidgets('행성 반응 — 에너지 이동 뒤 흡수 · 퀘스트 반영 문구, 잔액은 그대로', (tester) async { final zap = ZapController(SampleGalaxyRepository(), onFailure: (_) {}); addTearDown(zap.dispose); zap.balance = 100; await tester.pumpWidget( MaterialApp( home: Scaffold( body: ListenableBuilder( listenable: zap, builder: (_, _) => ZapFeedback( fandomName: 'BTS', quest: zap.questReaction, touchSerial: zap.touchSerial, pending: zap.pending > 0 || zap.sending, result: zap.result, source: Offset.zero, target: const Offset(100, 100), radius: 40, ), ), ), ), ); final reacting = zap.react(); await tester.pump(); expect(zap.touchSerial, 1, reason: '먼저 에너지 이동'); expect(zap.result, isNull, reason: '흡수는 이동 뒤'); await tester.pump(const Duration(milliseconds: 400)); await reacting; await tester.pump(); expect(find.text('퀘스트 완료가 BTS 성장에 반영되었습니다'), findsOneWidget); expect(zap.balance, 100); await tester.pump(const Duration(seconds: 2)); }); }); }