Files
fanuzu/app/test/timeattack_briefing_test.dart
T

271 lines
12 KiB
Dart

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'});
// 10/3 통합회신 — TIME ATTACK 은 원 Quest(여기서는 스트리밍)에 붙는 회차다.
await db.doc('quests/ta').set({
'type': 'streaming', 'title': '돌발', 'fandomId': 'bts',
'dailyLimit': 1, 'active': true,
'timeAttack': _round('r1', left),
});
return db;
}
Map<String, dynamic> _round(String id, Duration left) => {
'roundId': id, 'fandomId': 'bts', 'rewardPop': 30,
'activeFrom': Timestamp.fromDate(DateTime.now().subtract(const Duration(minutes: 1))),
'activeTo': Timestamp.fromDate(DateTime.now().add(left)),
};
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.streaming);
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));
// 회차가 닫히면 원 Quest 는 일반 Quest 로 남고 회차 표시(마감)만 빠진다.
expect((await FirestoreQuestRepository(backend).load()).single.endsAt, isNull);
expect(ServerClock.offset.inMinutes, inInclusiveRange(9, 10));
final home = await FirestoreHomeRepository(backend).load();
expect(home.quests.singleWhere((q) => q.id == 'ta').endsAt, isNull);
});
test('받은 회차는 날짜와 무관하게 보상 완료로 남는다(회차당 1인 1회)', () async {
final db = await _seed();
await db.doc('users/$_uid/questProgress/ta').set({
'dayKey': '2000-01-01', 'count': 1, 'timeAttackRoundId': 'r1',
});
final card = (await FirestoreQuestRepository(_Backend(db)).load()).single;
expect(card.stage, QuestStage.rewarded);
});
test('홈은 회차가 열린 Quest 를 맨 앞에 둔다', () async {
final db = await _seed();
await db.doc('quests/ta-old').set({
'type': 'vote', 'title': '지난 돌발', 'fandomId': 'bts', 'active': true,
'timeAttack': _round('r-old', const Duration(minutes: -1)),
});
await db.doc('quests/ta-off').set({
'type': 'vote', 'title': '내려진 돌발', 'fandomId': 'bts', 'active': false,
'timeAttack': _round('r-off', const Duration(minutes: 5)),
});
await db.doc('quests/attend').set({'type': 'attend', 'title': '출석', 'active': true});
final home = await FirestoreHomeRepository(_Backend(db)).load();
// 열린 회차가 맨 앞, 닫힌 회차의 원 Quest 는 일반 Quest 로, 내려진 Quest 는 빠진다.
expect(home.quests.map((q) => q.id), ['ta', 'ta-old', 'attend']);
expect(home.quests.first.endsAt, isNotNull);
expect(home.quests[1].endsAt, isNull);
});
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);
});
});
}