import 'dart:async'; import 'dart:math' as math; import 'package:fancake/core/data/repositories.dart' show eclipseLook, eclipseWarnWindow; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/core/ui/planet_25d.dart'; import 'package:fancake/features/eclipse/eclipse_data.dart'; import 'package:fancake/features/eclipse/eclipse_defend_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'eclipse_stubs.dart'; /// UX·FX v1.5 §5 사전 경고 「그림자 접근」 · §5.1 「끊어진 Ring → 완전한 Shield」 /// (진척현황 부록 B-3 5-1 · 5-4). void main() { final warn = DateTime(2026, 10, 8, 7, 47); final start = DateTime(2026, 10, 8, 7, 50); test('그림자 접근 진행 — 경고 시각 0 → 발생 시각 1, 경고 전 · 창 없음은 null', () { expect(eclipseApproach(null, start, warn), isNull); expect(eclipseApproach(warn, start, warn.subtract(const Duration(seconds: 1))), isNull); expect(eclipseApproach(warn, start, warn), 0); expect(eclipseApproach(warn, start, warn.add(const Duration(seconds: 90))), 0.5); expect( eclipseApproach(warn, start, start.add(const Duration(seconds: 3))), 1, reason: '서버가 회차를 열기 전 지연 동안 그림자가 빠지지 않는다', ); }); test('경보 단계 회차 — 이클립스 감소가 아니라 경고 창만', () { final scheduled = EclipseState( fandomId: 'bts', eclipseId: 'ecl-2', fandomName: 'BTS', erosionRate: 0, defense: 0, targetDefense: 10, defenseCount: 0, defended: false, scheduled: true, warnAt: warn, startAt: start, endsAt: start.add(const Duration(seconds: 60)), ); expect(eclipseLook(scheduled, warn), (false, false, null)); expect(eclipseWarnWindow(scheduled), (warn, start)); expect(eclipseWarnWindow(eclipseFixture()), isNull, reason: '진행 중 회차'); expect(eclipseWarnWindow(null), isNull); }); group('행성 그림자', () { Widget planet({DateTime? warnAt, DateTime? startAt, bool eclipsed = false}) => MaterialApp( home: Center( child: Planet25D( image: 'assets/figma/planet-home.png', size: 200, stage: 3, isEclipsed: eclipsed, eclipseWarnAt: warnAt, eclipseStartAt: startAt, ), ), ); final now = DateTime.now(); final w = now.subtract(const Duration(seconds: 90)); final s = now.add(const Duration(seconds: 90)); testWidgets('경고 창 안이면 그림자를 그린다', (tester) async { await tester.pumpWidget(planet(warnAt: w, startAt: s)); expect(find.byKey(const ValueKey('eclipse-shadow')), findsOneWidget); }); testWidgets('경고가 없거나 이미 이클립스 진행 중이면 그리지 않는다', (tester) async { await tester.pumpWidget(planet()); expect(find.byKey(const ValueKey('eclipse-shadow')), findsNothing); await tester.pumpWidget(planet(warnAt: w, startAt: s, eclipsed: true)); expect(find.byKey(const ValueKey('eclipse-shadow')), findsNothing); }); }); test('Shield Ring — 처음엔 끊어진 조각, 참여자만큼 이어지고 목표 달성이면 원 하나', () { const slot = 2 * math.pi / shieldSegments; final broken = shieldArcs(0); expect(broken, hasLength(shieldSegments)); expect(broken.first.$2, closeTo(slot * 0.5, 1e-9)); expect(shieldArcs(0.5).first.$2, closeTo(slot * 0.75, 1e-9)); expect(shieldArcs(0.99).first.$2, lessThan(slot)); expect(shieldArcs(1), [(-math.pi / 2, 2 * math.pi)]); }); testWidgets('방어 화면 Shield 는 서버 고유 참여자 / 목표 비율을 따른다', (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( defense: 3, targetDefense: 4, 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 < 6; i++) { await tester.pump(const Duration(milliseconds: 120)); } final ring = tester .widgetList(find.descendant( of: find.byKey(const ValueKey('shield-ring')), matching: find.byType(CustomPaint), )) .map((p) => p.painter) .whereType() .single; expect(ring.progress, 0.75); await tester.pumpWidget(const SizedBox()); }); }