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 = []; @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 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() async => nebulaTargetsFrom({'nebulaMinPop': 2000, 'nebulaMinMembers': 20, 'nebulaDays': 5}); } /// 생성 화면을 위에 얹고, 닫힐 때 돌려준 값을 받는다. Future> _pushed(WidgetTester tester, Widget screen) async { final results = []; await tester.pumpWidget( MaterialApp( theme: buildAppTheme(), home: Builder( builder: (context) => TextButton( onPressed: () async => results.add( await Navigator.of(context).push( MaterialPageRoute(builder: (_) => screen), ), ), child: const Text('열기'), ), ), ), ); await tester.tap(find.text('열기')); await tester.pumpAndSettle(); return results; } Future _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( 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); // 카드는 성장률 한 줄(사본 「성운 성장률 100%」) — 누르면 두 조건 상세. await tester.tap(find.textContaining('성운 성장률')); await tester.pumpAndSettle(); 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 [ const NebulaCreateScreen(), const GrowthCompleteScreen(), const PlanetCreateScreen(), const HomeOnboardingScreen(), ]) { await _pump(tester, screen); final texts = tester .widgetList(find.byType(Text)) .map((w) => w.data ?? '') .join('\n'); for (final leak in ['lib/', '.dart', 'Exception', 'wireframe/']) { expect(texts.contains(leak), isFalse, reason: '「$leak」 노출'); } } }); }