609 lines
24 KiB
Dart
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('구독 공유 — 팬덤당 하나', () {
|
|
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('마감이 지난 이클립스는 만료로 읽는다', () {
|
|
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,
|
|
);
|
|
});
|
|
});
|
|
}
|