import 'dart:async'; import 'package:fancake/core/data/planet_visual_state.dart'; import 'package:fancake/core/data/backend.dart'; import 'package:fancake/features/planet/planet_repository_firestore.dart'; import 'package:fancake/features/planet/planet_detail_screen.dart'; import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; import 'eclipse_stubs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/planet/planet_history_screen.dart'; class _Backend extends Backend { _Backend() : super( firestore: FakeFirebaseFirestore(), auth: MockFirebaseAuth( mockUser: MockUser(uid: 'reader'), signedIn: true, ), ); Map? payload; Map response = { 'items': [ { 'id': 'one', 'kind': 'evolution', 'occurredAt': '2026-09-22T00:00:00Z', 'fromStage': 1, 'toStage': 2, 'participationConfirmed': 'true', }, ], 'nextCursor': null, }; @override Future> call( String name, [ Map data = const {}, ]) async { expect(name, 'fnPlanetHistory'); payload = data; return response; } } class _PersonalHistory extends SamplePlanetRepository { final scopes = []; @override Future history({ String? cursor, bool personal = false, }) async { scopes.add(personal); return PlanetHistoryPage( items: [ PlanetHistoryEntry( id: 'one', kind: personal ? PlanetHistoryKind.zap : PlanetHistoryKind.evolution, occurredAt: DateTime.utc(2026, 9, 22), fromStage: personal ? null : 1, toStage: personal ? null : 2, amount: personal ? 37 : null, ), ], nextCursor: personal ? null : 'older', ); } } class _History extends SamplePlanetRepository { final cursors = []; @override Future history({ String? cursor, bool personal = false, }) async { cursors.add(cursor); if (cursors.length == 2) throw StateError('private details'); return PlanetHistoryPage( items: [ PlanetHistoryEntry( id: cursor ?? 'first', kind: PlanetHistoryKind.evolution, occurredAt: DateTime.utc(2026, 9, 22), fromStage: 1, toStage: 2, participationConfirmed: cursor == null, ), ], nextCursor: cursor == null ? 'next' : null, ); } } void main() { testWidgets('내 활동 전환은 이전 목록과 커서를 지우고 실제 성장량을 표시한다', (tester) async { final repository = _PersonalHistory(); await tester.pumpWidget( MaterialApp( home: PlanetHistoryScreen(repository: repository, fandomName: '팬덤'), ), ); await tester.pumpAndSettle(); await tester.tap(find.text('내 활동')); await tester.pumpAndSettle(); expect(repository.scopes, [false, true]); expect(find.text('Stage 1 → 2 진화'), findsNothing); expect(find.text('ZAP 기여'), findsOneWidget); expect(find.textContaining('성장에 37 POP 반영'), findsOneWidget); expect(find.textContaining('이 진화에 함께했습니다'), findsNothing); expect(find.text('이전 기록 더 보기'), findsNothing); }); testWidgets('서버 Stage 상승 완료에서 현재 행성의 역사 화면을 연다', (tester) async { const repository = SamplePlanetRepository(); final states = StreamController.broadcast(); addTearDown(states.close); await tester.pumpWidget( MaterialApp( home: PlanetDetailScreen( repository: repository, eclipse: const NoEclipseRepository(), watchPlanet: (_) => states.stream, ), ), ); await tester.pumpAndSettle(); states.add(const PlanetVisualState(stage: 1)); await tester.pumpAndSettle(); states.add(const PlanetVisualState(stage: 2)); await tester.pump(); await tester.pump(); await tester.pump(const Duration(seconds: 4)); await tester.tap(find.text('PASSPORT 기록 보기')); await tester.pumpAndSettle(); expect( tester .widget(find.byType(PlanetHistoryScreen)) .repository, same(repository), ); await tester.pumpWidget(const SizedBox()); }); test('실제 저장소는 대상 팬덤·커서만 요청하고 불완전한 응답을 빈 기록으로 숨기지 않는다', () async { final backend = _Backend(); final repository = FirestorePlanetRepository(backend, fandomId: 'target'); final page = await repository.history(cursor: 'previous'); expect(backend.payload, {'fandomId': 'target', 'cursor': 'previous'}); expect(page.items.single.participationConfirmed, isFalse); backend.response = { 'items': [ {'id': 'bad'}, ], 'nextCursor': null, }; await expectLater(repository.history(), throwsStateError); backend.response = { 'items': [ { 'id': 'zap', 'kind': 'zap', 'occurredAt': '2026-09-22T00:00:00Z', 'amount': 37, 'participationConfirmed': true, }, ], 'nextCursor': null, }; final personal = await repository.history(personal: true); expect(backend.payload, {'fandomId': 'target', 'scope': 'personal'}); expect(personal.items.single.amount, 37); expect(personal.items.single.participationConfirmed, isFalse); // PASSPORT Stamp 는 내 기록에서만 받는다(FX §5.2 · §7.2). backend.response = { 'items': [ { 'id': 'e', 'kind': 'eclipse_success', 'occurredAt': '2026-09-23T00:00:00Z', }, { 'id': 'v', 'kind': 'emblem_evolution', 'occurredAt': '2026-09-22T00:00:00Z', 'toStage': 3, }, { 'id': 'f', 'kind': 'badge_founder100', 'occurredAt': '2026-09-21T00:00:00Z', 'memberNo': 7, }, ], 'nextCursor': null, }; final stamps = await repository.history(personal: true); expect(stamps.items.map((i) => i.kind), [ PlanetHistoryKind.eclipseSuccess, PlanetHistoryKind.emblemEvolution, PlanetHistoryKind.founderBadge, ]); expect(stamps.items[1].toStage, 3); expect(stamps.items[2].memberNo, 7); await expectLater(repository.history(), throwsStateError); }); testWidgets('행성 상세에서 같은 저장소의 팬덤 역사를 연다', (tester) async { const repository = SamplePlanetRepository(); await tester.pumpWidget( const MaterialApp( home: PlanetDetailScreen( repository: repository, eclipse: NoEclipseRepository(), ), ), ); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('더 보기')); await tester.pumpAndSettle(); await tester.tap(find.text('PASSPORT · 팬덤 역사')); await tester.pumpAndSettle(); expect( tester .widget(find.byType(PlanetHistoryScreen)) .repository, same(repository), ); }); testWidgets('확정된 개인 참여만 표시하고 다음 페이지 실패는 같은 커서로 재시도한다', (tester) async { final repository = _History(); await tester.pumpWidget( MaterialApp( home: PlanetHistoryScreen(repository: repository, fandomName: '팬덤'), ), ); await tester.pumpAndSettle(); expect(find.textContaining('이 진화에 함께했습니다'), findsOneWidget); await tester.tap(find.text('이전 기록 더 보기')); await tester.pumpAndSettle(); expect(find.text('기록을 불러오지 못했습니다.'), findsOneWidget); expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsNothing); expect(find.textContaining('private details'), findsNothing); await tester.tap(find.text('다시 시도')); await tester.pumpAndSettle(); expect(repository.cursors, [null, 'next', 'next']); expect(find.text('Stage 1 → 2 진화'), findsNWidgets(2)); expect(find.textContaining('이 진화에 함께했습니다'), findsOneWidget); expect(find.text('이전 기록 더 보기'), findsNothing); }); testWidgets('조회 성공의 빈 기록을 오류와 구분한다', (tester) async { await tester.pumpWidget( const MaterialApp( home: PlanetHistoryScreen( repository: SamplePlanetRepository(), fandomName: '팬덤', ), ), ); await tester.pumpAndSettle(); expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsOneWidget); expect(find.text('다시 시도'), findsNothing); }); test('UX·FX §7.2 — 진화 · Eclipse 성공 · 기념 Badge 만 역사적 사건', () { expect(PlanetHistoryKind.values.where((k) => k.isHistoric).toSet(), { PlanetHistoryKind.evolution, PlanetHistoryKind.eclipseSuccess, PlanetHistoryKind.emblemEvolution, PlanetHistoryKind.founderBadge, }); }); testWidgets('역사적 사건 줄에만 「역사적 사건」 표지가 붙는다', (tester) async { await tester.pumpWidget( MaterialApp( home: PlanetHistoryScreen( repository: _PersonalHistory(), fandomName: '팬덤', ), ), ); await tester.pumpAndSettle(); expect(find.text('Stage 1 → 2 진화'), findsOneWidget); expect(find.text('역사적 사건'), findsOneWidget); await tester.tap(find.text('내 활동')); await tester.pumpAndSettle(); expect(find.text('ZAP 기여'), findsOneWidget); expect(find.text('역사적 사건'), findsNothing); }); }