import 'dart:async'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/core/ui/planet_25d.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/galaxy/widgets/galaxy_web_view.dart'; import 'package:fancake/features/galaxy/widgets/zap_button.dart'; import 'package:fancake/features/galaxy/zap_controller.dart'; import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_screen.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/planet/planet_detail_screen.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'eclipse_stubs.dart'; /// 서버 응답을 시험이 직접 풀어 주는 기여 저장소. class _GatedGalaxy extends SampleGalaxyRepository { _GatedGalaxy(); final counts = []; final fandoms = []; final keys = []; final gates = >[]; @override Future zap({ required String fandomId, required int count, required String clientSeq, }) { counts.add(count); fandoms.add(fandomId); keys.add(clientSeq); gates.add(Completer()); return gates.last.future; } } /// 바로 확정하는 기여 저장소. class _OkGalaxy extends SampleGalaxyRepository { const _OkGalaxy(); @override Future zap({ required String fandomId, required int count, required String clientSeq, }) async => ZapResult(txId: clientSeq, balanceAfter: 100000); } /// Figma `2033:15818` 행성 리액션 1 · `2024:20758` 행성 2단계 — GIVE 1회 흔들림 1번 · [+1] 0.5초(연속 가능), /// 10회 이상 흔들림 x2 · 자전 1.5배 · [+10]. void main() { group('ZapController 연타', () { testWidgets('전송 중에 누른 GIVE 도 버리지 않고 다음 묶음으로 보낸다', (tester) async { final repo = _GatedGalaxy(); final zap = ZapController(repo, onFailure: (_) {}); for (var i = 0; i < 3; i++) { zap.tap('f1'); } await tester.pump(const Duration(milliseconds: 450)); expect(repo.counts, [3]); expect(zap.sending, isTrue); zap.tap('f1'); zap.tap('f1'); expect(zap.pending, 2); expect(zap.queued, 5); await tester.pump(const Duration(milliseconds: 450)); expect(repo.counts, [3], reason: '앞 묶음이 확정되기 전에는 다음 묶음을 보내지 않는다'); repo.gates.first.complete(const ZapResult(txId: 'a', balanceAfter: 10)); await tester.pump(); expect(repo.counts, [3, 2]); repo.gates.last.complete(const ZapResult(txId: 'b', balanceAfter: 8)); await tester.pump(); expect(zap.queued, 0); zap.dispose(); }); testWidgets('연타 N회는 묶음이 여러 번이어도 정확히 N회분만 보내고 멱등키가 겹치지 않는다', (tester) async { final repo = _GatedGalaxy(); final zap = ZapController(repo, onFailure: (_) {}); for (var i = 0; i < 17; i++) { zap.tap('f1'); await tester.pump(const Duration(milliseconds: 120)); // 응답은 오는 대로 확정한다. for (final g in repo.gates) { if (!g.isCompleted) g.complete(ZapResult(txId: 't${repo.gates.indexOf(g)}', balanceAfter: 0)); } await tester.pump(); } await tester.pump(const Duration(milliseconds: 500)); for (final g in repo.gates) { if (!g.isCompleted) g.complete(const ZapResult(txId: 'last', balanceAfter: 0)); } await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(repo.counts.fold(0, (a, b) => a + b), 17); expect(repo.keys.toSet(), hasLength(repo.keys.length)); expect(zap.queued, 0); zap.dispose(); }); testWidgets('전송이 실패하면 보내는 중 묶음과 뒤에 쌓인 연타가 표시 잔액에서 빠지지 않는다(잔액 복원)', (tester) async { final repo = _GatedGalaxy(); final failures = []; final zap = ZapController(repo, onFailure: failures.add); zap.tap('f1'); zap.tap('f1'); await tester.pump(const Duration(milliseconds: 450)); zap.tap('f1'); expect(zap.queued, 3); repo.gates.single.completeError(StateError('down')); await tester.pump(); expect(failures, hasLength(1)); expect(zap.queued, 0, reason: '확정 안 된 연타를 잔액에서 미리 빼 두지 않는다'); expect(zap.needsRetry, isTrue); zap.tap('f1'); expect(zap.pending, 0, reason: '실패 묶음을 다시 확인하기 전에는 새 연타를 받지 않는다'); zap.retry(); await tester.pump(); expect(repo.keys[1], repo.keys[0], reason: '재확인은 같은 멱등키'); expect(repo.counts[1], 2); repo.gates.last.complete(const ZapResult(txId: 'ok', balanceAfter: 5)); await tester.pump(); expect(repo.counts, [2, 2]); zap.dispose(); }); testWidgets('보내는 중에 다른 행성으로 누른 연타가 앞 행성으로 가지 않는다', (tester) async { final repo = _GatedGalaxy(); final zap = ZapController(repo, onFailure: (_) {}); zap.tap('a'); await tester.pump(const Duration(milliseconds: 450)); zap.tap('b'); zap.tap('a'); expect(zap.pending, 1, reason: '쌓인 b 묶음에 a 를 섞지 않는다'); await tester.pump(const Duration(milliseconds: 450)); repo.gates.first.complete(const ZapResult(txId: 'a1', balanceAfter: 10)); await tester.pump(); expect(repo.fandoms, ['a', 'b']); repo.gates.last.complete(const ZapResult(txId: 'b1', balanceAfter: 9)); await tester.pump(); zap.dispose(); }); testWidgets('연속 횟수는 [+1] 이 떠 있는 0.5초 안에 다시 누를 때만 이어진다', (tester) async { final zap = ZapController(_GatedGalaxy(), onFailure: (_) {}); zap.tap('f1'); await tester.pump(const Duration(milliseconds: 300)); zap.tap('f1'); expect(zap.streak, 2); await tester.pump(const Duration(milliseconds: 600)); zap.tap('f1'); expect(zap.streak, 1); zap.dispose(); }); }); group('HOME 행성 GIVE 반응', () { Future pumpHome( WidgetTester tester, [ GalaxyRepository galaxy = const _OkGalaxy(), Size size = const Size(1125, 2436), ]) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 3; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: HomeScreen( repository: const SampleHomeRepository(), questRepository: const SampleQuestRepository(), eclipse: const NoEclipseRepository(), galaxyRepository: galaxy, planetRepository: samplePlanetRepository, ), ), ); await tester.pump(); await tester.pump(const Duration(seconds: 1)); } double shake(WidgetTester tester) => tester .widget(find.byKey(const ValueKey('planet-give-shake'))) .transform .getTranslation() .x; testWidgets('GIVE 1회: 행성은 바로 흔들리고, 서버 확정 뒤 행성 우하단에 [+1] 이 0.5초 뜬다', (tester) async { await pumpHome(tester); await tester.tap(find.byType(ZapButton)); await tester.pump(); await tester.pump(const Duration(milliseconds: 60)); expect(shake(tester), isNot(0)); final label = find.byKey(const ValueKey('planet-give-count')); expect(label, findsNothing, reason: '확정 전에는 기여 결과를 띄우지 않는다'); await tester.pump(const Duration(milliseconds: 400)); await tester.pump(); expect(tester.widget(label).data, '+1'); final planet = tester.getRect(find.byType(Planet25D).first); final at = tester.getCenter(label); expect(at.dx, greaterThan(planet.center.dx)); expect(at.dy, greaterThan(planet.center.dy)); await tester.pump(const Duration(milliseconds: 550)); expect(label, findsNothing); await tester.pump(const Duration(seconds: 3)); }); testWidgets('행성 터치 직후 GIVE 가 흔들림을 다시 시작해도 커뮤니티 이동은 간다', (tester) async { await pumpHome(tester); await tester.tap(find.byType(Planet25D).first, warnIfMissed: false); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byType(ZapButton)); await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(seconds: 1)); expect(find.byType(PlanetDetailScreen), findsOneWidget); await tester.pump(const Duration(seconds: 3)); }); testWidgets('확정 전에는 결과를 띄우지 않고, 실패하면 끝까지 띄우지 않으며 표시 잔액을 되돌린다', (tester) async { final repo = _GatedGalaxy(); await pumpHome(tester, repo); int shown() => tester.widget(find.byType(ZapButton)).myPop; final before = shown(); final unit = tester.widget(find.byType(ZapButton)).zapPop; expect(unit, greaterThan(0)); final label = find.byKey(const ValueKey('planet-give-count')); await tester.tap(find.byType(ZapButton)); await tester.pump(); await tester.tap(find.byType(ZapButton)); await tester.pump(const Duration(milliseconds: 600)); expect(repo.counts, [2]); await tester.tap(find.byType(ZapButton)); await tester.pump(); expect(shown(), before - 3 * unit, reason: '보내는 중 2회 + 쌓인 1회'); expect(label, findsNothing, reason: '보냈어도 확정 전에는 띄우지 않는다'); repo.gates.single.completeError(StateError('down')); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(label, findsNothing); expect(shown(), before, reason: '실패하면 확정 안 된 연타를 잔액에서 빼지 않는다'); // 같은 키 재확인이 성공하면 서버 잔액 · [+1]. await tester.tap(find.text('같은 기여 다시 확인')); await tester.pump(); expect(repo.counts, [2, 2]); repo.gates.last.complete(ZapResult(txId: 'ok', balanceAfter: before - 2 * unit)); await tester.pump(); expect(shown(), before - 2 * unit); expect(tester.widget(label).data, '+1'); await tester.pump(const Duration(seconds: 3)); }); testWidgets('10번째 탭이 아직 확정되지 않았으면 앞 묶음 확정에 [+10] 을 띄우지 않는다', (tester) async { final repo = _GatedGalaxy(); await pumpHome(tester, repo); for (var i = 0; i < 3; i++) { await tester.tap(find.byType(ZapButton)); await tester.pump(const Duration(milliseconds: 100)); } await tester.pump(const Duration(milliseconds: 200)); expect(repo.counts, [3]); for (var i = 0; i < 8; i++) { await tester.tap(find.byType(ZapButton)); await tester.pump(const Duration(milliseconds: 100)); } repo.gates.single.complete(const ZapResult(txId: 'a', balanceAfter: 0)); await tester.pump(); expect(find.text('+1'), findsOneWidget); expect(find.text('+10'), findsNothing); await tester.pump(const Duration(milliseconds: 500)); repo.gates.last.complete(const ZapResult(txId: 'b', balanceAfter: 0)); await tester.pump(); expect(find.text('+10'), findsOneWidget); await tester.pump(const Duration(seconds: 3)); }); // 10/22 iPhone 캡처 — [+10] 끝자리가 GALAXY 미니맵에 가려졌다. 행성 우하단에 두되 미니맵과 겹치지 않는다. for (final (name, size) in const [ ('360×760', Size(1080, 2280)), ('375×812', Size(1125, 2436)), ('390×844', Size(1170, 2532)), ]) { testWidgets('$name — [+10] 라벨이 GALAXY 미니맵과 겹치지 않는다', (tester) async { await pumpHome(tester, const _OkGalaxy(), size); for (var i = 0; i < 10; i++) { await tester.tap(find.byType(ZapButton)); await tester.pump(const Duration(milliseconds: 120)); } await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); final label = tester.getRect(find.text('+10')); final map = tester.getRect(find.byType(GalaxyWebView)); expect(label.overlaps(map), isFalse, reason: '[+10] $label · 미니맵 $map'); final planet = tester.getRect(find.byType(Planet25D).first); expect(label.center.dx, greaterThan(planet.center.dx)); expect(label.center.dy, greaterThan(planet.center.dy)); await tester.pump(const Duration(seconds: 3)); }); } testWidgets('GIVE 10회 이상: [+10] · 자전 1.5배(Planet25D.spinBoost)', (tester) async { await pumpHome(tester); for (var i = 0; i < 10; i++) { await tester.tap(find.byType(ZapButton)); await tester.pump(const Duration(milliseconds: 120)); } expect( tester.widget(find.byType(Planet25D).first).spinBoost, isTrue, ); // 마지막 묶음(9 · 10번째)이 확정되면 [+10]. await tester.pump(const Duration(milliseconds: 300)); await tester.pump(); expect(find.text('+10'), findsOneWidget); await tester.pump(const Duration(milliseconds: 600)); expect( tester.widget(find.byType(Planet25D).first).spinBoost, isFalse, ); await tester.pump(const Duration(seconds: 3)); }); }); testWidgets('Stage 6 은 자기장 라인이 행성을 감싼다(Figma 2024:20821) · Stage 5 는 없다', (tester) async { Future pumpStage(int stage) => tester.pumpWidget( MaterialApp( home: Center( child: Planet25D( image: 'assets/figma/planet-home.png', size: 200, stage: stage, ), ), ), ); await pumpStage(6); expect(find.byKey(const ValueKey('planet-field-lines')), findsOneWidget); await pumpStage(5); expect(find.byKey(const ValueKey('planet-field-lines')), findsNothing); }); }