UX·FX v1.5 — 햅틱 단계 · 라이브 띠 · HERO 전광판 · Push 우선순위 · Shield · 이클립스 성공·실패 연출
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fancake/core/data/repositories.dart' show eclipseLook;
|
||||
import 'package:fancake/core/push/push_messaging.dart';
|
||||
import 'package:fancake/core/theme/app_theme.dart';
|
||||
import 'package:fancake/core/ui/fx_intensity.dart';
|
||||
import 'package:fancake/core/ui/planet_25d.dart';
|
||||
import 'package:fancake/features/campaign/campaign_detail_screen.dart'
|
||||
show campaignJustCompleted;
|
||||
import 'package:fancake/features/eclipse/eclipse_data.dart';
|
||||
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
|
||||
import 'package:fancake/features/eclipse/widgets/live_proof.dart';
|
||||
import 'package:fancake/features/home/widgets/live_ticker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'eclipse_stubs.dart';
|
||||
|
||||
/// UX·FX 정렬 기준 v1.5 2차(10/8) — 햅틱 5단계 · 티커 집약 · Push 위계 · Shield Pulse ·
|
||||
/// Orbit Burst · 실패 Glow 감소 · 전광판 HERO (진척현황 부록 B-3 1-1 · 1-3 · 1-4 · 5-3 · 5-6 · 5-7 · 6-2).
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'강도 5단계 햅틱 — Micro·Small Light · Medium Medium · Large·Legendary Strong (v1.5 §1)',
|
||||
() async {
|
||||
final calls = <Object?>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate')
|
||||
calls.add(call.arguments);
|
||||
return null;
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
for (final level in FxIntensity.values) {
|
||||
await fxHaptic(level);
|
||||
}
|
||||
expect(calls, [
|
||||
'HapticFeedbackType.lightImpact',
|
||||
'HapticFeedbackType.lightImpact',
|
||||
'HapticFeedbackType.mediumImpact',
|
||||
'HapticFeedbackType.heavyImpact',
|
||||
'HapticFeedbackType.heavyImpact',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test('Campaign 100% Legendary 는 이번 후원으로 목표를 넘긴 때만', () {
|
||||
expect(campaignJustCompleted(900, 1000, 1000), isTrue);
|
||||
expect(campaignJustCompleted(900, 999, 1000), isFalse);
|
||||
expect(
|
||||
campaignJustCompleted(1000, 1200, 1000),
|
||||
isFalse,
|
||||
reason: '이미 달성한 캠페인',
|
||||
);
|
||||
});
|
||||
|
||||
group('앱 사용 중 Push — C-78 우선순위 · 같은 알림 중복 금지 (v1.5 §1.1 · 9장)', () {
|
||||
PushNotice n(String kind, [String title = 't']) =>
|
||||
PushNotice(title: title, body: 'b', kind: kind, fandomId: 'bts');
|
||||
|
||||
test('떠 있는 것이 없으면 띄운다', () {
|
||||
expect(shouldShowInApp(n('briefing'), null), isTrue);
|
||||
});
|
||||
test('같은 알림은 다시 띄우지 않는다', () {
|
||||
expect(shouldShowInApp(n('eclipse'), n('eclipse')), isFalse);
|
||||
});
|
||||
test('낮은 순위는 높은 순위를 덮지 않고, 높은 순위는 덮는다', () {
|
||||
expect(shouldShowInApp(n('briefing'), n('eclipse')), isFalse);
|
||||
expect(shouldShowInApp(n('evolution'), n('timeattack')), isFalse);
|
||||
expect(shouldShowInApp(n('eclipse'), n('briefing')), isTrue);
|
||||
expect(shouldShowInApp(n('evolution', 'x'), n('evolution', 'y')), isTrue);
|
||||
});
|
||||
test('위기 → 공동행동 → 성취 순', () {
|
||||
expect(n('eclipse').tier, PushTier.crisis);
|
||||
expect(n('timeattack').tier, PushTier.collective);
|
||||
expect(n('evolution').tier, PushTier.achievement);
|
||||
expect(n('briefing').tier, PushTier.other);
|
||||
});
|
||||
});
|
||||
|
||||
group('라이브 띠 — 위기 우선 · 방어 집약 · HERO 전광판', () {
|
||||
final start = DateTime.now().subtract(const Duration(seconds: 5));
|
||||
final join = LiveEvent(id: 'e', nickname: '아미', at: DateTime.now());
|
||||
TickerLine? line({
|
||||
EclipseState? eclipse,
|
||||
bool hero = false,
|
||||
List<String> names = const [],
|
||||
List<LiveEvent> events = const [],
|
||||
}) => tickerLine(
|
||||
eclipse: eclipse,
|
||||
heroTime: hero,
|
||||
heroNicknames: names,
|
||||
events: events,
|
||||
fandomName: 'BTS',
|
||||
now: DateTime.now(),
|
||||
fallback: 'fallback',
|
||||
);
|
||||
|
||||
test('발생 직후는 발생 소식', () {
|
||||
expect(
|
||||
line(eclipse: eclipseFixture(startAt: start))!.text,
|
||||
'BTS 행성에 이클립스가 발생했어요!',
|
||||
);
|
||||
});
|
||||
test('방어 참여가 오면 고유 참여자 2명 이상부터 집약한다', () {
|
||||
expect(
|
||||
line(
|
||||
eclipse: eclipseFixture(startAt: start, defense: 3),
|
||||
events: [join],
|
||||
)!.text,
|
||||
'[LIVE] 지금 3명의 팬이 함께 방어 중',
|
||||
);
|
||||
expect(
|
||||
line(
|
||||
eclipse: eclipseFixture(startAt: start, defense: 1),
|
||||
events: [join],
|
||||
)!.text,
|
||||
'[LIVE] 아미님이 이클립스 방어에 합류했습니다!',
|
||||
);
|
||||
});
|
||||
test('HERO TIME 은 공개 닉네임 + 팬덤으로 오르고 참여 진입이 된다', () {
|
||||
final hero = line(hero: true, names: ['아미'], events: [join])!;
|
||||
expect(hero.text, '[LIVE] BTS 행성 HERO TIME · by 아미');
|
||||
expect(hero.hero, isTrue);
|
||||
expect(line(hero: true)!.text, '[LIVE] BTS 행성 HERO TIME 진행 중');
|
||||
});
|
||||
test('위기(이클립스)가 성취(HERO)보다 먼저', () {
|
||||
final l = line(eclipse: eclipseFixture(startAt: start), hero: true)!;
|
||||
expect(l.hero, isFalse);
|
||||
});
|
||||
test('아무것도 없으면 폴백', () {
|
||||
expect(line()!.text, 'fallback');
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('HERO 전광판은 눌러서 참여로 간다', (tester) async {
|
||||
var tapped = 0;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: LiveTicker(text: 'HERO', action: 'GO', onTap: () => tapped++),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(LiveTicker));
|
||||
expect(tapped, 1);
|
||||
});
|
||||
|
||||
test('이클립스 회차 → 행성 표현 (진행 · 실패 · 성공 · 판정 대기)', () {
|
||||
final now = DateTime.now();
|
||||
final past = now.subtract(const Duration(seconds: 1));
|
||||
expect(eclipseLook(null, now), (false, false, null));
|
||||
expect(eclipseLook(eclipseFixture(), now), (true, false, null));
|
||||
expect(
|
||||
eclipseLook(
|
||||
eclipseFixture(endsAt: past, result: EclipseResult.failure),
|
||||
now,
|
||||
),
|
||||
(false, true, null),
|
||||
);
|
||||
expect(
|
||||
eclipseLook(
|
||||
eclipseFixture(endsAt: past, result: EclipseResult.success),
|
||||
now,
|
||||
),
|
||||
(false, false, 'ecl-1'),
|
||||
);
|
||||
expect(eclipseLook(eclipseFixture(endsAt: past), now), (
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
), reason: '판정 대기를 실패처럼 보이지 않는다');
|
||||
expect(
|
||||
eclipseLook(
|
||||
eclipseFixture(endsAt: past, result: EclipseResult.cancelled),
|
||||
now,
|
||||
),
|
||||
(false, false, null),
|
||||
);
|
||||
});
|
||||
|
||||
group('행성 — 실패 감소 · 성공 Orbit Burst (v1.5 §5)', () {
|
||||
Widget planet({bool failed = false, String? success}) => MaterialApp(
|
||||
home: Center(
|
||||
child: Planet25D(
|
||||
image: 'assets/figma/planet-home.png',
|
||||
size: 200,
|
||||
stage: 3,
|
||||
eclipseFailed: failed,
|
||||
eclipseSuccessId: success,
|
||||
),
|
||||
),
|
||||
);
|
||||
double light(WidgetTester tester) => tester
|
||||
.widget<Opacity>(find.byKey(const ValueKey('planet-surface-light')))
|
||||
.opacity;
|
||||
|
||||
testWidgets('실패 확정이면 밝기를 줄인다(파괴 표현 없음)', (tester) async {
|
||||
await tester.pumpWidget(planet(failed: true));
|
||||
expect(light(tester), 0.5);
|
||||
await tester.pumpWidget(planet());
|
||||
await tester.pumpAndSettle();
|
||||
expect(light(tester), 1);
|
||||
});
|
||||
|
||||
testWidgets('성공 회차가 새로 오면 복원 뒤 Orbit Burst 한 번 · 첫 값은 기준점', (tester) async {
|
||||
Planet25D.animationsEnabled = true;
|
||||
addTearDown(() => Planet25D.animationsEnabled = false);
|
||||
await tester.pumpWidget(planet(success: 'old'));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('eclipse-orbit-burst')), findsNothing);
|
||||
await tester.pumpWidget(planet(success: 'ecl-2'));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(find.byKey(const ValueKey('eclipse-orbit-burst')), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('유효 흔들기마다 Shield Pulse (v1.5 §5 수동 방어)', (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(
|
||||
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 < 4; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
expect(find.byKey(const ValueKey('shield-pulse-1')), findsNothing);
|
||||
shakes.add(DateTime.now());
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.byKey(const ValueKey('shield-pulse-1')), findsOneWidget);
|
||||
shakes.add(DateTime.now());
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.byKey(const ValueKey('shield-pulse-2')), findsOneWidget);
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
}
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user