앱 단위·화면·통합 테스트
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart' show Timestamp;
|
||||
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
||||
import 'package:fancake/core/data/backend.dart';
|
||||
import 'package:fancake/core/data/invite_code.dart';
|
||||
import 'package:fancake/core/data/server_clock.dart';
|
||||
import 'package:fancake/features/home/home_data.dart';
|
||||
import 'package:fancake/features/home/home_repository_firestore.dart';
|
||||
import 'package:fancake/features/home/widgets/briefing_share.dart';
|
||||
import 'package:fancake/features/quest/quest_data.dart';
|
||||
import 'package:fancake/features/quest/quest_detail_screen.dart';
|
||||
import 'package:fancake/features/quest/quest_repository_firestore.dart';
|
||||
import 'package:fancake/features/quest/widgets/time_attack_countdown.dart';
|
||||
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 긴급 돌발 타임어택(별첨 1 ③-4 · FR-QST-06) · 브리핑 공유(③-5 · 「홈_브리핑 공유」).
|
||||
///
|
||||
/// 지급 판정은 서버 `firebase/functions/test/timeattack.test.ts` 가 본다. 여기서는
|
||||
/// 앱이 **무엇을 읽어 무엇을 부르는지**와 마감 · 공유 문구를 본다.
|
||||
|
||||
const _uid = 'u-ta';
|
||||
|
||||
class _Backend extends Backend {
|
||||
_Backend(FakeFirebaseFirestore db, {this.serverAhead = Duration.zero, this.duplicate = false})
|
||||
: super(
|
||||
firestore: db,
|
||||
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
|
||||
);
|
||||
|
||||
final Duration serverAhead;
|
||||
final bool duplicate;
|
||||
final calls = <String>[];
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> call(
|
||||
String name, [
|
||||
Map<String, dynamic> payload = const {},
|
||||
]) async {
|
||||
if (name == 'fnServerTime') {
|
||||
calls.add(name);
|
||||
return {'now': DateTime.now().add(serverAhead).millisecondsSinceEpoch};
|
||||
}
|
||||
calls.add('$name:${payload['questId']}');
|
||||
return {'txId': 'tx', 'balanceAfter': 30, 'rewarded': 30, 'duplicate': duplicate};
|
||||
}
|
||||
}
|
||||
|
||||
/// `fnInviteCodeGet` 만 흉내 — 부른 횟수를 센다. [fail] 이면 서버 오류.
|
||||
class _InviteBackend extends Backend {
|
||||
_InviteBackend({this.fail = false})
|
||||
: super(
|
||||
firestore: FakeFirebaseFirestore(),
|
||||
auth: MockFirebaseAuth(signedIn: true, mockUser: MockUser(uid: _uid)),
|
||||
);
|
||||
final bool fail;
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> call(String name, [Map<String, dynamic> payload = const {}]) async {
|
||||
calls++;
|
||||
if (fail) throw const BackendException('E-SYS-01', 'down');
|
||||
return {'code': 'ABCDEFGH23'};
|
||||
}
|
||||
}
|
||||
|
||||
Future<FakeFirebaseFirestore> _seed({Duration left = const Duration(minutes: 5)}) async {
|
||||
final db = FakeFirebaseFirestore();
|
||||
await db.doc('users/$_uid').set({'uid': _uid, 'fandomId': 'bts'});
|
||||
await db.doc('quests/ta').set({
|
||||
'type': 'timeattack', 'title': '돌발', 'fandomId': 'bts', 'rewardPop': 30,
|
||||
'dailyLimit': 1, 'active': true,
|
||||
'activeFrom': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))),
|
||||
'activeTo': Timestamp.fromDate(DateTime.now().add(left)),
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
BriefingItem _brief(int rank, String headline) => BriefingItem(
|
||||
rank: rank,
|
||||
headline: headline,
|
||||
section: BriefingSection.news,
|
||||
sourceName: '출처',
|
||||
sourceAt: DateTime(2026, 9, 28),
|
||||
sourceUrl: 'https://example.com',
|
||||
articleUrl: 'https://example.com/a',
|
||||
aiSummary: '요약',
|
||||
);
|
||||
|
||||
Widget _app(Widget child) => MaterialApp(home: child);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ServerClock.reset();
|
||||
});
|
||||
|
||||
group('타임어택 — 앱 경로', () {
|
||||
test('카드에 마감을 싣고 참여는 fnQuestTimeAttack 으로 보낸다', () async {
|
||||
final db = await _seed();
|
||||
final backend = _Backend(db);
|
||||
final repo = FirestoreQuestRepository(backend);
|
||||
final card = (await repo.load()).single;
|
||||
expect(card.type, QuestType.timeAttack);
|
||||
expect(card.endsAt, isNotNull);
|
||||
expect(card.rewardPop, 30);
|
||||
final reward = await repo.verify(card);
|
||||
expect(reward.duplicate, isFalse);
|
||||
// 서버 시계는 타임어택이 있을 때 세션당 한 번만 잰다.
|
||||
expect(backend.calls, ['fnServerTime', 'fnQuestTimeAttack:ta']);
|
||||
});
|
||||
|
||||
test('재응답(duplicate)은 새 지급이 아니라고 표시한다', () async {
|
||||
final repo = FirestoreQuestRepository(_Backend(await _seed(), duplicate: true));
|
||||
final card = (await repo.load()).single;
|
||||
expect((await repo.verify(card)).duplicate, isTrue);
|
||||
});
|
||||
|
||||
test('노출 · 마감은 서버 시각으로 판정한다(단말 시계가 늦어도)', () async {
|
||||
// 서버가 10분 앞서 있다 → 단말 기준 5분 남은 창은 이미 닫혔다.
|
||||
final db = await _seed();
|
||||
final backend = _Backend(db, serverAhead: const Duration(minutes: 10));
|
||||
expect(await FirestoreQuestRepository(backend).load(), isEmpty);
|
||||
expect(ServerClock.offset.inMinutes, inInclusiveRange(9, 10));
|
||||
final home = await FirestoreHomeRepository(backend).load();
|
||||
expect(home.quests.where((q) => q.id == 'ta'), isEmpty);
|
||||
});
|
||||
|
||||
test('어제 받은 타임어택도 보상 완료로 남는다(1인 1회)', () async {
|
||||
final db = await _seed();
|
||||
await db.doc('users/$_uid/questProgress/ta').set({'dayKey': '2000-01-01', 'count': 1});
|
||||
final card = (await FirestoreQuestRepository(_Backend(db)).load()).single;
|
||||
expect(card.stage, QuestStage.rewarded);
|
||||
});
|
||||
|
||||
test('홈은 열린 타임어택을 맨 앞에 두고 마감된 것은 뺀다', () async {
|
||||
final db = await _seed();
|
||||
await db.doc('quests/ta-old').set({
|
||||
'type': 'timeattack', 'title': '지난 돌발', 'fandomId': 'bts', 'active': true,
|
||||
'activeTo': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))),
|
||||
});
|
||||
await db.doc('quests/ta-off').set({
|
||||
'type': 'timeattack', 'title': '내려진 돌발', 'fandomId': 'bts', 'active': false,
|
||||
'activeTo': Timestamp.fromDate(DateTime.now().add(const Duration(minutes: 5))),
|
||||
});
|
||||
await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'active': true});
|
||||
final home = await FirestoreHomeRepository(_Backend(db)).load();
|
||||
expect(home.quests.map((q) => q.id), ['ta', 'attend']);
|
||||
expect(home.quests.first.endsAt, isNotNull);
|
||||
});
|
||||
|
||||
test('성운 목표는 config/policy 운영값 · 탄생 권한은 창립자만(Q-PN-01)', () async {
|
||||
final db = await _seed();
|
||||
await db.doc('config/policy').set({'nebulaMinMembers': 12, 'nebulaMinPop': 1500});
|
||||
await db.doc('fandoms/bts').set({
|
||||
'name': 'BTS', 'stage': 0, 'memberCount': 12, 'growthPop': 1500, 'founderUid': _uid,
|
||||
});
|
||||
var growth = (await FirestoreHomeRepository(_Backend(db)).load()).growth;
|
||||
expect((growth.requiredMembers, growth.requiredPop), (12, 1500));
|
||||
expect(growth.nebulaReady && growth.isFounder, isTrue);
|
||||
await db.doc('fandoms/bts').update({'founderUid': 'someone-else'});
|
||||
growth = (await FirestoreHomeRepository(_Backend(db)).load()).growth;
|
||||
expect(growth.nebulaReady, isTrue);
|
||||
expect(growth.isFounder, isFalse);
|
||||
expect(await FirestoreHomeRepository(_Backend(db)).nebulaTargets(),
|
||||
(pop: 1500, members: 12, days: 7));
|
||||
});
|
||||
|
||||
test('남은 시간 표기', () {
|
||||
expect(formatLeft(const Duration(minutes: 9, seconds: 5)), '09:05');
|
||||
expect(formatLeft(const Duration(hours: 1, minutes: 2, seconds: 3)), '1:02:03');
|
||||
});
|
||||
|
||||
testWidgets('마감되면 상세 버튼이 잠긴다', (tester) async {
|
||||
final db = await tester.runAsync(() => _seed(left: const Duration(seconds: 2)));
|
||||
final repo = FirestoreQuestRepository(_Backend(db!));
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(_app(QuestDetailScreen(questId: 'ta', repository: repo)));
|
||||
for (var i = 0; i < 50 && find.text('남은 시간').evaluate().isEmpty; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.text('남은 시간'), findsOneWidget);
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(seconds: 3)));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
expect(find.text('마감'), findsNWidgets(2));
|
||||
final button = tester.widget<FilledButton>(find.byType(FilledButton));
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('브리핑 공유', () {
|
||||
test('초대 코드는 세션당 한 번 받고, 실패하면 ref 없이 나간다', () async {
|
||||
addTearDown(InviteCode.reset);
|
||||
InviteCode.reset();
|
||||
final down = _InviteBackend(fail: true);
|
||||
await InviteCode.ensure(down);
|
||||
expect(InviteCode.current, isNull);
|
||||
expect(briefingShareLink('bts', '2026-09-28', base: 'https://s.example'),
|
||||
'https://s.example/b/bts/2026-09-28');
|
||||
final up = _InviteBackend();
|
||||
await Future.wait([InviteCode.ensure(up), InviteCode.ensure(up)]);
|
||||
await InviteCode.ensure(up);
|
||||
expect(up.calls, 1);
|
||||
expect(briefingShareLink('bts', '2026-09-28', base: 'https://s.example'),
|
||||
'https://s.example/b/bts/2026-09-28?ref=ABCDEFGH23');
|
||||
});
|
||||
|
||||
test('링크는 설정된 웹 브릿지 주소가 있을 때만 만든다', () {
|
||||
expect(briefingShareLink('bts', '2026-09-28', base: ''), isNull);
|
||||
expect(
|
||||
briefingShareLink('bts', '2026-09-28', base: 'https://share.example/'),
|
||||
'https://share.example/b/bts/2026-09-28',
|
||||
);
|
||||
});
|
||||
|
||||
test('문구는 헤드라인 최대 3건 · 링크 · 동적 해시태그다', () {
|
||||
final text = briefingShareText(
|
||||
items: [for (var i = 1; i <= 4; i++) _brief(i, '뉴스 $i')],
|
||||
fandomName: 'BTS',
|
||||
link: 'https://share.example/b/bts/2026-09-28',
|
||||
);
|
||||
expect(text, contains('3. 뉴스 3'));
|
||||
expect(text, isNot(contains('뉴스 4')));
|
||||
expect(text, contains('https://share.example/b/bts/2026-09-28'));
|
||||
expect(text, contains('#FANUZU #BTS #AIBriefing'));
|
||||
expect(briefingShareText(items: [_brief(1, 'a')]), isNot(contains('http')));
|
||||
});
|
||||
|
||||
testWidgets('SNS 버튼은 카드 이미지와 문구를 공유 시트로 보낸다', (tester) async {
|
||||
ShareParams? sent;
|
||||
await tester.pumpWidget(_app(Scaffold(
|
||||
body: BriefingShareDialog(
|
||||
items: [_brief(1, '빌보드 수상')],
|
||||
fandomName: 'BTS',
|
||||
share: (params) async => sent = params,
|
||||
),
|
||||
)));
|
||||
expect(find.text('AI DAILY BRIEFING'), findsOneWidget);
|
||||
expect(find.text('빌보드 수상'), findsOneWidget);
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.byTooltip('X'));
|
||||
for (var i = 0; i < 50 && sent == null; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
});
|
||||
expect(sent?.text, contains('1. 빌보드 수상'));
|
||||
expect(sent?.files, hasLength(1));
|
||||
// 웹 브릿지 주소가 없으면 링크가 없으니 「문구 복사」로 부른다.
|
||||
expect(find.byTooltip('문구 복사'), findsOneWidget);
|
||||
expect(find.byTooltip('링크 복사'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user