import 'dart:async'; import 'dart:convert'; import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/inside/inside_data.dart'; import 'package:fancake/features/auth/auth_repository.dart'; import 'package:fancake/features/my/my_data.dart'; import 'package:fancake/features/my/my_lists_screen.dart'; import 'package:fancake/features/my/my_screen.dart'; import 'package:fancake/features/my/notice_screens.dart'; import 'package:fancake/features/my/payment_screens.dart'; import 'package:fancake/features/my/pop_history_screen.dart'; import 'package:fancake/features/my/profile_edit_screen.dart'; import 'package:fancake/features/my/push_settings_screen.dart'; import 'package:fancake/features/my/settings_screen.dart'; import 'package:fancake/features/my/support_screens.dart'; import 'package:fancake/features/my/tier_screens.dart'; import 'package:fancake/features/my/widgets/profile_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fancake/core/push/push_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:share_plus/share_plus.dart'; /// F6 MY — 화면 대조 원장 21화면. /// 1×1 투명 PNG. 화면이 고른 사진을 실제로 디코딩해 그리므로 진짜 이미지여야 한다. final Uint8List _pngPixel = base64Decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8' '/x8AAwMCAO+ip1sAAAAASUVORK5CYII=', ); PickedPhoto _photo() => PickedPhoto(bytes: _pngPixel, contentType: 'image/png', name: 'photo.png'); /// 상한을 1바이트 넘는 사진. 형식 이전에 용량에서 걸리므로 내용은 보지 않는다. PickedPhoto _oversizePhoto() => PickedPhoto( bytes: Uint8List(profilePhotoMaxBytes + 1), contentType: 'image/png', name: 'huge.png', ); PickedPhoto _wrongTypePhoto() => PickedPhoto( bytes: _pngPixel, contentType: 'application/pdf', name: 'doc.pdf', ); Future pumpWide(WidgetTester tester, Widget child) async { tester.view.physicalSize = const Size(1080, 2600); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); await tester.pumpWidget(MaterialApp(home: child)); await tester.pump(); } /// 머리의 「완료」가 눌리는가. 시안 글자 버튼이라 [Semantics.enabled] 로 본다. bool _done(WidgetTester tester) => tester .getSemantics(find.text('완료')) .flagsCollection .isEnabled .toBoolOrNull()!; /// 글자 없는 버튼(격자 칸 · 카메라 배지)을 [Semantics] 라벨로 찾는다. Finder _labeled(String label) => find.byWidgetPredicate( (w) => w is Semantics && w.properties.label == label, ); /// 시안 글자 버튼(환불 「요청」)의 눌림 처리. GestureTapCallback? _tapOf(WidgetTester tester, String text) => tester .widget( find .ancestor(of: find.text(text), matching: find.byType(GestureDetector)) .first, ) .onTap; /// 푸시 설정 한 줄의 스위치. Finder _switch(String label) => find.descendant( of: find.widgetWithText(Row, label), matching: find.byType(Switch), ); /// 올린 사진만 기록한다. 표본 URL 은 테스트에서 내려받을 수 없어 화면에 걸지 않는다. class _UploadRecorder extends SampleMyRepository { PickedPhoto? uploaded; @override Future uploadProfilePhoto(PickedPhoto photo) async { checkProfilePhoto(photo); uploaded = photo; return 'uploaded'; } } void main() { group('레벨 · 칭호 (F6 · 정책 원장 C-18 · B-65)', () { test('칭호는 레벨 구간으로 정해진다', () { final cases = { 1: LevelTitle.glow, 9: LevelTitle.glow, 10: LevelTitle.spark, 29: LevelTitle.spark, 30: LevelTitle.beam, 59: LevelTitle.beam, 60: LevelTitle.starlight, 99: LevelTitle.starlight, 100: LevelTitle.nova, 150: LevelTitle.nova, }; cases.forEach( (level, title) => expect(LevelTitle.of(level), title, reason: 'Lv.$level'), ); }); test('Lv.100 이후에도 숫자 레벨을 그대로 쓴다', () { const profile = MyProfile( nickname: 'a', email: 'a@b.c', contributedPop: 0, userExp: 0, userLevel: 150, balancePop: 0, steps: 0, completedQuests: 0, planetTiers: [], ); expect(profile.levelLabel, 'Lv.150 NOVA'); }); test('필드가 없으면 Lv.1 · SILVER 로 둔다', () { const profile = MyProfile( nickname: 'a', email: 'a@b.c', contributedPop: 0, balancePop: 0, steps: 0, completedQuests: 0, planetTiers: [], ); expect(profile.levelLabel, 'Lv.1 GLOW'); expect(profile.passportTier, PassportTier.silver); expect(PassportTier.parse(''), PassportTier.silver); expect(PassportTier.parse('diamond'), PassportTier.diamond); }); test('다음 레벨까지 남은 EXP 와 진행도를 낸다', () { final from = expForLevel(19); final to = expForLevel(20); final profile = MyProfile( nickname: 'a', email: 'a@b.c', contributedPop: 0, userExp: from + 100, userLevel: 19, balancePop: 0, steps: 0, completedQuests: 0, planetTiers: const [], ); expect(profile.toNextLevel, to - from - 100); expect(profile.levelProgress, closeTo(100 / (to - from), 0.0001)); }); }); group('MY 첫 화면 (F6 · 373:632962)', () { testWidgets('닉네임·등급·통계를 보여 준다', (tester) async { await pumpWide(tester, MyScreen(repository: SampleMyRepository())); await tester.pump(); expect(find.text('아미보이'), findsOneWidget); expect(find.text('Lv.18 SPARK'), findsOneWidget); expect(find.text(' · SILVER'), findsOneWidget); // 시안 티어 카드 아래 줄 — 레벨 구간 진행도의 EXP(C-18). expect(find.text('EXP'), findsOneWidget); expect(find.text('128,450'), findsOneWidget); expect(find.text('37'), findsOneWidget); }); testWidgets('행성/성운 칸은 가입 목록을 팬덤 stage 그림으로 그린다', (tester) async { await pumpWide( tester, MyScreen(repository: SampleMyRepository(), galaxy: _OneNebula()), ); await tester.pump(); final images = tester .widgetList(find.byType(Image)) .map((i) => (i.image as AssetImage).assetName) .toList(); expect(images, contains('assets/figma/nebula-card.png')); expect( images.where((a) => a.startsWith('assets/figma/my-planet-')), isEmpty, ); expect(find.bySemanticsLabel('새 성운'), findsOneWidget); }); testWidgets('프로필을 누르면 카드 오버레이가 뜬다', (tester) async { await pumpWide(tester, MyScreen(repository: SampleMyRepository())); await tester.pump(); await tester.tap(find.byType(CircleAvatar).first); await tester.pumpAndSettle(); // 프로필 웹 주소가 없어 마지막 칸은 「문구 복사」다(없는 링크를 약속하지 않는다). for (final target in const ['인스타그램', '틱톡', 'X', '문구 복사']) { expect(find.byTooltip(target), findsOneWidget); } }); testWidgets('공유는 OS 공유 시트로 프로필 문구를 보낸다 — 내부 일정 문구 없음', (tester) async { final profile = await SampleMyRepository().profile(); ShareParams? sent; final copied = await shareProfile( profile, '인스타그램', share: (params) async { sent = params; return const ShareResult('ok', ShareResultStatus.success); }, ); expect(copied, isFalse); expect(sent?.text, contains(profile.nickname)); expect(sent?.text, contains('#FANUZU')); expect(sent?.text, isNot(contains('회차'))); // 마지막 칸은 공유 시트 대신 같은 문구를 복사한다. String? clip; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, (call) async { if (call.method == 'Clipboard.setData') { clip = (call.arguments as Map)['text'] as String?; } return null; }, ); addTearDown( () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, null, ), ); expect(await shareProfile(profile, '문구 복사'), isTrue); expect(clip, profileShareText(profile)); }); testWidgets('티어 카드를 누르면 행성별 티어로 넘어간다', (tester) async { await pumpWide(tester, MyScreen(repository: SampleMyRepository())); await tester.pump(); await tester.tap(find.text('Lv.18 SPARK')); await tester.pumpAndSettle(); expect(find.byType(TierDetailScreen), findsOneWidget); }); }); group('티어 (F6 · 373:633256 · 494:16177)', () { testWidgets('티어 정보는 칭호를 낮은 순으로 늘어놓는다', (tester) async { await pumpWide(tester, const TierInfoScreen()); final labels = tester .widgetList(find.byType(Text)) .map((w) => w.data ?? '') .where(LevelTitle.values.map((t) => '${t.label} TIER').contains) .toList(); expect(labels, [ 'GLOW TIER', 'SPARK TIER', 'BEAM TIER', 'STARLIGHT TIER', 'NOVA TIER', ]); }); testWidgets('기준치는 레벨 구간이고 POP 문턱은 없다 (C-18)', (tester) async { await pumpWide(tester, const TierInfoScreen()); for (final range in const [ 'Lv.1~9', 'Lv.10~29', 'Lv.30~59', 'Lv.60~99', 'Lv.100~', ]) { expect(find.text(range), findsOneWidget); } expect(find.textContaining('POP'), findsNothing); for (final tier in const ['SILVER', 'GOLD', 'DIAMOND']) { await tester.scrollUntilVisible(find.text(tier), 200); expect(find.text(tier), findsOneWidget); } }); testWidgets('행성별 티어는 기본 행성이 맨 앞이다', (tester) async { const tiers = [ PlanetTier(fandomName: '작은팬덤', contributedPop: 100), PlanetTier(fandomName: '큰팬덤', contributedPop: 90000), PlanetTier(fandomName: '내행성', contributedPop: 500, isPrimary: true), ]; const screen = TierDetailScreen(tiers: tiers); expect(screen.ordered.map((p) => p.fandomName).toList(), [ '내행성', '큰팬덤', '작은팬덤', ]); }); testWidgets('팬덤별 Passport Tier · 탈퇴 팬덤 보존 · Badge 를 보인다 (10/3 #10 · 별첨 1 ②-5)', (tester) async { const tiers = [ PlanetTier(fandomName: '떠난팬덤', contributedPop: 900, passportTier: PassportTier.diamond, joined: false), PlanetTier(fandomName: '다른팬덤', contributedPop: 100, passportTier: PassportTier.gold), PlanetTier(fandomName: '내행성', contributedPop: 500, isPrimary: true), ]; const badges = [ MyBadge(id: 'activity_health', kind: 'activity', stage: 2, axis: 'health'), MyBadge(id: 'founder100_x', kind: 'founder100', stage: 1, memberNo: 7), MyBadge(id: 'evolution_x_2', kind: 'evolution', stage: 1, toStage: 2), ]; const screen = TierDetailScreen(tiers: tiers, badges: badges); expect(screen.ordered.map((p) => p.fandomName).toList(), ['내행성', '다른팬덤', '떠난팬덤']); await pumpWide(tester, screen); expect(find.text('PASSPORT SILVER'), findsOneWidget); expect(find.text('PASSPORT GOLD'), findsOneWidget); expect(find.text('PASSPORT DIAMOND'), findsOneWidget); await tester.scrollUntilVisible(find.text('BADGE'), 200); expect(find.text('HEALTH · 2/7'), findsOneWidget); expect(find.text('FIRST 100 · #007'), findsOneWidget); expect(find.text('EVOLUTION · STAGE 2'), findsOneWidget); }); }); group('프로필 수정 (F6 · 374:16164 · 374:15479)', () { testWidgets('바꾸기 전에는 완료가 잠겨 있다', (tester) async { await pumpWide( tester, ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), ); final done = _done(tester); expect(done, isFalse); }); testWidgets('중복 닉네임은 필드 아래 안내로 알린다', (tester) async { await pumpWide( tester, ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), ); await tester.enterText(find.byType(TextField), '아미보이2'); await tester.pump(); await tester.tap(find.text('완료')); await tester.pumpAndSettle(); expect(find.text('이미 쓰고 있는 닉네임입니다.'), findsOneWidget); }); testWidgets('닉네임 「팬」은 예약어 안내를 띄운다', (tester) async { await pumpWide( tester, ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), ); await tester.enterText(find.byType(TextField), ' 팬'); await tester.pump(); expect(find.text('운영자로 오해될 수 있는 이름은 쓸 수 없습니다.'), findsOneWidget); expect(_done(tester), isFalse); await tester.enterText(find.byType(TextField), '팬팬'); await tester.pump(); expect(find.text('운영자로 오해될 수 있는 이름은 쓸 수 없습니다.'), findsNothing); }); testWidgets('닉네임은 두 자 미만이면 완료가 열리지 않는다 (B-52)', (tester) async { await pumpWide( tester, ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), ); await tester.enterText(find.byType(TextField), '가'); await tester.pump(); var done = _done(tester); expect(done, isFalse); await tester.enterText(find.byType(TextField), '가나'); await tester.pump(); done = _done(tester); expect(done, isTrue); }); testWidgets('닉네임은 20자를 넘겨 입력할 수 없다 (B-52)', (tester) async { await pumpWide( tester, ProfileEditScreen(nickname: '아미보이', repository: SampleMyRepository()), ); final field = tester.widget(find.byType(TextField)); expect(field.maxLength, nicknameMaxLength); await tester.enterText(find.byType(TextField), '가' * 21); await tester.pump(); // maxLength 가 21자째를 잘라내므로 저장 값이 20자를 넘지 않는다. final done = _done(tester); expect(done, isTrue); }); testWidgets('사진을 고르기 전에는 완료가 잠겨 있다', (tester) async { await pumpWide( tester, ProfilePhotoScreen(pickPhoto: (_) async => _photo()), ); var done = _done(tester); expect(done, isFalse); await tester.tap(_labeled('앨범에서 고르기')); await tester.pumpAndSettle(); done = _done(tester); expect(done, isTrue); }); testWidgets('상한을 넘는 사진은 고른 자리에서 사유를 알린다', (tester) async { await pumpWide( tester, ProfilePhotoScreen(pickPhoto: (_) async => _oversizePhoto()), ); await tester.tap(_labeled('앨범에서 고르기')); await tester.pumpAndSettle(); expect(find.textContaining('10MB 를 넘습니다'), findsOneWidget); final done = _done(tester); expect(done, isFalse); }); testWidgets('허용하지 않는 형식은 거부한다', (tester) async { await pumpWide( tester, ProfilePhotoScreen(pickPhoto: (_) async => _wrongTypePhoto()), ); await tester.tap(_labeled('앨범에서 고르기')); await tester.pumpAndSettle(); expect(find.textContaining('이미지만 올릴 수 있습니다'), findsOneWidget); }); testWidgets('MY 카메라 배지에서 사진을 고르면 바로 올린다', (tester) async { final repo = _UploadRecorder(); await pumpWide(tester, MyScreen(repository: repo)); await tester.pump(); await tester.tap(_labeled('프로필 사진 수정')); await tester.pumpAndSettle(); expect(find.byType(ProfilePhotoScreen), findsOneWidget); // 선택기는 플러그인이라 화면 결과만 흉내 낸다 — 고른 사진을 돌려준다. Navigator.of( tester.element(find.byType(ProfilePhotoScreen)), ).pop(_photo()); await tester.pumpAndSettle(); expect(repo.uploaded, isNotNull); }); }); group('설정 (F6 · 354:222008)', () { testWidgets('연동 상태를 그대로 보여 준다', (tester) async { await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); await tester.pump(); expect(find.text('fan@gmail.com'), findsOneWidget); // 시안 `374:14705` — 연결된 줄은 「연결해제」, 아닌 줄은 「연결」. expect(find.text('연결해제'), findsNWidgets(2)); expect(find.text('연결'), findsNWidgets(2)); }); testWidgets('최신 버전이면 번호 대신 안내를 쓴다', (tester) async { await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); await tester.pump(); // 앱 버전은 목록 아래쪽이라 굴려서 확인한다. await tester.scrollUntilVisible(find.text('앱 버전'), 200); expect(find.text('최신 버전'), findsOneWidget); }); testWidgets('탈퇴는 되묻고 POP 소멸을 알린다 (시안 374:14364 · §P-13)', (tester) async { await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); await tester.pump(); await tester.scrollUntilVisible(find.text('서비스 탈퇴'), 200); await tester.ensureVisible(find.text('서비스 탈퇴')); await tester.pumpAndSettle(); await tester.tap(find.text('서비스 탈퇴')); await tester.pumpAndSettle(); expect(find.text('서비스 탈퇴를 하시겠습니까?'), findsOneWidget); expect(find.textContaining('POP 이 사라지고'), findsOneWidget); expect(find.text('취소'), findsOneWidget); }); Future confirmWithdraw(WidgetTester tester) async { await tester.scrollUntilVisible(find.text('서비스 탈퇴'), 200); await tester.ensureVisible(find.text('서비스 탈퇴')); await tester.pumpAndSettle(); await tester.tap(find.text('서비스 탈퇴')); await tester.pumpAndSettle(); await tester.tap(find.text('서비스 탈퇴').last); await tester.pumpAndSettle(); } testWidgets('SNS 연동이 남아 있으면 비정상 탈퇴 안내만 (554:24908)', (tester) async { final repo = _WithdrawRepo(); await pumpWide(tester, SettingsScreen(repository: repo)); await tester.pump(); await confirmWithdraw(tester); expect(find.text('연동된 계정 해제 후 계정 탈퇴를 진행해주세요.'), findsOneWidget); expect(repo.withdrawn, isFalse); }); testWidgets('연동이 하나뿐이면 서버 탈퇴를 부른다 (554:24885)', (tester) async { final repo = _WithdrawRepo(); await pumpWide( tester, SettingsScreen( repository: repo, linker: SampleAccountLinker( seed: const {AuthProviderKind.google: 'fan@gmail.com'}, ), ), ); await tester.pump(); await confirmWithdraw(tester); expect(repo.withdrawn, isTrue); }); testWidgets('SNS 연동 라벨은 제공자 이름만 쓴다 — 「LINE으」 잘림 없음', (tester) async { await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); await tester.pump(); await tester.scrollUntilVisible(find.text('LINE'), 200); expect(find.text('LINE'), findsOneWidget); expect(find.textContaining('LINE으'), findsNothing); }); testWidgets('언어 목록은 확정 5종이다', (tester) async { await pumpWide(tester, SettingsScreen(repository: SampleMyRepository())); await tester.pump(); // 시안 — 현재 언어 줄을 누르면 언어 설정 시트. await tester.scrollUntilVisible(find.text('한국어'), 200); await tester.ensureVisible(find.text('한국어')); await tester.pumpAndSettle(); await tester.tap(find.text('한국어')); await tester.pumpAndSettle(); expect(find.text('언어 설정'), findsOneWidget); for (final lang in const ['English', '日本語', '中文', 'Español']) { expect(find.text(lang), findsOneWidget); } }); }); group('SNS 연동 (F6 · 357:222740 외 3)', () { test('이메일 전체가 같을 때만 같은 계정으로 본다', () { // 앞부분만 같고 도메인이 다르면 다른 계정이다. expect( sameAccountIdentity('test123@naver.com', 'test123@gmail.com'), isFalse, ); expect( sameAccountIdentity('test123@naver.com', 'other123@gmail.com'), isFalse, ); // 대소문자·앞뒤 공백은 정규화한다. expect( sameAccountIdentity(' Test123@Naver.com ', 'test123@naver.com'), isTrue, ); // 빈 값 · `@` 없는 값은 이메일이 아니다. expect(sameAccountIdentity('', ''), isFalse); expect(sameAccountIdentity('test123', 'test123'), isFalse); }); // 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.* test('연동 대상은 확정 4종이고 X 는 없다', () { expect(linkableProviders, const [ AuthProviderKind.google, AuthProviderKind.apple, AuthProviderKind.kakao, AuthProviderKind.line, ]); expect(linkableProviders, isNot(contains(AuthProviderKind.email))); }); testWidgets('설정 행의 「연결」로 바로 연결하고 「연결해제」가 뜬다', (tester) async { final linker = SampleAccountLinker( seed: const {AuthProviderKind.google: 'fan@gmail.com'}, ); await pumpWide( tester, SettingsScreen(repository: SampleMyRepository(), linker: linker), ); await tester.pump(); await tester.tap( find.descendant( of: find.widgetWithText(Row, 'Apple'), matching: find.text('연결'), ), ); await tester.pumpAndSettle(); expect(linker.linkedAccounts.keys, contains(AuthProviderKind.apple)); expect( find.descendant( of: find.widgetWithText(Row, 'Apple'), matching: find.text('연결해제'), ), findsOneWidget, ); }); testWidgets('마지막 하나 남은 수단은 해제되지 않는다', (tester) async { final linker = SampleAccountLinker( seed: const {AuthProviderKind.google: 'fan@gmail.com'}, ); await pumpWide( tester, SettingsScreen(repository: SampleMyRepository(), linker: linker), ); await tester.pump(); await tester.tap( find.descendant( of: find.widgetWithText(Row, 'Google'), matching: find.text('연결해제'), ), ); await tester.pumpAndSettle(); // 시안 `554:23257` 확인 팝업을 거친다. expect(find.text('연결된 계정을 끊으시겠습니까?'), findsOneWidget); await tester.tap(find.text('연결 해제')); await tester.pumpAndSettle(); // 떼였으면 계정에 다시 들어올 방법이 사라진다. expect(linker.linkedAccounts.keys, contains(AuthProviderKind.google)); expect(find.textContaining('마지막 로그인 수단'), findsOneWidget); }); }); group('POP 내역 — 장 경계 (F6 · 554:23732)', () { testWidgets('당일 Zap 이 장을 넘어가도 한 줄로 합쳐진다', (tester) async { // 저장소는 한 장 안에서만 접는다. 당일 Zap 이 한 장 크기를 넘으면 장마다 // 「Zap 기여」 줄이 하나씩 생기므로 화면이 경계를 이어 붙여야 한다. await pumpWide( tester, PopHistoryScreen(repository: _PagedZapRepository()), ); await tester.pump(); expect(find.text('Zap 기여 1회'), findsOneWidget); await tester.tap(find.text('더 보기')); await tester.pumpAndSettle(); expect(find.text('Zap 기여 2회'), findsOneWidget); expect(find.textContaining('Zap 기여'), findsOneWidget); await tester.tap(find.text('더 보기')); await tester.pumpAndSettle(); // 날이 바뀐 Zap 은 별도 줄이다. expect(find.text('Zap 기여 2회'), findsOneWidget); expect(find.text('Zap 기여 1회'), findsOneWidget); }); }); group('POP 내역 (F6 · 374:15090)', () { testWidgets('보유 POP 과 증감을 함께 보여 준다', (tester) async { await pumpWide( tester, PopHistoryScreen(repository: SampleMyRepository()), ); await tester.pump(); expect(find.text('POP'), findsOneWidget); // 보유 값과 첫 줄 누적 값이 같다. expect(find.text('12,540'), findsWidgets); expect(find.text('+50'), findsOneWidget); expect(find.text('-500'), findsOneWidget); }); testWidgets('Zap 은 건별이 아니라 당일 합산 한 줄이다', (tester) async { await pumpWide( tester, PopHistoryScreen(repository: SampleMyRepository()), ); await tester.pump(); expect(find.text('Zap 기여 128회'), findsOneWidget); }); }); group('결제 (F6 · 374:15185 · 506:14308 · 506:14437)', () { testWidgets('결제 목록에서 상세로 간다', (tester) async { await pumpWide( tester, PaymentHistoryScreen(repository: SampleMyRepository()), ); await tester.pump(); await tester.tap(find.text('서포터 팩')); await tester.pumpAndSettle(); expect(find.text('결제 내역 상세'), findsOneWidget); expect(find.text('₩ 7,900'), findsWidgets); }); testWidgets('결제 완료 상태에서만 환불을 신청할 수 있다', (tester) async { for (final state in PaymentState.values) { expect(state.canRequestRefund, state == PaymentState.paid); } }); testWidgets('사유를 고르기 전에는 요청이 잠겨 있다', (tester) async { await pumpWide( tester, RefundRequestScreen(paymentId: 'p1', repository: SampleMyRepository()), ); expect(_tapOf(tester, '요청'), isNull); await tester.tap(find.text('중복 결제했습니다')); await tester.pump(); expect(_tapOf(tester, '요청'), isNotNull); }); testWidgets('환불을 신청하면 상태가 환불 요청으로 바뀐다', (tester) async { final repo = SampleMyRepository(); await pumpWide( tester, PaymentDetailScreen(paymentId: 'p1', repository: repo), ); await tester.pump(); expect(find.text('결제 완료'), findsOneWidget); await tester.tap(find.text('환불 신청')); await tester.pumpAndSettle(); await tester.tap(find.text('중복 결제했습니다')); await tester.pump(); await tester.tap(find.text('요청')); await tester.pumpAndSettle(); // 시안 `506:14615` iOS 경고창. expect(find.text('정말 환불 신청을 할까요?'), findsOneWidget); await tester.tap(find.text('확인')); await tester.pumpAndSettle(); // 시안 `571:18672` — 환불할 수 없으면 회색 「환불 불가」. expect(find.text('환불 요청'), findsOneWidget); expect(find.text('환불 불가'), findsOneWidget); }); }); group('푸시 알림 (F6 · 357:223444 · 정책 원장 B-77)', () { testWidgets('기기 알림이 꺼져 있으면 토글을 그리지 않는다', (tester) async { await pumpWide( tester, PushSettingsScreen( repository: SampleMyRepository(), deviceAllowed: false, ), ); await tester.pump(); expect(find.text('기기의 푸시 알림 설정이 꺼진 상태입니다.'), findsOneWidget); expect(find.byType(Switch), findsNothing); }); testWidgets('B-77 확정 5 개 구분이 노출된다', (tester) async { await pumpWide( tester, PushSettingsScreen(repository: SampleMyRepository()), ); await tester.pump(); expect(find.byType(Switch), findsNWidgets(5)); for (final label in const [ '서비스 필수', '팬덤 이벤트', 'AI Briefing', '커뮤니티', '마케팅·홍보', ]) { expect(find.text(label), findsOneWidget, reason: label); } }); testWidgets('Marketing 초기값은 OFF, 나머지는 ON 이다', (tester) async { await pumpWide( tester, PushSettingsScreen(repository: SampleMyRepository()), ); await tester.pump(); for (final topic in PushTopic.values) { final tile = tester.widget(_switch(topic.label)); expect( tile.value, topic == PushTopic.marketing ? isFalse : isTrue, reason: topic.label, ); } }); testWidgets('서비스 필수는 끌 수 없고 나머지 4 종은 끌 수 있다', (tester) async { await pumpWide( tester, PushSettingsScreen(repository: SampleMyRepository()), ); await tester.pump(); for (final topic in PushTopic.values) { final tile = tester.widget(_switch(topic.label)); expect( tile.onChanged, topic.locked ? isNull : isNotNull, reason: topic.label, ); } }); testWidgets('토글을 켜면 저장소에 반영된다', (tester) async { final repo = SampleMyRepository(); await pumpWide(tester, PushSettingsScreen(repository: repo)); await tester.pump(); await tester.tap(_switch('마케팅·홍보')); await tester.pumpAndSettle(); expect((await repo.pushSettings())[PushTopic.marketing], isTrue); await tester.tap(_switch('AI Briefing')); await tester.pumpAndSettle(); expect((await repo.pushSettings())[PushTopic.briefing], isFalse); }); testWidgets('권한이 거부된 상태에서 재요청이 성공하면 토글이 열린다', (tester) async { var asked = 0; await pumpWide( tester, PushSettingsScreen( repository: SampleMyRepository(), deviceAllowed: false, onRequestPermission: () async { asked += 1; return true; }, ), ); await tester.pump(); await tester.tap(find.text('설정하러 가기')); await tester.pumpAndSettle(); expect(asked, 1); expect(find.byType(Switch), findsNWidgets(5)); }); testWidgets('토글을 바꾸면 저장과 함께 구독 갱신을 부른다', (tester) async { // 저장만으로는 발송이 멈추지 않는다. 서버가 토픽으로만 보내므로 문서에 // false 를 적어도 구독이 남아 있으면 알림이 계속 온다(B-77 「OFF 0건」). final repo = SampleMyRepository(); final synced = >[]; await pumpWide( tester, PushSettingsScreen( repository: repo, onSettingsChanged: (prefs) async => synced.add({...prefs}), ), ); await tester.pump(); await tester.tap(_switch('AI Briefing')); await tester.pumpAndSettle(); expect(synced, hasLength(1)); expect(synced.single[PushTopic.briefing], isFalse); expect((await repo.pushSettings())[PushTopic.briefing], isFalse); }); testWidgets('재요청이 거부되면 기기 설정 안내를 띄운다', (tester) async { await pumpWide( tester, PushSettingsScreen( repository: SampleMyRepository(), deviceAllowed: false, onRequestPermission: () async => false, ), ); await tester.pump(); await tester.tap(find.text('설정하러 가기')); await tester.pumpAndSettle(); expect(find.textContaining('기기 설정에서 FANUZU 알림을'), findsOneWidget); expect(find.byType(Switch), findsNothing); }); }); group('공지·문의·차단 (F6 · 357:224574 · 373:634845)', () { testWidgets('공지 목록에서 상세로 간다', (tester) async { await pumpWide( tester, NoticeListScreen(repository: SampleMyRepository()), ); await tester.pump(); await tester.tap(find.text('서비스 점검 안내')); await tester.pumpAndSettle(); expect(find.textContaining('9월 3일 새벽'), findsOneWidget); }); testWidgets('내 문의 목록에서 상태와 함께 보여 주고 상세로 간다', (tester) async { await pumpWide(tester, InquiryScreen(repository: SampleMyRepository())); await tester.pump(); expect(find.text('구매 상품 환불 관련 문의 접수'), findsOneWidget); expect(find.textContaining('답변 완료'), findsOneWidget); await tester.tap(find.text('구매 상품 환불 관련 문의 접수')); await tester.pumpAndSettle(); expect(find.text('문의 상세'), findsOneWidget); expect(find.text('답변'), findsOneWidget); expect(find.textContaining('지급 처리했습니다'), findsOneWidget); }); testWidgets('문의는 제목과 열 글자 본문이 있어야 보낼 수 있다', (tester) async { final repo = SampleMyRepository(); await pumpWide(tester, InquiryComposeScreen(repository: repo)); var done = tester .widget(find.widgetWithText(TextButton, '완료')) .onPressed; expect(done, isNull); await tester.enterText(find.byType(TextField).first, '결제 문의'); await tester.enterText(find.byType(TextField).last, '짧음'); await tester.pump(); done = tester .widget(find.widgetWithText(TextButton, '완료')) .onPressed; expect(done, isNull); await tester.enterText(find.byType(TextField).last, '열 글자가 넘는 문의 내용입니다'); await tester.pump(); done = tester .widget(find.widgetWithText(TextButton, '완료')) .onPressed; expect(done, isNotNull); await tester.tap(find.widgetWithText(TextButton, '완료')); await tester.pumpAndSettle(); final mine = await repo.myInquiries(); expect(mine.first.title, '결제 문의'); expect(mine.first.status, InquiryStatus.pending); }); testWidgets('접수 상태의 문의는 수정할 수 있다', (tester) async { final repo = SampleMyRepository(); await repo.submitInquiry('결제 문의', '지급되지 않았습니다.'); final mine = await repo.myInquiries(); await pumpWide( tester, InquiryDetailScreen(inquiry: mine.first, repository: repo), ); await tester.tap(find.widgetWithText(TextButton, '수정')); await tester.pumpAndSettle(); expect(find.text('문의 수정'), findsOneWidget); await tester.enterText(find.byType(TextField).last, '내용을 고쳐 다시 적습니다'); await tester.pump(); await tester.tap(find.widgetWithText(TextButton, '완료')); await tester.pumpAndSettle(); final after = await repo.myInquiries(); expect(after.first.body, '내용을 고쳐 다시 적습니다'); expect(after.first.status, InquiryStatus.pending); }); testWidgets('답변이 끝난 문의에는 수정 입구가 없다', (tester) async { final repo = SampleMyRepository(); final answered = (await repo.myInquiries()).firstWhere( (i) => i.status == InquiryStatus.answered, ); await pumpWide( tester, InquiryDetailScreen(inquiry: answered, repository: repo), ); expect(find.widgetWithText(TextButton, '수정'), findsNothing); }); testWidgets('차단 해제하면 목록에서 빠진다', (tester) async { await pumpWide(tester, BlockedScreen(repository: SampleMyRepository())); await tester.pump(); expect(find.text('스팸계정'), findsOneWidget); await tester.tap(find.widgetWithText(TextButton, '차단 해제').first); await tester.pumpAndSettle(); expect(find.text('스팸계정'), findsNothing); }); }); group('내 활동 (F6 · 370:228289 · 370:229665)', () { testWidgets('내 인사이드는 저장소의 내 질문을 보여 준다 — 고정 표본 없음', (tester) async { await pumpWide( tester, const MyListsScreen(kind: MyListKind.inside, insides: _MyInsides()), ); await tester.pumpAndSettle(); expect(find.text('device-check-question'), findsOneWidget); expect(find.text('100 P'), findsOneWidget); // 10/4 실기기 8-3 — 내 글 대신 뜨던 표본(실제 아티스트명 포함). expect(find.textContaining('화양연화'), findsNothing); expect(find.textContaining('BTS'), findsNothing); }); testWidgets('내 캠페인에는 내가 만든 것만 나온다', (tester) async { await pumpWide(tester, const MyListsScreen(kind: MyListKind.campaign)); await tester.pumpAndSettle(); expect(find.textContaining('지하철 생일 광고'), findsOneWidget); expect(find.textContaining('강남역'), findsNothing); }); }); testWidgets('F6 화면 문구에 내부 경로가 새지 않는다', (tester) async { for (final screen in [ MyScreen(repository: SampleMyRepository()), SettingsScreen(repository: SampleMyRepository()), PopHistoryScreen(repository: SampleMyRepository()), PaymentHistoryScreen(repository: SampleMyRepository()), PushSettingsScreen(repository: SampleMyRepository()), NoticeListScreen(repository: SampleMyRepository()), InquiryScreen(repository: SampleMyRepository()), BlockedScreen(repository: SampleMyRepository()), const TierInfoScreen(), ]) { await pumpWide(tester, screen); await tester.pump(); for (final text in tester.widgetList(find.byType(Text))) { final data = text.data ?? ''; expect(data, isNot(contains('lib/'))); expect(data, isNot(contains('.dart'))); } } }); /// 로그아웃 확인 모달이 실제로 로그아웃을 부르는지 본다. /// /// 이 배선이 없던 동안 모달은 「09-10 회차에 연결합니다」 안내만 띄우고 세션을 /// 그대로 두었다. 화면만 보면 로그아웃된 것처럼 보이지 않아 결함이 드러나지 않는다. testWidgets('푸시 정리가 실패해도 첫 확인에 로그아웃된다 (10/6 실기기)', (tester) async { final session = _LogoutSession( const AuthAccount(uid: 'u1', provider: SocialProvider.google), ); final auth = AuthRepository(providers: const [], session: session, releaseMode: true); await pumpWide( tester, SettingsScreen(repository: SampleMyRepository(), auth: auth, push: _FailingPush()), ); await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('로그아웃'), 220, scrollable: find.byType(Scrollable).first); await tester.tap(find.text('로그아웃')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '로그아웃')); await tester.pumpAndSettle(); expect(session.signOutCalls, 1); auth.dispose(); await session.close(); }); testWidgets('푸시 정리가 멈춰도 상한 안에 첫 확인으로 로그아웃된다 — 확인 버튼은 진행 중 (10/6 r3)', (tester) async { final session = _LogoutSession( const AuthAccount(uid: 'u1', provider: SocialProvider.google), ); final auth = AuthRepository(providers: const [], session: session, releaseMode: true); await pumpWide( tester, SettingsScreen(repository: SampleMyRepository(), auth: auth, push: _HangingPush()), ); await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('로그아웃'), 220, scrollable: find.byType(Scrollable).first); await tester.tap(find.text('로그아웃')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '로그아웃')); await tester.pump(); // 정리가 도는 동안 확인 버튼은 진행 중 표시다. expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(session.signOutCalls, 0); await tester.pump(const Duration(seconds: 4)); await tester.pumpAndSettle(); expect(session.signOutCalls, 1); auth.dispose(); await session.close(); }); testWidgets('로그아웃을 확인하면 세션이 끊긴다 (F6 · 554:22903)', (tester) async { final session = _LogoutSession( const AuthAccount(uid: 'u1', provider: SocialProvider.google), ); final auth = AuthRepository( providers: const [], session: session, releaseMode: true, ); expect(auth.isSignedIn, isTrue); await pumpWide( tester, SettingsScreen(repository: SampleMyRepository(), auth: auth), ); await tester.pumpAndSettle(); await tester.scrollUntilVisible( find.text('로그아웃'), 220, scrollable: find.byType(Scrollable).first, ); await tester.tap(find.text('로그아웃')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '로그아웃')); await tester.pumpAndSettle(); expect(session.signOutCalls, 1); expect(auth.isSignedIn, isFalse); auth.dispose(); await session.close(); }); } /// 푸시 정리가 실패하는 경계 — 토픽 해제 · 토큰 쓰기가 네트워크 오류로 던진다. class _FailingPush implements PushMessaging { @override Future clearOnSignOut() async => throw Exception('offline'); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } /// 푸시 정리가 응답 없이 멈추는 경계 — 토픽 해제가 끝나지 않는다. class _HangingPush implements PushMessaging { @override Future clearOnSignOut() => Completer().future; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } /// 로그아웃 호출을 세는 최소 세션. 실제 Firebase 를 붙이지 않는다. final class _LogoutSession implements AuthSession { _LogoutSession(this._current); AuthAccount? _current; final StreamController _changes = StreamController.broadcast(); int signOutCalls = 0; @override AuthAccount? get currentAccount => _current; @override Stream get accountChanges => _changes.stream; @override Future signOut() async { signOutCalls += 1; _current = null; _changes.add(null); } Future close() => _changes.close(); } /// 한 장에 한 줄만 주는 원장. 장 경계 접기를 보이기 위한 것이다. /// /// 실제 저장소는 한 장을 20건으로 읽지만, 당일 Zap 이 20건을 넘는 상황을 /// 위젯 테스트에서 만들려면 장을 작게 쪼개는 쪽이 싸다. class _PagedZapRepository extends SampleMyRepository { static final _base = DateTime(2026, 9, 10, 21); /// 같은 날 Zap 둘 + 전날 Zap 하나. static final _rows = [ PopLedgerEntry( at: _base, title: 'Zap 기여', type: '기여', kind: PopKind.general, txType: PopTxType.spend, delta: -10, balanceAfter: 12540, repeatCount: 1, cursor: PopCursor(at: _base, id: 'z2'), ), PopLedgerEntry( at: _base.subtract(const Duration(hours: 1)), title: 'Zap 기여', type: '기여', kind: PopKind.general, txType: PopTxType.spend, delta: -10, balanceAfter: 12550, repeatCount: 1, cursor: PopCursor(at: _base.subtract(const Duration(hours: 1)), id: 'z1'), ), PopLedgerEntry( at: _base.subtract(const Duration(days: 1)), title: 'Zap 기여', type: '기여', kind: PopKind.general, txType: PopTxType.spend, delta: -10, balanceAfter: 12560, repeatCount: 1, cursor: PopCursor(at: _base.subtract(const Duration(days: 1)), id: 'z0'), ), ]; @override Future> popHistory({ int limit = 20, PopCursor? after, }) async { if (after == null) return [_rows.first]; final i = _rows.indexWhere((e) => e.cursor?.id == after.id); if (i < 0 || i + 1 >= _rows.length) return const []; return [_rows[i + 1]]; } } /// 내가 쓴 질문 한 건만 돌려주는 저장소 — 화면이 저장소를 읽는지 확인한다. class _MyInsides extends SampleInsideRepository { const _MyInsides(); @override Future> mine() async => [ InsideQuestion( id: 'mine-1', title: 'device-check-question', body: '본문', authorName: '팬', createdAt: DateTime(2026, 10, 4), bountyPop: 100, isMine: true, ), ]; } /// 가입 행성이 성운(Stage0) 하나뿐인 계정. class _OneNebula extends SampleGalaxyRepository { @override Future load() async => const GalaxySnapshot( planets: [], myPop: 0, memberships: [ PlanetNode( id: 'n1', name: '새 성운', rank: 0, fpi: 0, members: 1, online: 0, isMine: true, stage: 0, ), ], ); } class _WithdrawRepo extends SampleMyRepository { bool withdrawn = false; @override Future withdraw() async => withdrawn = true; }