Files
fanuzu/app/test/eclipse_figma_flow_test.dart
T

255 lines
11 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sensors_plus/sensors_plus.dart' show SensorInterval;
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
import 'package:fancake/features/eclipse/widgets/eclipse_event_button.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/quest/quest_evidence.dart';
import 'eclipse_stubs.dart';
/// 이클립스 Figma 흐름 — HOME 이벤트 버튼(`516:88124`) → 상세(`373:444865`) →
/// 발생 직전 오버레이(`373:444976`) → 방어(`373:445267`) → 결과, 그리고 흔들기 판정.
Future<void> _pump(WidgetTester tester, Widget home) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: home));
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
/// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나.
final _shakes = StreamController<DateTime>.broadcast();
void main() {
group('흔들기 판정 (iOS 16 · Galaxy 63 실기기 편차)', () {
test('표본 주기를 명시한다 — 기본 200ms(iOS 5Hz)를 쓰지 않는다', () {
expect(ShakeDetector.sampling, SensorInterval.gameInterval);
});
/// 흔들기 [count] 번을 50Hz 로 흉내 낸다. 정점마다 표본 여러 개가 문턱을 넘는다.
int countAt50Hz(int count, {Duration period = const Duration(milliseconds: 300)}) {
final d = ShakeDetector();
var at = DateTime(2026, 10, 5);
var hits = 0;
for (var i = 0; i < count; i++) {
// 정점 3 표본(60ms) 연속 문턱 위 → 나머지는 중력 근처.
for (final m in [20.0, 24.0, 21.0]) {
if (d.add(m, 0, 0, at)) hits++;
at = at.add(const Duration(milliseconds: 20));
}
for (var t = 60; t < period.inMilliseconds; t += 20) {
if (d.add(9.8, 0, 0, at)) hits++;
at = at.add(const Duration(milliseconds: 20));
}
}
return hits;
}
test('정점 하나는 표본이 여럿이어도 한 번이다', () {
expect(countAt50Hz(20), 20);
});
test('문턱 아래로 내려오지 않으면 다시 세지 않는다', () {
final d = ShakeDetector();
final t0 = DateTime(2026, 10, 5);
expect(d.add(25, 0, 0, t0), isTrue);
// 1초 내내 문턱 위 — 같은 동작이다.
for (var i = 1; i <= 50; i++) {
expect(d.add(25, 0, 0, t0.add(Duration(milliseconds: 20 * i))), isFalse);
}
expect(d.add(9.8, 0, 0, t0.add(const Duration(seconds: 2))), isFalse);
expect(d.add(25, 0, 0, t0.add(const Duration(milliseconds: 2020))), isTrue);
});
test('간격보다 촘촘한 정점은 같은 동작의 진동이다', () {
expect(countAt50Hz(10, period: const Duration(milliseconds: 120)), lessThan(10));
});
});
group('HOME 이클립스 이벤트 버튼 (516:88125)', () {
testWidgets('경보(scheduled) 단계부터 잔여 시간을 띄우고 누르면 이클립스 상세로 간다', (tester) async {
final repo = FakeEclipseRepository(
state: eclipseFixture(
scheduled: true,
startAt: DateTime.now().add(const Duration(minutes: 2, seconds: 30)),
endsAt: DateTime.now().add(const Duration(minutes: 3, seconds: 30)),
),
);
addTearDown(repo.dispose);
await _pump(
tester,
Scaffold(body: Center(child: EclipseEventButton(repository: repo, fandomId: 'bts'))),
);
expect(find.textContaining('후 이클립스가 발생해요!'), findsOneWidget);
expect(find.textContaining('2분'), findsOneWidget);
await tester.tap(find.byType(EclipseEventButton));
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
expect(find.byType(EclipseDefendScreen), findsOneWidget);
expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget);
});
testWidgets('발생 뒤에는 남은 방어 초를, 결과가 나면 접는다', (tester) async {
final repo = FakeEclipseRepository(
state: eclipseFixture(
startAt: DateTime.now().subtract(const Duration(seconds: 10)),
endsAt: DateTime.now().add(const Duration(seconds: 50)),
),
);
addTearDown(repo.dispose);
await _pump(
tester,
Scaffold(body: Center(child: EclipseEventButton(repository: repo, fandomId: 'bts'))),
);
expect(find.textContaining('이클립스가 발생했어요!'), findsOneWidget);
repo.push(eclipseFixture(defended: true));
await tester.pump(const Duration(milliseconds: 300));
expect(find.textContaining('이클립스'), findsNothing);
});
testWidgets('HOME 라이브 띠가 이클립스 발생을 알리고 행성 아래 버튼이 뜬다', (tester) async {
final repo = FakeEclipseRepository(
state: eclipseFixture(
startAt: DateTime.now().subtract(const Duration(seconds: 5)),
endsAt: DateTime.now().add(const Duration(seconds: 55)),
),
);
addTearDown(repo.dispose);
await _pump(
tester,
HomeScreen(
repository: const SampleHomeRepository(),
questRepository: const SampleQuestRepository(),
eclipse: repo,
),
);
expect(find.text('BTS 행성에 이클립스가 발생했어요!'), findsOneWidget);
expect(find.byType(EclipseEventButton), findsOneWidget);
// Figma 에 없는 카드형 배너는 HOME 에서 빠졌다.
expect(find.textContaining('이클립스 발동 —'), findsNothing);
});
});
group('이클립스 상세 → 오버레이 → 방어 (373:444865 · 373:444976 · 373:445267)', () {
testWidgets('발생 전은 상세 — 아래에 발생 남은 시간', (tester) async {
final repo = FakeEclipseRepository(
state: eclipseFixture(
scheduled: true,
startAt: DateTime.now().add(const Duration(seconds: 40)),
endsAt: DateTime.now().add(const Duration(seconds: 100)),
),
);
addTearDown(repo.dispose);
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
expect(find.text('휴대폰을 흔들어\n이클립스를 막으세요!'), findsOneWidget);
expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget);
expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsNothing);
});
testWidgets('발생 3초 전부터 오버레이 카운트다운', (tester) async {
final repo = FakeEclipseRepository(
state: eclipseFixture(
scheduled: true,
startAt: DateTime.now().add(const Duration(milliseconds: 2500)),
endsAt: DateTime.now().add(const Duration(seconds: 62)),
),
);
addTearDown(repo.dispose);
await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream));
expect(find.text('행성에 이클립스가\n시작됩니다!'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
});
testWidgets('방어 중 진행 표시 — 내 유효 Shake / 인정 횟수 · 팬덤 참여자 / 목표', (tester) async {
final repo = FakeEclipseRepository(
state: eclipseFixture(
defense: 3,
targetDefense: 10,
startAt: DateTime.now().subtract(const Duration(seconds: 1)),
endsAt: DateTime.now().add(const Duration(seconds: 59)),
),
);
addTearDown(repo.dispose);
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);
_shakes.add(DateTime.now());
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
expect(find.text('내 유효 Shake 1 / 60회'), findsOneWidget);
});
testWidgets('발생 직후 서버가 아직 scheduled 면 모았다가 active 가 되면 보낸다', (tester) async {
final startAt = DateTime.now().subtract(const Duration(seconds: 1));
final endsAt = DateTime.now().add(const Duration(seconds: 59));
final repo = FakeEclipseRepository(
state: eclipseFixture(scheduled: true, startAt: startAt, endsAt: endsAt),
);
addTearDown(repo.dispose);
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));
}
expect(repo.calls, isEmpty);
repo.push(eclipseFixture(startAt: startAt, endsAt: endsAt));
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
expect(repo.calls, hasLength(1));
expect(repo.calls.single.$3, 1);
});
});
group('Music Shake 보상 표시 (원장 B-24)', () {
test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 musicShake.pop 을 기본액으로 적는다', () {
const policy = {'stepsPop': 50, 'musicShake': {'pop': 1}};
// 걸음 보상(stepsPop)을 빌려 쓰지 않는다.
expect(QuestType.musicShake.rewardFrom(const {}, policy), 1);
expect(QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, policy), 30);
expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), isNull);
expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull);
});
});
group('걸음 보상 표시 (원장 B-26)', () {
const policy = {
'stepTiers': [
{'steps': 5000, 'pop': 6},
{'steps': 1000, 'pop': 5},
{'steps': 10000, 'pop': 7},
{'steps': 15000, 'pop': 8},
],
};
test('운영 걸음 Quest 는 오늘 다음 구간의 지급액이다 — 「- POP」 아님 (10/6 A-5)', () {
expect(QuestType.steps.rewardFrom(const {}, policy), 5);
expect(QuestType.steps.rewardFrom(const {}, policy, cleared: 2), 7);
// 다 받은 뒤에는 마지막 구간 값에 머문다.
expect(QuestType.steps.rewardFrom(const {}, policy, cleared: 4), 8);
});
test('사용자 생성 만보기는 생성자가 정한 보상, 구간표가 없으면 지어내지 않는다', () {
expect(
QuestType.steps.rewardFrom(const {'authorUid': 'u', 'rewardPop': 30}, policy),
30,
);
expect(QuestType.steps.rewardFrom(const {}, const {}), isNull);
});
});
}