Files
fanuzu/app/test/app_smoke_test.dart
T

420 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'package:fancake/app.dart';
import 'package:fancake/core/config/feature_flags.dart';
import 'package:fancake/core/data/repositories.dart';
import 'package:fancake/core/firebase/firebase_boot.dart';
import 'package:fancake/core/router/app_router.dart';
import 'package:fancake/features/auth/auth.dart';
import 'package:fancake/features/auth/auth_repository.dart';
import 'package:fancake/features/auth/login_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// 2주차 스모크 — Firebase 설정 파일이 **없는 상태**(목 모드)에서 전부 통과해야 한다.
void main() {
setUp(() {
FirebaseBoot.debugSetMode(
FirebaseMode.mock,
reason: '스모크 테스트 — 설정 파일 미투입 상황 재현',
);
});
testWidgets('1. 앱이 예외 없이 뜨고 로그인 화면이 나온다', (tester) async {
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(loginLogoKey), findsOneWidget);
// Figma 2단계 — 「로그인」을 누르면 간편 로그인에 별첨 B v1.4 확정 4종이 있다.
await tester.tap(find.widgetWithText(FilledButton, '로그인'));
await tester.pumpAndSettle();
for (final provider in AuthRepository().providers) {
expect(
find.text(providerButtonLabel(provider.provider)),
findsOneWidget,
reason: provider.provider.name,
);
}
});
// 별첨 B v1.4 — *이메일/비밀번호 가입 및 X 로그인은 제외합니다.*
// 발주사가 「질의 없이 임의 변경」을 지적한 뒤의 회신이라 회귀 가드를 건다.
group('이메일 가입은 사용자에게 노출되지 않는다', () {
test('노출 제공자 목록은 Google·Apple·Kakao·LINE 4종뿐이다', () {
final kinds = AuthRepository().providers.map((p) => p.provider).toList();
expect(kinds, const [
AuthProviderKind.google,
AuthProviderKind.apple,
AuthProviderKind.kakao,
AuthProviderKind.line,
]);
expect(kinds, isNot(contains(AuthProviderKind.email)));
});
testWidgets('로그인 화면에 이메일 버튼·가입·재설정 입구가 없다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
await tester.pumpAndSettle();
expect(find.text(AuthProviderKind.email.label), findsNothing);
expect(find.text('이메일'), findsNothing);
expect(find.text('비밀번호'), findsNothing);
expect(find.text('계정 만들기'), findsNothing);
expect(find.text('비밀번호 재설정'), findsNothing);
});
test('릴리스 빌드에는 QA 이메일 경로가 아예 없다', () async {
final auth = AuthRepository(releaseMode: true);
expect(auth.qaEmailProvider, isNull);
final outcome = await auth.signInWithEmail(
email: 'qa-test@fanuzu.co.kr',
password: 'password12',
);
expect(outcome, isA<AuthFailed>());
expect(auth.isSignedIn, isFalse);
});
test('디버그 빌드에서는 QA 이메일 경로가 살아 있다', () {
expect(AuthRepository(releaseMode: false).qaEmailProvider, isNotNull);
});
});
testWidgets('저장된 세션이 있으면 로그인 화면 대신 5탭 셸로 복원된다', (tester) async {
final session = _SmokeSession(
const AuthAccount(uid: 'restored-user', provider: SocialProvider.google),
);
final auth = AuthRepository(session: session, releaseMode: true);
await tester.pumpWidget(FancakeApp(auth: auth));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.text('목 세션으로 탭 둘러보기'), findsNothing);
auth.dispose();
});
testWidgets('회원가입 직후에는 가입 완료 화면(182:29344)을 거쳐 셸로 들어간다', (tester) async {
final auth = AuthRepository(
providers: [_SignupProvider()],
releaseMode: true,
);
await tester.pumpWidget(FancakeApp(auth: auth));
await tester.pumpAndSettle();
await auth.signIn(SocialProvider.google, signUp: true);
await tester.pumpAndSettle();
expect(find.text('회원가입이 완료되었습니다'), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
await tester.tap(find.text('시작'));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
auth.dispose();
});
testWidgets('2. 하단 탭 5개가 계약 표기·순서 그대로 렌더된다', (tester) async {
await _enterShell(tester);
final rendered = tester
.widgetList<NavigationDestination>(
find.descendant(
of: find.byType(NavigationBar),
matching: find.byType(NavigationDestination),
),
)
.map((d) => d.label)
.toList();
// 별첨 B v1.4 — STATION 은 사용자 탭에서 비노출. 계약(5탭)은 그대로 두고
// 화면에 그리는 목록만 줄인다.
expect(rendered, ['HOME', 'INSIDE', 'GALAXY', 'MY']);
expect(rendered, visibleTabs(FeatureFlags()).map((t) => t.label).toList());
expect(find.text('STATION'), findsNothing);
});
// 계약(견적서 v2.1 · craft-allowlist.yaml · 시안 탭바)은 5탭 그대로다.
// 화면에서 뺀 것과 계약에서 뺀 것은 다른 일이고, 뒤엣것은 하지 않았다.
test('2-1. 계약 고정 5탭 표기·순서는 그대로다', () {
expect(appTabs.map((t) => t.label).toList(), _contractTabs());
});
group('STATION 은 원격 플래그로 켠다 (별첨 B v1.4 · 재배포 없이)', () {
test('플래그 기본값은 꺼짐이다', () {
expect(FeatureFlags().stationTab, isFalse);
expect(visibleTabs(FeatureFlags()), hasLength(4));
});
test('config/features 의 stationEnabled 가 true 면 5탭이 된다', () async {
final source = StreamController<Map<String, Object?>>();
final flags = FeatureFlags(source: source.stream);
addTearDown(flags.dispose);
source.add({FeatureFlags.stationKey: true});
await Future<void>.delayed(Duration.zero);
expect(flags.stationTab, isTrue);
expect(
visibleTabs(flags).map((t) => t.label).toList(),
appTabs.map((t) => t.label).toList(),
);
await source.close();
});
test('문자열 "true" 나 읽기 실패로는 켜지지 않는다', () async {
final source = StreamController<Map<String, Object?>>();
final flags = FeatureFlags(source: source.stream);
addTearDown(flags.dispose);
source.add({FeatureFlags.stationKey: 'true'});
source.addError(StateError('permission-denied'));
await Future<void>.delayed(Duration.zero);
expect(flags.stationTab, isFalse);
await source.close();
});
test('STATION 라우트와 저장소는 남아 있다 — 비노출이지 삭제가 아니다', () {
final station = appTabs.firstWhere((t) => t.label == 'STATION');
expect(station.path, '/station');
expect(Repositories(live: false).station, isNotNull);
});
});
// A-01 회귀 가드 — 목 우회 입구가 배포 앱에 새어 나가지 않는지.
// FirebaseBoot.init() 은 모든 예외를 삼키고 목 모드로 내려가므로, 설정 파일이 정상
// 투입된 릴리스 빌드라도 초기화가 실패하면 목 모드가 된다. 그 상태를 그대로 재현한다.
group('릴리스 빌드에서는 목 우회 입구가 열리지 않는다', () {
test('목 모드 + 릴리스면 isMockSessionAvailable 이 false 다', () {
final auth = AuthRepository(releaseMode: true);
expect(FirebaseBoot.isLive, isFalse, reason: '목 모드 전제 확인');
expect(auth.isMockSessionAvailable, isFalse);
});
test('릴리스에서 signInWithMockSession 을 불러도 로그인되지 않는다', () {
final auth = AuthRepository(releaseMode: true)..signInWithMockSession();
expect(auth.isSignedIn, isFalse);
expect(auth.account, isNull);
});
testWidgets('릴리스 빌드 로그인 화면에 목 세션 버튼이 없다', (tester) async {
await tester.pumpWidget(
FancakeApp(auth: AuthRepository(releaseMode: true)),
);
await tester.pumpAndSettle();
expect(find.text('목 세션으로 탭 둘러보기'), findsNothing);
expect(
find.byType(NavigationBar),
findsNothing,
reason: '셸에 진입할 수 없어야 한다',
);
// 소셜 버튼은 그대로 있어야 한다 — 우회 입구만 막는 것이지 로그인을 막는 게 아니다.
await tester.tap(find.widgetWithText(FilledButton, '로그인'));
await tester.pumpAndSettle();
expect(
find.text(providerButtonLabel(SocialProvider.google)),
findsOneWidget,
);
});
testWidgets('디버그 빌드에서는 목 세션 버튼이 있다 (가드가 과하게 잠그지 않았는지)', (tester) async {
await tester.pumpWidget(
FancakeApp(auth: AuthRepository(releaseMode: false)),
);
await tester.pumpAndSettle();
// 목 세션 입구는 QA 폼과 같은 숨은 입구(로고 길게 누르기) 안에 있다.
await tester.longPress(find.byKey(loginLogoKey));
await tester.pumpAndSettle();
expect(find.text('목 세션으로 탭 둘러보기'), findsOneWidget);
});
});
// 화면에 나가는 문자열 전체를 훑는다. reason 만 고쳤다가 제공자 missingConfig 로
// 리포 경로가 그대로 새어 나간 적이 있다(에뮬레이터 실기동 중 발견).
test('제공자 4종의 미투입 안내에 내부 파일 경로가 들어가지 않는다', () {
const forbidden = [
'google-services.json',
'GoogleService-Info.plist',
'app/',
'.plist',
'.json',
];
for (final p in AuthRepository().providers) {
for (final item in p.missingConfig) {
for (final bad in forbidden) {
expect(
item.contains(bad),
isFalse,
reason: '${p.provider.name} 의 미투입 안내에 "$bad" 가 들어 있다: $item',
);
}
}
}
});
test('화면에 띄우는 사유에 내부 파일 경로가 들어가지 않는다', () {
FirebaseBoot.debugSetMode(
FirebaseMode.mock,
reason: 'Firebase 설정이 아직 투입되지 않아 임시(목) 모드로 동작 중입니다.',
);
expect(FirebaseBoot.reason, isNot(contains('google-services.json')));
expect(FirebaseBoot.reason, isNot(contains('GoogleService-Info.plist')));
expect(FirebaseBoot.reason, isNot(contains('/')));
});
testWidgets('3. 탭을 전환하면 각 탭 루트 화면이 바뀐다', (tester) async {
await _enterShell(tester);
// 5탭 모두 실제 화면이 들어갔다. 자리표시가 남아 있으면 발주사가 설치본에서
// 「2주차 범위」 문구를 보게 되므로, 탭마다 그 화면의 고정 구획명으로 확인한다.
// STATION 은 하단 탭에 없다(별첨 B v1.4). 나머지 4탭을 확인한다.
const implemented = {
'HOME': 'TODAY’S QUEST',
'INSIDE': '최신순',
'GALAXY': 'GALAXY',
'MY': '행성/성운',
};
final roots = implemented.keys;
for (final tab in roots) {
await tester.tap(
find.descendant(
of: find.byType(NavigationBar),
matching: find.text(tab),
),
);
// GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 영원히 기다린다.
// 탭 전환은 프레임 몇 장이면 끝나므로 시간을 정해 넘긴다.
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
// GALAXY 는 궤도 회전이 멈추지 않아 pumpAndSettle 이 끝나지 않는다.
expect(
find.text(implemented[tab]!),
findsWidgets,
reason: '$tab → ${implemented[tab]}',
);
// 자리표시가 되살아나면 여기서 잡힌다.
expect(
find.textContaining('2주차 범위'),
findsNothing,
reason: '$tab 이 자리표시로 돌아갔다',
);
expect(
find.textContaining('wireframe/'),
findsNothing,
reason: '$tab 화면에 내부 저장소 경로가 노출되면 안 된다',
);
expect(
find.textContaining('screen-spec'),
findsNothing,
reason: '\$tab 화면에 내부 명세 경로가 노출되면 안 된다',
);
}
});
}
/// 로그인 화면에서 목 세션으로 들어가 5탭 셸까지 도달한다.
///
/// 실 소셜 로그인은 Firebase 설정 파일이 없으면 물리적으로 불가능하므로,
/// 목 모드 전용 입구를 쓴다(AuthRepository.isMockSessionAvailable).
Future<void> _enterShell(WidgetTester tester) async {
// 홈 히어로가 시안대로 첫 화면의 78% 를 차지하므로 기본 800×600 창에서는 아래
// 구획이 지연 빌드되지 않는다. 실기기에 가까운 세로 길이를 준다(F3 테스트와 같다).
// 목 모드에서는 이클립스 경보 배너와 라이브 띠가 히어로 아래에 더 붙으므로
// 2400 으로는 TODAY'S QUEST 가 지연 빌드 밖으로 밀린다.
tester.view.physicalSize = const Size(1080, 3200);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
await tester.pumpWidget(FancakeApp(auth: AuthRepository()));
await tester.pumpAndSettle();
await tester.longPress(find.byKey(loginLogoKey));
await tester.pumpAndSettle();
final enter = find.text('목 세션으로 탭 둘러보기');
await tester.ensureVisible(enter);
await tester.pumpAndSettle();
await tester.tap(enter);
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
}
/// `design/craft-allowlist.yaml` 의 `APP_TABS` 를 읽어 온다.
/// 앱 코드가 계약 표기에서 벗어나면 여기서 깨진다.
///
/// 계약 파일은 저장소 루트 `design/` 에 있고, `app/design/` 에 같은 사본을 둔다.
/// **FANUZU_DESIGN_DIR → app/design/ → 저장소 루트 design/** 순으로 본다.
///
/// 하나도 없으면 **통과가 아니라 실패**로 끝낸다 — 못 읽은 것을 「맞다」로 세면
/// 계약 대조가 조용히 사라진다.
///
/// **사본 표류도 실패로 끝낸다.** 후보가 둘 이상 보이면(환경 변수로 가리킨 정본과
/// 저장소 내 사본이 함께 있는 경우) `APP_TABS` 줄을 서로 비교한다. 사본만 고치거나
/// 정본만 고치면 저장소마다 다른 답이 나오고, 그건 대조가 아니라 우연이다.
List<String> _contractTabs() {
final candidates = [
if (Platform.environment['FANUZU_DESIGN_DIR'] case final dir?)
'$dir/craft-allowlist.yaml',
'design/craft-allowlist.yaml',
'../design/craft-allowlist.yaml',
];
final found = candidates.map(File.new).where((f) => f.existsSync()).toList();
if (found.isEmpty) {
throw StateError(
'craft-allowlist.yaml 을 찾지 못했다. FANUZU_DESIGN_DIR 로 지정하라. '
'찾아본 곳: ${candidates.join(' · ')}',
);
}
final lines = {for (final file in found) _appTabsLine(file)};
if (lines.length > 1) {
throw StateError(
'craft-allowlist.yaml 의 APP_TABS 가 사본마다 다르다. 정본과 사본을 맞추라. '
'${found.map((f) => f.path).join(' · ')} → ${lines.join(' / ')}',
);
}
final inner = RegExp(r'\[(.*)\]').firstMatch(lines.single)!.group(1)!;
return inner.split(',').map((s) => s.trim()).toList();
}
/// `APP_TABS:` 한 줄. 없으면 파일 이름을 붙여 실패한다.
String _appTabsLine(File file) => file.readAsLinesSync().firstWhere(
(l) => l.trimLeft().startsWith('APP_TABS:'),
orElse: () =>
throw StateError('craft-allowlist.yaml 에 APP_TABS 가 없다: ${file.path}'),
);
final class _SmokeSession implements AuthSession {
const _SmokeSession(this.currentAccount);
@override
final AuthAccount? currentAccount;
@override
Stream<AuthAccount?> get accountChanges => const Stream.empty();
@override
Future<void> signOut() async {}
}
final class _SignupProvider implements SocialAuthProvider {
@override
AuthProviderKind get provider => SocialProvider.google;
@override
bool get isLive => true;
@override
List<String> get missingConfig => const [];
@override
Future<AuthOutcome> signIn() async => const AuthSucceeded(
AuthAccount(uid: 'new-user', provider: SocialProvider.google),
);
@override
Future<void> signOut() async {}
}