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