Files
fanuzu/app/test/f3_screens_test.dart
T

708 lines
28 KiB
Dart

import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/core/ui/relative_time.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/planet/fpi_ranking_screen.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/features/campaign/campaign_data.dart' show ContributorRank;
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/planet/lounge_post_screens.dart';
import 'package:fancake/features/planet/moderation.dart';
import 'package:fancake/features/planet/planet_detail_screen.dart';
import 'package:fancake/features/planet/widgets/planet_detail_hero.dart';
import 'eclipse_stubs.dart';
Future<void> _pump(WidgetTester tester, Widget screen) async {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
await tester.pumpAndSettle();
}
/// 세로가 긴 화면을 한 번에 담는다.
///
/// TabBarView 안에는 스크롤 가능한 것이 여럿이라 `scrollUntilVisible` 이 엉뚱한
/// 쪽을 잡는다. 실기기에 가까운 세로 길이를 주는 편이 확실하다.
Future<void> _pumpTall(
WidgetTester tester,
Widget screen, {
double height = 2400,
}) async {
tester.view.physicalSize = Size(1080, height);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await _pump(tester, screen);
}
/// 서버 산출 「내 기여 상위 %」가 아직 없는 상태의 저장소.
class _NoTopPercentRepository extends SamplePlanetRepository {
@override
Future<void> join() async {}
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) => _sample.history(cursor: cursor);
@override
Future<HeroDropReceipt> claimHeroDrop(String dropId) => _sample.claimHeroDrop(dropId);
const _NoTopPercentRepository();
static const _sample = SamplePlanetRepository();
@override
Future<PlanetDetail> load({required bool nebula}) async {
final detail = await _sample.load(nebula: nebula);
return PlanetDetail(
fandomId: detail.fandomId,
name: detail.name,
isNebula: detail.isNebula,
totalFpi: detail.totalFpi,
members: detail.members,
posts: detail.posts,
scores: [
for (final s in detail.scores)
FpiScore(
metric: s.metric,
value: s.value,
// 집계 모집단 밖(서버 fpiRanks 없음)이면 순위가 0 으로 온다.
rank: 0,
trend: s.trend,
myTopPercent: null,
),
],
mockNote: '「내 기여 상위 %」 집계 함수가 아직 없습니다',
);
}
@override
Future<List<LoungePost>> morePosts({required int offset, int limit = 5}) =>
_sample.morePosts(offset: offset, limit: limit);
@override
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30}) =>
_sample.planetRanking(metric: metric, limit: limit);
@override
Future<FpiRanking> userRanking({
required FpiMetric metric,
required String fandomId,
int limit = 30,
}) => _sample.userRanking(metric: metric, fandomId: fandomId, limit: limit);
@override
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30}) =>
_sample.countryRanking(metric: metric, limit: limit);
}
/// 가입 전 상태로 시작하고, [join] 이 불리면 가입중으로 바뀌는 표본.
class _Contributed extends SamplePlanetRepository {
const _Contributed();
@override
Future<List<ContributorRank>> contributors({int limit = 3}) async => const [
ContributorRank(rank: 1, name: '별빛수집가', pop: 990),
];
}
class _Joinable extends SamplePlanetRepository {
_Joinable();
int joins = 0;
int leaves = 0;
@override
Future<void> join() async => joins++;
@override
Future<void> leave() async => leaves++;
@override
Future<PlanetDetail> load({required bool nebula}) async {
final d = await super.load(nebula: nebula);
return PlanetDetail(
fandomId: d.fandomId, name: d.name, stage: d.stage, isNebula: d.isNebula,
totalFpi: d.totalFpi, scores: d.scores, members: d.members, posts: d.posts,
joined: joins > leaves,
);
}
}
void main() {
_fpiRankingTests();
testWidgets('가입하지 않은 행성은 「가입하기」를 눌러 fn-fandom-join 으로 가입한다', (tester) async {
final repo = _Joinable();
await _pumpTall(tester, PlanetDetailScreen(repository: repo, eclipse: const NoEclipseRepository()));
expect(find.text('가입하기'), findsOneWidget);
await tester.tap(find.text('가입하기'));
await tester.pumpAndSettle();
expect(repo.joins, 1);
expect(find.text('가입중'), findsOneWidget);
// 가입중 알약은 다시 눌러도 가입을 부르지 않는다(탈퇴 시트가 열린다).
await tester.tap(find.text('가입중'));
await tester.pumpAndSettle();
expect(repo.joins, 1);
});
testWidgets('「가입중」 → 탈퇴하기 → 확인 창(343:213582)에서 확인해야 fn-fandom-leave 를 부른다', (tester) async {
final repo = _Joinable()..joins = 1;
await _pumpTall(tester, PlanetDetailScreen(repository: repo, eclipse: const NoEclipseRepository()));
Future<void> openConfirm() async {
await tester.tap(find.text('가입중'));
await tester.pumpAndSettle();
await tester.tap(find.text('탈퇴하기'));
await tester.pumpAndSettle();
expect(find.text('행성 탈퇴를 할까요?'), findsOneWidget);
}
await openConfirm();
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(repo.leaves, 0);
await openConfirm();
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(repo.leaves, 1);
expect(find.text('가입하기'), findsOneWidget);
});
group('행성 상세 · FPI (F3 · 343:213428)', () {
testWidgets('TOTAL FPI 값과 갱신 주기를 띄운다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
expect(find.text('TOTAL FPI : 284,310'), findsOneWidget);
// 정책 원장 B-15 검증 기준 — 화면에 마지막 갱신 시각 노출(서버 시각이 없으면 --:--).
expect(find.text('마지막 갱신 --:--'), findsOneWidget);
expect(find.textContaining('30초'), findsNothing);
// 개설자 이름은 서버에 없으면 그리지 않는다 — 시안 표본 이름을 박지 않는다.
expect(find.text('BTS_Maker'), findsNothing);
});
testWidgets('5지표를 답변서 확정 표기로 띄운다', (tester) async {
// 시안(373:441117)은 1243 높이의 스크롤 화면이라 지표 다섯을 한 창에 담으려면
// 실기기보다 긴 창이 필요하다. 스크롤하면 위 카드가 해제되어 다섯을 동시에 못 본다.
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
), height: 4200);
// 답변서 v1.1 Q3 이 정본이다. Figma 의 BMP·STEPS·REFERAL 이 아니다.
for (final label in [
'BPM',
'HEALTH',
'REFERRAL',
'COMMUNITY',
'CONTRIBUTION',
]) {
expect(find.text(label), findsWidgets, reason: '지표 「$label」 누락');
}
});
testWidgets('지표마다 전체 순위와 내 기여 상위를 보여 준다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
), height: 4200);
expect(find.text('전체 1위'), findsOneWidget);
expect(find.text('상위 17%'), findsWidgets);
});
testWidgets('집계 값이 없으면 숫자를 짓지 않고 내부 사유도 띄우지 않는다 — 대조표 3-5', (tester) async {
await _pumpTall(
tester,
const PlanetDetailScreen(
repository: _NoTopPercentRepository(),
eclipse: NoEclipseRepository(),
),
height: 4200,
);
// 없는 값을 100%(최하위)로 지어내지 않는다.
expect(find.text('상위 100%'), findsNothing);
expect(find.text('—'), findsWidgets);
// 개발용 사유(MOCK · 집계 함수)는 사용자 화면에 내보내지 않는다(10/4 실기기 3-1).
expect(find.textContaining('MOCK'), findsNothing);
expect(find.textContaining('집계 함수'), findsNothing);
// 순위가 없으면 「전체 0위」 대신 「순위 집계 전」.
expect(find.text('전체 0위'), findsNothing);
expect(find.text('순위 집계 전'), findsWidgets);
});
testWidgets('이클립스가 발동 중이면 침식·방어 진행을 띄운다', (tester) async {
// 스펙 B — 「집계 문서를 갤럭시·행성 상세 HUD 에서 실시간으로 표시한다」.
// 침식은 TOTAL FPI 를 깎는 경로이므로(정책 원장 B-55) 그 카드 위에 온다.
await _pumpTall(tester, PlanetDetailScreen(
repository: const SamplePlanetRepository(),
eclipse: SampleEclipseRepository(),
));
expect(find.textContaining('이클립스 발동'), findsOneWidget);
expect(find.textContaining('침식 42%'), findsOneWidget);
// **POP 단위가 아니다** — 방어는 POP 을 소모하지 않는 공동 Shake 참여다
// (Firestore 설계서 v1.1 §9-#5).
expect(find.textContaining('유효 참여 5,800 / 10,000명'), findsOneWidget);
expect(find.textContaining('POP'), findsNothing);
});
testWidgets('이클립스가 없으면 침식 배너 자리가 비지 않는다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
expect(find.textContaining('이클립스 발동'), findsNothing);
// 배너가 없어도 본문은 그대로다 — 빈 자리가 남지 않는다.
expect(find.textContaining('TOTAL FPI'), findsOneWidget);
});
});
group('팬덤 라운지 (F3 · 251:51073)', () {
testWidgets('최초 5개를 최신순으로 띄운다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
await tester.tap(find.text('팬덤 라운지'));
await tester.pumpAndSettle();
// 명세 — 최초 5개, 스크롤 시 5개씩 호출.
expect(find.textContaining('오늘 공방 다녀왔어요'), findsOneWidget);
expect(find.byType(FloatingActionButton), findsOneWidget);
});
testWidgets('글이 하나도 없으면 빈 상태 안내를 띄운다', (tester) async {
await _pumpTall(tester, const PlanetDetailScreen(
repository: _NoPosts(),
eclipse: NoEclipseRepository(),
));
await tester.tap(find.text('팬덤 라운지'));
await tester.pumpAndSettle();
expect(find.text('팬덤 라운지에 자유롭게 글을 작성해 보세요.'), findsOneWidget);
expect(find.byType(FloatingActionButton), findsOneWidget);
});
testWidgets('스크롤하면 히어로가 접히고 탭은 위에 남는다 — 발주사 지적(글이 보이게)', (tester) async {
tester.view.physicalSize = const Size(750, 1624);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await _pump(
tester,
const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
),
);
await tester.tap(find.text('팬덤 라운지'));
await tester.pumpAndSettle();
final tabTop = tester.getTopLeft(find.text('팬덤 라운지')).dy;
await tester.drag(
find.textContaining('오늘 공방 다녀왔어요'),
const Offset(0, -500),
);
await tester.pumpAndSettle();
// 탭 줄이 앱 바 바로 아래로 올라와 고정되고, 글이 화면 위쪽까지 올라온다.
expect(tester.getTopLeft(find.text('팬덤 라운지')).dy, lessThan(tabTop - 300));
expect(find.text('팬덤 라운지').hitTestable(), findsOneWidget);
expect(find.textContaining('이번 앨범 최애 트랙').hitTestable(), findsOneWidget);
});
testWidgets('이 회원의 글 모두 숨기기 — 확인하면 그 작성자 글이 목록에서 빠진다', (tester) async {
await _pumpTall(
tester,
const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
),
);
await tester.tap(find.text('팬덤 라운지'));
await tester.pumpAndSettle();
expect(find.text('아미보이'), findsOneWidget);
await tester.tap(
find.image(const AssetImage('assets/figma/icon-post-more.png')).first,
);
await tester.pumpAndSettle();
await tester.tap(find.text('이 회원의 글 모두 숨기기'));
await tester.pumpAndSettle();
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(find.text('아미보이'), findsNothing);
});
testWidgets('글 쓰기는 본문이 있어야 완료된다', (tester) async {
// 시안 244:21746 · 설계서 §2.6 posts — 제목 칸 없이 본문 한 칸.
await _pump(tester, const LoungeComposeScreen());
final register = find.widgetWithText(TextButton, '완료');
expect(tester.widget<TextButton>(register).onPressed, isNull);
await tester.enterText(find.byType(TextField), ' ');
await tester.pumpAndSettle();
// 공백만으로는 아직 안 된다.
expect(tester.widget<TextButton>(register).onPressed, isNull);
await tester.enterText(find.byType(TextField), '본문입니다');
await tester.pumpAndSettle();
expect(tester.widget<TextButton>(register).onPressed, isNotNull);
});
});
group('신고 · 차단 (F3 · 252:162674 · 252:163306)', () {
testWidgets('사유를 고르기 전에는 신고할 수 없다', (tester) async {
await _pump(
tester,
const ReportScreen(
target: ModerationTarget(authorName: '아미보이', title: '테스트 글'),
),
);
// 신고 정보 영역 — 작성자 닉네임 · 게시물 제목
expect(find.text('아미보이'), findsOneWidget);
expect(find.text('테스트 글'), findsOneWidget);
final btn = tester.widget<TextButton>(find.widgetWithText(TextButton, '신고'));
expect(btn.onPressed, isNull);
});
testWidgets('사유는 단수 선택이고 고르면 확인을 되묻는다', (tester) async {
await _pump(
tester,
const ReportScreen(
target: ModerationTarget(authorName: '아미보이', title: '테스트 글'),
),
);
await tester.tap(find.text('부적절한 콘텐츠'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, '신고'));
await tester.pumpAndSettle();
// 화면 중앙 오버레이 — 취소 / 확인
expect(find.textContaining('신고할까요?'), findsOneWidget);
expect(find.text('취소'), findsOneWidget);
expect(find.text('확인'), findsOneWidget);
});
testWidgets('신고 사유는 어드민 등록 항목을 받는다', (tester) async {
await _pump(
tester,
const ReportScreen(
target: ModerationTarget(authorName: 'x', title: 'y'),
reasons: ['운영자가 정한 사유'],
),
);
expect(find.text('운영자가 정한 사유'), findsOneWidget);
expect(find.text('부적절한 콘텐츠'), findsNothing);
});
});
group('성운 상세 (F3 · 373:442708)', () {
testWidgets('성운도 같은 화면으로 열린다', (tester) async {
await _pump(tester, const PlanetDetailScreen(
nebula: true,
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
expect(find.text('New_BTS'), findsOneWidget);
});
testWidgets('기여자가 있으면 실시간 기여 랭킹에 단상이 그려진다(10/5 실기기)', (tester) async {
await _pump(tester, const PlanetDetailScreen(
nebula: true,
repository: _Contributed(),
eclipse: NoEclipseRepository(),
));
await tester.pumpAndSettle();
expect(find.text('별빛수집가', skipOffstage: false), findsOneWidget);
expect(find.text('아직 기여자가 없어요', skipOffstage: false), findsNothing);
});
});
testWidgets('상대 시각 표기', (tester) async {
final now = DateTime(2026, 8, 31, 15);
expect(relativeTime(now.subtract(const Duration(seconds: 20)), now: now), '방금 전');
expect(relativeTime(now.subtract(const Duration(minutes: 10)), now: now), '10분 전');
expect(relativeTime(now.subtract(const Duration(hours: 3)), now: now), '3시간 전');
expect(relativeTime(now.subtract(const Duration(days: 2)), now: now), '2일 전');
});
testWidgets('F3 화면 문구에 내부 경로가 새지 않는다', (tester) async {
await _pump(tester, const PlanetDetailScreen(
repository: SamplePlanetRepository(),
eclipse: NoEclipseRepository(),
));
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」 노출');
}
});
}
/// 지표별 랭킹 2단계 — F3 · Figma `546:14589`(행성) → `546:14646`(사용자).
///
/// 발주사 검토회신서 4절 회귀 가드다. 지적은 두 가지였다 —
/// ① 지표 카드와 Ranking 사이 수치·순위 불일치 ② 지표를 바꿔도 같은 Ranking.
/// 둘 다 여기서 깨지면 바로 보이게 고정한다.
void _fpiRankingTests() {
const repo = SamplePlanetRepository();
group('표본은 행성별로 갈린다 — 어느 HUD 를 눌러도 같은 상세가 열리면 안 된다', () {
// 갤럭시 HUD 는 남의 행성도 연다. 표본이 내 행성 하나로 고정돼 있으면
// 여섯 행성이 전부 BTS 상세로 열려 발주사 확인에서 바로 드러난다.
test('행성마다 이름·TOTAL FPI·멤버 수가 다르다', () async {
final details = <PlanetDetail>[
for (final id in [
'bts',
'newjeans',
'seventeen',
'ive',
'aespa',
'lesserafim',
])
await SamplePlanetRepository(fandomId: id).load(nebula: false),
];
expect(details.map((d) => d.fandomId).toSet(), hasLength(6));
expect(details.map((d) => d.name).toSet(), hasLength(6));
expect(details.map((d) => d.totalFpi).toSet(), hasLength(6));
expect(details.map((d) => d.members).toSet(), hasLength(6));
});
test('상세의 TOTAL FPI 와 멤버 수가 갤럭시 맵 표본과 같다', () async {
// 같은 행성인데 HUD 와 상세가 다른 수치를 띄우면 그것부터 지적받는다.
final snapshot = await const SampleGalaxyRepository().load();
for (final node in snapshot.planets) {
final detail =
await SamplePlanetRepository(fandomId: node.id).load(nebula: false);
expect(detail.totalFpi, node.fpi, reason: '${node.id} TOTAL FPI 불일치');
expect(detail.members, node.members, reason: '${node.id} 멤버 수 불일치');
expect(detail.name, node.name, reason: '${node.id} 이름 불일치');
}
});
test('전체 순위는 그 행성의 순위다 — 내 행성 순위를 돌려주지 않는다', () async {
final detail =
await SamplePlanetRepository(fandomId: 'lesserafim').load(nebula: false);
for (final score in detail.scores) {
final rows = (await const SamplePlanetRepository()
.planetRanking(metric: score.metric))
.rows;
final row = rows.firstWhere((r) => r.id == 'lesserafim');
expect(score.rank, row.rank, reason: '${score.metric.label} 순위 불일치');
expect(score.value, row.value, reason: '${score.metric.label} 수치 불일치');
}
});
test('남의 행성에서는 내 기여 상위 %를 지어내지 않는다 — 대조표 3-5', () async {
final mine = await const SamplePlanetRepository().load(nebula: false);
expect(mine.scores.every((s) => s.myTopPercent != null), isTrue);
final theirs =
await SamplePlanetRepository(fandomId: 'aespa').load(nebula: false);
expect(theirs.scores.every((s) => s.myTopPercent == null), isTrue);
});
test('남의 행성 사용자 랭킹에는 「나」 행이 없다', () async {
const repo = SamplePlanetRepository();
expect(
(await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'bts')).mine,
isNotNull,
);
expect(
(await repo.userRanking(metric: FpiMetric.bpm, fandomId: 'ive')).mine,
isNull,
);
});
test('ID 를 주지 않으면 내 행성이다 — 기존 호출부가 그대로 돈다', () async {
final byDefault = await const SamplePlanetRepository().load(nebula: false);
final explicit =
await SamplePlanetRepository(fandomId: 'bts').load(nebula: false);
expect(byDefault.fandomId, 'bts');
expect(byDefault.totalFpi, explicit.totalFpi);
});
});
group('지표별 랭킹 — 발주사 검토회신서 4절 회귀 가드', () {
test('지표를 바꾸면 랭킹 순서가 달라진다', () async {
final byMetric = <FpiMetric, List<String>>{
for (final m in FpiMetric.values)
m: (await repo.planetRanking(metric: m)).rows
.map((r) => r.name)
.toList(),
};
// 다섯 축의 1위가 모두 같으면 「같은 Ranking 반복」 상태로 돌아간 것이다.
final tops = byMetric.values.map((rows) => rows.first).toSet();
expect(tops.length, greaterThan(1), reason: '지표마다 1위가 같습니다');
// 순서 자체도 축마다 달라야 한다.
expect(byMetric.values.map((r) => r.join()).toSet(), hasLength(5));
});
test('지표 카드의 값·순위가 행성 랭킹의 내 행성 행과 같다', () async {
final detail = await repo.load(nebula: false);
for (final score in detail.scores) {
final mine = (await repo.planetRanking(metric: score.metric)).mine;
expect(mine, isNotNull, reason: '${score.metric.label} 내 행성 행 없음');
expect(mine!.value, score.value, reason: '${score.metric.label} 수치 불일치');
expect(mine.rank, score.rank, reason: '${score.metric.label} 순위 불일치');
}
});
test('TOTAL FPI 는 5축 단순 합산이다 — 별첨 B · 설계서 §4.1', () async {
final detail = await repo.load(nebula: false);
expect(
detail.totalFpi,
detail.scores.fold<int>(0, (a, s) => a + s.value),
);
});
test('사용자 랭킹도 지표마다 다르다', () async {
final tops = <String>{
for (final m in FpiMetric.values)
(await repo.userRanking(metric: m, fandomId: 'bts')).rows.first.name,
};
expect(tops.length, greaterThan(1));
});
});
group('행성 지표 랭킹 화면 (Figma 546:14589)', () {
Future<void> pump(WidgetTester tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const MaterialApp(
home: FpiRankingScreen(metric: FpiMetric.bpm, repository: repo),
),
);
await tester.pumpAndSettle();
}
testWidgets('제목은 지표명 + RANKING 이다', (tester) async {
await pump(tester);
expect(find.text('BPM RANKING'), findsOneWidget);
});
testWidgets('소속 행성 순위를 맨 위에 고정한다', (tester) async {
await pump(tester);
expect(find.text('내 행성의 BPM 랭킹'), findsOneWidget);
// 표본에서 BPM 1위는 NewJeans, 내 행성 BTS 는 2위다(내 줄 카드).
expect(find.text('BTS'), findsOneWidget);
expect(find.text('NewJeans'), findsOneWidget);
});
testWidgets('행성 행을 누르면 사용자 랭킹으로 간다 (546:14639)', (tester) async {
await pump(tester);
await tester.tap(find.text('BTS (나)'));
await tester.pumpAndSettle();
expect(find.byType(FpiUserRankingScreen), findsOneWidget);
expect(find.text('나의 BPM 랭킹'), findsOneWidget);
});
testWidgets('소속 행성 카드를 눌러도 사용자 랭킹으로 간다 (546:14629)', (tester) async {
await pump(tester);
// 맨 위 내 줄 카드는 「(나)」 없이 이름만 둔다.
await tester.tap(find.text('BTS'));
await tester.pumpAndSettle();
expect(find.byType(FpiUserRankingScreen), findsOneWidget);
});
});
testWidgets('내가 만든 행성은 가입 버튼을 숨긴다 (544:19765)', (tester) async {
Widget hero(bool owner) => MaterialApp(
theme: buildAppTheme(),
home: const Scaffold(body: SizedBox()),
builder: (_, _) => SizedBox(
height: 543,
child: PlanetDetailHero(
name: 'BTS',
members: 850,
totalFpi: 1220,
joined: true,
isOwner: owner,
),
),
);
await tester.pumpWidget(hero(false));
await tester.pump();
expect(find.text('가입중'), findsOneWidget);
expect(find.text('소형 팬덤'), findsOneWidget);
await tester.pumpWidget(hero(true));
await tester.pump();
expect(find.text('가입중'), findsNothing);
});
test('팬덤 규모 — 1,000명 미만 소형 · 50,000명 미만 중형 · 이상 대형 (544:19765)', () {
expect(FandomSize.of(999), FandomSize.small);
expect(FandomSize.of(1000), FandomSize.medium);
expect(FandomSize.of(49999), FandomSize.medium);
expect(FandomSize.of(50000), FandomSize.large);
});
test('국기 — 국가 코드 → 국기, 미지정은 흰 깃발 (546:14713)', () {
expect(flagOf('KR'), '🇰🇷');
expect(flagOf('ZZ'), '🏳');
});
group('사용자 지표 랭킹 화면 (Figma 546:14646)', () {
Future<void> pump(WidgetTester tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const MaterialApp(
home: FpiUserRankingScreen(
metric: FpiMetric.bpm,
fandomId: 'bts',
planetName: 'BTS',
repository: repo,
),
),
);
await tester.pumpAndSettle();
}
testWidgets('내 순위를 맨 위에 고정한다', (tester) async {
await pump(tester);
expect(find.text('나의 BPM 랭킹'), findsOneWidget);
// 내 줄 카드와 시상대(3위) 양쪽에 「나」가 있다.
expect(find.text('나'), findsOneWidget);
expect(find.text('나 (나)'), findsOneWidget);
});
testWidgets('필터는 실시간과 국가별 두 축이다', (tester) async {
await pump(tester);
await tester.tap(find.text('실시간 랭킹'));
await tester.pumpAndSettle();
expect(find.text('국가별'), findsWidgets);
expect(find.text('취소'), findsOneWidget);
});
testWidgets('국가별로 바꾸면 제목과 나의 랭킹 영역이 국가 기준이 된다', (tester) async {
await pump(tester);
await tester.tap(find.text('실시간 랭킹'));
await tester.pumpAndSettle();
await tester.tap(find.text('국가별').last);
await tester.pumpAndSettle();
// 전 국가 합산이므로 행성명이 없다.
expect(find.text('BTS'), findsNothing);
expect(find.text('국가별 랭킹'), findsOneWidget);
// 「나의 랭킹 영역」(546:14713)은 여기서 내 국가 행이다.
expect(find.text('나의 BPM 랭킹'), findsNothing);
expect(find.text('내 국가의 BPM 랭킹'), findsOneWidget);
// 목록에서도 내 국가 행에 표시가 붙는다.
expect(find.text('대한민국 (나)'), findsOneWidget);
});
});
}
/// 라운지 글이 0건인 행성.
class _NoPosts extends SamplePlanetRepository {
const _NoPosts();
@override
Future<PlanetDetail> load({required bool nebula}) async {
final d = await super.load(nebula: nebula);
return PlanetDetail(
fandomId: d.fandomId, name: d.name, isNebula: d.isNebula,
totalFpi: d.totalFpi, scores: d.scores, members: d.members,
posts: const [],
);
}
@override
Future<List<LoungePost>> morePosts({required int offset, int limit = 5}) async =>
const [];
}