Files
fanuzu/app/test/planet_give_reaction_test.dart

348 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = <int>[];
final fandoms = <String>[];
final keys = <String>[];
final gates = <Completer<ZapResult>>[];
@override
Future<ZapResult> 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<ZapResult> 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<int>(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 = <String>[];
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<void> 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<Transform>(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<Text>(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<ZapButton>(find.byType(ZapButton)).myPop;
final before = shown();
final unit = tester.widget<ZapButton>(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<Text>(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<Planet25D>(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<Planet25D>(find.byType(Planet25D).first).spinBoost,
isFalse,
);
await tester.pump(const Duration(seconds: 3));
});
});
testWidgets('Stage 6 은 자기장 라인이 행성을 감싼다(Figma 2024:20821) · Stage 5 는 없다', (tester) async {
Future<void> 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);
});
}