import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/features/eclipse/eclipse_data.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/galaxy/galaxy_motion.dart'; import 'package:fancake/core/data/backend.dart' show BackendException; import 'package:fancake/features/galaxy/galaxy_home_screen.dart'; import 'package:fancake/features/galaxy/galaxy_screen.dart'; import 'package:fancake/features/galaxy/galaxy_search_screen.dart'; import 'package:fancake/features/galaxy/widgets/zap_button.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/planet/planet_detail_screen.dart'; import 'eclipse_stubs.dart'; /// 저장소를 전부 주입한 궤도 맵. 화면이 표본을 기본값으로 만들지 않으므로 /// (공통 규칙 — 저장소 선택은 `repositories.dart` 한 곳) 테스트가 직접 넘긴다. GalaxyScreen _galaxy({ GalaxyRepository repository = const SampleGalaxyRepository(), EclipseRepository eclipse = const NoEclipseRepository(), }) => GalaxyScreen( repository: repository, planetRepository: samplePlanetRepository, eclipse: eclipse, ); /// 갤럭시 맵은 궤도 회전 애니메이션이 끊이지 않는다. `pumpAndSettle` 은 영원히 /// 기다리므로 이 화면 계열에서는 시간을 정해 [WidgetTester.pump] 한다. Future _pump(WidgetTester tester, Widget screen) async { await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen)); await tester.pump(); // FutureBuilder await tester.pump(const Duration(milliseconds: 950)); // 이벤트 띠 슬라이드 } /// 두 손가락으로 끝까지 확대하며 오른쪽 아래로 끈다(한 손가락 팬은 없다). Future _pinch(WidgetTester tester) async { final origin = tester.getTopLeft(find.byType(InteractiveViewer)); final a = await tester.startGesture(origin + const Offset(4, 4)); final b = await tester.startGesture( origin + const Offset(24, 24), pointer: 7, ); for (var i = 1; i <= 10; i++) { await b.moveTo(origin + Offset(24.0 + i * 32, 24.0 + i * 32)); await a.moveTo(origin + Offset(4.0 + i * 20, 4.0 + i * 20)); await tester.pump(); } await a.up(); await b.up(); await _settle(tester); } /// 회전이 도는 동안에도 안전하게 몇 프레임 넘긴다. Future _settle(WidgetTester tester) async { for (var i = 0; i < 6; i++) { await tester.pump(const Duration(milliseconds: 120)); } } /// 궤도 꼭대기에 바깥 궤도 행성을 세우는 표본. 목록 index 가 궤도 각도를 정하므로 /// (`index * 2pi / 5.4`) index 4 가 화면 위쪽 끝이다. class _TopOrbitRepository implements GalaxyRepository { const _TopOrbitRepository(); @override Future setPrimary(String fandomId) async {} @override Future> search(String query) async => const []; @override Future zap({ required String fandomId, required int count, required String clientSeq, }) async => const ZapResult(txId: '', balanceAfter: -1); @override Future load() async => const GalaxySnapshot( myPop: 0, joined: false, planets: [ PlanetNode(id: 'mine', name: '내 행성', rank: 1, fpi: 100, members: 1, online: 1, isMine: true), PlanetNode(id: 'a', name: 'A', rank: 2, fpi: 90, members: 1, online: 1), PlanetNode(id: 'b', name: 'B', rank: 3, fpi: 80, members: 1, online: 1), PlanetNode(id: 'c', name: 'C', rank: 4, fpi: 70, members: 1, online: 1), PlanetNode(id: 'top', name: 'TOP', rank: 6, fpi: 60, members: 1, online: 1), PlanetNode(id: 'e', name: 'E', rank: 5, fpi: 50, members: 1, online: 1), ], ); } /// 내 행성이 1위가 아닌 표본 — Ranking View 전환이 눈에 보이게 한다. class _RankedRepository extends _TopOrbitRepository { const _RankedRepository(); @override Future load() async => const GalaxySnapshot( myPop: 0, joined: false, planets: [ PlanetNode(id: 'top', name: 'TOP', rank: 1, fpi: 900, members: 99, online: 1, stage: 4), PlanetNode(id: 'a', name: 'A', rank: 2, fpi: 500, members: 5, online: 1), PlanetNode(id: 'mine', name: '내 행성', rank: 3, fpi: 100, members: 7, online: 1, isMine: true), ], ); } /// 대표 팬덤 변경 호출을 기록하는 표본. [error] 가 있으면 서버 거부를 흉내 낸다. class _PrimaryRepository extends SampleGalaxyRepository { _PrimaryRepository({this.error}); final BackendException? error; final changes = []; @override Future setPrimary(String fandomId) async { if (error case final e?) throw e; changes.add(fandomId); } } void main() { group('GALAXY 모아보기 (§2.56 · §2.58) — 기본 행성 변경', () { Future pumpHome(WidgetTester tester, GalaxyRepository repo) async { await tester.pumpWidget(MaterialApp( theme: buildAppTheme(), home: GalaxyHomeScreen( repository: repo, planetRepository: samplePlanetRepository, eclipse: const NoEclipseRepository(), ), )); await tester.pumpAndSettle(); } testWidgets('대표가 아닌 가입 행성을 누르면 변경 모달 → fn-primary-fandom-set', (tester) async { final repo = _PrimaryRepository(); await pumpHome(tester, repo); await tester.tap(find.bySemanticsLabel('NewJeans')); await tester.pumpAndSettle(); expect(find.text('선택한 행성을 기본 행성으로 변경할까요?'), findsOneWidget); await tester.tap(find.text('변경')); await tester.pumpAndSettle(); expect(repo.changes, ['newjeans']); }); testWidgets('30일 대기 중이면 서버 사유(E-FDM-04)를 알린다', (tester) async { final repo = _PrimaryRepository( error: const BackendException('E-FDM-04', '기본 행성은 변경 후 30일이 지나야 다시 바꿀 수 있습니다.'), ); await pumpHome(tester, repo); await tester.tap(find.bySemanticsLabel('NewJeans')); await tester.pumpAndSettle(); await tester.tap(find.text('변경')); await tester.pumpAndSettle(); expect(find.textContaining('30일이 지나야'), findsOneWidget); }); }); group('갤럭시 맵 (F2 · 354:220228)', () { testWidgets('내 행성 HUD 가 Rank·FPI·접속자수를 띄운다', (tester) async { await _pump(tester, _galaxy()); // 명세 — HUD 데이터 : Rank, FPI 총점, 접속자수(맴버수) expect(find.text('RANK'), findsOneWidget); expect(find.text('FPI'), findsOneWidget); expect(find.textContaining('접속 1,204명'), findsOneWidget); expect(find.textContaining('멤버 12,840명'), findsOneWidget); }); testWidgets('Ranking View 를 켜면 FPI 1위로 중심이 옮겨 가고 끄면 내 행성으로 돌아온다', (tester) async { await _pump(tester, _galaxy(repository: const _RankedRepository())); // 진입은 내 Primary 행성 중심(B-21) — HUD 가 내 행성을 가리킨다. expect(find.textContaining('멤버 7명'), findsOneWidget); await tester.tap(find.byTooltip('Ranking View')); await _settle(tester); expect(find.textContaining('멤버 99명'), findsOneWidget); expect(find.textContaining('멤버 7명'), findsNothing); await tester.tap(find.byTooltip('Ranking View')); await _settle(tester); expect(find.textContaining('멤버 7명'), findsOneWidget); }); testWidgets('줌인·줌아웃과 터치 이동이 가능하다', (tester) async { await _pump(tester, _galaxy()); // 명세 — 3d갤럭시 : 브라우징 줌인 줌아웃 / 터치 이동 expect(find.byType(InteractiveViewer), findsOneWidget); final v = tester.widget(find.byType(InteractiveViewer)); expect(v.minScale, lessThan(1)); expect(v.maxScale, greaterThan(1)); }); // 검토회신서 3절 — *「선택 행성 정보가 행성과 연계된 HUD 가 아니라 화면 // 최상단 고정 패널 형태」*. 고정 패널로 되돌아가면 여기서 깨진다. testWidgets('HUD 는 화면이 아니라 행성에 붙어 움직인다', (tester) async { tester.view.physicalSize = const Size(1200, 2000); tester.view.devicePixelRatio = 2.0; addTearDown(tester.view.reset); await _pump(tester, _galaxy()); await tester.tap(find.byKey(const ValueKey('newjeans'))); await _settle(tester); final before = tester.getCenter(find.text('RANK')); // 확대하면 행성이 커지고, 행성 위에 붙은 카드는 행성 가장자리를 따라 밀려난다. await _pinch(tester); final after = tester.getCenter(find.text('RANK')); expect( (after - before).distance, greaterThan(20), reason: '화면 고정 패널이면 확대해도 제자리에 있다', ); }); // 검토회신서 3절 — *「행성 선택 시 중심 전환과 HUD 데이터의 즉시 연동」*. testWidgets('행성을 고르면 HUD 가 즉시 바뀌고 중심 전환은 감속하며 간다', (tester) async { // 기본 테스트 화면(800×600)에서는 궤도가 하단 이벤트 띠와 겹쳐 탭이 // 띠에 먹힌다. 세로를 넉넉히 잡아 겹침을 없앤다. tester.view.physicalSize = const Size(1200, 2000); tester.view.devicePixelRatio = 2.0; addTearDown(tester.view.reset); await _pump(tester, _galaxy()); await tester.tap(find.byKey(const ValueKey('newjeans'))); await tester.pump(); // 즉시 연동 — 고른 행성의 FPI 총점이 바로 HUD 에 올라온다. expect(find.text('271,905'), findsOneWidget); // HUD 카드는 행성 위에 가운데를 맞춰 선다. 게이지의 x 가 곧 행성의 x 다. final gauge = find.byType(LinearProgressIndicator); final mid = tester.getSize(find.byType(Scaffold)).width / 2; await tester.pump(const Duration(milliseconds: 16)); final early = (tester.getCenter(gauge).dx - mid).abs(); await tester.pump(GalaxyMotion.recenter); final settled = (tester.getCenter(gauge).dx - mid).abs(); expect(settled, lessThan(12), reason: '전환이 끝나면 고른 행성이 한가운데다'); expect(early, greaterThan(settled + 20), reason: '한 프레임에 순간이동하면 「행성 간 이동감」이 없다'); }); // 리뷰 M3 — HUD 위/아래 뒤집힘이 비행 중에 일어나면 카드가 행성을 가로질러 // 순간 이동한다. 선택 시점에 정한 방향을 전환이 끝날 때까지 붙들어야 한다. // // 표본 6행성은 어느 것도 화면 위쪽 끝에서 출발하지 않아 뒤집힘이 나지 않는다. // 궤도 꼭대기(목록 index 4 → 각도 ≈ -pi/2)에 바깥 궤도 행성을 놓아 재현한다. testWidgets('중심 전환 중에 HUD 가 행성 위아래로 뒤집히지 않는다', (tester) async { // 세로 800 이 분기점이다 — 출발점에는 카드가 들어갈 자리가 없고(아래), // 도착점인 화면 한가운데에는 있다(위). tester.view.physicalSize = const Size(800, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); await _pump(tester, _galaxy(repository: const _TopOrbitRepository())); await tester.tap(find.byKey(const ValueKey('top'))); await tester.pump(); expect(find.byKey(const ValueKey('hud-top')), findsOneWidget); final planet = find.byKey(const ValueKey('top')); final card = find.byType(LinearProgressIndicator); int? side; // 전환이 끝나기 전까지(620ms) 방향이 한 번도 바뀌면 안 된다. for (var i = 0; i < 30; i++) { await tester.pump(const Duration(milliseconds: 16)); final now = tester.getCenter(card).dy < tester.getCenter(planet).dy ? -1 : 1; side ??= now; expect(now, side, reason: '비행 ${i * 16}ms 에서 HUD 가 반대편으로 넘어갔다'); } }); // 명세 — *HUD 터치시 FPI 상세 페이지 이동*. 목 모드에서 저장소가 누른 행성 // ID 를 버리면 여섯 행성이 전부 내 행성(BTS) 상세로 열린다. testWidgets('남의 행성 HUD 를 누르면 그 행성 상세가 열린다', (tester) async { tester.view.physicalSize = const Size(1200, 2400); tester.view.devicePixelRatio = 2.0; addTearDown(tester.view.reset); // 기본 팩토리(samplePlanetRepository)를 그대로 쓴다 — 실제 앱 경로와 같다. await _pump(tester, _galaxy()); await tester.tap(find.byKey(const ValueKey('newjeans'))); await tester.pump(); await tester.tap(find.byKey(const ValueKey('hud-newjeans'))); // 갤럭시는 회전이 멈추지 않으므로 pumpAndSettle 을 쓸 수 없다. for (var i = 0; i < 20; i++) { await tester.pump(const Duration(milliseconds: 60)); } expect(find.textContaining('TOTAL FPI'), findsOneWidget, reason: '상세로 넘어가지 않았다'); expect(find.text('NewJeans'), findsWidgets); expect(find.textContaining('271,905'), findsWidgets); // 내 행성 상세가 열리면 이 값들이 보인다. 하나라도 보이면 ID 를 버린 것이다. expect(find.text('BTS'), findsNothing, reason: '내 행성 상세가 열렸다'); expect(find.textContaining('284,310'), findsNothing, reason: '내 행성 상세가 열렸다'); // 남의 행성에는 내 기여도가 없다 — 대조표 3-5. expect(find.textContaining(RegExp(r'상위 \d+%')), findsNothing); }); // 리뷰 M2 — 행성이 뷰포트 밖이면 HUD 를 아예 그리지 않는다. 카드만 가장자리에 // clamp 해 두면 어느 행성을 가리키는지 알 수 없고 연결선은 화면 밖에 그려진다. // 고른 행성은 카메라가 따라가므로(10/5) 확대·초점 이동으로도 화면 밖으로 밀리지 않는다. testWidgets('확대해도 고른 행성은 가운데에 남고 HUD 도 남는다', (tester) async { await _pump(tester, _galaxy()); final mid = tester.getCenter(find.byType(InteractiveViewer)); await _pinch(tester); expect(find.text('RANK'), findsOneWidget); final mine = tester.widget(find.byType(InteractiveViewer)); expect(mine.transformationController!.value.getMaxScaleOnAxis(), greaterThan(GalaxyMotion.focusZoom)); final at = tester.getCenter(find.text('RANK')); expect((at.dx - mid.dx).abs(), lessThan(mid.dx), reason: '카드가 화면 안'); }); // 10/5 실기기(Flip3) — 다른 행성을 탭하면 중심 전환 뒤에도 궤도가 돌아 고른 행성이 // 화면 밖으로 흘러가고 내 행성은 가장자리에 남았다. 명세 「행성 터치시 화면 중심 전환」. testWidgets('고른 행성은 공전·끌기 회전 중에도 화면 가운데에 머문다', (tester) async { tester.view.physicalSize = const Size(1200, 2000); tester.view.devicePixelRatio = 2.0; addTearDown(tester.view.reset); await _pump(tester, _galaxy()); final viewer = find.byType(InteractiveViewer); final mid = tester.getCenter(viewer); final planet = find.byKey(const ValueKey('newjeans')); await tester.tap(planet); for (var i = 0; i < 25; i++) { await tester.pump(const Duration(milliseconds: 120)); // 3초 공전 } expect((tester.getCenter(planet) - mid).distance, lessThan(8)); await tester.fling(viewer, const Offset(-300, 0), 1500); for (var i = 0; i < 25; i++) { await tester.pump(const Duration(milliseconds: 120)); } expect( (tester.getCenter(planet) - mid).distance, lessThan(8), reason: '관성 회전 뒤에도 고른 행성이 가운데', ); }); // 10/5 실기기 — HUD 카드 「>」를 눌러도 행성 상세로 가지 않았다. // 명세 「HUD 터치시 FPI 상세 페이지 이동」. testWidgets('HUD 카드의 화살표를 누르면 고른 행성의 상세로 간다', (tester) async { tester.view.physicalSize = const Size(1200, 2000); tester.view.devicePixelRatio = 2.0; addTearDown(tester.view.reset); await _pump(tester, _galaxy()); await tester.tap(find.byKey(const ValueKey('newjeans'))); for (var i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 120)); } await tester.tap(find.byIcon(Icons.chevron_right)); await _settle(tester); expect(find.byType(PlanetDetailScreen), findsOneWidget); }); testWidgets('가로로 끌면 장면은 제자리에서 궤도가 돌고, 손을 떼도 관성으로 더 돈다', (tester) async { await _pump(tester, _galaxy()); final viewer = tester.widget( find.byType(InteractiveViewer), ); final view = viewer.transformationController!; final before = view.value.clone(); final mine = tester.getCenter(find.text('RANK')); for (var i = 0; i < 4; i++) { await tester.fling( find.byType(InteractiveViewer), const Offset(-300, 0), 1500, ); await tester.pump(); } expect(view.value, before, reason: '끌기는 맵을 옮기지 않는다(팬 없음)'); expect( (tester.getCenter(find.text('RANK')) - mine).distance, lessThan(1), reason: '가운데 내 행성은 화면 밖으로 밀려나지 않는다', ); // 관성 — 손을 뗀 뒤에도 기본 표류보다 빠르게 돌다가 감속한다. final state = tester.state(find.byType(GalaxyScreen)); final clock = (state as dynamic).clockForTest as GalaxyClock; final v0 = clock.velocity; expect(v0.abs(), greaterThan(GalaxyMotion.idleSpin)); await _settle(tester); expect( (clock.velocity - GalaxyMotion.idleSpin).abs(), lessThan((v0 - GalaxyMotion.idleSpin).abs()), ); }); testWidgets('GALAXY 는 좌측 하단, 돋보기는 우측 하단', (tester) async { await _pump(tester, _galaxy()); expect(find.text('GALAXY'), findsOneWidget); final label = tester.getCenter(find.text('GALAXY')); final glass = tester.getCenter(find.byIcon(Icons.search)); final size = tester.getSize(find.byType(Scaffold)); expect(label.dx, lessThan(size.width / 2), reason: 'GALAXY 는 좌측'); expect(glass.dx, greaterThan(size.width / 2), reason: '돋보기는 우측'); }); testWidgets('돋보기를 누르면 검색 화면으로 간다', (tester) async { await _pump(tester, _galaxy()); await tester.tap(find.byIcon(Icons.search)); await _settle(tester); expect(find.byType(GalaxySearchScreen), findsOneWidget); }); }); group('Zap (F2 · 명세 「Zap 버튼」)', () { testWidgets('가입한 사용자에게는 노출된다', (tester) async { await _pump(tester, _galaxy()); expect(find.byType(ZapButton), findsOneWidget); }); testWidgets('가입·생성하지 않은 사용자에게는 노출되지 않는다', (tester) async { await _pump( tester, _galaxy(repository: const SampleGalaxyRepository(joined: false)), ); expect(find.byType(ZapButton), findsNothing); }); testWidgets('롱프레스로 옵션이 열린다', (tester) async { await _pump(tester, _galaxy()); // 명세 — 2초간 롱프레싱할 경우 Zap 옵션 버튼으로 전환 final hold = await tester.startGesture(tester.getCenter(find.byType(ZapButton))); await tester.pump(ZapButton.longPress); await hold.up(); await _settle(tester); for (final label in ['기여', '아이템', '닫기']) { expect(find.text(label), findsOneWidget, reason: '옵션 「$label」 누락'); } // 「자동(오토 도네이션)」은 두지 않는다 — 별첨 1 ① 시나리오 4 가 적었으나 // 별첨 B 가 상위 기준이고 거기에 없는 기능이다(공통 기준 2항). expect(find.text('자동'), findsNothing); }); testWidgets('POP 이 모자라면 누를 수 없다', (tester) async { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: Scaffold( body: ZapButton(zapPop: 10, myPop: 3, onZap: () {}), ), ), ); await tester.pumpAndSettle(); expect(find.text('POP이 모자랍니다'), findsOneWidget); final btn = tester.widget(find.byType(FilledButton)); expect(btn.onPressed, isNull); }); }); group('갤럭시 검색 (F2 · 353:217832)', () { testWidgets('처음에는 최근 검색어가 없다고 알린다', (tester) async { await _pump(tester, const GalaxySearchScreen()); expect(find.text('최근 검색어'), findsOneWidget); expect(find.text('최근 검색어가 없습니다.'), findsOneWidget); }); testWidgets('검색하면 팬덤이 걸리고 최근 검색어에 남는다', (tester) async { await _pump(tester, const GalaxySearchScreen()); await tester.enterText(find.byType(TextField), 'BTS'); await tester.pumpAndSettle(); expect(find.textContaining('멤버 12,840명'), findsOneWidget); await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), ''); await tester.pumpAndSettle(); expect(find.text('전체 삭제'), findsOneWidget); }); testWidgets('결과 줄을 누르면 그 행성 상세로 간다(10/5 실기기)', (tester) async { PlanetNode? opened; await _pump(tester, GalaxySearchScreen(onOpen: (p) => opened = p)); await tester.enterText(find.byType(TextField), 'BTS'); await tester.pumpAndSettle(); await tester.tap(find.textContaining('멤버 12,840명')); expect(opened?.name, 'BTS'); }); testWidgets('없는 이름이면 사유를 알린다', (tester) async { await _pump(tester, const GalaxySearchScreen()); await tester.enterText(find.byType(TextField), 'zzzz'); await tester.pumpAndSettle(); expect(find.textContaining('찾는 팬덤이 없습니다'), findsOneWidget); }); }); testWidgets('F2 화면 문구에 내부 경로가 새지 않는다', (tester) async { for (final screen in [ _galaxy(), const GalaxySearchScreen(), ]) { await _pump(tester, screen); 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」 노출'); } } }); }