Files
fanuzu/app/test/f2_screens_test.dart
T

396 lines
18 KiB
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/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 '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<void> _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<void> _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<void> setPrimary(String fandomId) async {}
@override
Future<ZapResult> zap({
required String fandomId,
required int count,
required String clientSeq,
}) async => const ZapResult(txId: '', balanceAfter: -1);
@override
Future<GalaxySnapshot> 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<GalaxySnapshot> 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 = <String>[];
@override
Future<void> setPrimary(String fandomId) async {
if (error case final e?) throw e;
changes.add(fandomId);
}
}
void main() {
group('GALAXY 모아보기 (§2.56 · §2.58) — 기본 행성 변경', () {
Future<void> 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<InteractiveViewer>(find.byType(InteractiveViewer));
expect(v.minScale, lessThan(1));
expect(v.maxScale, greaterThan(1));
});
// 검토회신서 3절 — *「선택 행성 정보가 행성과 연계된 HUD 가 아니라 화면
// 최상단 고정 패널 형태」*. 고정 패널로 되돌아가면 여기서 깨진다.
testWidgets('HUD 는 화면이 아니라 행성에 붙어 움직인다', (tester) async {
await _pump(tester, _galaxy());
final before = tester.getCenter(find.text('RANK'));
await tester.drag(find.byType(InteractiveViewer), const Offset(-90, -70));
await _settle(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 해 두면 어느 행성을 가리키는지 알 수 없고 연결선은 화면 밖에 그려진다.
testWidgets('행성이 화면 밖으로 나가면 HUD 도 사라진다', (tester) async {
await _pump(tester, _galaxy());
expect(find.text('RANK'), findsOneWidget);
// 맵을 끝까지 밀어 내 행성을 화면 밖으로 보낸다.
for (var i = 0; i < 6; i++) {
await tester.drag(
find.byType(InteractiveViewer),
const Offset(-400, -400),
);
await _settle(tester);
}
expect(find.text('RANK'), findsNothing);
});
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<FilledButton>(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('없는 이름이면 사유를 알린다', (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 <Widget>[
_galaxy(),
const GalaxySearchScreen(),
]) {
await _pump(tester, screen);
final texts = tester
.widgetList<Text>(find.byType(Text))
.map((w) => w.data ?? '')
.join('\n');
for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) {
expect(texts.contains(leak), isFalse, reason: '「$leak」 노출');
}
}
});
}