Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림

This commit is contained in:
Sn-Kinos
2026-10-06 03:03:20 +09:00
parent 64825e72b0
commit ecf5998725
77 changed files with 2703 additions and 640 deletions
+23
View File
@@ -0,0 +1,23 @@
import 'package:fancake/core/config/feature_flags.dart';
import 'package:flutter_test/flutter_test.dart';
/// 스플래시 업데이트 확인(Figma `510:19053`) — 설치 버전이 운영 최신 버전보다 낮을 때만 안내.
void main() {
test('버전 비교', () {
expect(isOlderVersion('0.5.0', '0.5.1'), isTrue);
expect(isOlderVersion('0.5.0+7', '0.10.0'), isTrue);
expect(isOlderVersion('0.5.0', '0.5.0'), isFalse);
expect(isOlderVersion('0.6.0', '0.5.9'), isFalse);
expect(isOlderVersion('1.0', '1.0.0'), isFalse);
expect(isOlderVersion('0.5.0', 'abc'), isFalse);
});
test('config/features.latestAppVersion 을 읽고, 없으면 null', () async {
final flags = FeatureFlags(
source: Stream.value({FeatureFlags.latestVersionKey: ' 0.6.0 '}),
);
await Future<void>.delayed(Duration.zero);
expect(flags.latestVersion, '0.6.0');
expect(FeatureFlags().latestVersion, isNull);
});
}
+15 -6
View File
@@ -198,7 +198,9 @@ void main() {
expect(snapshot.popBalance, 12540);
expect(snapshot.liveTicker, contains('버프'));
expect(snapshot.growth.fandomName, 'BTS');
expect(snapshot.growth.contributorName, 'BTS_Hong');
// 설명 프레임 `2097:16391` — 인사 줄은 로그인 사용자 기준. 팬덤의 직전 기여자가 아니다.
expect(snapshot.growth.contributorName, isNot('BTS_Hong'));
expect(snapshot.growth.contributedPop, 0);
expect(snapshot.growth.currentPop, 6270);
// 서버가 growthPercent 를 주지 않으면 진행도로 낸다.
expect(snapshot.growth.growthPercent, 63);
@@ -827,14 +829,21 @@ void main() {
expect(profile.nickname, '팬');
});
test('홈 기여자명에 이메일이 남아 있어도 가린다', () async {
test('홈 인사 줄은 로그인 사용자의 닉네임 · 사진이고 이메일 형태는 가린다', () async {
final db = await _seed();
await db.doc('fandoms/bts').set({
'name': 'BTS', 'lastContributorName': 'test123@naver.com',
'lastContributedPop': 10,
await db.doc('users/$_uid').set({
'nickname': 'ARMY_Kim', 'photoUrl': 'https://example.com/p.png',
}, SetOptions(merge: true));
final snapshot = await FirestoreHomeRepository(_backend(db)).load();
var snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.growth.contributorName, 'ARMY_Kim');
expect(snapshot.growth.photoUrl, 'https://example.com/p.png');
await db.doc('users/$_uid').set({
'nickname': 'test123@naver.com', 'photoUrl': '',
}, SetOptions(merge: true));
snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.growth.contributorName, '팬');
expect(snapshot.growth.photoUrl, isNull);
});
});
+6 -6
View File
@@ -118,7 +118,7 @@ void main() {
testWidgets('이름을 넣고 만들면 기본 행성 변경을 되묻는다', (tester) async {
await _pump(tester, const NebulaCreateScreen());
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
@@ -131,13 +131,13 @@ void main() {
testWidgets('변경을 누르면 fn-nebula-create 로 만들고 새 성운 ID 를 들고 닫힌다', (tester) async {
final repo = _CreateRepo();
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(repo.calls, ['BTS_RM']);
expect(repo.calls, ['BTS RM']);
expect(results, ['new-nebula']);
expect(find.byType(NebulaCreateScreen), findsNothing);
});
@@ -147,7 +147,7 @@ void main() {
error: const BackendException('E-FDM-02', '이미 오늘 성운을 생성하셨습니다.'),
);
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
@@ -161,7 +161,7 @@ void main() {
testWidgets('대표 변경 30일 대기 중이면 성운만 생겼다고 알리고 닫힌다(C-15)', (tester) async {
final repo = _CreateRepo(primaryChanged: false);
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
@@ -180,7 +180,7 @@ void main() {
testWidgets('오늘 이미 만들었으면 사유를 알리고 진행하지 않는다', (tester) async {
await _pump(tester, const NebulaCreateScreen(alreadyCreatedToday: true));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
+74 -2
View File
@@ -121,6 +121,20 @@ class _PrimaryRepository extends SampleGalaxyRepository {
}
}
/// 오늘 성운을 이미 만든 계정.
class _CreatedTodayRepository extends SampleGalaxyRepository {
@override
Future<GalaxySnapshot> load() async {
final s = await super.load();
return GalaxySnapshot(
planets: s.planets,
myPop: s.myPop,
memberships: s.memberships,
nebulaCreatedToday: true,
);
}
}
void main() {
group('GALAXY 모아보기 (§2.56 · §2.58) — 기본 행성 변경', () {
Future<void> pumpHome(WidgetTester tester, GalaxyRepository repo) async {
@@ -146,6 +160,24 @@ void main() {
expect(repo.changes, ['newjeans']);
});
testWidgets('그리드 변경 — 격자 ↔ 리스트(540:19580)', (tester) async {
await pumpHome(tester, _PrimaryRepository());
expect(find.text('NewJeans'), findsNothing); // 격자는 그림만
await tester.tap(find.bySemanticsLabel('리스트 그리드'));
await tester.pumpAndSettle();
expect(find.text('NewJeans'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('격자 그리드'));
await tester.pumpAndSettle();
expect(find.text('NewJeans'), findsNothing);
});
testWidgets('오늘 성운을 만들었으면 성운 생성은 초과 안내 모달만(540:19592 · 543:19609)', (tester) async {
await pumpHome(tester, _CreatedTodayRepository());
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget);
});
testWidgets('30일 대기 중이면 서버 사유(E-FDM-04)를 알린다', (tester) async {
final repo = _PrimaryRepository(
error: const BackendException('E-FDM-04', '기본 행성은 변경 후 30일이 지나야 다시 바꿀 수 있습니다.'),
@@ -465,7 +497,10 @@ void main() {
await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'BTS');
await tester.pumpAndSettle();
expect(find.textContaining('멤버 12,840명'), findsOneWidget);
// 결과 줄 — Figma `353:218091` 가입자 수 · 「FPI : n」 · 규모 칩.
expect(find.text('12,840'), findsOneWidget);
expect(find.text('FPI : 284,310'), findsOneWidget);
expect(find.text('중형 팬덤'), findsOneWidget);
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
@@ -479,10 +514,47 @@ void main() {
await _pump(tester, GalaxySearchScreen(onOpen: (p) => opened = p));
await tester.enterText(find.byType(TextField), 'BTS');
await tester.pumpAndSettle();
await tester.tap(find.textContaining('멤버 12,840명'));
await tester.tap(find.text('12,840'));
expect(opened?.name, 'BTS');
});
testWidgets('탭(행성 · 성운) · 필터(전체 · 가입자 수 · FPI) — 543:19672 · 543:19683', (tester) async {
await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'e');
await tester.pumpAndSettle();
expect(find.text('행성'), findsOneWidget);
expect(find.text('성운'), findsOneWidget);
// 가입자 수 순 — NewJeans(11,230) 가 SEVENTEEN(10,450) 보다 위.
await tester.tap(find.text('가입자 수'));
await tester.pumpAndSettle();
expect(
tester.getTopLeft(find.text('NewJeans')).dy,
lessThan(tester.getTopLeft(find.text('SEVENTEEN')).dy),
);
// 성운 탭 — 표본은 모두 정식 행성이라 비고, FPI 필터는 없다.
await tester.tap(find.text('성운'));
await tester.pumpAndSettle();
expect(find.text('NewJeans'), findsNothing);
expect(find.text('FPI'), findsNothing);
});
testWidgets('가입 → 가입 처리 후 가입중(543:19694)', (tester) async {
final joined = <String>[];
await _pump(
tester,
GalaxySearchScreen(
repository: const SampleGalaxyRepository(),
onJoin: (p) async => joined.add(p.id),
),
);
await tester.enterText(find.byType(TextField), 'IVE');
await tester.pumpAndSettle();
await tester.tap(find.text('가입'));
await tester.pumpAndSettle();
expect(joined, ['ive']);
expect(find.text('가입중'), findsOneWidget);
});
testWidgets('없는 이름이면 사유를 알린다', (tester) async {
await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'zzzz');
+45
View File
@@ -11,6 +11,7 @@ import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/planet/lounge_post_screens.dart';
import 'package:fancake/features/planet/moderation.dart';
import 'package:fancake/features/planet/planet_detail_screen.dart';
import 'package:fancake/features/planet/widgets/planet_detail_hero.dart';
import 'eclipse_stubs.dart';
@@ -588,6 +589,50 @@ void _fpiRankingTests() {
expect(find.byType(FpiUserRankingScreen), findsOneWidget);
expect(find.text('나의 BPM 랭킹'), findsOneWidget);
});
testWidgets('소속 행성 카드를 눌러도 사용자 랭킹으로 간다 (546:14629)', (tester) async {
await pump(tester);
// 맨 위 내 줄 카드는 「(나)」 없이 이름만 둔다.
await tester.tap(find.text('BTS'));
await tester.pumpAndSettle();
expect(find.byType(FpiUserRankingScreen), findsOneWidget);
});
});
testWidgets('내가 만든 행성은 가입 버튼을 숨긴다 (544:19765)', (tester) async {
Widget hero(bool owner) => MaterialApp(
theme: buildAppTheme(),
home: const Scaffold(body: SizedBox()),
builder: (_, _) => SizedBox(
height: 543,
child: PlanetDetailHero(
name: 'BTS',
members: 850,
totalFpi: 1220,
joined: true,
isOwner: owner,
),
),
);
await tester.pumpWidget(hero(false));
await tester.pump();
expect(find.text('가입중'), findsOneWidget);
expect(find.text('소형 팬덤'), findsOneWidget);
await tester.pumpWidget(hero(true));
await tester.pump();
expect(find.text('가입중'), findsNothing);
});
test('팬덤 규모 — 1,000명 미만 소형 · 50,000명 미만 중형 · 이상 대형 (544:19765)', () {
expect(FandomSize.of(999), FandomSize.small);
expect(FandomSize.of(1000), FandomSize.medium);
expect(FandomSize.of(49999), FandomSize.medium);
expect(FandomSize.of(50000), FandomSize.large);
});
test('국기 — 국가 코드 → 국기, 미지정은 흰 깃발 (546:14713)', () {
expect(flagOf('KR'), '🇰🇷');
expect(flagOf('ZZ'), '🏳');
});
group('사용자 지표 랭킹 화면 (Figma 546:14646)', () {
+50 -7
View File
@@ -69,7 +69,7 @@ void main() {
final repo = _AdRepo();
await watchAd(tester, repo);
expect(repo.claimed, ['imp-1']);
expect(find.text('광고 추가 보상 +20 POP'), findsOneWidget);
expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget);
});
testWidgets('불러오지 못하면 기본 보상만 받는다', (tester) async {
@@ -193,8 +193,11 @@ void main() {
}
});
test('완료 탭은 진행 완료와 보상 완료를 함께 받는다', () {
expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isTrue);
// 명세 `518:88405` — 진행완료는 진행중 탭, 보상완료만 완료 탭.
test('진행완료는 진행중 탭, 보상완료만 완료 탭이 받는다', () {
expect(QuestTab.inProgress.accepts(_stage(card, QuestStage.completed)), isTrue);
expect(QuestTab.inProgress.accepts(_stage(card, QuestStage.inProgress)), isTrue);
expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isFalse);
expect(QuestTab.done.accepts(_stage(card, QuestStage.rewarded)), isTrue);
expect(QuestTab.done.accepts(_stage(card, QuestStage.inProgress)), isFalse);
});
@@ -328,10 +331,10 @@ void main() {
// 시청 후 대기(482:15084)는 지급이 끝나면 걷힌다.
expect(find.byType(QuestAdWarpScreen), findsNothing);
expect(find.byType(QuestRewardScreen), findsOneWidget);
// 별첨 B **B-28** 이 배수형(×2/×3)을 철회했다. 기본 보상과 광고 추가 보상은
// 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다(`fnAdReward.rewarded`).
expect(find.text('+10 받았어요!'), findsOneWidget);
expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget);
// 기본 보상과 광고 추가 보상은 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다
// (`fnAdReward.rewarded`). 운영 Quest 는 명세 `518:88685` 대로 둘의 합계(× 배수)를 크게 보인다.
expect(find.text('+20 받았어요!'), findsOneWidget);
expect(find.text('광고 추가 보상 +10 기여 POP'), findsOneWidget);
});
testWidgets('스트리밍은 무보상 참여로 열려 있고 보상 · 광고 화면 없이 참여만 알린다(C-04)', (tester) async {
@@ -530,6 +533,46 @@ void main() {
}
}
});
group('Figma 설명 프레임 대조 (2026-10-06)', () {
QuestCard ugc({int participants = 0, int? capacity, QuestStage stage = QuestStage.notStarted}) =>
QuestCard(
id: 'u', title: 't', categoryName: 'c', author: QuestAuthor.user,
stage: stage, goal: 1, progress: 0, rewardPop: 10,
participants: participants, capacity: capacity,
);
// 명세 `523:18176` 퀘스트 종료 시점 1 — 참여 인원 충족 시 종료.
test('사용자 퀘스트는 모집 인원이 차면 마감이다', () {
expect(questFull(ugc(participants: 5, capacity: 5)), isTrue);
expect(questFull(ugc(participants: 4, capacity: 5)), isFalse);
expect(questFull(ugc(participants: 3)), isFalse);
});
// 명세 `518:88395` — 카테고리 값은 등록된 분류(건강 · 커뮤니티 …)다. 활동명(걷기)이 아니다.
test('카드 카테고리는 활동이 아니라 분류 이름이다', () {
expect(QuestType.steps.category, '건강');
expect(QuestType.community.category, '커뮤니티');
expect(QuestType.vote.category, '행성 활동');
});
// 명세 `518:88685` — 광고 시청 POP = 보상 POP × 배수. 운영 Quest 는 합계를 크게 보인다.
testWidgets('운영 퀘스트는 광고를 보면 기본 + 증분 합계를 띄운다', (tester) async {
const card = QuestCard(
id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin,
stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 20, participants: 0,
);
await pumpWide(
tester,
const QuestRewardScreen(
detail: QuestDetail(card: card, imageLabel: '', description: ''),
reward: QuestReward(rewardedPop: 20, balanceAfter: 0),
adBonusPop: 20,
),
);
expect(find.text('+40 받았어요!'), findsOneWidget);
expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget);
});
});
}
/// 지갑이 서버 값으로 바뀐 표본 — 생성 예치 · 수수료 뒤 헤더가 다시 맞는가.
+58
View File
@@ -14,6 +14,9 @@ import 'package:fancake/features/home/widgets/quest_row.dart';
import 'package:fancake/features/inside/inside_detail_screen.dart';
import 'package:fancake/features/quest/widgets/quest_card_tile.dart' show QuestIconBox;
import 'package:fancake/core/router/app_router.dart' show scrollTabToTop;
import 'package:fancake/features/notification/notification_screen.dart';
import 'package:fancake/features/home/nebula_create_screen.dart' show isValidNebulaName;
import 'eclipse_stubs.dart';
class _LiveGrowthRepo extends SampleHomeRepository {
@@ -379,4 +382,59 @@ void main() {
await tester.pumpAndSettle();
expect(opened, ['https://example.com/a']);
});
group('설명 프레임 대조(홈 일반)', () {
Future<bool> badgeShown(WidgetTester tester, List<AppNotification> items) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: HomeHeader(
key: UniqueKey(),
pop: 0,
notifications: SampleNotificationRepository(items: items),
),
),
),
);
await tester.pumpAndSettle();
return tester.widget<Badge>(find.byKey(const ValueKey('home-bell-badge'))).isLabelVisible;
}
AppNotification n(bool read) => AppNotification(
id: 'n$read', kind: NotificationKind.quest, title: 't', at: DateTime(2026), read: read,
);
testWidgets('2097:16385 — 안 읽은 알림이 있으면 알림 아이콘에 배지', (tester) async {
expect(await badgeShown(tester, [n(false), n(true)]), isTrue);
expect(await badgeShown(tester, [n(true)]), isFalse);
});
testWidgets('2097:16427 — 열린 탭을 다시 누르면 루트 화면이 맨 위로', (tester) async {
final key = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: key,
home: Scaffold(
body: ListView(children: [for (var i = 0; i < 50; i++) SizedBox(height: 100, child: Text('$i'))]),
),
),
);
await tester.drag(find.byType(ListView), const Offset(0, -1500));
await tester.pumpAndSettle();
expect(find.text('0'), findsNothing);
scrollTabToTop(key);
await tester.pumpAndSettle();
expect(find.text('0'), findsOneWidget);
});
test('516:87397 — 성운명 규칙은 서버와 같다', () {
for (final ok in ['방탄', 'BTS Army 7', '가' * 20]) {
expect(isValidNebulaName(ok), isTrue, reason: ok);
}
for (final bad in ['가', '가' * 21, ' 방탄', '방탄 ', '방 탄', '방탄!', '방탄😀']) {
expect(isValidNebulaName(bad), isFalse, reason: bad);
}
});
});
}
+68
View File
@@ -110,6 +110,74 @@ void main() {
);
});
testWidgets('프로필 국가 — 사본 국가 리스트 모달(178:23489) 대로 국가명 · 단수 선택 · 「선택」 후 적용', (
tester,
) async {
final (auth, repo) = await boot(tester, needs: true);
await auth.signIn(SocialProvider.google);
await tester.pumpAndSettle();
for (final label in [
'서비스 이용약관 (필수)',
'개인정보 수집·이용 및 처리방침 동의 (필수)',
'위치기반 서비스 이용약관 (필수)',
'만 14세 이상입니다. (필수)',
]) {
await tester.tap(find.text(label));
}
await tester.pump();
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
await tester.tap(find.text('국가/지역').first);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('대한민국'),
300,
scrollable: find.byType(Scrollable).last,
);
await tester.tap(find.text('대한민국'));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '선택'));
await tester.pumpAndSettle();
expect(find.textContaining('대한민국'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, '우주팬');
await tester.pump();
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
expect(repo.profile?.country, 'KR');
});
testWidgets('프로필 뒤로가기(511:86825) — 약관으로 돌아가 동의가 체크된 채 보이고 다시 기록하지 않는다', (
tester,
) async {
final (auth, repo) = await boot(tester, needs: true);
await auth.signIn(SocialProvider.google);
await tester.pumpAndSettle();
for (final label in [
'서비스 이용약관 (필수)',
'개인정보 수집·이용 및 처리방침 동의 (필수)',
'위치기반 서비스 이용약관 (필수)',
'만 14세 이상입니다. (필수)',
]) {
await tester.tap(find.text(label));
}
await tester.pump();
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
final first = repo.agreed;
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
// 필수 4항이 이미 체크돼 있어 「완료」가 바로 켜져 있다.
final done = find.widgetWithText(FilledButton, '완료');
expect(tester.widget<FilledButton>(done).onPressed, isNotNull);
await tester.tap(done);
await tester.pumpAndSettle();
expect(identical(repo.agreed, first), isTrue);
expect(find.byType(BackButton), findsOneWidget); // ③ 프로필로 돌아왔다.
});
testWidgets('가입 모드에서 받은 약관은 다시 묻지 않고 기록만 한다', (tester) async {
final (auth, repo) = await boot(tester, needs: true);
auth.pendingConsent = {
+4 -1
View File
@@ -29,7 +29,10 @@ void main() {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: Scaffold(body: widget)));
await tester.pumpAndSettle();
expect(find.textContaining('0 POP', findRichText: true), findsNothing);
expect(find.textContaining('님의', findRichText: true), findsNothing);
// HOME 히어로의 「{닉네임} 님의」는 로그인 사용자 인사 줄이라 늘 있다(설명 프레임 `2097:16391`).
if (widget is PlanetGrowthCard) {
expect(find.textContaining('님의', findRichText: true), findsNothing);
}
expect(find.text('함께 키워가는 팬덤 행성'), findsOneWidget);
}
});