Zap·후원 FPI 귀속 · 이클립스 방어 Shake 전용 · 가입 팬덤만 방어

This commit is contained in:
Sn-Kinos
2026-10-05 21:53:20 +09:00
parent 17c658b272
commit 71ec17dc3c
21 changed files with 196 additions and 134 deletions
+7 -1
View File
@@ -9,7 +9,7 @@ import { callerUid, num, str } from '../core/callable.js';
import { ERR, fail, positiveInt } from '../core/errors.js';
import { FPI_AXES, loadPolicy, weightsAreUsable, type FpiAxis } from '../core/config.js';
import { spendPop } from '../ledger/ledger.js';
import { rankWithTies } from '../ledger/fpi.js';
import { isMemberOf, rankWithTies } from '../ledger/fpi.js';
import { txIdOf } from '../core/ids.js';
import { dayKey } from '../core/time.js';
import { notifyEclipseWarning } from '../notify/push.js';
@@ -106,6 +106,10 @@ export async function defendEclipse(input: {
db.doc(`users/${uid}`).get(),
loadEclipseSettings(),
]);
// 방어는 그 팬덤의 활동이다 — 가입하지 않은 행성의 이클립스는 막을 수 없다(10/3 회신 #10).
if (!isMemberOf(user, fandomId)) {
fail(ERR.permissionDenied, '가입한 팬덤의 이클립스에만 참여할 수 있습니다.');
}
const idempotencyKey = `defend:${uid}:${eclipseId}:${clientSeq}`;
const logRef = eclipseRef.collection('defenseLogs').doc(txIdOf(idempotencyKey));
@@ -327,6 +331,8 @@ export const DEFAULT_ECLIPSE: EclipseSettings = {
dauWindowDays: 7,
windowSec: 60,
qualifyShakes: 60,
// 🟡 임시값 — 회신 대기(해석 확인 요청 2026-10-07 영역A Q-A10). 아래 세 값은 자료 근거가 없다
// (9/16 회신 3절 4항 「센서 판정 수치는 근거 없이 추가 확정하지 않음」). `config/eclipse` 운영값이 덮는다.
perCallMaxShakes: 50,
perUserMaxDefense: 500,
minIntervalMs: 300,
+7
View File
@@ -28,6 +28,13 @@ export function fandomOfUser(user: DocumentSnapshot | null | undefined): string
return typeof id === 'string' && id.length > 0 && !id.includes('/') ? id : null;
}
/** 사용자가 그 팬덤에 가입해 있는가 — `users.fandomIds`(가입 · 탈퇴 트랜잭션이 유지) 또는 대표 팬덤. */
export function isMemberOf(user: DocumentSnapshot | null | undefined, fandomId: string): boolean {
const joined = user?.get('fandomIds');
return fandomId === fandomOfUser(user) || fandomId === user?.get('fandomId') ||
(Array.isArray(joined) && joined.includes(fandomId));
}
export interface FpiActivity {
uid: string;
fandomId: string | null;
+17 -7
View File
@@ -1,10 +1,10 @@
import { getFirestore, FieldValue, Timestamp } from 'firebase-admin/firestore';
import type { DocumentReference, Firestore, Transaction } from 'firebase-admin/firestore';
import type { DocumentReference, DocumentSnapshot, Firestore, Transaction } from 'firebase-admin/firestore';
import { ERR, fail } from '../core/errors.js';
import { lotIdOf, txIdOf } from '../core/ids.js';
import { levelOf, passportRank, passportTierOf, type PassportTier } from '../core/level.js';
import { fandomOfUser, voidCompletions, writeFpiEvent } from './fpi.js';
import { fandomOfUser, isMemberOf, voidCompletions, writeFpiEvent } from './fpi.js';
import { publicNameOf } from '../core/names.js';
/**
@@ -491,10 +491,13 @@ async function spendParts(
}, { merge: true });
});
// Zap 의 귀속 팬덤은 **서버가 이 트랜잭션에서 읽은 대표 팬덤**이다(FPI 와 같은 판정).
// 클라이언트 `fandomId` 를 믿으면 남의 행성 성장을 올린다. 후원은 호출자(fundCampaign)가
// 캠페인 문서에서 정한 팬덤을 넘긴다.
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION' ? fandomOfUser(userSnap) : input.fandomId;
// Zap 은 **Zap 한 행성**에 귀속한다(상세정의 5장 「B팬덤 Zap 은 B행성에 귀속」 · 10/3 회신 #10).
// 남의 행성 성장을 올리지 못하게 대상이 **이 트랜잭션에서 읽은 가입 팬덤**인지 서버가
// 확인한다. 대상이 없으면(구 클라이언트) 대표 팬덤. 후원은 호출자(fundCampaign)가 캠페인
// 문서에서 정한 팬덤을 넘긴다 — 성장 · FPI 모두 그 팬덤이다.
const fandomId = input.reasonCode === 'ZAP_CONTRIBUTION'
? zapFandomOf(userSnap, input.fandomId)
: input.fandomId;
if (CONTRIBUTION_REASONS.includes(input.reasonCode)) {
// 누적 기여 POP 은 Passport 입력 · 기여 실적 표시용으로 유지한다. 등급 문턱은
// 더 이상 여기서 매기지 않는다(C-18 — 누적 기여 POP 5등급 치환 폐기).
@@ -519,7 +522,7 @@ async function spendParts(
// 금액 ÷ zapPop 으로 계산) · 후원 1건. EXP 는 주지 않는다 — POP 을 쓰는 행위는 1 POP
// 단위로 쪼개 반복할 수 있어 건당 EXP 가 파밍된다(C-18 · 리뷰 B1).
writeFpiEvent(tx, {
uid: input.uid, fandomId: fandomOfUser(userSnap), axis: 'contribution',
uid: input.uid, fandomId: fandomId ?? null, axis: 'contribution',
units: input.reasonCode === 'ZAP_CONTRIBUTION' ? input.count ?? 1 : 1,
activity: input.reasonCode === 'ZAP_CONTRIBUTION' ? 'zap' : 'campaign_fund',
sourceId, occurredAt: new Date(),
@@ -541,6 +544,13 @@ async function spendParts(
}
}
/** Zap 대상 팬덤 — 가입 팬덤(`users.fandomIds` 또는 대표 팬덤)일 때만. 없으면 대표 팬덤. */
function zapFandomOf(user: DocumentSnapshot | null, target: string | undefined): string | null {
if (!target) return fandomOfUser(user);
if (isMemberOf(user, target)) return target;
fail(ERR.permissionDenied, '가입한 팬덤의 행성에만 Zap 할 수 있습니다.');
}
type RefundReason = Extract<ReasonCode,
'CAMPAIGN_REFUND' | 'QNA_REFUND' | 'CAMPAIGN_FEE_REFUND' | 'QUEST_POOL_REFUND'>;
+4 -2
View File
@@ -55,8 +55,9 @@ const USER_SPEND_REASONS: readonly ReasonCode[] = ['ZAP_CONTRIBUTION'];
* `fn-pop-spend` — 본인 계정에서만 차감한다. 지금 열린 사유는 Zap 하나다.
*
* **Zap 횟수는 서버가 센다** — `금액 ÷ zapPop`(운영값). 클라이언트 `count` 를 믿으면
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤도 원장이 사용자
* 문서에서 정한다 — 클라이언트 `fandomId` 는 받지 않는다(리뷰 M1).
* 1 POP 으로 FPI CONTRIBUTION 축을 무제한 올린다(리뷰 B1). 귀속 팬덤은 Zap 한 행성
* `fandomId` 다(상세정의 5장 · 10/3 #10). 원장이 같은 트랜잭션에서 가입 여부를 확인하므로
* 가입하지 않은 행성은 거부된다(리뷰 M1 — 남의 행성 성장을 올리는 길을 막는다).
*/
export const fnPopSpend = onCall(async (req) => {
const uid = callerUid(req);
@@ -79,6 +80,7 @@ export const fnPopSpend = onCall(async (req) => {
// (`bounty:…` · `campaignFee:…`)를 먼저 차지해 그 거래를 막을 수 있다.
idempotencyKey: `user:${uid}:${str(req.data, 'idempotencyKey')}`,
...(optionalStr(req.data, 'refPath') ? { refPath: str(req.data, 'refPath') } : {}),
...(optionalStr(req.data, 'fandomId') ? { fandomId: str(req.data, 'fandomId') } : {}),
// 묶음 횟수. 원장 행에 남아 「Zap N 회」를 세고, FPI 단위가 된다.
count,
});
@@ -278,6 +278,16 @@ describe('서버 검증 — 단말 값을 믿지 않는다 (별첨 1 ②)', () =
expect((await shardSum()).defense).toBe(5);
});
it('가입하지 않은 팬덤의 이클립스는 막을 수 없다(10/3 #10)', async () => {
await seed();
await getFirestore().doc('users/test-eclipse-outsider').set({ nickname: '외부', fandomId: 'other' });
await expect(defendEclipse({
uid: 'test-eclipse-outsider', fandomId: FANDOM, eclipseId: ECLIPSE, clientSeq: 'o1', shakes: 5,
})).rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
expect((await shardSum()).defense).toBe(0);
await getFirestore().doc('users/test-eclipse-outsider').delete();
});
it('다른 사용자는 서로의 속도 제한에 걸리지 않는다', async () => {
await seed({ minIntervalMs: 60_000 });
await defendEclipse({
+23 -6
View File
@@ -146,12 +146,16 @@ it('캠페인 후원은 기여 POP 만 쓰고 targetPop 초과를 막으며 캠
expect(wallet.activityBalance).toBe(50);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(20);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
// FPI 도 캠페인 팬덤 b 로 — 성장과 갈라지지 않는다(10/3 #10).
expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(1);
expect((await db.collection(`fandoms/${a}/fpiEvents`).get()).size).toBe(0);
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
const donations = await db.collection(`campaigns/${cid}/donations`).get();
expect(donations.docs.map((d) => d.get('donorUid'))).toEqual([uid]);
await db.recursiveDelete(db.doc(`campaigns/${cid}`));
});
it('fn-pop-spend 는 Zap 만 받고 횟수 · 귀속 팬덤을 서버가 정하며 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
it('fn-pop-spend 는 Zap 만 받고 횟수를 서버가 세며 Zap 한 가입 행성에 귀속하고 EXP 를 주지 않는다(리뷰 B1 · H1 · M1)', async () => {
const db = getFirestore();
const { fnPopSpend } = await import('../src/ledger/pop.js');
const run = (data: Record<string, unknown>) => fnPopSpend.run(
@@ -164,14 +168,27 @@ it('fn-pop-spend 는 Zap 만 받고 횟수 · 귀속 팬덤을 서버가 정하
await expect(run({ popType: 'activity', amount: 5, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:odd' }))
.rejects.toMatchObject({ details: { code: 'E-POP-02' } });
// 클라이언트가 count 1e9 · 남의 팬덤 b 를 보내도 서버는 30 ÷ 10 = 3 회 · 대표 팬덤 a 로 적는다.
// 가입하지 않은 행성 b 에는 Zap 할 수 없다 — 남의 행성 성장을 올리지 못한다(리뷰 M1).
await expect(run({ popType: 'activity', amount: 30, fandomId: b,
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b0' }))
.rejects.toMatchObject({ details: { code: 'E-PERM-01' } });
// b 에 가입한 뒤에는 **Zap 한 행성 b** 에 성장 · FPI · 기여가 귀속된다(상세정의 5장 · 10/3 #10).
// 대표 팬덤은 a 그대로다. 클라이언트 count 1e9 는 무시하고 30 ÷ 10 = 3 회로 적는다.
await db.doc(`users/${uid}`).update({ fandomIds: [a, b] });
await run({ popType: 'activity', amount: 30, count: 1e9, fandomId: b,
reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:b1' });
const events = await db.collection(`fandoms/${a}/fpiEvents`).get();
const events = await db.collection(`fandoms/${b}/fpiEvents`).get();
expect(events.docs.map((e) => [e.get('axis'), e.get('units')])).toEqual([['contribution', 3]]);
expect((await db.collection(`fandoms/${b}/fpiEvents`).get()).size).toBe(0);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(30);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBeUndefined();
expect((await db.collection(`fandoms/${a}/fpiEvents`).get()).size).toBe(0);
expect((await db.doc(`fandoms/${b}`).get()).get('growthPop')).toBe(30);
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBeUndefined();
expect((await db.doc(`fandoms/${b}/members/${uid}`).get()).get('contributedPop')).toBe(30);
// 대상이 없는 구 클라이언트는 대표 팬덤 a 로 간다.
await run({ popType: 'activity', amount: 10, reasonCode: 'ZAP_CONTRIBUTION', idempotencyKey: 'zap:a1' });
expect((await db.doc(`fandoms/${a}`).get()).get('growthPop')).toBe(10);
expect((await db.doc(`users/${uid}`).get()).get('userExp')).toBeUndefined();
await db.recursiveDelete(db.collection(`fandoms/${a}/fpiEvents`));
await db.recursiveDelete(db.collection(`fandoms/${b}/fpiEvents`));
});
@@ -11,7 +11,7 @@ import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/live_stream.dart';
import '../home/home_screen.dart' show formatPop;
import '../quest/quest_evidence.dart' show deviceShakes;
import '../quest/quest_evidence.dart' show ShakeProbe, deviceShakes;
import 'eclipse_data.dart';
import '../../core/i18n/app_locale.dart';
@@ -34,10 +34,14 @@ class EclipseDefendScreen extends StatefulWidget {
super.key,
required this.repository,
required this.fandomId,
this.shakes = deviceShakes,
});
final EclipseRepository repository;
/// 흔들기 입력. 테스트는 가짜 스트림을 넣는다 — 방어 수단은 **Shake 하나**다(FX §5).
final ShakeProbe shakes;
/// 어느 팬덤의 이클립스인가. 화면은 구독으로 현재 상태를 따라간다.
final String fandomId;
@@ -67,6 +71,9 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
Timer? _burst;
bool _sending = false;
/// 가속도 센서가 없는 단말. 탭 대체 참여는 두지 않는다(FX §5 「수동 방어 Shake only」).
bool _noSensor = false;
({String fandomId, String eclipseId, String seq, int shakes})? _batch;
/// 묶어 보내는 시간창. 흔들 때마다 부르면 호출이 초당 여러 번 나간다.
@@ -77,11 +84,12 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
super.initState();
WidgetsBinding.instance.addObserver(this);
// 흔들기 판정 규칙(문턱 · 표본 주기)은 Music Shake 와 같다 — [ShakeDetector].
_accel = deviceShakes().listen(
_accel = widget.shakes().listen(
(_) => _shake(),
onError: (_) {
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 화면은 그대로 두고
// 아래의 「직접 누르기」로 참여할 수 있게 한다.
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 방어는 Shake only(FX §5)라
// 대체 입력을 두지 않고 지원하지 않는다고만 알린다.
if (mounted) setState(() => _noSensor = true);
},
);
}
@@ -396,7 +404,6 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
final remaining = window.inMilliseconds <= 0
? 0.0
: (left.inMilliseconds / window.inMilliseconds).clamp(0.0, 1.0);
final canShake = state.canShake(now);
return Column(
children: [
const _CloseBar(),
@@ -467,26 +474,8 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
// 소모가 없다는 것을 화면이 직접 말한다 — 「POP 이 빠지나?」를 묻게 두지 않는다(설계서 v1.1 §9-#5).
_Note(context.l10n.eclipseNoPopNote('${state.qualifyShakes}')),
if (_batch != null) _retryButton(),
// 센서가 없는 단말에서도 참여할 수 있게 둔다. 판정은 서버가 하므로 이 버튼이
// 흔들기보다 유리하지 않다(상한·속도 제한이 같다). 사본에 없는 보조 입력이라
// 채움 없는 낮은 강조로 둔다.
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s2,
AppSpace.s4,
AppSpace.s4,
),
child: FilledButton(
onPressed: canShake && (_batch == null || _sending) ? _shake : null,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicSurfaceContainer,
foregroundColor: AppColors.onCosmicVariant,
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
),
child: Text(context.l10n.eclipseTapToJoin),
),
),
if (_noSensor) _Note(context.l10n.eclipseNoSensor),
const SizedBox(height: AppSpace.s4),
],
);
}
+6 -6
View File
@@ -179,7 +179,7 @@
"eclipseEnded": "The eclipse has already ended or was successfully defended.",
"eclipseTooFast": "You're participating too fast. Please try again shortly.",
"eclipseNone": "No eclipse in progress",
"eclipseSuccessTitle": "You stopped\nthe meteor impact!",
"eclipseSuccessTitle": "You stopped\nthe eclipse!",
"eclipseQualified": "Participation confirmed",
"eclipseFailTitle": "The planet defense\nfailed this time",
"eclipseFailBody": "This round's defense goal wasn't reached.",
@@ -187,13 +187,13 @@
"eclipseCancelBody": "Cancelled due to a tally error. No FPI was deducted.",
"eclipsePendingTitle": "Checking defense result",
"eclipsePendingBody": "Participation time is over. Waiting for the server's decision.",
"eclipseShakeTimed": "s — shake your phone\nto stop the meteor impact!",
"eclipseShake": "Shake your phone\nto stop the meteor impact!",
"eclipseShakeTimed": "s — shake your phone\nto stop the eclipse!",
"eclipseShake": "Shake your phone\nto stop the eclipse!",
"eclipseMyCount": "My shakes: {count}",
"eclipsePendingSuffix": " · +{count} pending",
"eclipseSendingSuffix": " · Sending",
"eclipseNoPopNote": "No POP is spent. Make at least {count} valid shakes to count as a participant.",
"eclipseTapToJoin": "Can't shake? Tap to join",
"eclipseNoSensor": "This device doesn't support shake defense",
"eclipseTimeLeft": "Eclipse time left:",
"eclipseSeconds": " {seconds}s",
"eclipseBannerDefended": "Defended — no FPI loss for {name}",
@@ -800,11 +800,11 @@
"homeGrowTogether": "Grow together",
"homeArtistNews": "Artist News",
"growthConditionsTitle": "Growth conditions",
"eclipseButtonSoon": "Eclipse in {time}!\nTap the planet to stop it!",
"eclipseButtonSoon": "Eclipse in {time}!\nShake your phone to stop it!",
"eclipseButtonActive": "The eclipse has started!\nShake your phone within {seconds}s to stop it!",
"eclipseSec": "{seconds}s",
"eclipseMinSec": "{minutes}m {seconds}s",
"eclipseOverlayTitle": "Meteor impact\nis starting!",
"eclipseOverlayTitle": "An eclipse is\nstarting on the planet!",
"eclipseLiveOccurred": "An eclipse has hit planet {name}!",
"eclipseMyShakes": "My valid shakes {count} / {total}",
"questGoalBpm": "Target BPM",
+6 -6
View File
@@ -179,7 +179,7 @@
"eclipseEnded": "El eclipse ya terminó o se defendió con éxito.",
"eclipseTooFast": "Estás participando demasiado rápido. Inténtalo en un momento.",
"eclipseNone": "No hay ningún eclipse en curso",
"eclipseSuccessTitle": "¡Detuviste\nel impacto del meteorito!",
"eclipseSuccessTitle": "¡Detuviste\nel eclipse!",
"eclipseQualified": "Participación confirmada",
"eclipseFailTitle": "Lamentablemente,\nla defensa del planeta falló",
"eclipseFailBody": "No se alcanzó el objetivo de defensa de esta ronda.",
@@ -187,13 +187,13 @@
"eclipseCancelBody": "Se canceló por un error de recuento. No se descontó FPI.",
"eclipsePendingTitle": "Comprobando resultado de la defensa",
"eclipsePendingBody": "El tiempo de participación terminó. Esperando la decisión del servidor.",
"eclipseShakeTimed": "s: ¡agita tu móvil\npara detener el meteorito!",
"eclipseShake": "¡Agita tu móvil\npara detener el meteorito!",
"eclipseShakeTimed": "s: ¡agita tu móvil\npara detener el eclipse!",
"eclipseShake": "¡Agita tu móvil\npara detener el eclipse!",
"eclipseMyCount": "Mis participaciones: {count}",
"eclipsePendingSuffix": " · +{count} pendientes",
"eclipseSendingSuffix": " · Enviando",
"eclipseNoPopNote": "No se gasta POP. Haz al menos {count} sacudidas válidas para contar como participante.",
"eclipseTapToJoin": "¿No puedes agitar? Toca para participar",
"eclipseNoSensor": "Este dispositivo no admite la defensa con agitación",
"eclipseTimeLeft": "Tiempo restante del eclipse:",
"eclipseSeconds": " {seconds} s",
"eclipseBannerDefended": "Defensa exitosa: {name} sin pérdida de FPI",
@@ -800,11 +800,11 @@
"homeGrowTogether": "Crecer juntos",
"homeArtistNews": "Noticias del artista",
"growthConditionsTitle": "Condiciones de crecimiento",
"eclipseButtonSoon": "¡Eclipse en {time}!\n¡Toca el planeta para detenerlo!",
"eclipseButtonSoon": "¡Eclipse en {time}!\n¡Agita tu móvil para detenerlo!",
"eclipseButtonActive": "¡El eclipse ha comenzado!\n¡Agita el teléfono en {seconds} s para detenerlo!",
"eclipseSec": "{seconds} s",
"eclipseMinSec": "{minutes} min {seconds} s",
"eclipseOverlayTitle": "¡El impacto de meteoritos\nva a comenzar!",
"eclipseOverlayTitle": "¡Un eclipse\nva a comenzar en el planeta!",
"eclipseLiveOccurred": "¡Un eclipse ha llegado al planeta {name}!",
"eclipseMyShakes": "Mis sacudidas válidas {count} / {total}",
"questGoalBpm": "BPM objetivo",
+6 -6
View File
@@ -179,7 +179,7 @@
"eclipseEnded": "イクリプスはすでに終了したか、防衛に成功しました。",
"eclipseTooFast": "参加が速すぎます。しばらくしてから再度お試しください。",
"eclipseNone": "進行中のイクリプスはありません",
"eclipseSuccessTitle": "隕石の衝突を\n見事に防ぎました!",
"eclipseSuccessTitle": "イクリプスを\n見事に防ぎました!",
"eclipseQualified": "参加認定完了",
"eclipseFailTitle": "惜しくも惑星の防衛に\n失敗しました",
"eclipseFailBody": "今回の防衛目標を達成できませんでした。",
@@ -187,13 +187,13 @@
"eclipseCancelBody": "集計エラーのため中止されました。FPIは差し引かれていません。",
"eclipsePendingTitle": "防衛結果を確認中",
"eclipsePendingBody": "参加時間が終了しました。サーバーの判定を待っています。",
"eclipseShakeTimed": "秒間スマホを振って\n隕石の衝突を防ごう!",
"eclipseShake": "スマホを振って\n隕石の衝突を防ごう!",
"eclipseShakeTimed": "秒間スマホを振って\nイクリプスを防ごう!",
"eclipseShake": "スマホを振って\nイクリプスを防ごう!",
"eclipseMyCount": "自分の参加 {count}回",
"eclipsePendingSuffix": " · +{count} 送信待ち",
"eclipseSendingSuffix": " · 送信中",
"eclipseNoPopNote": "POPは消費されません。有効Shakeを{count}回以上振って防衛に参加すると参加者として認定されます。",
"eclipseTapToJoin": "振るのが難しければタップで参加",
"eclipseNoSensor": "この端末は振る防御に対応していません",
"eclipseTimeLeft": "イクリプス残り時間:",
"eclipseSeconds": " {seconds}秒",
"eclipseBannerDefended": "防衛成功 — {name} FPI減少なし",
@@ -800,11 +800,11 @@
"homeGrowTogether": "一緒に成長する",
"homeArtistNews": "アーティストニュース",
"growthConditionsTitle": "成長条件",
"eclipseButtonSoon": "{time}後にイクリプスが発生します!\n惑星をタップしてイクリプスを防ぎましょう!",
"eclipseButtonSoon": "{time}後にイクリプスが発生します!\nスマホを振ってイクリプスを防ぎましょう!",
"eclipseButtonActive": "イクリプスが発生しました!\n{seconds}秒以内にスマホを振って防ぎましょう!",
"eclipseSec": "{seconds}秒",
"eclipseMinSec": "{minutes}分{seconds}秒",
"eclipseOverlayTitle": "惑星への隕石衝突が\n始まります!",
"eclipseOverlayTitle": "惑星にイクリプスが\n始まります!",
"eclipseLiveOccurred": "{name}惑星にイクリプスが発生しました!",
"eclipseMyShakes": "自分の有効Shake {count} / {total}回",
"questGoalBpm": "目標BPM",
+6 -6
View File
@@ -328,7 +328,7 @@
"eclipseEnded": "이클립스가 이미 끝났거나 방어에 성공했습니다.",
"eclipseTooFast": "너무 빠르게 참여했습니다. 잠시 뒤 다시 시도해 주십시오.",
"eclipseNone": "진행 중인 이클립스가 없습니다",
"eclipseSuccessTitle": "운석 충돌을\n성공적으로 막았어요!",
"eclipseSuccessTitle": "이클립스를\n성공적으로 막았어요!",
"eclipseQualified": "참여 인정 완료",
"eclipseFailTitle": "아쉽게 행성 방어를\n실패했어요",
"eclipseFailBody": "이번 회차의 방어 목표를 달성하지 못했습니다.",
@@ -336,8 +336,8 @@
"eclipseCancelBody": "집계 오류로 취소되었습니다. FPI는 차감되지 않았습니다.",
"eclipsePendingTitle": "방어 결과 확인 중",
"eclipsePendingBody": "참여 시간이 끝났습니다. 서버 판정을 기다리고 있습니다.",
"eclipseShakeTimed": "초간 휴대폰을 흔들어\n행성 운석 충돌을 막으세요!",
"eclipseShake": "휴대폰을 흔들어\n행성 운석 충돌을 막으세요!",
"eclipseShakeTimed": "초간 휴대폰을 흔들어\n이클립스를 막으세요!",
"eclipseShake": "휴대폰을 흔들어\n이클립스를 막으세요!",
"eclipseMyCount": "내 참여 {count}회",
"@eclipseMyCount": {
"placeholders": {
@@ -363,7 +363,7 @@
}
}
},
"eclipseTapToJoin": "흔들기 어려우면 눌러서 참여",
"eclipseNoSensor": "이 기기는 흔들기 방어를 지원하지 않아요",
"eclipseTimeLeft": "이클립스 발생 남은 시간 :",
"eclipseSeconds": " {seconds}초",
"@eclipseSeconds": {
@@ -1654,7 +1654,7 @@
"homeGrowTogether": "함께 성장하기",
"homeArtistNews": "아티스트 소식",
"growthConditionsTitle": "성장 조건",
"eclipseButtonSoon": "{time} 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!",
"eclipseButtonSoon": "{time} 후 이클립스가 발생해요!\n휴대폰을 흔들어 이클립스를 막으세요!",
"@eclipseButtonSoon": {
"placeholders": {
"time": {
@@ -1689,7 +1689,7 @@
}
}
},
"eclipseOverlayTitle": "행성 운석 충돌이\n시작됩니다!",
"eclipseOverlayTitle": "행성에 이클립스가\n시작됩니다!",
"eclipseLiveOccurred": "{name} 행성에 이클립스가 발생했어요!",
"@eclipseLiveOccurred": {
"placeholders": {
+8 -8
View File
@@ -1181,7 +1181,7 @@ abstract class AppLocalizations {
/// No description provided for @eclipseSuccessTitle.
///
/// In ko, this message translates to:
/// **'운석 충돌을\n성공적으로 막았어요!'**
/// **'이클립스를\n성공적으로 막았어요!'**
String get eclipseSuccessTitle;
/// No description provided for @eclipseQualified.
@@ -1229,13 +1229,13 @@ abstract class AppLocalizations {
/// No description provided for @eclipseShakeTimed.
///
/// In ko, this message translates to:
/// **'초간 휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'**
/// **'초간 휴대폰을 흔들어\n이클립스를 막으세요!'**
String get eclipseShakeTimed;
/// No description provided for @eclipseShake.
///
/// In ko, this message translates to:
/// **'휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'**
/// **'휴대폰을 흔들어\n이클립스를 막으세요!'**
String get eclipseShake;
/// No description provided for @eclipseMyCount.
@@ -1262,11 +1262,11 @@ abstract class AppLocalizations {
/// **'POP은 소모되지 않습니다. 유효 Shake {count}회 이상 흔들어 방어에 참여하면 참여자로 인정됩니다.'**
String eclipseNoPopNote(String count);
/// No description provided for @eclipseTapToJoin.
/// No description provided for @eclipseNoSensor.
///
/// In ko, this message translates to:
/// **'흔들기 어려우면 눌러서 참여'**
String get eclipseTapToJoin;
/// **'이 기기는 흔들기 방어를 지원하지 않아요'**
String get eclipseNoSensor;
/// No description provided for @eclipseTimeLeft.
///
@@ -4919,7 +4919,7 @@ abstract class AppLocalizations {
/// No description provided for @eclipseButtonSoon.
///
/// In ko, this message translates to:
/// **'{time} 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!'**
/// **'{time} 후 이클립스가 발생해요!\n휴대폰을 흔들어 이클립스를 막으세요!'**
String eclipseButtonSoon(String time);
/// No description provided for @eclipseButtonActive.
@@ -4943,7 +4943,7 @@ abstract class AppLocalizations {
/// No description provided for @eclipseOverlayTitle.
///
/// In ko, this message translates to:
/// **'행성 운석 충돌이\n시작됩니다!'**
/// **'행성에 이클립스가\n시작됩니다!'**
String get eclipseOverlayTitle;
/// No description provided for @eclipseLiveOccurred.
+6 -7
View File
@@ -642,7 +642,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get eclipseNone => 'No eclipse in progress';
@override
String get eclipseSuccessTitle => 'You stopped\nthe meteor impact!';
String get eclipseSuccessTitle => 'You stopped\nthe eclipse!';
@override
String get eclipseQualified => 'Participation confirmed';
@@ -668,11 +668,10 @@ class AppLocalizationsEn extends AppLocalizations {
'Participation time is over. Waiting for the server\'s decision.';
@override
String get eclipseShakeTimed =>
's — shake your phone\nto stop the meteor impact!';
String get eclipseShakeTimed => 's — shake your phone\nto stop the eclipse!';
@override
String get eclipseShake => 'Shake your phone\nto stop the meteor impact!';
String get eclipseShake => 'Shake your phone\nto stop the eclipse!';
@override
String eclipseMyCount(String count) {
@@ -693,7 +692,7 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get eclipseTapToJoin => 'Can\'t shake? Tap to join';
String get eclipseNoSensor => 'This device doesn\'t support shake defense';
@override
String get eclipseTimeLeft => 'Eclipse time left:';
@@ -2791,7 +2790,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String eclipseButtonSoon(String time) {
return 'Eclipse in $time!\nTap the planet to stop it!';
return 'Eclipse in $time!\nShake your phone to stop it!';
}
@override
@@ -2810,7 +2809,7 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get eclipseOverlayTitle => 'Meteor impact\nis starting!';
String get eclipseOverlayTitle => 'An eclipse is\nstarting on the planet!';
@override
String eclipseLiveOccurred(String name) {
+7 -6
View File
@@ -648,7 +648,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get eclipseNone => 'No hay ningún eclipse en curso';
@override
String get eclipseSuccessTitle => '¡Detuviste\nel impacto del meteorito!';
String get eclipseSuccessTitle => '¡Detuviste\nel eclipse!';
@override
String get eclipseQualified => 'Participación confirmada';
@@ -677,10 +677,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get eclipseShakeTimed =>
's: ¡agita tu móvil\npara detener el meteorito!';
's: ¡agita tu móvil\npara detener el eclipse!';
@override
String get eclipseShake => '¡Agita tu móvil\npara detener el meteorito!';
String get eclipseShake => '¡Agita tu móvil\npara detener el eclipse!';
@override
String eclipseMyCount(String count) {
@@ -701,7 +701,8 @@ class AppLocalizationsEs extends AppLocalizations {
}
@override
String get eclipseTapToJoin => '¿No puedes agitar? Toca para participar';
String get eclipseNoSensor =>
'Este dispositivo no admite la defensa con agitación';
@override
String get eclipseTimeLeft => 'Tiempo restante del eclipse:';
@@ -2803,7 +2804,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String eclipseButtonSoon(String time) {
return '¡Eclipse en $time!\n¡Toca el planeta para detenerlo!';
return '¡Eclipse en $time!\n¡Agita tu móvil para detenerlo!';
}
@override
@@ -2822,7 +2823,7 @@ class AppLocalizationsEs extends AppLocalizations {
}
@override
String get eclipseOverlayTitle => '¡El impacto de meteoritos\nva a comenzar!';
String get eclipseOverlayTitle => '¡Un eclipse\nva a comenzar en el planeta!';
@override
String eclipseLiveOccurred(String name) {
+6 -6
View File
@@ -614,7 +614,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get eclipseNone => '進行中のイクリプスはありません';
@override
String get eclipseSuccessTitle => '隕石の衝突を\n見事に防ぎました!';
String get eclipseSuccessTitle => 'イクリプスを\n見事に防ぎました!';
@override
String get eclipseQualified => '参加認定完了';
@@ -638,10 +638,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get eclipsePendingBody => '参加時間が終了しました。サーバーの判定を待っています。';
@override
String get eclipseShakeTimed => '秒間スマホを振って\n隕石の衝突を防ごう!';
String get eclipseShakeTimed => '秒間スマホを振って\nイクリプスを防ごう!';
@override
String get eclipseShake => 'スマホを振って\n隕石の衝突を防ごう!';
String get eclipseShake => 'スマホを振って\nイクリプスを防ごう!';
@override
String eclipseMyCount(String count) {
@@ -662,7 +662,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get eclipseTapToJoin => '振るのが難しければタップで参加';
String get eclipseNoSensor => 'この端末は振る防御に対応していません';
@override
String get eclipseTimeLeft => 'イクリプス残り時間:';
@@ -2698,7 +2698,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String eclipseButtonSoon(String time) {
return '$time後にイクリプスが発生します!\n惑星をタップしてイクリプスを防ぎましょう!';
return '$time後にイクリプスが発生します!\nスマホを振ってイクリプスを防ぎましょう!';
}
@override
@@ -2717,7 +2717,7 @@ class AppLocalizationsJa extends AppLocalizations {
}
@override
String get eclipseOverlayTitle => '惑星への隕石衝突が\n始まります!';
String get eclipseOverlayTitle => '惑星にイクリプスが\n始まります!';
@override
String eclipseLiveOccurred(String name) {
+6 -6
View File
@@ -614,7 +614,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get eclipseNone => '진행 중인 이클립스가 없습니다';
@override
String get eclipseSuccessTitle => '운석 충돌을\n성공적으로 막았어요!';
String get eclipseSuccessTitle => '이클립스를\n성공적으로 막았어요!';
@override
String get eclipseQualified => '참여 인정 완료';
@@ -638,10 +638,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get eclipsePendingBody => '참여 시간이 끝났습니다. 서버 판정을 기다리고 있습니다.';
@override
String get eclipseShakeTimed => '초간 휴대폰을 흔들어\n행성 운석 충돌을 막으세요!';
String get eclipseShakeTimed => '초간 휴대폰을 흔들어\n이클립스를 막으세요!';
@override
String get eclipseShake => '휴대폰을 흔들어\n행성 운석 충돌을 막으세요!';
String get eclipseShake => '휴대폰을 흔들어\n이클립스를 막으세요!';
@override
String eclipseMyCount(String count) {
@@ -662,7 +662,7 @@ class AppLocalizationsKo extends AppLocalizations {
}
@override
String get eclipseTapToJoin => '흔들기 어려우면 눌러서 참여';
String get eclipseNoSensor => '이 기기는 흔들기 방어를 지원하지 않아요';
@override
String get eclipseTimeLeft => '이클립스 발생 남은 시간 :';
@@ -2700,7 +2700,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String eclipseButtonSoon(String time) {
return '$time 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!';
return '$time 후 이클립스가 발생해요!\n휴대폰을 흔들어 이클립스를 막으세요!';
}
@override
@@ -2719,7 +2719,7 @@ class AppLocalizationsKo extends AppLocalizations {
}
@override
String get eclipseOverlayTitle => '행성 운석 충돌이\n시작됩니다!';
String get eclipseOverlayTitle => '행성에 이클립스가\n시작됩니다!';
@override
String eclipseLiveOccurred(String name) {
+6 -6
View File
@@ -614,7 +614,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get eclipseNone => '当前没有进行中的日蚀';
@override
String get eclipseSuccessTitle => '成功阻止了\n陨石撞击!';
String get eclipseSuccessTitle => '成功阻止了\n日食!';
@override
String get eclipseQualified => '参与已认定';
@@ -638,10 +638,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get eclipsePendingBody => '参与时间已结束,正在等待服务器判定。';
@override
String get eclipseShakeTimed => '秒内摇动手机\n阻止陨石撞击星球!';
String get eclipseShakeTimed => '秒内摇动手机\n阻止日食!';
@override
String get eclipseShake => '摇动手机\n阻止陨石撞击星球!';
String get eclipseShake => '摇动手机\n阻止日食!';
@override
String eclipseMyCount(String count) {
@@ -662,7 +662,7 @@ class AppLocalizationsZh extends AppLocalizations {
}
@override
String get eclipseTapToJoin => '不方便摇动?点击参与';
String get eclipseNoSensor => '此设备不支持摇动防御';
@override
String get eclipseTimeLeft => '日蚀剩余时间:';
@@ -2695,7 +2695,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String eclipseButtonSoon(String time) {
return '$time后将发生日食!\n点击行星来阻止日食!';
return '$time后将发生日食!\n摇动手机来阻止日食!';
}
@override
@@ -2714,7 +2714,7 @@ class AppLocalizationsZh extends AppLocalizations {
}
@override
String get eclipseOverlayTitle => '陨石即将\n撞击行星!';
String get eclipseOverlayTitle => '日食即将\n降临行星!';
@override
String eclipseLiveOccurred(String name) {
+6 -6
View File
@@ -179,7 +179,7 @@
"eclipseEnded": "日蚀已结束或已防御成功。",
"eclipseTooFast": "参与过快,请稍后再试。",
"eclipseNone": "当前没有进行中的日蚀",
"eclipseSuccessTitle": "成功阻止了\n陨石撞击!",
"eclipseSuccessTitle": "成功阻止了\n日食!",
"eclipseQualified": "参与已认定",
"eclipseFailTitle": "很遗憾,\n星球防御失败了",
"eclipseFailBody": "未达成本轮防御目标。",
@@ -187,13 +187,13 @@
"eclipseCancelBody": "因统计错误已取消,FPI 未扣除。",
"eclipsePendingTitle": "正在确认防御结果",
"eclipsePendingBody": "参与时间已结束,正在等待服务器判定。",
"eclipseShakeTimed": "秒内摇动手机\n阻止陨石撞击星球!",
"eclipseShake": "摇动手机\n阻止陨石撞击星球!",
"eclipseShakeTimed": "秒内摇动手机\n阻止日食!",
"eclipseShake": "摇动手机\n阻止日食!",
"eclipseMyCount": "我的参与 {count} 次",
"eclipsePendingSuffix": " · +{count} 待发送",
"eclipseSendingSuffix": " · 发送中",
"eclipseNoPopNote": "不会消耗 POP。有效摇动 {count} 次以上即可认定为参与者。",
"eclipseTapToJoin": "不方便摇动?点击参与",
"eclipseNoSensor": "此设备不支持摇动防御",
"eclipseTimeLeft": "日蚀剩余时间:",
"eclipseSeconds": " {seconds} 秒",
"eclipseBannerDefended": "防御成功 — {name} FPI 未减少",
@@ -800,11 +800,11 @@
"homeGrowTogether": "一起成长",
"homeArtistNews": "艺人动态",
"growthConditionsTitle": "成长条件",
"eclipseButtonSoon": "{time}后将发生日食!\n点击行星来阻止日食!",
"eclipseButtonSoon": "{time}后将发生日食!\n摇动手机来阻止日食!",
"eclipseButtonActive": "日食已发生!\n请在{seconds}秒内摇动手机来阻止!",
"eclipseSec": "{seconds}秒",
"eclipseMinSec": "{minutes}分{seconds}秒",
"eclipseOverlayTitle": "陨石即将\n撞击行星!",
"eclipseOverlayTitle": "日食即将\n降临行星!",
"eclipseLiveOccurred": "{name}行星发生了日食!",
"eclipseMyShakes": "我的有效摇动 {count} / {total}次",
"questGoalBpm": "目标 BPM",
+3 -3
View File
@@ -604,6 +604,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String eclipseNoPopNote(String count) => _fill('eclipseNoPopNote', {'count': count}) ?? _base.eclipseNoPopNote(count);
@override
String get eclipseNoSensor => _remote['eclipseNoSensor'] ?? _base.eclipseNoSensor;
@override
String get eclipseNone => _remote['eclipseNone'] ?? _base.eclipseNone;
@@ -646,9 +649,6 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get eclipseSuccessTitle => _remote['eclipseSuccessTitle'] ?? _base.eclipseSuccessTitle;
@override
String get eclipseTapToJoin => _remote['eclipseTapToJoin'] ?? _base.eclipseTapToJoin;
@override
String get eclipseTimeLeft => _remote['eclipseTimeLeft'] ?? _base.eclipseTimeLeft;
+14 -9
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sensors_plus/sensors_plus.dart' show SensorInterval;
@@ -24,6 +26,9 @@ Future<void> _pump(WidgetTester tester, Widget home) async {
}
}
/// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나.
final _shakes = StreamController<DateTime>.broadcast();
void main() {
group('흔들기 판정 (iOS 16 · Galaxy 63 실기기 편차)', () {
test('표본 주기를 명시한다 — 기본 200ms(iOS 5Hz)를 쓰지 않는다', () {
@@ -147,10 +152,10 @@ void main() {
),
);
addTearDown(repo.dispose);
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
expect(find.text('휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'), findsOneWidget);
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
expect(find.text('휴대폰을 흔들어\n이클립스를 막으세요!'), findsOneWidget);
expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget);
expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing);
expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsNothing);
});
testWidgets('발생 3초 전부터 오버레이 카운트다운', (tester) async {
@@ -162,8 +167,8 @@ void main() {
),
);
addTearDown(repo.dispose);
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
expect(find.text('행성 운석 충돌이\n시작됩니다!'), findsOneWidget);
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
expect(find.text('행성에 이클립스가\n시작됩니다!'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
});
@@ -177,12 +182,12 @@ void main() {
),
);
addTearDown(repo.dispose);
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
expect(find.textContaining('초간 휴대폰을 흔들어'), findsOneWidget);
expect(find.text('내 유효 Shake 0 / 60회'), findsOneWidget);
expect(find.text('유효 참여 3 / 10명'), findsOneWidget);
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
_shakes.add(DateTime.now());
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
@@ -196,8 +201,8 @@ void main() {
state: eclipseFixture(scheduled: true, startAt: startAt, endsAt: endsAt),
);
addTearDown(repo.dispose);
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts'));
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
_shakes.add(DateTime.now());
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
+27 -11
View File
@@ -56,7 +56,7 @@ Future<void> _pumpGalaxy(
}
}
/// 흔들어 방어 화면. 센서가 없는 테스트 환경이라 눌러서 참여하는 경로로 본다.
/// 흔들어 방어 화면. 센서 대신 가짜 흔들기 스트림을 넣는다 — 방어는 Shake only(FX §5).
Future<void> _pumpDefend(WidgetTester tester, EclipseRepository repo) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
@@ -64,7 +64,7 @@ Future<void> _pumpDefend(WidgetTester tester, EclipseRepository repo) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: EclipseDefendScreen(repository: repo, fandomId: 'bts'),
home: EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream),
),
);
await tester.pump();
@@ -77,6 +77,9 @@ Future<void> _settle(WidgetTester tester) async {
}
}
/// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나.
final _shakes = StreamController<DateTime>.broadcast();
void main() {
group('이클립스 경보 배너 (별첨 1 ① 「인앱 워닝 배너 병행」)', () {
testWidgets('활성 이클립스가 없으면 배너가 뜨지 않는다', (tester) async {
@@ -367,14 +370,13 @@ void main() {
expect(find.textContaining('흔들어 방어'), findsWidgets);
});
testWidgets('누르면 흔들기가 묶여 서버로 나간다', (tester) async {
testWidgets('흔들기가 묶여 서버로 나간다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
// 센서가 없는 단말용 버튼. 판정은 서버가 하므로 흔들기와 같은 경로다.
for (var i = 0; i < 4; i++) {
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
_shakes.add(DateTime.now());
await tester.pump(const Duration(milliseconds: 80));
}
await _settle(tester);
@@ -392,7 +394,7 @@ void main() {
// 다섯 번 참여했는데 서버가 둘만 받아들인 상황.
for (var i = 0; i < 5; i++) {
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
_shakes.add(DateTime.now());
await tester.pump(const Duration(milliseconds: 80));
}
await _settle(tester);
@@ -406,15 +408,15 @@ void main() {
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing);
expect(find.text('운석 충돌을\n성공적으로 막았어요!'), findsOneWidget);
expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsNothing);
expect(find.text('이클립스를\n성공적으로 막았어요!'), findsOneWidget);
});
testWidgets('마감은 판정 대기이며 서버 실패·취소만 해당 결과로 표시한다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
_shakes.add(DateTime.now());
repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1))));
await tester.pump();
await _settle(tester);
@@ -437,7 +439,7 @@ void main() {
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
for (var i = 0; i < 12; i++) {
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
_shakes.add(DateTime.now());
await tester.pump(const Duration(milliseconds: 100));
}
expect(repo.calls, isNotEmpty);
@@ -451,7 +453,7 @@ void main() {
..failWith = const BackendException('unavailable', 'private backend error');
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
_shakes.add(DateTime.now());
await _settle(tester);
expect(repo.calls, hasLength(1));
final original = repo.calls.single;
@@ -465,6 +467,20 @@ void main() {
expect(find.text('같은 참여 다시 확인'), findsNothing);
});
testWidgets('센서가 없는 단말은 탭 대체 없이 미지원 안내만 (FX §5 Shake only)', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await tester.pumpWidget(MaterialApp(
theme: buildAppTheme(),
home: EclipseDefendScreen(repository: repo, fandomId: 'bts',
shakes: () => Stream<DateTime>.error(StateError('no sensor'))),
));
await _settle(tester);
expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsOneWidget);
expect(find.byType(FilledButton), findsNothing);
expect(repo.calls, isEmpty);
});
testWidgets('진행 중인 이클립스가 없으면 그 사실을 알린다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);