import 'dart:async'; import 'package:fancake/core/data/repositories.dart' show eclipseLook; import 'package:fancake/core/push/push_messaging.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/core/ui/fx_intensity.dart'; import 'package:fancake/core/ui/planet_25d.dart'; import 'package:fancake/features/campaign/campaign_detail_screen.dart' show campaignJustCompleted; import 'package:fancake/features/eclipse/eclipse_data.dart'; import 'package:fancake/features/eclipse/eclipse_defend_screen.dart'; import 'package:fancake/features/eclipse/widgets/live_proof.dart'; import 'package:fancake/features/home/widgets/live_ticker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'eclipse_stubs.dart'; /// UX·FX 정렬 기준 v1.5 2차(10/8) — 햅틱 5단계 · 티커 집약 · Push 위계 · Shield Pulse · /// Orbit Burst · 실패 Glow 감소 · 전광판 HERO (진척현황 부록 B-3 1-1 · 1-3 · 1-4 · 5-3 · 5-6 · 5-7 · 6-2). void main() { TestWidgetsFlutterBinding.ensureInitialized(); test( '강도 5단계 햅틱 — Micro·Small Light · Medium Medium · Large·Legendary Strong (v1.5 §1)', () async { final calls = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, (call) async { if (call.method == 'HapticFeedback.vibrate') calls.add(call.arguments); return null; }); addTearDown( () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.platform, null), ); for (final level in FxIntensity.values) { await fxHaptic(level); } expect(calls, [ 'HapticFeedbackType.lightImpact', 'HapticFeedbackType.lightImpact', 'HapticFeedbackType.mediumImpact', 'HapticFeedbackType.heavyImpact', 'HapticFeedbackType.heavyImpact', ]); }, ); test('Campaign 100% Legendary 는 이번 후원으로 목표를 넘긴 때만', () { expect(campaignJustCompleted(900, 1000, 1000), isTrue); expect(campaignJustCompleted(900, 999, 1000), isFalse); expect( campaignJustCompleted(1000, 1200, 1000), isFalse, reason: '이미 달성한 캠페인', ); }); group('앱 사용 중 Push — C-78 우선순위 · 같은 알림 중복 금지 (v1.5 §1.1 · 9장)', () { PushNotice n(String kind, [String title = 't']) => PushNotice(title: title, body: 'b', kind: kind, fandomId: 'bts'); test('떠 있는 것이 없으면 띄운다', () { expect(shouldShowInApp(n('briefing'), null), isTrue); }); test('같은 알림은 다시 띄우지 않는다', () { expect(shouldShowInApp(n('eclipse'), n('eclipse')), isFalse); }); test('낮은 순위는 높은 순위를 덮지 않고, 높은 순위는 덮는다', () { expect(shouldShowInApp(n('briefing'), n('eclipse')), isFalse); expect(shouldShowInApp(n('evolution'), n('timeattack')), isFalse); expect(shouldShowInApp(n('eclipse'), n('briefing')), isTrue); expect(shouldShowInApp(n('evolution', 'x'), n('evolution', 'y')), isTrue); }); test('위기 → 공동행동 → 성취 순', () { expect(n('eclipse').tier, PushTier.crisis); expect(n('timeattack').tier, PushTier.collective); expect(n('evolution').tier, PushTier.achievement); expect(n('briefing').tier, PushTier.other); }); }); group('라이브 띠 — 위기 우선 · 방어 집약 · HERO 전광판', () { final start = DateTime.now().subtract(const Duration(seconds: 5)); final join = LiveEvent(id: 'e', nickname: '아미', at: DateTime.now()); TickerLine? line({ EclipseState? eclipse, bool hero = false, List names = const [], List events = const [], }) => tickerLine( eclipse: eclipse, heroTime: hero, heroNicknames: names, events: events, fandomName: 'BTS', now: DateTime.now(), fallback: 'fallback', ); test('발생 직후는 발생 소식', () { expect( line(eclipse: eclipseFixture(startAt: start))!.text, 'BTS 행성에 이클립스가 발생했어요!', ); }); test('방어 참여가 오면 고유 참여자 2명 이상부터 집약한다', () { expect( line( eclipse: eclipseFixture(startAt: start, defense: 3), events: [join], )!.text, '[LIVE] 지금 3명의 팬이 함께 방어 중', ); expect( line( eclipse: eclipseFixture(startAt: start, defense: 1), events: [join], )!.text, '[LIVE] 아미님이 이클립스 방어에 합류했습니다!', ); }); test('HERO TIME 은 공개 닉네임 + 팬덤으로 오르고 참여 진입이 된다', () { final hero = line(hero: true, names: ['아미'], events: [join])!; expect(hero.text, '[LIVE] BTS 행성 HERO TIME · by 아미'); expect(hero.hero, isTrue); expect(line(hero: true)!.text, '[LIVE] BTS 행성 HERO TIME 진행 중'); }); test('위기(이클립스)가 성취(HERO)보다 먼저', () { final l = line(eclipse: eclipseFixture(startAt: start), hero: true)!; expect(l.hero, isFalse); }); test('아무것도 없으면 폴백', () { expect(line()!.text, 'fallback'); }); }); testWidgets('HERO 전광판은 눌러서 참여로 간다', (tester) async { var tapped = 0; await tester.pumpWidget( MaterialApp( home: Scaffold( body: LiveTicker(text: 'HERO', action: 'GO', onTap: () => tapped++), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byType(LiveTicker)); expect(tapped, 1); }); test('이클립스 회차 → 행성 표현 (진행 · 실패 · 성공 · 판정 대기)', () { final now = DateTime.now(); final past = now.subtract(const Duration(seconds: 1)); expect(eclipseLook(null, now), (false, false, null)); expect(eclipseLook(eclipseFixture(), now), (true, false, null)); expect( eclipseLook( eclipseFixture(endsAt: past, result: EclipseResult.failure), now, ), (false, true, null), ); expect( eclipseLook( eclipseFixture(endsAt: past, result: EclipseResult.success), now, ), (false, false, 'ecl-1'), ); expect(eclipseLook(eclipseFixture(endsAt: past), now), ( false, false, null, ), reason: '판정 대기를 실패처럼 보이지 않는다'); expect( eclipseLook( eclipseFixture(endsAt: past, result: EclipseResult.cancelled), now, ), (false, false, null), ); }); group('행성 — 실패 감소 · 성공 Orbit Burst (v1.5 §5)', () { Widget planet({bool failed = false, String? success}) => MaterialApp( home: Center( child: Planet25D( image: 'assets/figma/planet-home.png', size: 200, stage: 3, eclipseFailed: failed, eclipseSuccessId: success, ), ), ); double light(WidgetTester tester) => tester .widget(find.byKey(const ValueKey('planet-surface-light'))) .opacity; testWidgets('실패 확정이면 밝기를 줄인다(파괴 표현 없음)', (tester) async { await tester.pumpWidget(planet(failed: true)); expect(light(tester), 0.5); await tester.pumpWidget(planet()); await tester.pumpAndSettle(); expect(light(tester), 1); }); testWidgets('성공 회차가 새로 오면 복원 뒤 Orbit Burst 한 번 · 첫 값은 기준점', (tester) async { Planet25D.animationsEnabled = true; addTearDown(() => Planet25D.animationsEnabled = false); await tester.pumpWidget(planet(success: 'old')); await tester.pump(); expect(find.byKey(const ValueKey('eclipse-orbit-burst')), findsNothing); await tester.pumpWidget(planet(success: 'ecl-2')); await tester.pump(const Duration(milliseconds: 100)); expect(find.byKey(const ValueKey('eclipse-orbit-burst')), findsOneWidget); await tester.pumpWidget(const SizedBox()); }); }); testWidgets('유효 흔들기마다 Shield Pulse (v1.5 §5 수동 방어)', (tester) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); final shakes = StreamController.broadcast(); addTearDown(shakes.close); final repo = FakeEclipseRepository( state: eclipseFixture( startAt: DateTime.now().subtract(const Duration(seconds: 1)), endsAt: DateTime.now().add(const Duration(seconds: 59)), ), ); addTearDown(repo.dispose); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: EclipseDefendScreen( repository: repo, fandomId: 'bts', shakes: () => shakes.stream, ), ), ); for (var i = 0; i < 4; i++) { await tester.pump(const Duration(milliseconds: 120)); } expect(find.byKey(const ValueKey('shield-pulse-1')), findsNothing); shakes.add(DateTime.now()); await tester.pump(const Duration(milliseconds: 50)); expect(find.byKey(const ValueKey('shield-pulse-1')), findsOneWidget); shakes.add(DateTime.now()); await tester.pump(const Duration(milliseconds: 50)); expect(find.byKey(const ValueKey('shield-pulse-2')), findsOneWidget); for (var i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 120)); } await tester.pumpWidget(const SizedBox()); }); }