diff --git a/app/lib/features/galaxy/zap_controller.dart b/app/lib/features/galaxy/zap_controller.dart index 29cafce..f6e391e 100644 --- a/app/lib/features/galaxy/zap_controller.dart +++ b/app/lib/features/galaxy/zap_controller.dart @@ -17,7 +17,7 @@ class ZapController extends ChangeNotifier { ZapResult? result; Timer? _burst; bool _disposed = false; - ({String fandomId, int count, String key})? _batch; + ({String fandomId, int count, String key, int streak})? _batch; bool get needsRetry => _batch != null && !sending; /// 아직 확정되지 않은 연타 — 보내는 중인 묶음 + 다음 묶음. 표시 잔액은 이만큼 미리 뺀다. @@ -27,6 +27,10 @@ class ZapController extends ChangeNotifier { /// [streakWindow] 안에 다시 누르면 이어지고, 넘기면 1 부터 다시 센다. int streak = 0; Timer? _streakTimer; + + /// 마지막으로 확정된 묶음의 끝 탭이 몇 번째 연속이었나 — 확정 결과 [+1] · [+10] 은 이것으로 정한다. + /// 확정 안 된 뒤 연타는 세지 않는다. + int confirmedStreak = 0; String? _pendingFandom; static const streakWindow = Duration(milliseconds: 500); @@ -85,6 +89,7 @@ class ZapController extends ChangeNotifier { fandomId: fandomId, count: pending, key: 'z${DateTime.now().microsecondsSinceEpoch}', + streak: streak, ); if (batch.count == 0) return; _burst?.cancel(); @@ -102,6 +107,7 @@ class ZapController extends ChangeNotifier { if (_disposed) return; if (receipt.txId.isEmpty) throw StateError('Missing ZAP receipt'); if (receipt.balanceAfter >= 0) balance = receipt.balanceAfter; + confirmedStreak = batch.streak; result = receipt; _batch = null; } catch (error) { diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index 4992af3..3e77e36 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -1112,9 +1112,10 @@ class HomeZap extends StatelessWidget { /// 행성 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · `2033:15818` 행성 리액션 1. /// /// - 행성 터치: Light Haptic · 흔들림 1번이 끝나면 [onTap](커뮤니티 이동). -/// - GIVE: 누를 때마다 흔들림 1번 · 행성 우하단 [+1] 0.5초(연속 가능). 이어서 10회 이상이면 -/// 흔들림 x2 · 자전 1.5배([Planet25D.spinBoost]) · [+10]. 숫자는 누른 횟수이고 기여 결과는 -/// 서버 확정 뒤 [ZapFeedback] 이 따로 띄운다(v1.5 §1.2). +/// - GIVE 입력 반응(즉시): 누를 때마다 흔들림 1번, 이어서 10회 이상이면 흔들림 x2 · 자전 1.5배 +/// ([Planet25D.spinBoost]). +/// - GIVE 결과(서버 확정 뒤, v1.5 §1.2 · §3): 행성 우하단 [+1] 0.5초(연속 가능), 10회 이상 이어진 +/// 연타의 확정이면 [+10]. class _PlanetReaction extends StatefulWidget { const _PlanetReaction({ required this.builder, @@ -1148,11 +1149,17 @@ class _PlanetReactionState extends State<_PlanetReaction> int? _seenSerial; int _streak = 0; Timer? _countTimer; + String? _seenTx; + + /// 확정 결과 표시 — 「+1」 · 「+10」. null 이면 숨김. + String? _count; + Timer? _resultTimer; @override void initState() { super.initState(); _seenSerial = widget.zap?.touchSerial; + _seenTx = widget.zap?.result?.txId; widget.zap?.addListener(_onZap); } @@ -1162,6 +1169,7 @@ class _PlanetReactionState extends State<_PlanetReaction> if (old.zap != widget.zap) { old.zap?.removeListener(_onZap); _seenSerial = widget.zap?.touchSerial; + _seenTx = widget.zap?.result?.txId; widget.zap?.addListener(_onZap); } } @@ -1170,12 +1178,25 @@ class _PlanetReactionState extends State<_PlanetReaction> void dispose() { widget.zap?.removeListener(_onZap); _countTimer?.cancel(); + _resultTimer?.cancel(); _shake.dispose(); super.dispose(); } void _onZap() { final zap = widget.zap!; + final tx = zap.result?.txId; + if (tx != null && tx.isNotEmpty && tx != _seenTx) { + _seenTx = tx; + if (!zap.questReaction) { + setState(() => _count = + zap.confirmedStreak >= _PlanetReaction.boostStreak ? '+10' : '+1'); + _resultTimer?.cancel(); + _resultTimer = Timer(ZapController.streakWindow, () { + if (mounted) setState(() => _count = null); + }); + } + } if (zap.touchSerial == _seenSerial) return; _seenSerial = zap.touchSerial; if (zap.questReaction) return; @@ -1233,7 +1254,7 @@ class _PlanetReactionState extends State<_PlanetReaction> ), child: widget.builder(boost), ), - if (_streak > 0) + if (_count case final count?) // 행성 우측 하단 — 행성 그림 상자([planetSize]) 안 오른쪽 아래. Positioned.fill( child: IgnorePointer( @@ -1243,7 +1264,7 @@ class _PlanetReactionState extends State<_PlanetReaction> child: Align( alignment: const Alignment(0.85, 0.85), child: Text( - boost ? '+10' : '+1', + count, key: const ValueKey('planet-give-count'), style: const TextStyle( fontFamily: AppFonts.body, diff --git a/app/test/planet_give_reaction_test.dart b/app/test/planet_give_reaction_test.dart index 0b5197f..8712fc3 100644 --- a/app/test/planet_give_reaction_test.dart +++ b/app/test/planet_give_reaction_test.dart @@ -20,6 +20,7 @@ class _GatedGalaxy extends SampleGalaxyRepository { _GatedGalaxy(); final counts = []; final fandoms = []; + final keys = []; final gates = >[]; @override @@ -30,6 +31,7 @@ class _GatedGalaxy extends SampleGalaxyRepository { }) { counts.add(count); fandoms.add(fandomId); + keys.add(clientSeq); gates.add(Completer()); return gates.last.future; } @@ -78,6 +80,56 @@ void main() { 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: (_) {}); @@ -109,7 +161,10 @@ void main() { }); group('HOME 행성 GIVE 반응', () { - Future pumpHome(WidgetTester tester) async { + Future pumpHome( + WidgetTester tester, [ + GalaxyRepository galaxy = const _OkGalaxy(), + ]) async { tester.view.physicalSize = const Size(1125, 2436); tester.view.devicePixelRatio = 3; addTearDown(tester.view.reset); @@ -120,7 +175,7 @@ void main() { repository: const SampleHomeRepository(), questRepository: const SampleQuestRepository(), eclipse: const NoEclipseRepository(), - galaxyRepository: const _OkGalaxy(), + galaxyRepository: galaxy, planetRepository: samplePlanetRepository, ), ), @@ -135,19 +190,22 @@ void main() { .getTranslation() .x; - testWidgets('GIVE 1회: 행성이 흔들리고 행성 우하단에 [+1] 이 0.5초 뜬다', (tester) async { + 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: 450)); + await tester.pump(const Duration(milliseconds: 550)); expect(label, findsNothing); await tester.pump(const Duration(seconds: 3)); }); @@ -163,17 +221,77 @@ void main() { 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)); + }); + 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(find.text('+10'), findsOneWidget); 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,