import 'dart:async'; import 'package:fancake/features/eclipse/eclipse_data.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; /// 활성 이클립스가 없는 상태. /// /// **이클립스와 무관한 화면 검증의 기본값이다.** [SampleEclipseRepository] 는 목 /// 모드 시연을 위해 늘 활성 이클립스를 내므로, 그것을 쓰면 배너와 라이브 띠가 /// 화면 상단을 차지해 F1·F2·F3 의 레이아웃 검증이 이클립스 유무에 딸려 흔들린다. class NoEclipseRepository implements EclipseRepository { const NoEclipseRepository(); @override Stream watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) => Stream.value(null); @override Stream> liveEvents({String? fandomId, int limit = 20}) => Stream.value(const []); @override Future defend({ required String fandomId, required String eclipseId, required String clientSeq, required int shakes, }) async => const DefendResult(accepted: 0, clamped: 0, myTotal: 0, duplicate: false); } /// 값을 밖에서 밀어 넣는 저장소. 스트림이 바뀔 때 화면이 따라오는지 본다. /// /// 방어 호출은 실제로 나가지 않고 [calls] 에 기록만 한다 — 연타가 **몇 번의 /// 호출로 묶였는지**와 `count` 합이 맞는지가 검증 대상이다. class FakeEclipseRepository implements EclipseRepository { FakeEclipseRepository({EclipseState? state}) { if (state != null) push(state); } final _eclipse = StreamController.broadcast(); final _events = StreamController>.broadcast(); /// 마지막으로 밀어 넣은 값. 새 구독자가 곧바로 받는다. EclipseState? _last; List _lastEvents = const []; /// (eclipseId, clientSeq, shakes) 기록. final List<(String, String, int)> calls = []; /// 다음 방어 호출이 던질 실패. null 이면 성공한다. Object? failWith; /// 서버가 받아들였다고 답할 양. null 이면 보낸 값을 그대로 받아들인다. int? acceptedOverride; void push(EclipseState? state) { _last = state; if (_eclipse.hasListener) _eclipse.add(state); } void pushEvents(List events) { _lastEvents = events; if (_events.hasListener) _events.add(events); } Future dispose() async { await _eclipse.close(); await _events.close(); } @override Stream watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) async* { yield _last; yield* _eclipse.stream; } @override Stream> liveEvents({String? fandomId, int limit = 20}) async* { yield _lastEvents; yield* _events.stream; } @override Future defend({ required String fandomId, required String eclipseId, required String clientSeq, required int shakes, }) async { calls.add((eclipseId, clientSeq, shakes)); final failure = failWith; if (failure != null) throw failure; final accepted = acceptedOverride ?? shakes; return DefendResult( accepted: accepted, clamped: shakes - accepted, myTotal: calls.fold(0, (a, c) => a + c.$3), duplicate: false, ); } } /// 검증에 쓰는 이클립스 한 건. 값은 인자로 덮는다. EclipseState eclipseFixture({ String fandomId = 'bts', String eclipseId = 'ecl-1', int erosionRate = 40, int defense = 60_000, int targetDefense = 100_000, int defenseCount = 12, bool defended = false, EclipseResult? result, DateTime? startAt, bool scheduled = false, DateTime? endsAt, }) => EclipseState( fandomId: fandomId, eclipseId: eclipseId, fandomName: 'BTS', erosionRate: erosionRate, defense: defense, targetDefense: targetDefense, defenseCount: defenseCount, defended: defended, result: result, startAt: startAt, scheduled: scheduled, // 마감이 없으면 화면이 「지났는가」를 물을 수 없다. 기본을 넉넉히 둔다. endsAt: endsAt ?? DateTime.now().add(const Duration(minutes: 20)), ); /// 구독을 몇 번 열었고 몇 개가 살아 있는지 센다. /// /// 실시간 동기화의 두 고장은 **끊지 않는 것**(백그라운드에서 읽기 비용이 계속 /// 든다)과 **다시 열지 않는 것**(복귀한 화면이 잠들기 전 값을 그린다)이다. 둘 다 /// 화면만 보고는 구분되지 않으므로 구독 수를 직접 센다. class CountingEclipseRepository implements EclipseRepository { CountingEclipseRepository(this.state); final EclipseState? state; /// 지금까지 열린 구독 수(누적). int subscriptions = 0; /// 아직 살아 있는 구독 수. int active = 0; @override Stream watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) { late StreamController controller; controller = StreamController( onListen: () { subscriptions += 1; active += 1; controller.add(state); }, onCancel: () { active -= 1; }, ); return controller.stream; } @override Stream> liveEvents({String? fandomId, int limit = 20}) => Stream.value(const []); @override Future defend({ required String fandomId, required String eclipseId, required String clientSeq, required int shakes, }) async => const DefendResult(accepted: 0, clamped: 0, myTotal: 0, duplicate: false); } /// ZAP(행성 성장 기여) 호출을 기록하는 갤럭시 저장소. /// /// 별첨 B **B-23** — ZAP 은 이클립스 방어가 아니라 행성 성장 기여다. 이 스텁으로 /// 「ZAP 이 방어 함수가 아니라 POP 소비 경로로 나가는가」를 본다. class RecordingGalaxyRepository implements GalaxyRepository { RecordingGalaxyRepository({this.snapshot}); /// 돌려줄 스냅샷. 없으면 표본을 쓴다. final GalaxySnapshot? snapshot; /// (fandomId, clientSeq, count) 기록. final List<(String, String, int)> zaps = []; /// 서버가 확정했다고 답할 잔액. -1 이면 「모름」이다. int balanceAfter = -1; /// 다음 호출이 던질 실패. null 이면 성공한다. Object? failWith; @override Future setPrimary(String fandomId) async {} @override Future load() async => snapshot ?? await const SampleGalaxyRepository().load(); @override Future zap({ required String fandomId, required int count, required String clientSeq, }) async { zaps.add((fandomId, clientSeq, count)); final failure = failWith; if (failure != null) throw failure; return ZapResult(txId: 'tx-${zaps.length}', balanceAfter: balanceAfter); } }