이클립스 사전 경고 연출 · 브리핑 출처·게시시각 · PASSPORT 역사적 사건 Stamp
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fancake/core/data/repositories.dart'
|
||||
show eclipseLook, eclipseWarnWindow;
|
||||
import 'package:fancake/core/theme/app_theme.dart';
|
||||
import 'package:fancake/core/ui/planet_25d.dart';
|
||||
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'eclipse_stubs.dart';
|
||||
|
||||
/// UX·FX v1.5 §5 사전 경고 「그림자 접근」 · §5.1 「끊어진 Ring → 완전한 Shield」
|
||||
/// (진척현황 부록 B-3 5-1 · 5-4).
|
||||
void main() {
|
||||
final warn = DateTime(2026, 10, 8, 7, 47);
|
||||
final start = DateTime(2026, 10, 8, 7, 50);
|
||||
|
||||
test('그림자 접근 진행 — 경고 시각 0 → 발생 시각 1, 경고 전 · 창 없음은 null', () {
|
||||
expect(eclipseApproach(null, start, warn), isNull);
|
||||
expect(eclipseApproach(warn, start, warn.subtract(const Duration(seconds: 1))), isNull);
|
||||
expect(eclipseApproach(warn, start, warn), 0);
|
||||
expect(eclipseApproach(warn, start, warn.add(const Duration(seconds: 90))), 0.5);
|
||||
expect(
|
||||
eclipseApproach(warn, start, start.add(const Duration(seconds: 3))),
|
||||
1,
|
||||
reason: '서버가 회차를 열기 전 지연 동안 그림자가 빠지지 않는다',
|
||||
);
|
||||
});
|
||||
|
||||
test('경보 단계 회차 — 이클립스 감소가 아니라 경고 창만', () {
|
||||
final scheduled = EclipseState(
|
||||
fandomId: 'bts',
|
||||
eclipseId: 'ecl-2',
|
||||
fandomName: 'BTS',
|
||||
erosionRate: 0,
|
||||
defense: 0,
|
||||
targetDefense: 10,
|
||||
defenseCount: 0,
|
||||
defended: false,
|
||||
scheduled: true,
|
||||
warnAt: warn,
|
||||
startAt: start,
|
||||
endsAt: start.add(const Duration(seconds: 60)),
|
||||
);
|
||||
expect(eclipseLook(scheduled, warn), (false, false, null));
|
||||
expect(eclipseWarnWindow(scheduled), (warn, start));
|
||||
expect(eclipseWarnWindow(eclipseFixture()), isNull, reason: '진행 중 회차');
|
||||
expect(eclipseWarnWindow(null), isNull);
|
||||
});
|
||||
|
||||
group('행성 그림자', () {
|
||||
Widget planet({DateTime? warnAt, DateTime? startAt, bool eclipsed = false}) =>
|
||||
MaterialApp(
|
||||
home: Center(
|
||||
child: Planet25D(
|
||||
image: 'assets/figma/planet-home.png',
|
||||
size: 200,
|
||||
stage: 3,
|
||||
isEclipsed: eclipsed,
|
||||
eclipseWarnAt: warnAt,
|
||||
eclipseStartAt: startAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
final now = DateTime.now();
|
||||
final w = now.subtract(const Duration(seconds: 90));
|
||||
final s = now.add(const Duration(seconds: 90));
|
||||
|
||||
testWidgets('경고 창 안이면 그림자를 그린다', (tester) async {
|
||||
await tester.pumpWidget(planet(warnAt: w, startAt: s));
|
||||
expect(find.byKey(const ValueKey('eclipse-shadow')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('경고가 없거나 이미 이클립스 진행 중이면 그리지 않는다', (tester) async {
|
||||
await tester.pumpWidget(planet());
|
||||
expect(find.byKey(const ValueKey('eclipse-shadow')), findsNothing);
|
||||
await tester.pumpWidget(planet(warnAt: w, startAt: s, eclipsed: true));
|
||||
expect(find.byKey(const ValueKey('eclipse-shadow')), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
test('Shield Ring — 처음엔 끊어진 조각, 참여자만큼 이어지고 목표 달성이면 원 하나', () {
|
||||
const slot = 2 * math.pi / shieldSegments;
|
||||
final broken = shieldArcs(0);
|
||||
expect(broken, hasLength(shieldSegments));
|
||||
expect(broken.first.$2, closeTo(slot * 0.5, 1e-9));
|
||||
expect(shieldArcs(0.5).first.$2, closeTo(slot * 0.75, 1e-9));
|
||||
expect(shieldArcs(0.99).first.$2, lessThan(slot));
|
||||
expect(shieldArcs(1), [(-math.pi / 2, 2 * math.pi)]);
|
||||
});
|
||||
|
||||
testWidgets('방어 화면 Shield 는 서버 고유 참여자 / 목표 비율을 따른다', (tester) async {
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2;
|
||||
addTearDown(tester.view.reset);
|
||||
final shakes = StreamController<DateTime>.broadcast();
|
||||
addTearDown(shakes.close);
|
||||
final repo = FakeEclipseRepository(
|
||||
state: eclipseFixture(
|
||||
defense: 3,
|
||||
targetDefense: 4,
|
||||
startAt: DateTime.now().subtract(const Duration(seconds: 1)),
|
||||
endsAt: DateTime.now().add(const Duration(seconds: 59)),
|
||||
),
|
||||
);
|
||||
addTearDown(repo.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: EclipseDefendScreen(
|
||||
repository: repo,
|
||||
fandomId: 'bts',
|
||||
shakes: () => shakes.stream,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (var i = 0; i < 6; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
final ring = tester
|
||||
.widgetList<CustomPaint>(find.descendant(
|
||||
of: find.byKey(const ValueKey('shield-ring')),
|
||||
matching: find.byType(CustomPaint),
|
||||
))
|
||||
.map((p) => p.painter)
|
||||
.whereType<ShieldRingPainter>()
|
||||
.single;
|
||||
expect(ring.progress, 0.75);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
@@ -359,6 +359,16 @@ void main() {
|
||||
expect(find.text('NEWS'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('B-3 8-4 — 카드 줄에 출처명 · 게시시각이 보인다(상세 시트를 열지 않아도)', (tester) async {
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: Scaffold(body: BriefingList(items: [_brief('출처가 보이는 줄')])),
|
||||
));
|
||||
expect(find.text('연합뉴스 · ${briefingStamp(DateTime(2026, 9, 14, 8))}'), findsOneWidget);
|
||||
expect(briefingStamp(DateTime(2026, 9, 4, 7, 5)), '2026.09.04 07:05');
|
||||
expect(find.text('AI 요약'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('줄을 누르면 B-75 5 요소 상세가 뜨고, 원문 보기로 나간다', (tester) async {
|
||||
final opened = <String>[];
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
|
||||
@@ -48,20 +48,34 @@ class _Backend extends Backend {
|
||||
class _PersonalHistory extends SamplePlanetRepository {
|
||||
final scopes = <bool>[];
|
||||
@override
|
||||
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
|
||||
Future<PlanetHistoryPage> history({
|
||||
String? cursor,
|
||||
bool personal = false,
|
||||
}) async {
|
||||
scopes.add(personal);
|
||||
return PlanetHistoryPage(items: [PlanetHistoryEntry(
|
||||
id: 'one', kind: personal ? PlanetHistoryKind.zap : PlanetHistoryKind.evolution,
|
||||
occurredAt: DateTime.utc(2026, 9, 22), fromStage: personal ? null : 1,
|
||||
toStage: personal ? null : 2, amount: personal ? 37 : null,
|
||||
)], nextCursor: personal ? null : 'older');
|
||||
return PlanetHistoryPage(
|
||||
items: [
|
||||
PlanetHistoryEntry(
|
||||
id: 'one',
|
||||
kind: personal ? PlanetHistoryKind.zap : PlanetHistoryKind.evolution,
|
||||
occurredAt: DateTime.utc(2026, 9, 22),
|
||||
fromStage: personal ? null : 1,
|
||||
toStage: personal ? null : 2,
|
||||
amount: personal ? 37 : null,
|
||||
),
|
||||
],
|
||||
nextCursor: personal ? null : 'older',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _History extends SamplePlanetRepository {
|
||||
final cursors = <String?>[];
|
||||
@override
|
||||
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
|
||||
Future<PlanetHistoryPage> history({
|
||||
String? cursor,
|
||||
bool personal = false,
|
||||
}) async {
|
||||
cursors.add(cursor);
|
||||
if (cursors.length == 2) throw StateError('private details');
|
||||
return PlanetHistoryPage(
|
||||
@@ -83,9 +97,11 @@ class _History extends SamplePlanetRepository {
|
||||
void main() {
|
||||
testWidgets('내 활동 전환은 이전 목록과 커서를 지우고 실제 성장량을 표시한다', (tester) async {
|
||||
final repository = _PersonalHistory();
|
||||
await tester.pumpWidget(MaterialApp(home: PlanetHistoryScreen(
|
||||
repository: repository, fandomName: '팬덤',
|
||||
)));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: PlanetHistoryScreen(repository: repository, fandomName: '팬덤'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('내 활동'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -101,10 +117,15 @@ void main() {
|
||||
const repository = SamplePlanetRepository();
|
||||
final states = StreamController<PlanetVisualState>.broadcast();
|
||||
addTearDown(states.close);
|
||||
await tester.pumpWidget(MaterialApp(home: PlanetDetailScreen(
|
||||
repository: repository, eclipse: const NoEclipseRepository(),
|
||||
watchPlanet: (_) => states.stream,
|
||||
)));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: PlanetDetailScreen(
|
||||
repository: repository,
|
||||
eclipse: const NoEclipseRepository(),
|
||||
watchPlanet: (_) => states.stream,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
states.add(const PlanetVisualState(stage: 1));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -114,7 +135,12 @@ void main() {
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.tap(find.text('PASSPORT 기록 보기'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<PlanetHistoryScreen>(find.byType(PlanetHistoryScreen)).repository, same(repository));
|
||||
expect(
|
||||
tester
|
||||
.widget<PlanetHistoryScreen>(find.byType(PlanetHistoryScreen))
|
||||
.repository,
|
||||
same(repository),
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
|
||||
@@ -131,26 +157,54 @@ void main() {
|
||||
'nextCursor': null,
|
||||
};
|
||||
await expectLater(repository.history(), throwsStateError);
|
||||
backend.response = {'items': [{'id': 'zap', 'kind': 'zap',
|
||||
'occurredAt': '2026-09-22T00:00:00Z', 'amount': 37, 'participationConfirmed': true}], 'nextCursor': null};
|
||||
backend.response = {
|
||||
'items': [
|
||||
{
|
||||
'id': 'zap',
|
||||
'kind': 'zap',
|
||||
'occurredAt': '2026-09-22T00:00:00Z',
|
||||
'amount': 37,
|
||||
'participationConfirmed': true,
|
||||
},
|
||||
],
|
||||
'nextCursor': null,
|
||||
};
|
||||
final personal = await repository.history(personal: true);
|
||||
expect(backend.payload, {'fandomId': 'target', 'scope': 'personal'});
|
||||
expect(personal.items.single.amount, 37);
|
||||
expect(personal.items.single.participationConfirmed, isFalse);
|
||||
// PASSPORT Stamp 는 내 기록에서만 받는다(FX §5.2 · §7.2).
|
||||
backend.response = {'items': [
|
||||
{'id': 'e', 'kind': 'eclipse_success', 'occurredAt': '2026-09-23T00:00:00Z'},
|
||||
{'id': 'v', 'kind': 'emblem_evolution', 'occurredAt': '2026-09-22T00:00:00Z', 'toStage': 3},
|
||||
{'id': 'f', 'kind': 'badge_founder100', 'occurredAt': '2026-09-21T00:00:00Z', 'memberNo': 7},
|
||||
], 'nextCursor': null};
|
||||
backend.response = {
|
||||
'items': [
|
||||
{
|
||||
'id': 'e',
|
||||
'kind': 'eclipse_success',
|
||||
'occurredAt': '2026-09-23T00:00:00Z',
|
||||
},
|
||||
{
|
||||
'id': 'v',
|
||||
'kind': 'emblem_evolution',
|
||||
'occurredAt': '2026-09-22T00:00:00Z',
|
||||
'toStage': 3,
|
||||
},
|
||||
{
|
||||
'id': 'f',
|
||||
'kind': 'badge_founder100',
|
||||
'occurredAt': '2026-09-21T00:00:00Z',
|
||||
'memberNo': 7,
|
||||
},
|
||||
],
|
||||
'nextCursor': null,
|
||||
};
|
||||
final stamps = await repository.history(personal: true);
|
||||
expect(stamps.items.map((i) => i.kind), [
|
||||
PlanetHistoryKind.eclipseSuccess, PlanetHistoryKind.emblemEvolution, PlanetHistoryKind.founderBadge,
|
||||
PlanetHistoryKind.eclipseSuccess,
|
||||
PlanetHistoryKind.emblemEvolution,
|
||||
PlanetHistoryKind.founderBadge,
|
||||
]);
|
||||
expect(stamps.items[1].toStage, 3);
|
||||
expect(stamps.items[2].memberNo, 7);
|
||||
await expectLater(repository.history(), throwsStateError);
|
||||
|
||||
});
|
||||
|
||||
testWidgets('행성 상세에서 같은 저장소의 팬덤 역사를 연다', (tester) async {
|
||||
@@ -211,4 +265,31 @@ void main() {
|
||||
expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsOneWidget);
|
||||
expect(find.text('다시 시도'), findsNothing);
|
||||
});
|
||||
|
||||
test('UX·FX §7.2 — 진화 · Eclipse 성공 · 기념 Badge 만 역사적 사건', () {
|
||||
expect(PlanetHistoryKind.values.where((k) => k.isHistoric).toSet(), {
|
||||
PlanetHistoryKind.evolution,
|
||||
PlanetHistoryKind.eclipseSuccess,
|
||||
PlanetHistoryKind.emblemEvolution,
|
||||
PlanetHistoryKind.founderBadge,
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('역사적 사건 줄에만 「역사적 사건」 표지가 붙는다', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: PlanetHistoryScreen(
|
||||
repository: _PersonalHistory(),
|
||||
fandomName: '팬덤',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Stage 1 → 2 진화'), findsOneWidget);
|
||||
expect(find.text('역사적 사건'), findsOneWidget);
|
||||
await tester.tap(find.text('내 활동'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('ZAP 기여'), findsOneWidget);
|
||||
expect(find.text('역사적 사건'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user