diff --git a/app/firebase/functions/src/fandom/fandom.ts b/app/firebase/functions/src/fandom/fandom.ts index 96c75bc..9aec245 100644 --- a/app/firebase/functions/src/fandom/fandom.ts +++ b/app/firebase/functions/src/fandom/fandom.ts @@ -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, diff --git a/app/firebase/functions/src/ledger/fpi.ts b/app/firebase/functions/src/ledger/fpi.ts index ce92ec2..ab6a2f3 100644 --- a/app/firebase/functions/src/ledger/fpi.ts +++ b/app/firebase/functions/src/ledger/fpi.ts @@ -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; diff --git a/app/firebase/functions/src/ledger/ledger.ts b/app/firebase/functions/src/ledger/ledger.ts index 956b241..7ab6259 100644 --- a/app/firebase/functions/src/ledger/ledger.ts +++ b/app/firebase/functions/src/ledger/ledger.ts @@ -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; diff --git a/app/firebase/functions/src/ledger/pop.ts b/app/firebase/functions/src/ledger/pop.ts index 8766ade..32cbc02 100644 --- a/app/firebase/functions/src/ledger/pop.ts +++ b/app/firebase/functions/src/ledger/pop.ts @@ -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, }); diff --git a/app/firebase/functions/test/eclipse.test.ts b/app/firebase/functions/test/eclipse.test.ts index 872af3e..f3719f7 100644 --- a/app/firebase/functions/test/eclipse.test.ts +++ b/app/firebase/functions/test/eclipse.test.ts @@ -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({ diff --git a/app/firebase/functions/test/growth.test.ts b/app/firebase/functions/test/growth.test.ts index 1de1e48..b0f3f00 100644 --- a/app/firebase/functions/test/growth.test.ts +++ b/app/firebase/functions/test/growth.test.ts @@ -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) => 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`)); }); diff --git a/app/lib/features/eclipse/eclipse_defend_screen.dart b/app/lib/features/eclipse/eclipse_defend_screen.dart index a796cd8..0b684b9 100644 --- a/app/lib/features/eclipse/eclipse_defend_screen.dart +++ b/app/lib/features/eclipse/eclipse_defend_screen.dart @@ -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 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 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 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 // 소모가 없다는 것을 화면이 직접 말한다 — 「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), ], ); } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index b59a409..1475344 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -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", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 3c711fa..029ba6d 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -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", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index a1f381b..7c08713 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -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", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index cbb1956..75820fb 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -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": { diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 86a5ce5..d5b00b5 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -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. diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 12e686a..7d661eb 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -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) { diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index a48066d..db683c5 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -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) { diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 9d515cc..6e55940 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -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) { diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 312bd30..5f9a300 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -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) { diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index a704bb8..3307784 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -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) { diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index a29cb40..a733c9d 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -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", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 649ff0e..3595b8b 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -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; diff --git a/app/test/eclipse_figma_flow_test.dart b/app/test/eclipse_figma_flow_test.dart index 8ad4075..bc47256 100644 --- a/app/test/eclipse_figma_flow_test.dart +++ b/app/test/eclipse_figma_flow_test.dart @@ -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 _pump(WidgetTester tester, Widget home) async { } } +/// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나. +final _shakes = StreamController.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)); } diff --git a/app/test/eclipse_live_test.dart b/app/test/eclipse_live_test.dart index b179b3e..611c969 100644 --- a/app/test/eclipse_live_test.dart +++ b/app/test/eclipse_live_test.dart @@ -56,7 +56,7 @@ Future _pumpGalaxy( } } -/// 흔들어 방어 화면. 센서가 없는 테스트 환경이라 눌러서 참여하는 경로로 본다. +/// 흔들어 방어 화면. 센서 대신 가짜 흔들기 스트림을 넣는다 — 방어는 Shake only(FX §5). Future _pumpDefend(WidgetTester tester, EclipseRepository repo) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; @@ -64,7 +64,7 @@ Future _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 _settle(WidgetTester tester) async { } } +/// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나. +final _shakes = StreamController.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('흔들기 어려우면 눌러서 참여')); + for (var i = 0; i < 4; i++) { + _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.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);