Files
fanuzu/app/test/f1_screens_test.dart
T

350 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'eclipse_stubs.dart';
import 'package:fancake/core/data/backend.dart' show BackendException;
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/home/growth_complete_screen.dart';
import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/home/home_onboarding_screen.dart';
import 'package:fancake/features/home/nebula_create_screen.dart';
import 'package:fancake/features/home/planet_create_screen.dart';
/// 생성 호출을 기록하는 표본. [error] 가 있으면 서버 거부를 흉내 낸다.
class _CreateRepo extends SampleHomeRepository {
_CreateRepo({this.error, this.primaryChanged = true});
final BackendException? error;
final bool primaryChanged;
final calls = <String>[];
@override
Future<({String fandomId, bool primaryChanged})> createNebula(String name) async {
calls.add(name);
if (error case final e?) throw e;
return (fandomId: 'new-nebula', primaryChanged: primaryChanged);
}
@override
Future<void> createPlanet({
required String fandomId,
required String planetType,
required String colorKey,
}) async {
calls.add('$fandomId · $planetType · $colorKey');
if (error case final e?) throw e;
}
}
/// 운영값을 바꾼 `config/policy` 흉내.
class _PolicyRepo extends SampleHomeRepository {
@override
Future<NebulaTargets> nebulaTargets() async =>
nebulaTargetsFrom({'nebulaMinPop': 2000, 'nebulaMinMembers': 20, 'nebulaDays': 5});
}
/// 생성 화면을 위에 얹고, 닫힐 때 돌려준 값을 받는다.
Future<List<Object?>> _pushed(WidgetTester tester, Widget screen) async {
final results = <Object?>[];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Builder(
builder: (context) => TextButton(
onPressed: () async => results.add(
await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => screen),
),
),
child: const Text('열기'),
),
),
),
);
await tester.tap(find.text('열기'));
await tester.pumpAndSettle();
return results;
}
Future<void> _pump(WidgetTester tester, Widget screen) async {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: screen));
await tester.pumpAndSettle();
}
void main() {
group('성운 생성 (F1 · 340:75466)', () {
testWidgets('이름을 넣기 전에는 「성운 생성」이 없고 목표는 원장 고정값이다', (tester) async {
await _pump(tester, const NebulaCreateScreen());
expect(find.text('성운 생성'), findsNothing);
// 정책 원장 B-10 — 1,000 POP · 10명 · 7일. 사본의 슬라이더는 조작할 수 없다.
expect(find.text('1,000P / 10'), findsOneWidget);
expect(find.byType(Slider), findsNothing);
expect(find.textContaining('7일내'), findsOneWidget);
});
testWidgets('목표는 config/policy 운영값(서버와 같은 키)을 따른다', (tester) async {
await _pump(tester, NebulaCreateScreen(repository: _PolicyRepo()));
expect(find.text('2,000P / 20'), findsOneWidget);
expect(find.textContaining('5일내'), findsOneWidget);
});
test('운영값이 없거나 잘못되면 원장 기본값 1,000 · 10 · 7', () {
expect(nebulaTargetsFrom(null), defaultNebulaTargets);
expect(nebulaTargetsFrom({'nebulaMinPop': -1, 'nebulaMinMembers': 'x'}),
(pop: 1000, members: 10, days: 7));
});
testWidgets('서버가 쓰이는 이름(E-FDM-01)이라 하면 입력칸 오류로 알리고 만들 수 없다', (tester) async {
final repo = _CreateRepo(
error: const BackendException('E-FDM-01', '이미 사용중인 이름입니다.'),
);
await _pump(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS');
await tester.pumpAndSettle();
// 중복 판정은 서버만 한다 — 입력만으로는 오류를 띄우지 않는다.
expect(find.text('이미 사용중인 이름입니다.'), findsNothing);
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(find.text('이미 사용중인 이름입니다.'), findsOneWidget);
final btn = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, '성운 생성'),
);
expect(btn.onPressed, isNull);
});
testWidgets('이름을 넣고 만들면 기본 행성 변경을 되묻는다', (tester) async {
await _pump(tester, const NebulaCreateScreen());
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
// Figma 문구 그대로여야 한다.
expect(find.text('선택한 행성을 기본 행성으로 변경할까요?'), findsOneWidget);
expect(find.text('취소'), findsOneWidget);
expect(find.text('변경'), findsOneWidget);
});
testWidgets('변경을 누르면 fn-nebula-create 로 만들고 새 성운 ID 를 들고 닫힌다', (tester) async {
final repo = _CreateRepo();
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(repo.calls, ['BTS_RM']);
expect(results, ['new-nebula']);
expect(find.byType(NebulaCreateScreen), findsNothing);
});
testWidgets('서버가 거부하면 사유를 알리고 화면에 남는다', (tester) async {
final repo = _CreateRepo(
error: const BackendException('E-FDM-02', '이미 오늘 성운을 생성하셨습니다.'),
);
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget);
expect(results, isEmpty);
expect(find.byType(NebulaCreateScreen), findsOneWidget);
});
testWidgets('대표 변경 30일 대기 중이면 성운만 생겼다고 알리고 닫힌다(C-15)', (tester) async {
final repo = _CreateRepo(primaryChanged: false);
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(find.textContaining('30일이 지나야'), findsOneWidget);
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(results, ['new-nebula']);
});
testWidgets('보유 POP 은 여는 쪽이 넘긴 지갑 값이다', (tester) async {
await _pump(tester, const NebulaCreateScreen(myPop: 3210));
expect(find.textContaining('3,210'), findsOneWidget);
});
testWidgets('오늘 이미 만들었으면 사유를 알리고 진행하지 않는다', (tester) async {
await _pump(tester, const NebulaCreateScreen(alreadyCreatedToday: true));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget);
// 제한에 걸렸으면 변경 확인으로 넘어가면 안 된다.
expect(find.text('변경'), findsNothing);
});
});
group('성장 완료 (F1 · 344:213778) — HOME 성운 상태', () {
testWidgets('두 조건을 모두 채우면 성운을 눌러 행성 생성으로 간다', (tester) async {
await _pump(tester, const GrowthCompleteScreen());
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('성운'));
await tester.pumpAndSettle();
expect(find.byType(PlanetCreateScreen), findsOneWidget);
});
testWidgets('창립자가 아니면 조건을 채워도 탄생 안내 · 탭이 없다(Q-PN-01 A안)', (tester) async {
await _pump(tester, const GrowthCompleteScreen(isFounder: false));
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsNothing);
await tester.tap(find.bySemanticsLabel('성운'));
await tester.pumpAndSettle();
expect(find.byType(PlanetCreateScreen), findsNothing);
});
testWidgets('한쪽만 채우면 대기다 — 정책 원장 B-10 동시 충족', (tester) async {
await _pump(
tester,
// 인원은 넘었으나 POP 이 모자란 경우.
const GrowthCompleteScreen(members: 12, contributedPop: 400),
);
expect(find.text('성운을 클릭해서\n행성으로 탄생 시켜보세요!'), findsNothing);
await tester.tap(find.bySemanticsLabel('성운'));
await tester.pumpAndSettle();
expect(
find.byType(PlanetCreateScreen),
findsNothing,
reason: '한쪽만 채웠는데 승격이 열리면 안 된다',
);
});
testWidgets('두 조건을 갈라 보여 준다', (tester) async {
await _pump(tester, const GrowthCompleteScreen());
expect(find.text('활동을 통해 얻은 POP을 통해\n성운에 기여해 보세요!'), findsOneWidget);
expect(find.text('POP 조건 충족 · 1,240 / 1,000'), findsOneWidget);
expect(find.text('창립 멤버 조건 충족 · 12 / 10'), findsOneWidget);
});
});
group('행성 생성 (F1 · 344:214421)', () {
testWidgets('형태 → 색 두 단계만 고른다 — C-50', (tester) async {
final repo = _CreateRepo();
final results = await _pushed(
tester,
PlanetCreateScreen(fandomId: 'nebula-1', repository: repo),
);
expect(find.text('행성 타입 선택'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('형태 2'));
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
expect(find.text('행성 색 선택'), findsOneWidget);
expect(find.text('배경색 선택'), findsOneWidget);
// 생성 때 고르는 것은 형태와 컬러뿐 — 오브젝트 색 · 직접 고르기는 없다.
expect(find.text('오브젝트 색 선택'), findsNothing);
await tester.tap(find.bySemanticsLabel('gold'));
await tester.tap(find.text('완료').last);
await tester.pumpAndSettle();
expect(find.text('선택한 옵션으로\n행성을 생성할까요?'), findsOneWidget);
await tester.tap(find.text('생성'));
await tester.pumpAndSettle();
// fn-planet-create 에는 형태 · 컬러 두 값만 간다. 성공하면 닫혀 HOME 으로 돌아간다.
expect(repo.calls, ['nebula-1 · shape2 · gold']);
expect(results, ['nebula-1']);
expect(find.byType(PlanetCreateScreen), findsNothing);
});
testWidgets('전환 조건 미달 등 서버 거부는 사유를 띄우고 화면에 남는다', (tester) async {
final repo = _CreateRepo(
error: const BackendException('E-FDM-03', '성운 전환 조건을 아직 채우지 못했습니다.'),
);
await _pump(tester, PlanetCreateScreen(fandomId: 'n', repository: repo));
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
await tester.tap(find.text('완료').last);
await tester.pumpAndSettle();
await tester.tap(find.text('생성'));
await tester.pumpAndSettle();
expect(find.text('성운 전환 조건을 아직 채우지 못했습니다.'), findsOneWidget);
expect(find.byType(PlanetCreateScreen), findsOneWidget);
});
testWidgets('색 단계에서 이전으로 돌아간다', (tester) async {
await _pump(tester, const PlanetCreateScreen());
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
await tester.tap(find.text('이전'));
await tester.pumpAndSettle();
expect(find.text('행성 타입 선택'), findsOneWidget);
});
});
group('초기 사용자 홈 (F1 · 343:212939) — HOME 무소속 상태', () {
testWidgets('「+」 를 누르면 행성 생성·가입 시트가 뜨고 고른 동작을 부른다', (tester) async {
var created = 0;
await _pump(
tester,
HomeOnboardingScreen(onCreateNebula: () => created++),
);
// 사용자 노출 브랜드는 팬우주다(공통 기준 4항).
expect(find.text('행성을 생성 또는 가입하고\n팬우주를 시작해 보세요!'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('행성 생성 또는 가입'));
await tester.pumpAndSettle();
expect(find.text('행성 가입'), findsOneWidget);
expect(find.text('취소'), findsOneWidget);
await tester.tap(find.text('행성 생성 (성운)'));
await tester.pumpAndSettle();
expect(created, 1);
expect(find.text('행성 가입'), findsNothing);
});
});
testWidgets('헤더 지갑은 「아이템 구입」 시트를 열고 구매는 준비 중이다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: const HomeScreen(
repository: SampleHomeRepository(),
questRepository: SampleQuestRepository(),
eclipse: NoEclipseRepository(),
),
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.byTooltip('아이템'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
expect(find.text('아이템 구입'), findsOneWidget);
expect(find.text('₩1,200'), findsOneWidget);
await tester.tap(find.text('베이직 드랍'));
await tester.pump();
expect(find.text('아이템 구입은 준비 중입니다.'), findsOneWidget);
});
testWidgets('F1 화면 문구에 내부 경로가 새지 않는다', (tester) async {
for (final screen in <Widget>[
const NebulaCreateScreen(),
const GrowthCompleteScreen(),
const PlanetCreateScreen(),
const HomeOnboardingScreen(),
]) {
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」 노출');
}
}
});
}