Files
fanuzu/app/test/eclipse_live_test.dart
T

609 lines
24 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/data/shared_stream.dart';
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/eclipse/widgets/live_proof.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/widgets/zap_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/planet/planet_data.dart';
import 'eclipse_stubs.dart';
/// 이클립스 방어·침식·실시간 동기화·라이브 소셜 프루프.
///
/// 판정 근거
/// - 별첨 1 ① 완료 시나리오 4 「이클립스 발동 → 워닝 → 방어 → 침식률 회복」
/// - 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프가 타 사용자 화면에
/// 실시간 노출」
/// - 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱 워닝 배너 병행」
/// - 별첨 1 ② 서버검증 원칙 「클라이언트 단독 지급 0건」 — 화면이 잔액을 스스로
/// 확정하지 않고 서버 응답으로 덮는지 본다.
///
/// **스트림을 밖에서 밀어 넣어 본다.** 화면이 첫 값만 그리고 갱신을 놓치는 것이
/// 실시간 동기화에서 가장 흔한 고장이고, 눈으로는 「조금 늦는다」로만 보인다.
/// 궤도 맵은 궤도 회전이 멈추지 않아 `pumpAndSettle` 이 끝나지 않는다.
Future<void> _pumpGalaxy(
WidgetTester tester,
EclipseRepository eclipse, {
GalaxyRepository? galaxy,
}) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: GalaxyScreen(
repository: galaxy ?? const SampleGalaxyRepository(),
planetRepository: samplePlanetRepository,
eclipse: eclipse,
),
),
);
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
/// 흔들어 방어 화면. 센서 대신 가짜 흔들기 스트림을 넣는다 — 방어는 Shake only(FX §5).
Future<void> _pumpDefend(WidgetTester tester, EclipseRepository repo) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: EclipseDefendScreen(repository: repo, fandomId: 'bts', shakes: () => _shakes.stream),
),
);
await tester.pump();
await _settle(tester);
}
Future<void> _settle(WidgetTester tester) async {
for (var i = 0; i < 8; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
}
/// 가짜 가속도 센서 — 흔들기 한 번마다 이벤트 하나.
final _shakes = StreamController<DateTime>.broadcast();
void main() {
group('이클립스 경보 배너 (별첨 1 ① 「인앱 워닝 배너 병행」)', () {
testWidgets('활성 이클립스가 없으면 배너가 뜨지 않는다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
expect(find.textContaining('침식'), findsNothing);
expect(find.textContaining('방어 '), findsNothing);
});
testWidgets('발동하면 침식률과 방어 진행이 배너에 뜬다', (tester) async {
final repo = FakeEclipseRepository(
state: eclipseFixture(erosionRate: 40, defense: 60000, targetDefense: 100000),
);
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
expect(find.textContaining('이클립스 발동 — BTS 침식 40%'), findsOneWidget);
// 행성 HUD 도 같은 침식률을 띄운다(스펙 B — 갤럭시·행성 상세 HUD).
expect(find.textContaining('침식 40% · 방어 60%'), findsOneWidget);
expect(
find.textContaining('유효 참여 60,000 / 100,000명'),
findsOneWidget,
);
});
testWidgets('스트림이 새 집계를 내면 배너가 따라 바뀐다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture(erosionRate: 40));
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
// 배너와 행성 HUD 가 같은 집계를 보므로 둘 다 잡힌다 — 값이 갈리면 안 된다.
expect(find.textContaining('침식 40%'), findsWidgets);
// 다른 단말의 방어가 집계에 반영된 상황.
repo.push(eclipseFixture(erosionRate: 15, defense: 85000, defenseCount: 40));
await _settle(tester);
expect(find.textContaining('침식 40%'), findsNothing);
expect(find.textContaining('침식 15%'), findsWidgets);
expect(find.textContaining('유효 참여 85,000 / 100,000명'), findsOneWidget);
});
testWidgets('방어에 성공하면 감소 없음 문구로 바뀐다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture(erosionRate: 20));
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
repo.push(eclipseFixture(
erosionRate: 0,
defense: 100000,
defended: true,
));
await _settle(tester);
expect(find.textContaining('방어 성공 — BTS FPI 감소 없음'), findsOneWidget);
});
});
group('ZAP — 행성 성장 기여 (별첨 B B-23)', () {
testWidgets('ZAP 은 이클립스가 없어도 눌린다', (tester) async {
// 방어는 POP 을 쓰지 않는 공동 Shake 참여이고(설계서 v1.1 §9-#5) ZAP 은
// 행성 성장 기여다. 둘이 분리되어 있으므로 이클립스 유무와 무관하다.
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository();
await _pumpGalaxy(tester, repo, galaxy: galaxy);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
expect(galaxy.zaps, hasLength(1));
// 표본의 내 행성은 `bts` 다 — ZAP 은 내 행성의 성장에 기여한다.
expect(galaxy.zaps.single.$1, 'bts');
expect(galaxy.zaps.single.$3, 1);
// 방어 호출은 나가지 않는다 — 이클립스 샤드와 분리되어 있다.
expect(repo.calls, isEmpty);
});
testWidgets('연타는 한 번의 호출로 묶이고 count 가 누적된다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository();
await _pumpGalaxy(tester, repo, galaxy: galaxy);
for (var i = 0; i < 5; i++) {
await tester.tap(find.byType(ZapButton));
await tester.pump(const Duration(milliseconds: 60));
}
await _settle(tester);
// 호출 1회 · count 5 — 원장 한 행에 묶음 횟수가 남아야 MY 가 「Zap 5회」를 센다.
expect(galaxy.zaps, hasLength(1));
expect(galaxy.zaps.single.$3, 5);
});
testWidgets('시간창이 닫힌 뒤의 연타는 새 묶음이고 멱등키가 다르다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository()..balanceAfter = 9_000;
await _pumpGalaxy(tester, repo, galaxy: galaxy);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
expect(galaxy.zaps, hasLength(2));
// 멱등키가 같으면 서버가 두 번째를 같은 거래로 보아 기여가 사라진다.
expect(galaxy.zaps[0].$2, isNot(galaxy.zaps[1].$2));
});
testWidgets('잔액은 서버가 확정한 값으로 덮인다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository()..balanceAfter = 7_777;
await _pumpGalaxy(tester, repo, galaxy: galaxy);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
final button = tester.widget<ZapButton>(find.byType(ZapButton));
expect(button.myPop, 7_777);
});
testWidgets('잔액 부족은 서버 오류 코드로 안내한다', (tester) async {
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
final galaxy = RecordingGalaxyRepository()
..failWith = const BackendException('E-POP-01', 'POP 잔액이 부족합니다.');
await _pumpGalaxy(tester, repo, galaxy: galaxy);
await tester.tap(find.byType(ZapButton));
await _settle(tester);
expect(find.textContaining('POP 이 모자라'), findsOneWidget);
});
testWidgets('자동(오토 도네이션) 옵션은 없다', (tester) async {
// 별첨 1 ① 시나리오 4 가 적었으나 별첨 B 에 없는 기능이다(공통 기준 2항).
final repo = FakeEclipseRepository();
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
final hold = await tester.startGesture(tester.getCenter(find.byType(ZapButton)));
await tester.pump(ZapButton.longPress);
await hold.up();
await _settle(tester);
expect(find.text('기여'), findsOneWidget);
expect(find.text('아이템'), findsOneWidget);
expect(find.text('자동'), findsNothing);
});
});
group('라이브 소셜 프루프 (별첨 1 ① 시나리오 5)', () {
testWidgets('갤럭시 섬광 배너가 다른 사용자의 방어를 흘린다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
repo.pushEvents([
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
]);
await _settle(tester);
expect(
find.text('[LIVE] 버니즈님이 이클립스 방어에 합류했습니다!'),
findsOneWidget,
);
});
testWidgets('새 이벤트가 오면 문구가 갈린다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpGalaxy(tester, repo);
repo.pushEvents([
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
]);
await _settle(tester);
repo.pushEvents([
LiveEvent(id: 'e2', nickname: '캐럿', defense: 50, at: DateTime(2026, 9, 14, 1)),
LiveEvent(id: 'e1', nickname: '버니즈', defense: 30, at: DateTime(2026, 9, 14)),
]);
await _settle(tester);
expect(find.textContaining('버니즈'), findsNothing);
expect(
find.text('[LIVE] 캐럿님이 이클립스 방어에 합류했습니다!'),
findsOneWidget,
);
});
testWidgets('홈 라이브 띠가 라이브 이벤트를 그린다', (tester) async {
// 방어 합류는 진행 중인 회차에서만 띠에 오른다(r11 2-5).
final repo = FakeEclipseRepository(
state: eclipseFixture(startAt: DateTime(2026, 9, 13), defense: 1),
);
addTearDown(repo.dispose);
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: HomeScreen(
repository: const SampleHomeRepository(),
questRepository: const SampleQuestRepository(),
eclipse: repo,
),
),
);
await tester.pumpAndSettle();
// 합류 이벤트가 아직 없으면 발생 소식을 그린다.
expect(find.text('BTS 행성에 이클립스가 발생했어요!'), findsOneWidget);
repo.pushEvents([
LiveEvent(id: 'e1', nickname: '무민', defense: 20, at: DateTime(2026, 9, 14)),
]);
await tester.pumpAndSettle();
// 이벤트가 오면 그 문구가 발생 소식을 대체한다.
expect(find.text('BTS 행성에 이클립스가 발생했어요!'), findsNothing);
expect(
find.text('[LIVE] 무민님이 이클립스 방어에 합류했습니다!'),
findsOneWidget,
);
});
test('문구는 종류마다 한 곳에서 만든다', () {
final at = DateTime(2026, 9, 14);
expect(
liveEventMessage(LiveEvent(id: 'x', nickname: '나', defense: 1200, at: at)),
'[LIVE] 나님이 이클립스 방어에 합류했습니다!',
);
// 별첨 B 「Global Ticker」 — 다른 종류가 같은 컬렉션에 들어온다.
expect(
liveEventMessage(LiveEvent(
id: 'y',
nickname: '나',
at: at,
kind: LiveEventKind.heroGift,
)),
'[LIVE] 나님의 영웅의 선물이 도착했습니다!',
);
});
});
group('실시간 구독 — 해제와 백그라운드 복귀 (스펙 C)', () {
testWidgets('화면이 사라지면 구독을 끊는다', (tester) async {
final repo = CountingEclipseRepository(eclipseFixture());
await _pumpGalaxy(tester, repo);
expect(repo.subscriptions, 1);
expect(repo.active, 1);
// 화면을 치운다. 구독이 남으면 백그라운드에서 읽기 비용이 계속 든다.
await tester.pumpWidget(const MaterialApp(home: SizedBox()));
await _settle(tester);
expect(repo.active, 0);
});
testWidgets('백그라운드에서 돌아오면 다시 구독한다', (tester) async {
final repo = CountingEclipseRepository(eclipseFixture());
await _pumpGalaxy(tester, repo);
expect(repo.subscriptions, 1);
// 오래 잠든 리스너는 서버와 끊긴 채 남을 수 있다. 복귀 때 새로 열어
// 첫 스냅샷을 다시 받는다 — 별첨 1 ① 「백그라운드 복귀 시 렌더링 정상 재개」.
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await _settle(tester);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await _settle(tester);
expect(repo.subscriptions, 2);
// 옛 구독은 남지 않는다 — 남으면 같은 문서를 두 번 읽는다.
expect(repo.active, 1);
expect(find.textContaining('이클립스 발동'), findsWidgets);
});
});
group('흔들어 방어 화면 (B-55 공동 Shake)', () {
testWidgets('POP 을 쓰지 않는다는 것을 화면이 말한다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
expect(find.textContaining('POP은 소모되지 않습니다'), findsOneWidget);
expect(find.textContaining('흔들어 방어'), findsWidgets);
});
testWidgets('흔들기가 묶여 서버로 나간다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
for (var i = 0; i < 4; i++) {
_shakes.add(DateTime.now());
await tester.pump(const Duration(milliseconds: 80));
}
await _settle(tester);
expect(repo.calls, hasLength(1));
expect(repo.calls.single.$3, 4);
});
testWidgets('서버가 받아들인 값만 내 참여로 그린다', (tester) async {
// 단말이 센 값을 그대로 믿으면 서버가 깎은 뒤 숫자가 줄어 보인다.
final repo = FakeEclipseRepository(state: eclipseFixture())
..acceptedOverride = 2;
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
// 다섯 번 참여했는데 서버가 둘만 받아들인 상황.
for (var i = 0; i < 5; i++) {
_shakes.add(DateTime.now());
await tester.pump(const Duration(milliseconds: 80));
}
await _settle(tester);
expect(repo.calls.single.$3, 5);
expect(find.textContaining('참여 상한에 닿아'), findsOneWidget);
});
testWidgets('성공한 이클립스에서는 참여 버튼이 닫힌다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture(defended: true));
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
expect(find.text('이 기기는 흔들기 방어를 지원하지 않아요'), findsNothing);
expect(find.text('이클립스를\n성공적으로 막았어요!'), findsOneWidget);
});
testWidgets('마감은 판정 대기이며 서버 실패·취소만 해당 결과로 표시한다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
_shakes.add(DateTime.now());
repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1))));
await tester.pump();
await _settle(tester);
expect(find.text('방어 결과 확인 중'), findsOneWidget);
expect(find.textContaining('FAIL'), findsNothing);
expect(find.byType(FilledButton), findsNothing);
expect(repo.calls, isEmpty);
repo.push(eclipseFixture(result: EclipseResult.failure));
await _settle(tester);
expect(find.text('아쉽게 행성 방어를\n실패했어요'), findsOneWidget);
repo.push(eclipseFixture(result: EclipseResult.cancelled));
await _settle(tester);
expect(find.text('이클립스 취소'), findsOneWidget);
expect(find.textContaining('FPI는 차감되지 않았습니다'), findsOneWidget);
expect(find.textContaining('FAIL'), findsNothing);
});
testWidgets('계속 입력해도 700ms마다 전송하고 멈춘 뒤 남은 입력을 보낸다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture());
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
for (var i = 0; i < 12; i++) {
_shakes.add(DateTime.now());
await tester.pump(const Duration(milliseconds: 100));
}
expect(repo.calls, isNotEmpty);
await _settle(tester);
expect(repo.calls.fold<int>(0, (sum, call) => sum + call.$3), 12);
expect(repo.calls.length, greaterThan(1));
});
testWidgets('응답 유실은 마감 후에도 같은 회차·요청ID·횟수로 재확인한다', (tester) async {
final repo = FakeEclipseRepository(state: eclipseFixture())
..failWith = const BackendException('unavailable', 'private backend error');
addTearDown(repo.dispose);
await _pumpDefend(tester, repo);
_shakes.add(DateTime.now());
await _settle(tester);
expect(repo.calls, hasLength(1));
final original = repo.calls.single;
expect(find.textContaining('private backend error'), findsNothing);
repo.push(eclipseFixture(endsAt: DateTime.now().subtract(const Duration(seconds: 1))));
await _settle(tester);
repo.failWith = null;
await tester.tap(find.text('같은 참여 다시 확인'));
await _settle(tester);
expect(repo.calls, [original, original]);
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);
await _pumpDefend(tester, repo);
expect(find.text('진행 중인 이클립스가 없습니다'), findsOneWidget);
});
});
group('구독 공유 — 팬덤당 하나 (검수 D5)', () {
test('여러 화면이 같은 원본 하나를 나눠 쓴다', () async {
// 화면마다 snapshots() 를 열면 한 팬덤에 리스너가 화면 수만큼 붙는다.
var opened = 0;
final controller = StreamController<int>.broadcast();
addTearDown(controller.close);
final shared = SharedStream<int>(() {
opened += 1;
return controller.stream;
});
final a = <int>[];
final b = <int>[];
final subA = shared.stream.listen(a.add);
final subB = shared.stream.listen(b.add);
await Future<void>.delayed(Duration.zero);
controller.add(7);
await Future<void>.delayed(Duration.zero);
// 원본은 한 번만 열렸는데 두 화면 모두 값을 받았다.
expect(opened, 1);
expect(a, [7]);
expect(b, [7]);
await subA.cancel();
await subB.cancel();
});
test('늦게 붙은 화면이 현재 값을 곧바로 받는다', () async {
final controller = StreamController<int>.broadcast();
addTearDown(controller.close);
final shared = SharedStream<int>(() => controller.stream);
final first = <int>[];
final subA = shared.stream.listen(first.add);
await Future<void>.delayed(Duration.zero);
controller.add(42);
await Future<void>.delayed(Duration.zero);
// 뒤늦게 열린 화면이 다음 갱신까지 빈 상태로 남으면 안 된다.
final late = <int>[];
final subB = shared.stream.listen(late.add);
await Future<void>.delayed(Duration.zero);
expect(late, [42]);
await subA.cancel();
await subB.cancel();
});
test('마지막 화면이 떠나면 원본을 닫는다', () async {
var cancelled = false;
final controller = StreamController<int>(onCancel: () => cancelled = true);
addTearDown(controller.close);
final shared = SharedStream<int>(() => controller.stream);
final sub = shared.stream.listen((_) {});
await Future<void>.delayed(Duration.zero);
expect(cancelled, isFalse);
await sub.cancel();
await Future<void>.delayed(Duration.zero);
// 남겨 두면 화면이 없는 동안에도 읽기 비용이 계속 든다.
expect(cancelled, isTrue);
});
});
group('이클립스 상태 계산', () {
test('진행률은 목표가 0 이면 0 이다', () {
expect(eclipseFixture(targetDefense: 0, defense: 10).progress, 0);
});
test('진행률은 1 을 넘지 않는다', () {
expect(
eclipseFixture(targetDefense: 100, defense: 400).progress,
1.0,
);
});
test('마감이 지났으면 남은 시간을 말하지 않는다', () {
final now = DateTime(2026, 9, 14, 12);
expect(
eclipseFixture(endsAt: now.subtract(const Duration(minutes: 1)))
.remaining(now),
isNull,
);
expect(
eclipseFixture(endsAt: now.add(const Duration(minutes: 20)))
.remaining(now),
const Duration(minutes: 20),
);
});
test('서버 kind 문자열을 모르는 값이면 공지로 읽는다', () {
// 티커가 새 종류에 막혀 멈추지 않게 한다(별첨 B Global Ticker).
expect(LiveEventKind.of('eclipse_defense'), LiveEventKind.eclipseDefense);
expect(LiveEventKind.of('hero_gift'), LiveEventKind.heroGift);
expect(LiveEventKind.of('무엇인가'), LiveEventKind.notice);
});
test('마감이 지난 이클립스는 만료로 읽는다 (검수 D3)', () {
final now = DateTime(2026, 9, 14, 12);
expect(
eclipseFixture(endsAt: now.subtract(const Duration(seconds: 1)))
.isExpired(now),
isTrue,
);
expect(
eclipseFixture(endsAt: now.add(const Duration(minutes: 1))).isExpired(now),
isFalse,
);
});
});
}