import 'package:cloud_firestore/cloud_firestore.dart'; import 'dart:async'; import 'package:fancake/core/data/planet_visual_state.dart'; import 'package:fancake/core/ui/live_planet.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/data/repositories.dart'; import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'package:fancake/core/ui/planet_25d.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { for (final reduced in [false, true]) { testWidgets('진화 완료 기록 버튼: 첫 조회/Level 제외, Reduce Motion=$reduced', (tester) async { var opened = 0; Future show(int stage, int level) => tester.pumpWidget(MaterialApp( home: MediaQuery(data: MediaQueryData(disableAnimations: reduced), child: Scaffold(body: Center(child: Planet25D( image: 'assets/figma/planet-home.png', size: 240, stage: stage, level: level, onOpenHistory: () => opened++, )))), )); await show(1, 1); await tester.pump(const Duration(seconds: 5)); expect(find.text('PASSPORT 기록 보기'), findsNothing); await show(1, 2); await tester.pump(const Duration(seconds: 2)); expect(find.text('PASSPORT 기록 보기'), findsNothing); await show(2, 3); await tester.pump(const Duration(milliseconds: 500)); expect(find.text('PASSPORT 기록 보기'), findsNothing); await tester.pump(const Duration(seconds: 4)); expect(find.text('PASSPORT 기록 보기'), findsOneWidget); await tester.tap(find.text('PASSPORT 기록 보기')); await tester.pump(); expect(opened, 1); expect(find.text('PASSPORT 기록 보기'), findsNothing); await show(2, 3); await tester.pump(const Duration(seconds: 4)); expect(find.text('PASSPORT 기록 보기'), findsNothing); }); } test('팬덤 서버 스트림은 유효 Stage/Level 변화만 전달한다', () async { final db = FakeFirebaseFirestore(); await db.doc('fandoms/bts').set({'stage': 1, 'level': 2}); final repos = Repositories( live: true, backend: Backend(firestore: db, auth: MockFirebaseAuth()), ); final states = []; final sub = repos.watchPlanet('bts').listen(states.add); await Future.delayed(Duration.zero); expect(states.last.stage, 1); expect(states.last.level, 2); await db.doc('fandoms/bts').update({'stage': 2, 'level': 10}); await Future.delayed(Duration.zero); expect(states.last.stage, 2); expect(states.last.level, 10); await db.doc('fandoms/bts').update({'stage': 9, 'level': -1}); await Future.delayed(Duration.zero); expect(states.last.stage, isNull); expect(states.last.level, isNull); await sub.cancel(); }); test('Eclipse 성공은 행성 조명만 회복하고 Stage/Level은 유지한다', () async { final db = FakeFirebaseFirestore(); await db.doc('fandoms/bts').set({'stage': 3, 'level': 20}); final event = db.doc('fandoms/bts/eclipseRounds/one'); await event.set({ 'status': 'active', 'startAt': Timestamp.now(), 'defended': false, }); final repos = Repositories( live: true, backend: Backend(firestore: db, auth: MockFirebaseAuth()), ); final states = []; final sub = repos.watchPlanet('bts').listen(states.add); addTearDown(sub.cancel); await Future.delayed(const Duration(milliseconds: 20)); expect(states.last.isEclipsed, true); expect(states.last.stage, 3); await event.update({'defended': true}); await Future.delayed(const Duration(milliseconds: 20)); expect(states.last.isEclipsed, false); expect(states.last.stage, 3); expect(states.last.level, 20); }); testWidgets('Eclipse 밝기는 점진 복귀하며 행성 표면과 단계는 유지한다', (tester) async { Future show(bool danger) => tester.pumpWidget( MaterialApp( home: Center( child: Planet25D( image: 'assets/figma/planet-home.png', size: 200, stage: 3, isEclipsed: danger, ), ), ), ); final opacity = find.byKey(const ValueKey('planet-surface-light')); await show(true); expect(tester.widget(opacity).opacity, 0.5); await show(false); await tester.pump(const Duration(seconds: 1)); expect(tester.widget(opacity).opacity, greaterThan(0.5)); expect(tester.widget(opacity).opacity, lessThan(1)); await tester.pump(const Duration(seconds: 1)); expect(tester.widget(opacity).opacity, 1); expect(tester.widget(find.byType(Planet25D)).stage, 3); expect(find.text('이상 반응'), findsNothing); expect(tester.takeException(), isNull); }); test('HERO TIME은 실제 남은 Pool과 서버 종료시각을 따르고 Stage는 유지한다', () async { final db = FakeFirebaseFirestore(); await db.doc('fandoms/bts').set({'stage': 3, 'level': 20}); final drop = db.doc('fandoms/bts/heroDrops/one'); await drop.set({ 'heroNickname': '공개팬', 'claimedCount': 3, 'triggeredBy': 'private-uid', 'email': 'private@example.com', 'remainingPop': 100, 'endsAt': Timestamp.fromDate( DateTime.now().add(const Duration(hours: 1)), ), }); final repos = Repositories( live: true, backend: Backend(firestore: db, auth: MockFirebaseAuth()), ); final states = []; final sub = repos.watchPlanet('bts').listen(states.add); addTearDown(sub.cancel); await Future.delayed(const Duration(milliseconds: 20)); expect(states.last.isHeroTime, true); expect(states.last.heroEndsAt, isNotNull); expect(states.last.heroNicknames, ['공개팬']); expect(states.last.heroClaimCount, 3); // 닉네임 없는 영웅은 서버가 「팬」으로 싣는다 — 이름 줄에는 띄우지 않는다(M-7 이전과 같음). await db.doc('fandoms/bts/heroDrops/nameless').set({ 'heroNickname': '팬', 'claimedCount': 0, 'remainingPop': 50, 'endsAt': Timestamp.fromDate( DateTime.now().add(const Duration(hours: 1)), ), }); await Future.delayed(const Duration(milliseconds: 20)); expect(states.last.heroNicknames, ['공개팬']); await db.doc('fandoms/bts/heroDrops/nameless').delete(); await Future.delayed(const Duration(milliseconds: 20)); expect(states.last.stage, 3); await drop.update({'remainingPop': 0}); await Future.delayed(const Duration(milliseconds: 20)); expect(states.last.isHeroTime, false); expect(states.last.heroEndsAt, isNull); await drop.update({ 'remainingPop': 100, 'endsAt': Timestamp.fromDate( DateTime.now().subtract(const Duration(seconds: 1)), ), }); await Future.delayed(const Duration(milliseconds: 20)); expect(states.last.isHeroTime, false); expect(states.last.level, 20); }); test('마지막 HERO TIME 종료시각에 추가 스냅샷 없이 축제를 해제한다', () async { final db = FakeFirebaseFirestore(); await db.doc('fandoms/bts').set({'stage': 3}); await db.doc('fandoms/bts/heroDrops/one').set({ 'remainingPop': 100, 'endsAt': Timestamp.fromDate( DateTime.now().add(const Duration(seconds: 1)), ), }); final repos = Repositories( live: true, backend: Backend(firestore: db, auth: MockFirebaseAuth()), ); final states = []; final sub = repos.watchPlanet('bts').listen(states.add); addTearDown(sub.cancel); await Future.delayed(const Duration(milliseconds: 20)); expect(states.last.isHeroTime, true); await Future.delayed(const Duration(milliseconds: 1100)); expect(states.last.isHeroTime, false); expect(states.last.stage, 3); }); testWidgets('첫 실시간 값은 캐시와 달라도 진화로 연출하지 않는다', (tester) async { final states = StreamController(); await tester.pumpWidget( MaterialApp( home: Center( child: LivePlanet25D( fandomId: 'bts', watch: (_) => states.stream, stage: 0, image: 'assets/figma/planet-home.png', size: 240, ), ), ), ); states.add(const PlanetVisualState(stage: 2, level: 10)); await tester.pump(); await tester.pump(); expect(tester.widget(find.byType(Planet25D)).stage, 2); expect(find.text('이상 반응'), findsNothing); states.add(const PlanetVisualState(stage: 3, level: 20)); await tester.pump(); await tester.pump(); expect(find.text('이상 반응'), findsOneWidget); await tester.pump(const Duration(seconds: 4)); await tester.pumpWidget(const SizedBox()); await tester.runAsync(states.close); }); testWidgets('서버 Stage 상승은 5구간 진화로 공개하고 재수신에는 반복하지 않는다', (tester) async { Future show(int stage) => tester.pumpWidget( MaterialApp( home: Center( child: Planet25D( image: 'assets/figma/planet-home.png', size: 240, stage: stage, ), ), ), ); await show(1); expect(find.text('에너지 집중'), findsNothing); await show(2); expect(find.text('이상 반응'), findsOneWidget); await tester.pump(const Duration(milliseconds: 600)); expect(find.text('에너지 집중'), findsOneWidget); await tester.pump(const Duration(milliseconds: 650)); expect(find.text('전환'), findsOneWidget); await tester.pump(const Duration(milliseconds: 400)); expect(find.text('Stage 2 · 성장'), findsOneWidget); await tester.pump(const Duration(milliseconds: 800)); expect(find.text('행성 진화 완료'), findsOneWidget); await tester.pumpAndSettle(); await show(2); expect(find.text('이상 반응'), findsNothing); await show(1); // 정정된 낮은 값 이후 같은 최고 Stage도 재축하하지 않는다. await show(2); expect(find.text('이상 반응'), findsNothing); }); testWidgets('Level 상승은 Stage Evolution과 구분된 짧은 결과를 표시한다', (tester) async { Future show(int level) => tester.pumpWidget( MaterialApp( home: Center( child: Planet25D( image: 'assets/figma/planet-home.png', size: 240, stage: 2, level: level, ), ), ), ); await show(5); await show(6); expect(find.text('Lv.6'), findsOneWidget); expect(find.text('이상 반응'), findsNothing); await tester.pumpAndSettle(); expect(find.text('Lv.6'), findsNothing); }); testWidgets('Reduce Motion에서도 확정된 새 Stage를 즉시 알린다', (tester) async { Future show(int stage) => tester.pumpWidget( MaterialApp( home: MediaQuery( data: const MediaQueryData(disableAnimations: true), child: Center( child: Planet25D( image: 'assets/figma/planet-home.png', size: 240, stage: stage, ), ), ), ), ); await show(0); await show(1); expect(find.text('이상 반응'), findsNothing); expect(find.text('Stage 1 · 시작'), findsOneWidget); await tester.pumpAndSettle(); }); }