import 'package:fancake/features/eclipse/eclipse_data.dart'; import 'package:fancake/core/ui/relative_time.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/planet/fpi_ranking_screen.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/features/planet/lounge_post_screens.dart'; import 'package:fancake/features/planet/moderation.dart'; import 'package:fancake/features/planet/planet_detail_screen.dart'; import 'eclipse_stubs.dart'; Future _pump(WidgetTester tester, Widget screen) async { await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen)); await tester.pumpAndSettle(); } /// 세로가 긴 화면을 한 번에 담는다. /// /// TabBarView 안에는 스크롤 가능한 것이 여럿이라 `scrollUntilVisible` 이 엉뚱한 /// 쪽을 잡는다. 실기기에 가까운 세로 길이를 주는 편이 확실하다. Future _pumpTall( WidgetTester tester, Widget screen, { double height = 2400, }) async { tester.view.physicalSize = Size(1080, height); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await _pump(tester, screen); } /// 서버 산출 「내 기여 상위 %」가 아직 없는 상태의 저장소. class _NoTopPercentRepository extends SamplePlanetRepository { @override Future join() async {} @override Future history({String? cursor, bool personal = false}) => _sample.history(cursor: cursor); @override Future claimHeroDrop(String dropId) => _sample.claimHeroDrop(dropId); const _NoTopPercentRepository(); static const _sample = SamplePlanetRepository(); @override Future load({required bool nebula}) async { final detail = await _sample.load(nebula: nebula); return PlanetDetail( fandomId: detail.fandomId, name: detail.name, isNebula: detail.isNebula, totalFpi: detail.totalFpi, members: detail.members, posts: detail.posts, scores: [ for (final s in detail.scores) FpiScore( metric: s.metric, value: s.value, // 집계 모집단 밖(서버 fpiRanks 없음)이면 순위가 0 으로 온다. rank: 0, trend: s.trend, myTopPercent: null, ), ], mockNote: '「내 기여 상위 %」 집계 함수가 아직 없습니다', ); } @override Future> morePosts({required int offset, int limit = 5}) => _sample.morePosts(offset: offset, limit: limit); @override Future planetRanking({required FpiMetric metric, int limit = 30}) => _sample.planetRanking(metric: metric, limit: limit); @override Future userRanking({ required FpiMetric metric, required String fandomId, int limit = 30, }) => _sample.userRanking(metric: metric, fandomId: fandomId, limit: limit); @override Future countryRanking({required FpiMetric metric, int limit = 30}) => _sample.countryRanking(metric: metric, limit: limit); } /// 가입 전 상태로 시작하고, [join] 이 불리면 가입중으로 바뀌는 표본. class _Joinable extends SamplePlanetRepository { _Joinable(); int joins = 0; @override Future join() async => joins++; @override Future load({required bool nebula}) async { final d = await super.load(nebula: nebula); return PlanetDetail( fandomId: d.fandomId, name: d.name, stage: d.stage, isNebula: d.isNebula, totalFpi: d.totalFpi, scores: d.scores, members: d.members, posts: d.posts, joined: joins > 0, ); } } void main() { _fpiRankingTests(); testWidgets('가입하지 않은 행성은 「가입하기」를 눌러 fn-fandom-join 으로 가입한다', (tester) async { final repo = _Joinable(); await _pumpTall(tester, PlanetDetailScreen(repository: repo, eclipse: const NoEclipseRepository())); expect(find.text('가입하기'), findsOneWidget); await tester.tap(find.text('가입하기')); await tester.pumpAndSettle(); expect(repo.joins, 1); expect(find.text('가입중'), findsOneWidget); // 가입중 알약은 다시 눌러도 호출하지 않는다. await tester.tap(find.text('가입중')); await tester.pumpAndSettle(); expect(repo.joins, 1); }); group('행성 상세 · FPI (F3 · 343:213428)', () { testWidgets('TOTAL FPI 값과 갱신 주기를 띄운다', (tester) async { await _pumpTall(tester, const PlanetDetailScreen( repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), )); expect(find.text('TOTAL FPI : 284,310'), findsOneWidget); // 원장 B-15 검증 기준 — 화면에 마지막 갱신 시각 노출(서버 시각이 없으면 --:--). expect(find.text('마지막 갱신 --:--'), findsOneWidget); expect(find.textContaining('30초'), findsNothing); // 개설자 이름은 서버에 없으면 그리지 않는다 — 시안 표본 이름을 박지 않는다. expect(find.text('BTS_Maker'), findsNothing); }); testWidgets('5지표를 답변서 확정 표기로 띄운다', (tester) async { // 시안(373:441117)은 1243 높이의 스크롤 화면이라 지표 다섯을 한 창에 담으려면 // 실기기보다 긴 창이 필요하다. 스크롤하면 위 카드가 해제되어 다섯을 동시에 못 본다. await _pumpTall(tester, const PlanetDetailScreen( repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), ), height: 4200); // 답변서 v1.1 Q3 이 정본이다. Figma 의 BMP·STEPS·REFERAL 이 아니다. for (final label in [ 'BPM', 'HEALTH', 'REFERRAL', 'COMMUNITY', 'CONTRIBUTION', ]) { expect(find.text(label), findsWidgets, reason: '지표 「$label」 누락'); } }); testWidgets('지표마다 전체 순위와 내 기여 상위를 보여 준다', (tester) async { await _pumpTall(tester, const PlanetDetailScreen( repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), ), height: 4200); expect(find.text('전체 1위'), findsOneWidget); expect(find.text('상위 17%'), findsWidgets); }); testWidgets('집계 값이 없으면 숫자를 짓지 않고 내부 사유도 띄우지 않는다 — 대조표 3-5', (tester) async { await _pumpTall( tester, const PlanetDetailScreen( repository: _NoTopPercentRepository(), eclipse: NoEclipseRepository(), ), height: 4200, ); // 없는 값을 100%(최하위)로 지어내지 않는다. expect(find.text('상위 100%'), findsNothing); expect(find.text('—'), findsWidgets); // 개발용 사유(MOCK · 집계 함수)는 사용자 화면에 내보내지 않는다(10/4 실기기 3-1). expect(find.textContaining('MOCK'), findsNothing); expect(find.textContaining('집계 함수'), findsNothing); // 순위가 없으면 「전체 0위」 대신 「순위 집계 전」. expect(find.text('전체 0위'), findsNothing); expect(find.text('순위 집계 전'), findsWidgets); }); testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async { // 스펙 B — 「집계 문서를 갤럭시·행성 상세 HUD 에서 실시간으로 표시한다」. // 침식은 TOTAL FPI 를 깎는 경로이므로(원장 B-55) 그 카드 위에 온다. await _pumpTall(tester, PlanetDetailScreen( repository: const SamplePlanetRepository(), eclipse: SampleEclipseRepository(), )); expect(find.textContaining('이클립스 발동'), findsOneWidget); expect(find.textContaining('침식 42%'), findsOneWidget); // **POP 단위가 아니다** — 방어는 POP 을 소모하지 않는 공동 Shake 참여다 // (Firestore 설계서 v1.1 §9-#5). expect(find.textContaining('유효 참여 5,800 / 10,000명'), findsOneWidget); expect(find.textContaining('POP'), findsNothing); }); testWidgets('이클립스가 없으면 침식 배너 자리가 비지 않는다', (tester) async { await _pumpTall(tester, const PlanetDetailScreen( repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), )); expect(find.textContaining('이클립스 발동'), findsNothing); // 배너가 없어도 본문은 그대로다 — 빈 자리가 남지 않는다. expect(find.textContaining('TOTAL FPI'), findsOneWidget); }); }); group('팬덤 라운지 (F3 · 251:51073)', () { testWidgets('최초 5개를 최신순으로 띄운다', (tester) async { await _pumpTall(tester, const PlanetDetailScreen( repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), )); await tester.tap(find.text('팬덤 라운지')); await tester.pumpAndSettle(); // 명세 — 최초 5개, 스크롤 시 5개씩 호출. expect(find.textContaining('오늘 공방 다녀왔어요'), findsOneWidget); expect(find.byType(FloatingActionButton), findsOneWidget); }); testWidgets('스크롤하면 히어로가 접히고 탭은 위에 남는다 — 발주사 지적(글이 보이게)', (tester) async { tester.view.physicalSize = const Size(750, 1624); tester.view.devicePixelRatio = 2; addTearDown(tester.view.reset); await _pump( tester, const PlanetDetailScreen( repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), ), ); await tester.tap(find.text('팬덤 라운지')); await tester.pumpAndSettle(); final tabTop = tester.getTopLeft(find.text('팬덤 라운지')).dy; await tester.drag( find.textContaining('오늘 공방 다녀왔어요'), const Offset(0, -500), ); await tester.pumpAndSettle(); // 탭 줄이 앱 바 바로 아래로 올라와 고정되고, 글이 화면 위쪽까지 올라온다. expect(tester.getTopLeft(find.text('팬덤 라운지')).dy, lessThan(tabTop - 300)); expect(find.text('팬덤 라운지').hitTestable(), findsOneWidget); expect(find.textContaining('이번 앨범 최애 트랙').hitTestable(), findsOneWidget); }); testWidgets('이 회원의 글 모두 숨기기 — 확인하면 그 작성자 글이 목록에서 빠진다', (tester) async { await _pumpTall( tester, const PlanetDetailScreen( repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), ), ); await tester.tap(find.text('팬덤 라운지')); await tester.pumpAndSettle(); expect(find.text('아미보이'), findsOneWidget); await tester.tap( find.image(const AssetImage('assets/figma/icon-post-more.png')).first, ); await tester.pumpAndSettle(); await tester.tap(find.text('이 회원의 글 모두 숨기기')); await tester.pumpAndSettle(); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); expect(find.text('아미보이'), findsNothing); }); testWidgets('글 쓰기는 본문이 있어야 완료된다', (tester) async { // 시안 244:21746 · 설계서 §2.6 posts — 제목 칸 없이 본문 한 칸. await _pump(tester, const LoungeComposeScreen()); final register = find.widgetWithText(TextButton, '완료'); expect(tester.widget(register).onPressed, isNull); await tester.enterText(find.byType(TextField), ' '); await tester.pumpAndSettle(); // 공백만으로는 아직 안 된다. expect(tester.widget(register).onPressed, isNull); await tester.enterText(find.byType(TextField), '본문입니다'); await tester.pumpAndSettle(); expect(tester.widget(register).onPressed, isNotNull); }); }); group('신고 · 차단 (F3 · 252:162674 · 252:163306)', () { testWidgets('사유를 고르기 전에는 신고할 수 없다', (tester) async { await _pump( tester, const ReportScreen( target: ModerationTarget(authorName: '아미보이', title: '테스트 글'), ), ); // 신고 정보 영역 — 작성자 닉네임 · 게시물 제목 expect(find.text('아미보이'), findsOneWidget); expect(find.text('테스트 글'), findsOneWidget); final btn = tester.widget(find.widgetWithText(TextButton, '신고')); expect(btn.onPressed, isNull); }); testWidgets('사유는 단수 선택이고 고르면 확인을 되묻는다', (tester) async { await _pump( tester, const ReportScreen( target: ModerationTarget(authorName: '아미보이', title: '테스트 글'), ), ); await tester.tap(find.text('부적절한 콘텐츠')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(TextButton, '신고')); await tester.pumpAndSettle(); // 화면 중앙 오버레이 — 취소 / 확인 expect(find.textContaining('신고할까요?'), findsOneWidget); expect(find.text('취소'), findsOneWidget); expect(find.text('확인'), findsOneWidget); }); testWidgets('신고 사유는 어드민 등록 항목을 받는다', (tester) async { await _pump( tester, const ReportScreen( target: ModerationTarget(authorName: 'x', title: 'y'), reasons: ['운영자가 정한 사유'], ), ); expect(find.text('운영자가 정한 사유'), findsOneWidget); expect(find.text('부적절한 콘텐츠'), findsNothing); }); }); group('성운 상세 (F3 · 373:442708)', () { testWidgets('성운도 같은 화면으로 열린다', (tester) async { await _pump(tester, const PlanetDetailScreen( nebula: true, repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), )); expect(find.text('New_BTS'), findsOneWidget); }); }); testWidgets('상대 시각 표기', (tester) async { final now = DateTime(2026, 8, 31, 15); expect(relativeTime(now.subtract(const Duration(seconds: 20)), now: now), '방금 전'); expect(relativeTime(now.subtract(const Duration(minutes: 10)), now: now), '10분 전'); expect(relativeTime(now.subtract(const Duration(hours: 3)), now: now), '3시간 전'); expect(relativeTime(now.subtract(const Duration(days: 2)), now: now), '2일 전'); }); testWidgets('F3 화면 문구에 내부 경로가 새지 않는다', (tester) async { await _pump(tester, const PlanetDetailScreen( repository: SamplePlanetRepository(), eclipse: NoEclipseRepository(), )); final texts = tester .widgetList(find.byType(Text)) .map((w) => w.data ?? '') .join('\n'); for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) { expect(texts.contains(leak), isFalse, reason: '「$leak」 노출'); } }); } /// 지표별 랭킹 2단계 — F3 · Figma `546:14589`(행성) → `546:14646`(사용자). /// /// 발주사 검토회신서 4절 회귀 가드다. 지적은 두 가지였다 — /// ① 지표 카드와 Ranking 사이 수치·순위 불일치 ② 지표를 바꿔도 같은 Ranking. /// 둘 다 여기서 깨지면 바로 보이게 고정한다. void _fpiRankingTests() { const repo = SamplePlanetRepository(); group('표본은 행성별로 갈린다 — 어느 HUD 를 눌러도 같은 상세가 열리면 안 된다', () { // 갤럭시 HUD 는 남의 행성도 연다. 표본이 내 행성 하나로 고정돼 있으면 // 여섯 행성이 전부 BTS 상세로 열려 발주사 확인에서 바로 드러난다. test('행성마다 이름·TOTAL FPI·멤버 수가 다르다', () async { final details = [ for (final id in [ 'bts', 'newjeans', 'seventeen', 'ive', 'aespa', 'lesserafim', ]) await SamplePlanetRepository(fandomId: id).load(nebula: false), ]; expect(details.map((d) => d.fandomId).toSet(), hasLength(6)); expect(details.map((d) => d.name).toSet(), hasLength(6)); expect(details.map((d) => d.totalFpi).toSet(), hasLength(6)); expect(details.map((d) => d.members).toSet(), hasLength(6)); }); test('상세의 TOTAL FPI 와 멤버 수가 갤럭시 맵 표본과 같다', () async { // 같은 행성인데 HUD 와 상세가 다른 수치를 띄우면 그것부터 지적받는다. final snapshot = await const SampleGalaxyRepository().load(); for (final node in snapshot.planets) { final detail = await SamplePlanetRepository(fandomId: node.id).load(nebula: false); expect(detail.totalFpi, node.fpi, reason: '${node.id} TOTAL FPI 불일치'); expect(detail.members, node.members, reason: '${node.id} 멤버 수 불일치'); expect(detail.name, node.name, reason: '${node.id} 이름 불일치'); } }); test('전체 순위는 그 행성의 순위다 — 내 행성 순위를 돌려주지 않는다', () async { final detail = await SamplePlanetRepository(fandomId: 'lesserafim').load(nebula: false); for (final score in detail.scores) { final rows = (await const SamplePlanetRepository() .planetRanking(metric: score.metric)) .rows; final row = rows.firstWhere((r) => r.id == 'lesserafim'); expect(score.rank, row.rank, reason: '${score.metric.label} 순위 불일치'); expect(score.value, row.value, reason: '${score.metric.label} 수치 불일치'); } }); test('남의 행성에서는 내 기여 상위 %를 지어내지 않는다 — 대조표 3-5', () async { final mine = await const SamplePlanetRepository().load(nebula: false); expect(mine.scores.every((s) => s.myTopPercent != null), isTrue); final theirs = await SamplePlanetRepository(fandomId: 'aespa').load(nebula: false); expect(theirs.scores.every((s) => s.myTopPercent == null), isTrue); }); test('남의 행성 사용자 랭킹에는 「나」 행이 없다', () async { const repo = SamplePlanetRepository(); expect( (await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts')).mine, isNotNull, ); expect( (await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'ive')).mine, isNull, ); }); test('ID 를 주지 않으면 내 행성이다 — 기존 호출부가 그대로 돈다', () async { final byDefault = await const SamplePlanetRepository().load(nebula: false); final explicit = await SamplePlanetRepository(fandomId: 'bts').load(nebula: false); expect(byDefault.fandomId, 'bts'); expect(byDefault.totalFpi, explicit.totalFpi); }); }); group('지표별 랭킹 — 발주사 검토회신서 4절 회귀 가드', () { test('지표를 바꾸면 랭킹 순서가 달라진다', () async { final byMetric = >{ for (final m in FpiMetric.values) m: (await repo.planetRanking(metric: m)).rows .map((r) => r.name) .toList(), }; // 다섯 축의 1위가 모두 같으면 「같은 Ranking 반복」 상태로 돌아간 것이다. final tops = byMetric.values.map((rows) => rows.first).toSet(); expect(tops.length, greaterThan(1), reason: '지표마다 1위가 같습니다'); // 순서 자체도 축마다 달라야 한다. expect(byMetric.values.map((r) => r.join()).toSet(), hasLength(5)); }); test('지표 카드의 값·순위가 행성 랭킹의 내 행성 행과 같다', () async { final detail = await repo.load(nebula: false); for (final score in detail.scores) { final mine = (await repo.planetRanking(metric: score.metric)).mine; expect(mine, isNotNull, reason: '${score.metric.label} 내 행성 행 없음'); expect(mine!.value, score.value, reason: '${score.metric.label} 수치 불일치'); expect(mine.rank, score.rank, reason: '${score.metric.label} 순위 불일치'); } }); test('TOTAL FPI 는 5축 단순 합산이다 — 별첨 B · 설계서 §4.1', () async { final detail = await repo.load(nebula: false); expect( detail.totalFpi, detail.scores.fold(0, (a, s) => a + s.value), ); }); test('사용자 랭킹도 지표마다 다르다', () async { final tops = { for (final m in FpiMetric.values) (await repo.userRanking(metric: m, fandomId: 'bts')).rows.first.name, }; expect(tops.length, greaterThan(1)); }); }); group('행성 지표 랭킹 화면 (Figma 546:14589)', () { Future pump(WidgetTester tester) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget( const MaterialApp( home: FpiRankingScreen(metric: FpiMetric.bpm, repository: repo), ), ); await tester.pumpAndSettle(); } testWidgets('제목은 지표명 + RANKING 이다', (tester) async { await pump(tester); expect(find.text('BPM RANKING'), findsOneWidget); }); testWidgets('소속 행성 순위를 맨 위에 고정한다', (tester) async { await pump(tester); expect(find.text('내 행성의 BPM 랭킹'), findsOneWidget); // 표본에서 BPM 1위는 NewJeans, 내 행성 BTS 는 2위다(내 줄 카드). expect(find.text('BTS'), findsOneWidget); expect(find.text('NewJeans'), findsOneWidget); }); testWidgets('행성 행을 누르면 사용자 랭킹으로 간다 (546:14639)', (tester) async { await pump(tester); await tester.tap(find.text('BTS (나)')); await tester.pumpAndSettle(); expect(find.byType(FpiUserRankingScreen), findsOneWidget); expect(find.text('나의 BPM 랭킹'), findsOneWidget); }); }); group('사용자 지표 랭킹 화면 (Figma 546:14646)', () { Future pump(WidgetTester tester) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget( const MaterialApp( home: FpiUserRankingScreen( metric: FpiMetric.bpm, fandomId: 'bts', planetName: 'BTS', repository: repo, ), ), ); await tester.pumpAndSettle(); } testWidgets('내 순위를 맨 위에 고정한다', (tester) async { await pump(tester); expect(find.text('나의 BPM 랭킹'), findsOneWidget); // 내 줄 카드와 시상대(3위) 양쪽에 「나」가 있다. expect(find.text('나'), findsOneWidget); expect(find.text('나 (나)'), findsOneWidget); }); testWidgets('필터는 실시간과 국가별 두 축이다', (tester) async { await pump(tester); await tester.tap(find.text('실시간 랭킹')); await tester.pumpAndSettle(); expect(find.text('국가별'), findsWidgets); expect(find.text('취소'), findsOneWidget); }); testWidgets('국가별로 바꾸면 제목과 나의 랭킹 영역이 국가 기준이 된다', (tester) async { await pump(tester); await tester.tap(find.text('실시간 랭킹')); await tester.pumpAndSettle(); await tester.tap(find.text('국가별').last); await tester.pumpAndSettle(); // 전 국가 합산이므로 행성명이 없다. expect(find.text('BTS'), findsNothing); expect(find.text('국가별 랭킹'), findsOneWidget); // 「나의 랭킹 영역」(546:14713)은 여기서 내 국가 행이다. expect(find.text('나의 BPM 랭킹'), findsNothing); expect(find.text('내 국가의 BPM 랭킹'), findsOneWidget); // 목록에서도 내 국가 행에 표시가 붙는다. expect(find.text('대한민국 (나)'), findsOneWidget); }); }); }