import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sensors_plus/sensors_plus.dart' show SensorInterval; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/features/eclipse/eclipse_defend_screen.dart'; import 'package:fancake/features/eclipse/widgets/eclipse_event_button.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/quest/quest_evidence.dart'; import 'eclipse_stubs.dart'; /// 이클립스 Figma 흐름 — HOME 이벤트 버튼(`516:88124`) → 상세(`373:444865`) → /// 발생 직전 오버레이(`373:444976`) → 방어(`373:445267`) → 결과, 그리고 흔들기 판정. Future _pump(WidgetTester tester, Widget home) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: home)); for (var i = 0; i < 6; i++) { await tester.pump(const Duration(milliseconds: 120)); } } void main() { group('흔들기 판정 (iOS 16 · Galaxy 63 실기기 편차)', () { test('표본 주기를 명시한다 — 기본 200ms(iOS 5Hz)를 쓰지 않는다', () { expect(ShakeDetector.sampling, SensorInterval.gameInterval); }); /// 흔들기 [count] 번을 50Hz 로 흉내 낸다. 정점마다 표본 여러 개가 문턱을 넘는다. int countAt50Hz(int count, {Duration period = const Duration(milliseconds: 300)}) { final d = ShakeDetector(); var at = DateTime(2026, 10, 5); var hits = 0; for (var i = 0; i < count; i++) { // 정점 3 표본(60ms) 연속 문턱 위 → 나머지는 중력 근처. for (final m in [20.0, 24.0, 21.0]) { if (d.add(m, 0, 0, at)) hits++; at = at.add(const Duration(milliseconds: 20)); } for (var t = 60; t < period.inMilliseconds; t += 20) { if (d.add(9.8, 0, 0, at)) hits++; at = at.add(const Duration(milliseconds: 20)); } } return hits; } test('정점 하나는 표본이 여럿이어도 한 번이다', () { expect(countAt50Hz(20), 20); }); test('문턱 아래로 내려오지 않으면 다시 세지 않는다', () { final d = ShakeDetector(); final t0 = DateTime(2026, 10, 5); expect(d.add(25, 0, 0, t0), isTrue); // 1초 내내 문턱 위 — 같은 동작이다. for (var i = 1; i <= 50; i++) { expect(d.add(25, 0, 0, t0.add(Duration(milliseconds: 20 * i))), isFalse); } expect(d.add(9.8, 0, 0, t0.add(const Duration(seconds: 2))), isFalse); expect(d.add(25, 0, 0, t0.add(const Duration(milliseconds: 2020))), isTrue); }); test('간격보다 촘촘한 정점은 같은 동작의 진동이다', () { expect(countAt50Hz(10, period: const Duration(milliseconds: 120)), lessThan(10)); }); }); group('HOME 이클립스 이벤트 버튼 (516:88125)', () { testWidgets('경보(scheduled) 단계부터 잔여 시간을 띄우고 누르면 이클립스 상세로 간다', (tester) async { final repo = FakeEclipseRepository( state: eclipseFixture( scheduled: true, startAt: DateTime.now().add(const Duration(minutes: 2, seconds: 30)), endsAt: DateTime.now().add(const Duration(minutes: 3, seconds: 30)), ), ); addTearDown(repo.dispose); await _pump( tester, Scaffold(body: Center(child: EclipseEventButton(repository: repo, fandomId: 'bts'))), ); expect(find.textContaining('후 이클립스가 발생해요!'), findsOneWidget); expect(find.textContaining('2분'), findsOneWidget); await tester.tap(find.byType(EclipseEventButton)); for (var i = 0; i < 6; i++) { await tester.pump(const Duration(milliseconds: 120)); } expect(find.byType(EclipseDefendScreen), findsOneWidget); expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget); }); testWidgets('발생 뒤에는 남은 방어 초를, 결과가 나면 접는다', (tester) async { final repo = FakeEclipseRepository( state: eclipseFixture( startAt: DateTime.now().subtract(const Duration(seconds: 10)), endsAt: DateTime.now().add(const Duration(seconds: 50)), ), ); addTearDown(repo.dispose); await _pump( tester, Scaffold(body: Center(child: EclipseEventButton(repository: repo, fandomId: 'bts'))), ); expect(find.textContaining('이클립스가 발생했어요!'), findsOneWidget); repo.push(eclipseFixture(defended: true)); await tester.pump(const Duration(milliseconds: 300)); expect(find.textContaining('이클립스'), findsNothing); }); testWidgets('HOME 라이브 띠가 이클립스 발생을 알리고 행성 아래 버튼이 뜬다', (tester) async { final repo = FakeEclipseRepository( state: eclipseFixture( startAt: DateTime.now().subtract(const Duration(seconds: 5)), endsAt: DateTime.now().add(const Duration(seconds: 55)), ), ); addTearDown(repo.dispose); await _pump( tester, HomeScreen( repository: const SampleHomeRepository(), questRepository: const SampleQuestRepository(), eclipse: repo, ), ); expect(find.text('BTS 행성에 이클립스가 발생했어요!'), findsOneWidget); expect(find.byType(EclipseEventButton), findsOneWidget); // Figma 에 없는 카드형 배너는 HOME 에서 빠졌다. expect(find.textContaining('이클립스 발동 —'), findsNothing); }); }); group('이클립스 상세 → 오버레이 → 방어 (373:444865 · 373:444976 · 373:445267)', () { testWidgets('발생 전은 상세 — 아래에 발생 남은 시간', (tester) async { final repo = FakeEclipseRepository( state: eclipseFixture( scheduled: true, startAt: DateTime.now().add(const Duration(seconds: 40)), endsAt: DateTime.now().add(const Duration(seconds: 100)), ), ); addTearDown(repo.dispose); await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts')); expect(find.text('휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'), findsOneWidget); expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget); expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing); }); testWidgets('발생 3초 전부터 오버레이 카운트다운', (tester) async { final repo = FakeEclipseRepository( state: eclipseFixture( scheduled: true, startAt: DateTime.now().add(const Duration(milliseconds: 2500)), endsAt: DateTime.now().add(const Duration(seconds: 62)), ), ); addTearDown(repo.dispose); await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts')); expect(find.text('행성 운석 충돌이\n시작됩니다!'), findsOneWidget); expect(find.text('3'), findsOneWidget); }); testWidgets('방어 중 진행 표시 — 내 유효 Shake / 인정 횟수 · 팬덤 참여자 / 목표', (tester) async { final repo = FakeEclipseRepository( state: eclipseFixture( defense: 3, targetDefense: 10, startAt: DateTime.now().subtract(const Duration(seconds: 1)), endsAt: DateTime.now().add(const Duration(seconds: 59)), ), ); addTearDown(repo.dispose); await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts')); expect(find.textContaining('초간 휴대폰을 흔들어'), findsOneWidget); expect(find.text('내 유효 Shake 0 / 60회'), findsOneWidget); expect(find.text('유효 참여 3 / 10명'), findsOneWidget); await tester.tap(find.text('흔들기 어려우면 눌러서 참여')); for (var i = 0; i < 8; i++) { await tester.pump(const Duration(milliseconds: 120)); } expect(find.text('내 유효 Shake 1 / 60회'), findsOneWidget); }); testWidgets('발생 직후 서버가 아직 scheduled 면 모았다가 active 가 되면 보낸다', (tester) async { final startAt = DateTime.now().subtract(const Duration(seconds: 1)); final endsAt = DateTime.now().add(const Duration(seconds: 59)); final repo = FakeEclipseRepository( state: eclipseFixture(scheduled: true, startAt: startAt, endsAt: endsAt), ); addTearDown(repo.dispose); await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts')); await tester.tap(find.text('흔들기 어려우면 눌러서 참여')); for (var i = 0; i < 8; i++) { await tester.pump(const Duration(milliseconds: 120)); } expect(repo.calls, isEmpty); repo.push(eclipseFixture(startAt: startAt, endsAt: endsAt)); for (var i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 120)); } expect(repo.calls, hasLength(1)); expect(repo.calls.single.$3, 1); }); }); group('Music Shake 보상 표시 (원장 B-24)', () { test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 musicShake.pop 을 기본액으로 적는다', () { const policy = {'stepsPop': 50, 'musicShake': {'pop': 1}}; // 걸음 보상(stepsPop)을 빌려 쓰지 않는다. expect(QuestType.musicShake.rewardFrom(const {}, policy), 1); expect(QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, policy), 30); expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), isNull); expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull); }); }); }