1003 lines
36 KiB
Dart
1003 lines
36 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:fancake/features/auth/auth.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:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.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('프로필을 누르면 카드 오버레이가 뜬다', (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('티어 카드를 누르면 행성별 티어로 넘어간다', (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(), [
|
|
'내행성',
|
|
'큰팬덤',
|
|
'작은팬덤',
|
|
]);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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));
|
|
await tester.pump();
|
|
expect(find.textContaining('화양연화'), findsOneWidget);
|
|
expect(find.text('500 P'), findsOneWidget);
|
|
});
|
|
|
|
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('로그아웃을 확인하면 세션이 끊긴다 (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();
|
|
});
|
|
}
|
|
|
|
/// 로그아웃 호출을 세는 최소 세션. 실제 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]];
|
|
}
|
|
}
|