Files
fanuzu/app/test/f6_screens_test.dart
T

1237 lines
45 KiB
Dart

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<void> 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<GestureDetector>(
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<String> 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<Image>(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<Text>(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<TextField>(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<void> 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>(_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>(_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 = <Map<PushTopic, bool>>[];
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<TextButton>(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<TextButton>(find.widgetWithText(TextButton, '완료'))
.onPressed;
expect(done, isNull);
await tester.enterText(find.byType(TextField).last, '열 글자가 넘는 문의 내용입니다');
await tester.pump();
done = tester
.widget<TextButton>(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 <Widget>[
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<Text>(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<void> clearOnSignOut() async => throw Exception('offline');
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// 푸시 정리가 응답 없이 멈추는 경계 — 토픽 해제가 끝나지 않는다.
class _HangingPush implements PushMessaging {
@override
Future<void> clearOnSignOut() => Completer<void>().future;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// 로그아웃 호출을 세는 최소 세션. 실제 Firebase 를 붙이지 않는다.
final class _LogoutSession implements AuthSession {
_LogoutSession(this._current);
AuthAccount? _current;
final StreamController<AuthAccount?> _changes =
StreamController<AuthAccount?>.broadcast();
int signOutCalls = 0;
@override
AuthAccount? get currentAccount => _current;
@override
Stream<AuthAccount?> get accountChanges => _changes.stream;
@override
Future<void> signOut() async {
signOutCalls += 1;
_current = null;
_changes.add(null);
}
Future<void> 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<List<PopLedgerEntry>> 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<List<InsideQuestion>> 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<GalaxySnapshot> 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<void> withdraw() async => withdrawn = true;
}