import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fancake/core/data/planet_visual_state.dart'; import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/core/ui/planet_25d.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/galaxy/galaxy_search_screen.dart'; import 'package:fancake/features/planet/planet_detail_screen.dart'; import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart'; import 'package:fancake/features/galaxy/widgets/galaxy_web_view.dart'; import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_screen.dart'; import 'package:fancake/features/home/widgets/home_hero.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/quest/quest_data.dart'; import 'eclipse_stubs.dart'; import 'galaxy_mini_map_capture_test.dart' show loadAppFonts, mineAt; import 'perf_paint_test.dart' show medianPaintMicros, paintBudgetMicros; /// 프로토타입 궤도 간격 450 × Mass Volume 1.5 × Orbit Exp 1.0. const _spacing = 675.0; GalaxySnapshot _snap(List planets) => GalaxySnapshot(planets: planets, myPop: 0); const _base = [ PlanetNode( id: 'a', name: 'Alpha', rank: 1, fpi: 5000, members: 10, online: 1204, isMine: true, ), PlanetNode( id: 'b', name: 'Beta', rank: 2, fpi: 4000, members: 10, online: 30, ), PlanetNode( id: 'c', name: 'Gamma', rank: 3, fpi: 1000, members: 10, online: 20, ), ]; void main() { late int opened; Future pumpMap( WidgetTester tester, GalaxySnapshot data, { double size = 140, }) => tester.pumpWidget( MaterialApp( home: Center( child: GalaxyMiniMap(data: data, onOpen: () => opened++, size: size), ), ), ); GalaxyMiniMapPainter painterOf(WidgetTester tester) => tester .widgetList( find.descendant( of: find.byType(GalaxyMiniMap), matching: find.byType(CustomPaint), ), ) .map((w) => w.painter) .whereType() .single; setUp(() => opened = 0); testWidgets('실데이터의 내 행성 HUD 숫자를 그리고 궤도는 순위로 정한다', (tester) async { await pumpMap(tester, _snap(_base)); final label = tester.getSemantics(find.byType(GalaxyMiniMap)).label; expect(label, contains('ALPHA · RANK #1 · FPI: 5000 · LIVE: 1,204')); final p = painterOf(tester); expect(p.orbitOf('a'), 0); expect(p.orbitOf('b'), _spacing); expect(p.orbitOf('c'), 2 * _spacing); expect(tester.takeException(), isNull); }); testWidgets('행성 색은 팬덤 colorKey 를 따르고, 없거나 모르는 키는 순번 색으로 둔다', (tester) async { await pumpMap( tester, _snap([ for (final (i, p) in _base.indexed) PlanetNode( id: p.id, name: p.name, rank: p.rank, fpi: p.fpi, members: p.members, online: p.online, isMine: p.isMine, colorKey: const ['gold', 'violet', null][i], ), ]), ); final p = painterOf(tester); expect(p.colorOf('a'), planetColorOf('gold')); // 모르는 키(서버 기본값 violet)·미제공은 FPI 순번 프로토타입 색(2번째 · 3번째). expect(p.colorOf('b'), const Color(0xFF8B5CF6)); expect(p.colorOf('c'), const Color(0xFF10B981)); }); testWidgets('누르면 열리고, 가로로 끌면 장면이 돈다(±180° 한계)', (tester) async { await pumpMap(tester, _snap(_base)); await tester.tap(find.byType(GalaxyMiniMap)); expect(opened, 1); await tester.drag(find.byType(GalaxyMiniMap), const Offset(30, 0)); await tester.pump(); final turned = painterOf(tester).rotation; expect(turned, greaterThan(0)); await tester.drag(find.byType(GalaxyMiniMap), const Offset(-2000, 0)); await tester.pump(); expect(painterOf(tester).rotation, closeTo(-math.pi, 1e-9)); expect(opened, 1, reason: '끌기는 열기가 아니다'); }); testWidgets('순위가 바뀌면 궤도와 HUD 숫자가 따라 바뀐다', (tester) async { await pumpMap(tester, _snap(_base)); await pumpMap( tester, _snap(const [ PlanetNode( id: 'c', name: 'Gamma', rank: 1, fpi: 6000, members: 10, online: 20, ), PlanetNode( id: 'a', name: 'Alpha', rank: 2, fpi: 5100, members: 10, online: 1300, isMine: true, ), PlanetNode( id: 'b', name: 'Beta', rank: 3, fpi: 4000, members: 10, online: 30, ), ]), ); final p = painterOf(tester); expect(p.orbitOf('c'), 0); expect(p.orbitOf('a'), _spacing); expect( tester.getSemantics(find.byType(GalaxyMiniMap)).label, contains('ALPHA · RANK #2 · FPI: 5100 · LIVE: 1,300'), ); }); testWidgets('움직임이 켜져 있으면 새 궤도로 미끄러져 간다(프로토타입 lerp)', (tester) async { Planet25D.animationsEnabled = true; addTearDown(() => Planet25D.animationsEnabled = false); await pumpMap(tester, _snap(_base)); // 한 프레임 dt 는 1/20 초로 잘리므로 프레임 단위로 흘린다. for (var i = 0; i < 30; i++) { await tester.pump(const Duration(milliseconds: 16)); } final mid = painterOf(tester).orbitOf('c')!; expect(mid, greaterThan(0)); expect(mid, lessThan(2 * _spacing)); for (var i = 0; i < 600; i++) { await tester.pump(const Duration(milliseconds: 16)); } expect(painterOf(tester).orbitOf('c'), closeTo(2 * _spacing, 5)); }); // 10/4 실기기 — 140pt 에서 HUD 글상자가 미니맵 대부분을 덮어 궤도·행성이 안 보였다. testWidgets('140 에서는 내 행성 간략 카드만(C-105) — 장면을 덮지 않는 크기', (tester) async { await tester.runAsync(loadAppFonts); await pumpMap(tester, _snap(_base)); final p = painterOf(tester); expect(p.compactText, [ ('ALPHA', GalaxyMiniMapPainter.compactNamePt), ('#1 · FPI 5,000', GalaxyMiniMapPainter.compactLinePt), ('LIVE 1,204', GalaxyMiniMapPainter.compactLinePt), ]); for (final (_, pt) in p.compactText) { expect(pt, greaterThanOrEqualTo(8)); } final card = p.compactSize; expect(card.width, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor)); expect( card.width * card.height, lessThan(140 * 140 * 0.2), reason: '카드는 미니맵 넓이의 1/5 미만', ); expect(p.hudWidthOf('b'), 0, reason: '다른 행성 카드는 뺀다'); expect(p.hudWidthOf('c'), 0); }); testWidgets('긴 행성 이름은 말줄임 — 순위·FPI·LIVE 숫자는 줄이지 않는다', (tester) async { await tester.runAsync(loadAppFonts); await pumpMap( tester, _snap(const [ PlanetNode( id: 'a', name: 'FANUZU 데모 행성 아주 긴 이름 #1', rank: 1, fpi: 123456, members: 10, online: 1204, isMine: true, ), ]), ); final p = painterOf(tester); expect(p.compactText.skip(1).map((t) => t.$1), [ '#1 · FPI 123,456', 'LIVE 1,204', ]); for (final (_, pt) in p.compactText.skip(1)) { expect(pt, GalaxyMiniMapPainter.compactLinePt); } expect( p.compactSize.width, lessThanOrEqualTo(140 * GalaxyMiniMapPainter.hudFloor + 0.01), ); }); testWidgets('내 행성이 1위가 아니어도 첫 화면 가운데에 둔다(C-103)', (tester) async { await pumpMap(tester, _snap(mineAt(3))); final p = painterOf(tester); expect(p.worldOf('3')!.distance, lessThan(1), reason: '내 행성 = 장면 원점'); expect(p.orbitOf('3'), 2 * _spacing, reason: '궤도는 FPI 순위 그대로'); expect(p.worldOf('1')!.distance, greaterThan(_spacing / 2)); }); testWidgets('320 에서는 모든 행성에 프로토타입 카드를 그린다', (tester) async { await pumpMap(tester, _snap(_base), size: 320); final p = painterOf(tester); for (final id in ['a', 'b', 'c']) { expect(p.hudWidthOf(id), greaterThan(0), reason: id); } expect(p.compactText, isEmpty); }); testWidgets('FPI 상위 10개 + 순위 밖 내 행성만 그린다', (tester) async { await pumpMap( tester, _snap([ for (var i = 0; i < 12; i++) PlanetNode( id: 'p$i', name: 'P$i', rank: i + 1, fpi: 1000 - i, members: 1, online: 1, isMine: i == 11, ), ]), ); final p = painterOf(tester); expect(p.orbitOf('p9'), isNotNull); expect(p.orbitOf('p10'), isNull); expect(p.orbitOf('p11'), isNotNull); }); testWidgets('보조 기술은 버튼 하나로 읽고 눌러 연다', (tester) async { final handle = tester.ensureSemantics(); await pumpMap(tester, _snap(_base)); final node = tester.getSemantics(find.byType(GalaxyMiniMap)); expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); node.owner!.performAction(node.id, SemanticsAction.tap); expect(opened, 1); handle.dispose(); }); testWidgets('게이지 조절 UI 는 없다(C-51)', (tester) async { await pumpMap(tester, _snap(_base)); expect(find.byType(Slider), findsNothing); expect(find.byType(RangeSlider), findsNothing); for (final gauge in [ 'Mass Volume', 'Orbit Exp.', 'HUD Size', 'HUD Dist.', 'Camera Height', 'View Rotation', 'Action Flare', ]) { expect(find.text(gauge), findsNothing); } }); test('행성 12개 한 프레임 그리기가 예산 안에 든다', () { final painter = GalaxyMiniMapPainter() ..update([ for (var i = 0; i < 12; i++) PlanetNode( id: 'p$i', name: 'PLANET $i', rank: i + 1, fpi: 300000 - i * 20000, members: 100, online: 10, isMine: i == 2, ), ]) ..settle(); addTearDown(painter.dispose); final micros = medianPaintMicros(painter, const Size(140, 140)); expect(micros, lessThan(paintBudgetMicros)); }); Future> pumpHome( WidgetTester tester, ) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3; addTearDown(tester.view.reset); final states = StreamController.broadcast(); addTearDown(states.close); await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: HomeScreen( repository: const SampleHomeRepository(), questRepository: const SampleQuestRepository(), eclipse: const NoEclipseRepository(), galaxyRepository: const SampleGalaxyRepository(), planetRepository: samplePlanetRepository, watchPlanet: (_) => states.stream, ), ), ); await tester.pump(); await tester.pump(const Duration(seconds: 1)); return states; } GalaxyMiniMapPainter expandedPainter(WidgetTester tester) => tester .widgetList( find.descendant( of: find.byType(HomeGalaxyHero), matching: find.byType(CustomPaint), ), ) .map((w) => w.painter) .whereType() .single; testWidgets('HOME 미니맵 자리 — Figma 「홈」 히어로 우측 상단', (tester) async { final states = await pumpHome(tester); final mini = find.byType(GalaxyMiniMap); expect(mini, findsOneWidget); // C-113 자리 = Figma 「홈」(2097:16428) 히어로 우측 상단 · 오른쪽 여백 0. expect(tester.getSize(mini), const Size.square(HomeHero.miniMapSize)); expect( tester.getTopRight(mini).dx, closeTo(tester.getSize(find.byType(HomeScreen)).width, 1), ); expect( tester.getTopLeft(mini).dy, closeTo( tester.getTopLeft(find.byType(HomeHero)).dy + MediaQuery.paddingOf(tester.element(mini)).top - 48 + // 사본 y 는 상태 표시줄 48 을 포함한다(HomeHero 와 같은 환산). HomeHero.miniMapTop, 1, ), ); await tester.pumpWidget(const SizedBox()); expect(states.hasListener, isFalse); }); testWidgets( '미니맵을 누르면 히어로가 「GALAXY 맵 확장」(343:211127)으로 바뀌고, GALAXY · 뒤로가기로 돌아온다', (tester) async { await pumpHome(tester); await tester.tap(find.byType(GalaxyMiniMap)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.byType(HomeGalaxyHero), findsOneWidget); expect(find.byType(HomeHero), findsNothing); // 확장 맵 하나만 — 상단 미니맵은 사라진다. TODAY'S QUEST 등 아래 구획은 그대로. expect(find.byType(GalaxyMiniMap), findsOneWidget); expect(find.text('TODAY’S QUEST'), findsOneWidget); // 히어로 680 안 y124~680 = 맵(375×556), 아래 54 「GALAXY」 바. final map = find.byType(GalaxyMiniMap); expect(tester.getSize(map), const Size(360, 556)); // 시험 창 폭 360. expect(find.text('GALAXY'), findsOneWidget); await tester.tap(find.byKey(const ValueKey('galaxy-bar-close'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.byType(HomeGalaxyHero), findsNothing); expect(find.byType(HomeHero), findsOneWidget); await tester.tap(find.byType(GalaxyMiniMap)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.byType(HomeGalaxyHero), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.byType(HomeGalaxyHero), findsNothing); expect(find.byType(HomeScreen), findsOneWidget); expect(tester.takeException(), isNull); }, ); testWidgets('확장 맵 — 다른 행성을 누르면 그 행성이 화면 중심이 된다(2008:14486)', (tester) async { await tester.pumpWidget( MaterialApp( home: Center( child: SizedBox( width: 375, height: 556, child: GalaxyMiniMap.expanded( data: _snap(_base), onOpenPlanet: (_) {}, ), ), ), ), ); await tester.pump(); final painter = tester .widgetList(find.byType(CustomPaint)) .map((w) => w.painter) .whereType() .single; expect(painter.focusId, 'a'); // Figma `343:211127` 축척 — HUD 카드는 고정 144×72, 줌과 무관. expect(painter.hudRectOf('a')!.size, GalaxyMiniMapPainter.figmaHud); painter.zoomTo(2); await tester.pump(); expect(painter.hudRectOf('a')!.size, GalaxyMiniMapPainter.figmaHud); painter.zoomTo(1); await tester.pump(); final origin = tester.getTopLeft(find.byType(GalaxyMiniMap)); final at = painter.screenOf('b')!; expect(painter.hudRectOf('b')!.contains(at), isFalse); await tester.tapAt(origin + at); await tester.pump(); expect(painter.focusId, 'b'); // 카메라가 따라가 고른 행성이 가운데로 온다. await tester.pump(const Duration(seconds: 3)); expect(painter.screenOf('b')!.dx, closeTo(375 / 2, 2)); }); testWidgets('확장 맵 — 끌어 회전 · 핀치 줌 · HUD 탭 상세 · 돋보기 검색', (tester) async { await pumpHome(tester); await tester.tap(find.byType(GalaxyMiniMap)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); final painter = expandedPainter(tester); final map = find.byType(GalaxyMiniMap); final origin = tester.getTopLeft(map); expect(painter.focusId, isNotNull); // 끌기 — 회전(±180° 한계). await tester.dragFrom(tester.getCenter(map), const Offset(2000, 0)); await tester.pump(const Duration(seconds: 3)); expect(painter.rotation, closeTo(math.pi, 0.05)); // 핀치 — 두 손가락 벌리기. final c = tester.getCenter(map); final a = await tester.startGesture(c - const Offset(40, 0)); final b = await tester.startGesture(c + const Offset(40, 0)); await a.moveBy(const Offset(-40, 0)); await b.moveBy(const Offset(40, 0)); await tester.pump(); await a.up(); await b.up(); await tester.pump(); expect(painter.zoom, greaterThan(1.2)); // HUD 탭 → 행성 상세. await tester.pump(const Duration(milliseconds: 16)); final focus = painter.focusId!; final hud = painter.hudRectOf(focus)!; expect(hud.isEmpty, isFalse); await tester.tapAt(origin + hud.center); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.byType(PlanetDetailScreen), findsOneWidget); Navigator.of(tester.element(find.byType(PlanetDetailScreen))).pop(); await tester.pump(); await tester.pump(const Duration(seconds: 1)); // 돋보기 → 갤럭시 검색. await tester.tap(find.byIcon(Icons.search)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(find.byType(GalaxySearchScreen), findsOneWidget); }); test('WebView 로 넘기는 데이터 — FPI 상위 10 + 순위 밖 내 행성 · 실제 FPI · 색은 RGB 정수', () { final planets = [ for (var i = 1; i <= 12; i++) PlanetNode( id: 'p$i', name: 'P$i', rank: i, fpi: 100000 - i, members: 1, online: i, isMine: i == 12, ), ]; final list = GalaxyWebView.payload(_snap(planets)); expect(list, hasLength(11)); expect(list.first['fpi'], 99999); expect(list.last['id'], 'p12'); expect(list.last['mine'], isTrue); expect(list.every((p) => (p['color']! as int) <= 0xFFFFFF), isTrue); }); testWidgets('미니맵 → 확장은 같은 지도 위젯 상태를 옮긴다(WebView 재생성 없음)', (tester) async { await pumpHome(tester); final before = tester.state(find.byType(GalaxyWebView)); expect(tester.widget(find.byType(GalaxyWebView)).expanded, isFalse); await tester.tap(find.byType(GalaxyMiniMap)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(tester.state(find.byType(GalaxyWebView)), same(before)); expect(tester.widget(find.byType(GalaxyWebView)).expanded, isTrue); }); }