Files
fanuzu/app/test/galaxy_mini_map_test.dart
T

550 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PlanetNode> 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<void> 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<CustomPaint>(
find.descendant(
of: find.byType(GalaxyMiniMap),
matching: find.byType(CustomPaint),
),
)
.map((w) => w.painter)
.whereType<GalaxyMiniMapPainter>()
.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<StreamController<PlanetVisualState>> pumpHome(
WidgetTester tester,
) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
final states = StreamController<PlanetVisualState>.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<CustomPaint>(
find.descendant(
of: find.byType(HomeGalaxyHero),
matching: find.byType(CustomPaint),
),
)
.map((w) => w.painter)
.whereType<GalaxyMiniMapPainter>()
.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<CustomPaint>(find.byType(CustomPaint))
.map((w) => w.painter)
.whereType<GalaxyMiniMapPainter>()
.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<GalaxyWebView>(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<GalaxyWebView>(find.byType(GalaxyWebView)).expanded, isTrue);
});
}