이클립스 흐름 — 배너 진입 · 이벤트 버튼 · 오버레이 · 방어 진행 표시 · 흔들기 감지 통일

This commit is contained in:
Sn-Kinos
2026-10-05 19:05:21 +09:00
parent be623d2e46
commit 576dcfbdfd
27 changed files with 1369 additions and 369 deletions
+225
View File
@@ -0,0 +1,225 @@
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));
}
}
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'));
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'));
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'));
expect(find.textContaining('초간 휴대폰을 흔들어'), findsOneWidget);
expect(find.text('내 유효 Shake 0 / 60회'), findsOneWidget);
expect(find.text('유효 참여 3 / 10명'), findsOneWidget);
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
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'));
await tester.tap(find.text('흔들기 어려우면 눌러서 참여'));
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 이 없으면 서버와 같은 운영값 stepsPop 을 기본액으로 적는다', () {
expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), 50);
expect(
QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, const {'stepsPop': 50}),
30,
);
expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull);
});
});
}
+7 -3
View File
@@ -12,7 +12,7 @@ class NoEclipseRepository implements EclipseRepository {
const NoEclipseRepository();
@override
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) => Stream.value(null);
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) => Stream.value(null);
@override
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
@@ -69,7 +69,7 @@ class FakeEclipseRepository implements EclipseRepository {
}
@override
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) async* {
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) async* {
yield _last;
yield* _eclipse.stream;
}
@@ -110,6 +110,8 @@ EclipseState eclipseFixture({
int defenseCount = 12,
bool defended = false,
EclipseResult? result,
DateTime? startAt,
bool scheduled = false,
DateTime? endsAt,
}) => EclipseState(
fandomId: fandomId,
@@ -121,6 +123,8 @@ EclipseState eclipseFixture({
defenseCount: defenseCount,
defended: defended,
result: result,
startAt: startAt,
scheduled: scheduled,
// 마감이 없으면 화면이 「지났는가」를 물을 수 없다. 기본을 넉넉히 둔다.
endsAt: endsAt ?? DateTime.now().add(const Duration(minutes: 20)),
);
@@ -142,7 +146,7 @@ class CountingEclipseRepository implements EclipseRepository {
int active = 0;
@override
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) {
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) {
late StreamController<EclipseState?> controller;
controller = StreamController<EclipseState?>(
onListen: () {