import 'dart:ui' as ui; import 'package:fancake/features/eclipse/eclipse_data.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/galaxy/galaxy_screen.dart'; import 'package:fancake/features/galaxy/widgets/galaxy_field.dart'; import 'package:fancake/features/planet/planet_data.dart'; import 'package:fancake/features/planet/widgets/fpi_hexagon.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; /// 그리기 비용 측정 — 09-03 GALAXY 2.5D 시연의 프레임 예산을 지키는지 본다. /// /// **왜 이걸 재는가.** 갤럭시 맵은 매 프레임 별밭 220개와 궤도·행성을 다시 그린다. /// 한 프레임의 예산은 60fps 기준 16.7ms 이고, 그 안에 레이아웃·합성·래스터화가 전부 /// 들어가야 한다. 그리기 하나가 예산을 다 먹으면 맵이 끊긴다. /// /// 여기서 재는 것은 **[CustomPainter.paint] 가 표시 목록을 만드는 데 드는 CPU 시간**이다. /// GPU 래스터화 시간은 기기에서만 잴 수 있으므로 이 수치가 곧 프레임 시간은 아니다. /// 다만 이 값이 예산을 넘으면 어떤 기기에서도 60fps 가 나오지 않는다 — 하한 검증이다. /// 표시 목록 생성 시간의 중앙값(마이크로초). int medianPaintMicros( CustomPainter painter, Size size, { int warmup = 20, int samples = 60, }) { // 첫 몇 회는 JIT 예열이라 버린다. 넣으면 중앙값이 실제보다 나쁘게 나온다. for (var i = 0; i < warmup; i++) { _paintOnce(painter, size); } final measured = [ for (var i = 0; i < samples; i++) _paintOnce(painter, size), ]..sort(); return measured[measured.length ~/ 2]; } int _paintOnce(CustomPainter painter, Size size) { final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); final watch = Stopwatch()..start(); painter.paint(canvas, size); watch.stop(); recorder.endRecording().dispose(); return watch.elapsedMicroseconds; } /// 60fps 프레임 예산. 그리기 하나가 이 절반을 넘으면 여유가 없다고 본다. const int frameBudgetMicros = 16700; const int paintBudgetMicros = frameBudgetMicros ~/ 2; void main() { test('갤럭시 맵 한 프레임 그리기가 예산 안에 든다', () { const planets = [ PlanetNode(id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 421, isMine: true), PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 231000, members: 9800, online: 388), PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 198400, members: 8600, online: 254), PlanetNode(id: 'ive', name: 'IVE', rank: 4, fpi: 176200, members: 7400, online: 199), PlanetNode(id: 'aespa', name: 'aespa', rank: 5, fpi: 155900, members: 6900, online: 174), PlanetNode(id: 'lesserafim', name: 'LE SSERAFIM', rank: 6, fpi: 141300, members: 6100, online: 143), PlanetNode(id: 'txt', name: 'TXT', rank: 7, fpi: 128700, members: 5500, online: 121), PlanetNode(id: 'straykids', name: 'Stray Kids', rank: 8, fpi: 119400, members: 5200, online: 108), PlanetNode(id: 'enhypen', name: 'ENHYPEN', rank: 9, fpi: 104800, members: 4700, online: 96), PlanetNode(id: 'zerobaseone', name: 'ZEROBASEONE', rank: 10, fpi: 98200, members: 4100, online: 87), PlanetNode(id: 'riize', name: 'RIIZE', rank: 11, fpi: 90500, members: 3800, online: 74), PlanetNode(id: 'boynextdoor', name: 'BOYNEXTDOOR', rank: 12, fpi: 82100, members: 3400, online: 61), ]; final micros = medianPaintMicros( galaxyBackdropPainterForTest(planets: planets), const Size(GalaxyField.extent, GalaxyField.extent), ); // ignore: avoid_print — 성능 리포트가 이 값을 그대로 인용한다. print('[perf] galaxy-backdrop median paint: ${micros}us'); expect(micros, lessThan(paintBudgetMicros), reason: '갤럭시 맵 그리기가 프레임 예산의 절반을 넘었습니다'); }); test('행성 수를 배로 늘려도 예산 안에 있다', () { final planets = [ for (var i = 0; i < 24; i++) PlanetNode( id: 'f$i', name: 'F$i', rank: i + 1, fpi: 300000 - i * 9000, members: 12000 - i * 300, online: 400 - i * 12, isMine: i == 0, ), ]; final micros = medianPaintMicros( galaxyBackdropPainterForTest(planets: planets), const Size(GalaxyField.extent, GalaxyField.extent), ); print('[perf] galaxy-backdrop(24) median paint: ${micros}us'); expect(micros, lessThan(paintBudgetMicros)); }); test('FPI 육각형 그래프 그리기 비용', () { const scores = [ FpiScore(metric: FpiMetric.bpm, value: 82400, rank: 1, trend: 1240, myTopPercent: 3), FpiScore(metric: FpiMetric.health, value: 61200, rank: 2, trend: -320, myTopPercent: 12), FpiScore(metric: FpiMetric.referral, value: 38900, rank: 4, trend: 640, myTopPercent: 21), FpiScore(metric: FpiMetric.community, value: 55100, rank: 2, trend: 210, myTopPercent: 8), FpiScore(metric: FpiMetric.contribution, value: 46710, rank: 3, trend: 980, myTopPercent: 5), ]; final micros = medianPaintMicros( fpiHexagonPainterForTest(scores: scores, max: 82400), const Size(320, 320), ); print('[perf] fpi-hexagon median paint: ${micros}us'); expect(micros, lessThan(paintBudgetMicros)); }); testWidgets('갤럭시 맵이 두 프레임 안에 선다', (tester) async { // 앱 시작 시간은 여기서 재지 않는다. 테스트 하네스가 바인딩을 세우는 비용이 // 섞여 기기 실측과 자릿수가 다르다 — 시작 시간은 기기에서만 잰다. tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget( MaterialApp( home: GalaxyScreen( repository: SampleGalaxyRepository(), planetRepository: samplePlanetRepository, eclipse: SampleEclipseRepository(), ), ), ); await tester.pump(const Duration(milliseconds: 16)); await tester.pump(const Duration(milliseconds: 16)); expect(find.byType(GalaxyField), findsOneWidget); }); }