import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/data/shared_stream.dart'; import 'package:fancake/features/eclipse/eclipse_defend_screen.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/features/eclipse/eclipse_data.dart'; import 'package:fancake/features/eclipse/widgets/live_proof.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/galaxy/galaxy_screen.dart'; import 'package:fancake/features/galaxy/widgets/zap_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/planet/planet_data.dart'; import 'eclipse_stubs.dart'; /// 이클립스 방어·침식·실시간 동기화·라이브 소셜 프루프. /// /// 판정 근거 /// - 별첨 1 ① 완료 시나리오 4 「이클립스 발동 → 워닝 → 방어 → 침식률 회복」 /// - 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프가 타 사용자 화면에 /// 실시간 노출」 /// - 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱 워닝 배너 병행」 /// - 별첨 1 ② 서버검증 원칙 「클라이언트 단독 지급 0건」 — 화면이 잔액을 스스로 /// 확정하지 않고 서버 응답으로 덮는지 본다. /// /// **스트림을 밖에서 밀어 넣어 본다.** 화면이 첫 값만 그리고 갱신을 놓치는 것이 /// 실시간 동기화에서 가장 흔한 고장이고, 눈으로는 「조금 늦는다」로만 보인다. /// 궤도 맵은 궤도 회전이 멈추지 않아 `pumpAndSettle` 이 끝나지 않는다. Future _pumpGalaxy( WidgetTester tester, EclipseRepository eclipse, { GalaxyRepository? galaxy, }) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: GalaxyScreen( repository: galaxy ?? const SampleGalaxyRepository(), planetRepository: samplePlanetRepository, eclipse: eclipse, ), ), ); for (var i = 0; i < 8; i++) { await tester.pump(const Duration(milliseconds: 120)); } } /// 흔들어 방어 화면. 센서 대신 가짜 흔들기 스트림을 넣는다 — 방어는 Shake only(FX §5). Future _pumpDefend(WidgetTester tester, EclipseRepository repo) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream), ), ); await tester.pump(); await _settle(tester); } Future _settle(WidgetTester tester) async { for (var i = 0; i < 8; i++) { await tester.pump(const Duration(milliseconds: 120)); } } /// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나. final _shakes = StreamController.broadcast(); void main() { group('이클립스 경보 배너 (별첨 1 ① 「인앱 워닝 배너 병행」)', () { testWidgets('활성 이클립스가 없으면 배너가 뜨지 않는다', (tester) async { final repo = FakeEclipseRepository(); addTearDown(repo.dispose); await _pumpGalaxy(tester, repo); expect(find.textContaining('침식'), findsNothing); expect(find.textContaining('방어 '), findsNothing); }); testWidgets('발동하면 침식률과 방어 진행이 배너에 뜬다', (tester) async { final repo = FakeEclipseRepository( state: eclipseFixture(erosionRate: 40, defense: 60000, targetDefense: 100000), ); addTearDown(repo.dispose); await _pumpGalaxy(tester, repo); expect(find.textContaining('이클립스 발동 — BTS 침식 40%'), findsOneWidget); // 행성 HUD 도 같은 침식률을 띄운다(스펙 B — 갤럭시·행성 상세 HUD). expect(find.textContaining('침식 40% · 방어 60%'), findsOneWidget); expect( find.textContaining('유효 참여 60,000 / 100,000명'), findsOneWidget, ); }); testWidgets('스트림이 새 집계를 내면 배너가 따라 바뀐다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture(erosionRate: 40)); addTearDown(repo.dispose); await _pumpGalaxy(tester, repo); // 배너와 행성 HUD 가 같은 집계를 보므로 둘 다 잡힌다 — 값이 갈리면 안 된다. expect(find.textContaining('침식 40%'), findsWidgets); // 다른 단말의 방어가 집계에 반영된 상황. repo.push(eclipseFixture(erosionRate: 15, defense: 85000, defenseCount: 40)); await _settle(tester); expect(find.textContaining('침식 40%'), findsNothing); expect(find.textContaining('침식 15%'), findsWidgets); expect(find.textContaining('유효 참여 85,000 / 100,000명'), findsOneWidget); }); testWidgets('방어에 성공하면 감소 없음 문구로 바뀐다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture(erosionRate: 20)); addTearDown(repo.dispose); await _pumpGalaxy(tester, repo); repo.push(eclipseFixture( erosionRate: 0, defense: 100000, defended: true, )); await _settle(tester); expect(find.textContaining('방어 성공 — BTS FPI 감소 없음'), findsOneWidget); }); }); group('ZAP — 행성 성장 기여 (별첨 B B-23)', () { testWidgets('ZAP 은 이클립스가 없어도 눌린다', (tester) async { // 방어는 POP 을 쓰지 않는 공동 Shake 참여이고(설계서 v1.1 §9-#5) ZAP 은 // 행성 성장 기여다. 둘이 분리되어 있으므로 이클립스 유무와 무관하다. final repo = FakeEclipseRepository(); addTearDown(repo.dispose); final galaxy = RecordingGalaxyRepository(); await _pumpGalaxy(tester, repo, galaxy: galaxy); await tester.tap(find.byType(ZapButton)); await _settle(tester); expect(galaxy.zaps, hasLength(1)); // 표본의 내 행성은 `bts` 다 — ZAP 은 내 행성의 성장에 기여한다. expect(galaxy.zaps.single.$1, 'bts'); expect(galaxy.zaps.single.$3, 1); // 방어 호출은 나가지 않는다 — 이클립스 샤드와 분리되어 있다. expect(repo.calls, isEmpty); }); testWidgets('연타는 한 번의 호출로 묶이고 count 가 누적된다', (tester) async { final repo = FakeEclipseRepository(); addTearDown(repo.dispose); final galaxy = RecordingGalaxyRepository(); await _pumpGalaxy(tester, repo, galaxy: galaxy); for (var i = 0; i < 5; i++) { await tester.tap(find.byType(ZapButton)); await tester.pump(const Duration(milliseconds: 60)); } await _settle(tester); // 호출 1회 · count 5 — 원장 한 행에 묶음 횟수가 남아야 MY 가 「Zap 5회」를 센다. expect(galaxy.zaps, hasLength(1)); expect(galaxy.zaps.single.$3, 5); }); testWidgets('시간창이 닫힌 뒤의 연타는 새 묶음이고 멱등키가 다르다', (tester) async { final repo = FakeEclipseRepository(); addTearDown(repo.dispose); final galaxy = RecordingGalaxyRepository()..balanceAfter = 9_000; await _pumpGalaxy(tester, repo, galaxy: galaxy); await tester.tap(find.byType(ZapButton)); await _settle(tester); await tester.tap(find.byType(ZapButton)); await _settle(tester); expect(galaxy.zaps, hasLength(2)); // 멱등키가 같으면 서버가 두 번째를 같은 거래로 보아 기여가 사라진다. expect(galaxy.zaps[0].$2, isNot(galaxy.zaps[1].$2)); }); testWidgets('잔액은 서버가 확정한 값으로 덮인다', (tester) async { final repo = FakeEclipseRepository(); addTearDown(repo.dispose); final galaxy = RecordingGalaxyRepository()..balanceAfter = 7_777; await _pumpGalaxy(tester, repo, galaxy: galaxy); await tester.tap(find.byType(ZapButton)); await _settle(tester); final button = tester.widget(find.byType(ZapButton)); expect(button.myPop, 7_777); }); testWidgets('잔액 부족은 서버 오류 코드로 안내한다', (tester) async { final repo = FakeEclipseRepository(); addTearDown(repo.dispose); final galaxy = RecordingGalaxyRepository() ..failWith = const BackendException('E-POP-01', 'POP 잔액이 부족합니다.'); await _pumpGalaxy(tester, repo, galaxy: galaxy); await tester.tap(find.byType(ZapButton)); await _settle(tester); expect(find.textContaining('POP 이 모자라'), findsOneWidget); }); testWidgets('자동(오토 도네이션) 옵션은 없다', (tester) async { // 별첨 1 ① 시나리오 4 가 적었으나 별첨 B 에 없는 기능이다(공통 기준 2항). final repo = FakeEclipseRepository(); addTearDown(repo.dispose); await _pumpGalaxy(tester, repo); final hold = await tester.startGesture(tester.getCenter(find.byType(ZapButton))); await tester.pump(ZapButton.longPress); await hold.up(); await _settle(tester); expect(find.text('기여'), findsOneWidget); expect(find.text('아이템'), findsOneWidget); expect(find.text('자동'), findsNothing); }); }); group('라이브 소셜 프루프 (별첨 1 ① 시나리오 5)', () { testWidgets('갤럭시 섬광 배너가 다른 사용자의 방어를 흘린다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture()); addTearDown(repo.dispose); await _pumpGalaxy(tester, repo); repo.pushEvents([ LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)), ]); await _settle(tester); expect( find.text('[LIVE] 버니즈님이 이클립스 방어에 참여했습니다! (+30)'), findsOneWidget, ); }); testWidgets('새 이벤트가 오면 문구가 갈린다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture()); addTearDown(repo.dispose); await _pumpGalaxy(tester, repo); repo.pushEvents([ LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)), ]); await _settle(tester); repo.pushEvents([ LiveEvent(id: 'e2', nickname: '캐럿', defense: 50, at: DateTime(2026, 9, 14, 1)), LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)), ]); await _settle(tester); expect(find.textContaining('버니즈'), findsNothing); expect( find.text('[LIVE] 캐럿님이 이클립스 방어에 참여했습니다! (+50)'), findsOneWidget, ); }); testWidgets('홈 라이브 띠가 라이브 이벤트를 그린다', (tester) async { final repo = FakeEclipseRepository(); addTearDown(repo.dispose); tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: HomeScreen( repository: const SampleHomeRepository(), questRepository: const SampleQuestRepository(), eclipse: repo, ), ), ); await tester.pumpAndSettle(); // 이벤트가 없으면 스냅샷의 폴백 문구를 그린다. expect(find.textContaining('아이템 사용으로 버프가 발동'), findsOneWidget); repo.pushEvents([ LiveEvent(id: 'e1', nickname: '무민', defense: 20, at: DateTime(2026, 9, 14)), ]); await tester.pumpAndSettle(); // 이벤트가 오면 그 문구가 폴백을 대체한다. expect(find.textContaining('아이템 사용으로 버프가 발동'), findsNothing); expect( find.text('[LIVE] 무민님이 이클립스 방어에 참여했습니다! (+20)'), findsOneWidget, ); }); test('문구는 종류마다 한 곳에서 만든다', () { final at = DateTime(2026, 9, 14); expect( liveEventMessage(LiveEvent(id: 'x', nickname: '나', defense: 1200, at: at)), '[LIVE] 나님이 이클립스 방어에 참여했습니다! (+1,200)', ); // 별첨 B 「Global Ticker」 — 다른 종류가 같은 컬렉션에 들어온다. expect( liveEventMessage(LiveEvent( id: 'y', nickname: '나', at: at, kind: LiveEventKind.heroGift, )), '[LIVE] 나님의 영웅의 선물이 도착했습니다!', ); }); }); group('실시간 구독 — 해제와 백그라운드 복귀 (스펙 C)', () { testWidgets('화면이 사라지면 구독을 끊는다', (tester) async { final repo = CountingEclipseRepository(eclipseFixture()); await _pumpGalaxy(tester, repo); expect(repo.subscriptions, 1); expect(repo.active, 1); // 화면을 치운다. 구독이 남으면 백그라운드에서 읽기 비용이 계속 든다. await tester.pumpWidget(const MaterialApp(home: SizedBox())); await _settle(tester); expect(repo.active, 0); }); testWidgets('백그라운드에서 돌아오면 다시 구독한다', (tester) async { final repo = CountingEclipseRepository(eclipseFixture()); await _pumpGalaxy(tester, repo); expect(repo.subscriptions, 1); // 오래 잠든 리스너는 서버와 끊긴 채 남을 수 있다. 복귀 때 새로 열어 // 첫 스냅샷을 다시 받는다 — 별첨 1 ① 「백그라운드 복귀 시 렌더링 정상 재개」. tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await _settle(tester); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await _settle(tester); expect(repo.subscriptions, 2); // 옛 구독은 남지 않는다 — 남으면 같은 문서를 두 번 읽는다. expect(repo.active, 1); expect(find.textContaining('이클립스 발동'), findsWidgets); }); }); group('흔들어 방어 화면 (B-55 공동 Shake)', () { testWidgets('POP 을 쓰지 않는다는 것을 화면이 말한다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture()); addTearDown(repo.dispose); await _pumpDefend(tester, repo); expect(find.textContaining('POP은 소모되지 않습니다'), findsOneWidget); expect(find.textContaining('흔들어 방어'), findsWidgets); }); testWidgets('흔들기가 묶여 서버로 나간다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture()); addTearDown(repo.dispose); await _pumpDefend(tester, repo); for (var i = 0; i < 4; i++) { _shakes.add(DateTime.now()); await tester.pump(const Duration(milliseconds: 80)); } await _settle(tester); expect(repo.calls, hasLength(1)); expect(repo.calls.single.$3, 4); }); testWidgets('서버가 받아들인 값만 내 참여로 그린다', (tester) async { // 단말이 센 값을 그대로 믿으면 서버가 깎은 뒤 숫자가 줄어 보인다. final repo = FakeEclipseRepository(state: eclipseFixture()) ..acceptedOverride = 2; addTearDown(repo.dispose); await _pumpDefend(tester, repo); // 다섯 번 참여했는데 서버가 둘만 받아들인 상황. for (var i = 0; i < 5; i++) { _shakes.add(DateTime.now()); await tester.pump(const Duration(milliseconds: 80)); } await _settle(tester); expect(repo.calls.single.$3, 5); expect(find.textContaining('참여 상한에 닿아'), findsOneWidget); }); testWidgets('성공한 이클립스에서는 참여 버튼이 닫힌다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture(defended: true)); addTearDown(repo.dispose); await _pumpDefend(tester, repo); expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsNothing); expect(find.text('이클립스를\n성공적으로 막았어요!'), findsOneWidget); }); testWidgets('마감은 판정 대기이며 서버 실패·취소만 해당 결과로 표시한다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture()); addTearDown(repo.dispose); await _pumpDefend(tester, repo); _shakes.add(DateTime.now()); repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1)))); await tester.pump(); await _settle(tester); expect(find.text('방어 결과 확인 중'), findsOneWidget); expect(find.textContaining('FAIL'), findsNothing); expect(find.byType(FilledButton), findsNothing); expect(repo.calls, isEmpty); repo.push(eclipseFixture(result: EclipseResult.failure)); await _settle(tester); expect(find.text('아쉽게 행성 방어를\n실패했어요'), findsOneWidget); repo.push(eclipseFixture(result: EclipseResult.cancelled)); await _settle(tester); expect(find.text('이클립스 취소'), findsOneWidget); expect(find.textContaining('FPI는 차감되지 않았습니다'), findsOneWidget); expect(find.textContaining('FAIL'), findsNothing); }); testWidgets('계속 입력해도 700ms마다 전송하고 멈춘 뒤 남은 입력을 보낸다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture()); addTearDown(repo.dispose); await _pumpDefend(tester, repo); for (var i = 0; i < 12; i++) { _shakes.add(DateTime.now()); await tester.pump(const Duration(milliseconds: 100)); } expect(repo.calls, isNotEmpty); await _settle(tester); expect(repo.calls.fold(0, (sum, call) => sum + call.$3), 12); expect(repo.calls.length, greaterThan(1)); }); testWidgets('응답 유실은 마감 후에도 같은 회차·요청ID·횟수로 재확인한다', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture()) ..failWith = const BackendException('unavailable', 'private backend error'); addTearDown(repo.dispose); await _pumpDefend(tester, repo); _shakes.add(DateTime.now()); await _settle(tester); expect(repo.calls, hasLength(1)); final original = repo.calls.single; expect(find.textContaining('private backend error'), findsNothing); repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1)))); await _settle(tester); repo.failWith = null; await tester.tap(find.text('같은 참여 다시 확인')); await _settle(tester); expect(repo.calls, [original, original]); expect(find.text('같은 참여 다시 확인'), findsNothing); }); testWidgets('센서가 없는 단말은 탭 대체 없이 미지원 안내만 (FX §5 Shake only)', (tester) async { final repo = FakeEclipseRepository(state: eclipseFixture()); addTearDown(repo.dispose); await tester.pumpWidget(MaterialApp( theme: buildAppTheme(), home: EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => Stream.error(StateError('no sensor'))), )); await _settle(tester); expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsOneWidget); expect(find.byType(FilledButton), findsNothing); expect(repo.calls, isEmpty); }); testWidgets('진행 중인 이클립스가 없으면 그 사실을 알린다', (tester) async { final repo = FakeEclipseRepository(); addTearDown(repo.dispose); await _pumpDefend(tester, repo); expect(find.text('진행 중인 이클립스가 없습니다'), findsOneWidget); }); }); group('구독 공유 — 팬덤당 하나 (검수 D5)', () { test('여러 화면이 같은 원본 하나를 나눠 쓴다', () async { // 화면마다 snapshots() 를 열면 한 팬덤에 리스너가 화면 수만큼 붙는다. var opened = 0; final controller = StreamController.broadcast(); addTearDown(controller.close); final shared = SharedStream(() { opened += 1; return controller.stream; }); final a = []; final b = []; final subA = shared.stream.listen(a.add); final subB = shared.stream.listen(b.add); await Future.delayed(Duration.zero); controller.add(7); await Future.delayed(Duration.zero); // 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다. expect(opened, 1); expect(a, [7]); expect(b, [7]); await subA.cancel(); await subB.cancel(); }); test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async { final controller = StreamController.broadcast(); addTearDown(controller.close); final shared = SharedStream(() => controller.stream); final first = []; final subA = shared.stream.listen(first.add); await Future.delayed(Duration.zero); controller.add(42); await Future.delayed(Duration.zero); // 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다. final late = []; final subB = shared.stream.listen(late.add); await Future.delayed(Duration.zero); expect(late, [42]); await subA.cancel(); await subB.cancel(); }); test('마지막 화면이 떠나면 원본을 닫는다', () async { var cancelled = false; final controller = StreamController(onCancel: () => cancelled = true); addTearDown(controller.close); final shared = SharedStream(() => controller.stream); final sub = shared.stream.listen((_) {}); await Future.delayed(Duration.zero); expect(cancelled, isFalse); await sub.cancel(); await Future.delayed(Duration.zero); // 남겨 두면 화면이 없는 동안에도 읽기 비용이 계속 든다. expect(cancelled, isTrue); }); }); group('이클립스 상태 계산', () { test('진행률은 목표가 0 이면 0 이다', () { expect(eclipseFixture(targetDefense: 0, defense: 10).progress, 0); }); test('진행률은 1 을 넘지 않는다', () { expect( eclipseFixture(targetDefense: 100, defense: 400).progress, 1.0, ); }); test('마감이 지났으면 남은 시간을 말하지 않는다', () { final now = DateTime(2026, 9, 14, 12); expect( eclipseFixture(endsAt: now.subtract(const Duration(minutes: 1))) .remaining(now), isNull, ); expect( eclipseFixture(endsAt: now.add(const Duration(minutes: 20))) .remaining(now), const Duration(minutes: 20), ); }); test('서버 kind 문자열을 모르는 값이면 공지로 읽는다', () { // 티커가 새 종류에 막혀 멈추지 않게 한다(별첨 B Global Ticker). expect(LiveEventKind.of('eclipse_defense'), LiveEventKind.eclipseDefense); expect(LiveEventKind.of('hero_gift'), LiveEventKind.heroGift); expect(LiveEventKind.of('무엇인가'), LiveEventKind.notice); }); test('마감이 지난 이클립스는 만료로 읽는다 (검수 D3)', () { final now = DateTime(2026, 9, 14, 12); expect( eclipseFixture(endsAt: now.subtract(const Duration(seconds: 1))) .isExpired(now), isTrue, ); expect( eclipseFixture(endsAt: now.add(const Duration(minutes: 1))).isExpired(now), isFalse, ); }); }); }