이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입

This commit is contained in:
2025-09-12 06:33:44 +09:00
parent 107abc963f
commit a5f2544d11
121 changed files with 39277 additions and 3911 deletions
+125 -65
View File
@@ -7,7 +7,6 @@ import '../../utils/url_utils.dart';
import '../../utils/test_utils.dart';
import '../../models/event_model.dart';
import '../../services/auth_service.dart';
import '../../services/event_service.dart';
import '../../widgets/loading_indicator.dart';
@@ -26,8 +25,8 @@ class EventFormScreen extends StatefulWidget {
class _EventFormScreenState extends State<EventFormScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _isInit = false;
bool _obscurePassword = true; // 비밀번호 숨김 상태 관리
bool _testSaveMode = false; // 테스트 훅에서 저장 시 네비/스낵바 우회
final _currencyFormat = NumberFormat.currency(locale: 'ko_KR', symbol: '', decimalDigits: 0);
// 폼 필드 컨트롤러
@@ -114,6 +113,13 @@ class _EventFormScreenState extends State<EventFormScreen> {
// 새 이벤트 생성 시 기본값 설정
_type = _typeOptions[0];
_status = _statusOptions[0];
// 공개 해시를 동기로 초기화하여 초기 렌더 타이밍 변동을 줄임
if (_publicHashController.text.isEmpty && !_isHashGenerated) {
_isHashGenerated = true;
final hash = UrlUtils.generatePublicHash();
_publicHashController.text = hash;
// 스낵바는 테스트에서 불필요하므로 생략, 일반 환경에서도 최초 자동 생성 시에는 표시하지 않음
}
}
// 참가비 입력 컨트롤러에 리스너 추가
@@ -122,21 +128,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
bool _isHashGenerated = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 새 이벤트 생성 시에만 해시 자동 생성 (한 번만 실행되도록 플래그 사용)
if (widget.event == null && _publicHashController.text.isEmpty && !_isHashGenerated) {
_isHashGenerated = true;
// 다음 프레임에서 해시 생성 (context 사용 문제 방지)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_generatePublicHash();
}
});
}
}
// didChangeDependencies 사용 시 프레임 콜백에 의한 타이밍 변동이 생겨 테스트 플래키를 유발할 수 있어 제거
@override
void dispose() {
@@ -238,7 +230,6 @@ class _EventFormScreenState extends State<EventFormScreen> {
try {
final eventService = Provider.of<EventService>(context, listen: false);
final authService = Provider.of<AuthService>(context, listen: false);
// 이벤트 데이터 준비 - null 값 처리 개선
final eventData = {
@@ -271,47 +262,44 @@ class _EventFormScreenState extends State<EventFormScreen> {
if (widget.event == null) {
// 새 이벤트 생성
await eventService.createEvent(eventData);
if (mounted) {
// 테스트 환경에서 안전하게 SnackBar 표시
if (TestUtils.isInTest) {
debugPrint('이벤트가 생성되었습니다');
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('이벤트가 생성되었습니다'),
action: SnackBarAction(
label: '확인',
onPressed: () {},
),
duration: const Duration(seconds: 3),
if (mounted && !_testSaveMode) {
// 테스트가 아니면 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('이벤트가 생성되었습니다'),
action: SnackBarAction(
label: '확인',
onPressed: () {},
),
);
}
duration: const Duration(seconds: 3),
),
);
}
} else {
// 기존 이벤트 수정
await eventService.updateEvent(widget.event!.id, eventData);
if (mounted) {
// 테스트 환경에서 안전하게 SnackBar 표시
if (TestUtils.isInTest) {
debugPrint('이벤트가 수정되었습니다');
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('이벤트가 수정되었습니다'),
action: SnackBarAction(
label: '확인',
onPressed: () {},
),
duration: const Duration(seconds: 3),
if (mounted && !_testSaveMode) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('이벤트가 수정되었습니다'),
action: SnackBarAction(
label: '확인',
onPressed: () {},
),
);
}
duration: const Duration(seconds: 3),
),
);
}
}
if (mounted) {
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
// 성공 후 로딩 해제 (테스트/일반 공통)
setState(() {
_isLoading = false;
});
if (!_testSaveMode) {
Navigator.of(context).pop(true); // 성공 결과와 함께 이전 화면으로 돌아가기
}
}
} catch (e) {
if (mounted) {
@@ -331,6 +319,17 @@ class _EventFormScreenState extends State<EventFormScreen> {
}
}
// 테스트 편의를 위한 공개 훅: 버튼 탭 없이 저장 플로우 직접 호출
@visibleForTesting
Future<void> invokeSaveForTest() async {
_testSaveMode = true;
try {
await _saveEvent();
} finally {
_testSaveMode = false;
}
}
@override
Widget build(BuildContext context) {
// 테스트 환경에서 애니메이션 비활성화
@@ -427,6 +426,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
// 이벤트 유형
DropdownButtonFormField<String>(
key: const Key('event_form_type_dropdown'),
value: _type,
decoration: InputDecoration(
labelText: '이벤트 유형 *',
@@ -463,6 +463,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
),
),
),
isExpanded: true,
items: _typeOptions
.map((type) => DropdownMenuItem<String>(
value: type,
@@ -477,13 +478,15 @@ class _EventFormScreenState extends State<EventFormScreen> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _typeColors[type]?.withOpacity(0.2),
color: _typeColors[type]?.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _typeColors[type] ?? Colors.grey),
),
child: Text(
type,
style: TextStyle(color: _typeColors[type]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
@@ -510,12 +513,12 @@ class _EventFormScreenState extends State<EventFormScreen> {
margin: const EdgeInsets.only(left: 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _typeColors[_type]?.withOpacity(0.2),
color: _typeColors[_type]?.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _typeColors[_type] ?? Colors.grey),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
@@ -548,6 +551,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
// 상태
DropdownButtonFormField<String>(
key: const Key('event_form_status_dropdown'),
value: _status,
decoration: InputDecoration(
labelText: '상태 *',
@@ -584,6 +588,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
),
),
),
isExpanded: true,
items: _statusOptions
.map((status) => DropdownMenuItem<String>(
value: status,
@@ -598,13 +603,15 @@ class _EventFormScreenState extends State<EventFormScreen> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _statusColors[status]?.withOpacity(0.2),
color: _statusColors[status]?.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _statusColors[status] ?? Colors.grey),
),
child: Text(
status,
style: TextStyle(color: _statusColors[status]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
@@ -631,12 +638,12 @@ class _EventFormScreenState extends State<EventFormScreen> {
margin: const EdgeInsets.only(left: 12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _statusColors[_status]?.withOpacity(0.2),
color: _statusColors[_status]?.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _statusColors[_status] ?? Colors.grey),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
@@ -665,6 +672,58 @@ class _EventFormScreenState extends State<EventFormScreen> {
),
const SizedBox(height: 24),
// 공개 접근 섹션
_buildSectionTitle('공개 접근'),
Builder(
builder: (ctx) {
final hasHash = _publicHashController.text.trim().isNotEmpty;
final publicUrl = hasHash
? UrlUtils.getEventPublicUrl(_publicHashController.text.trim())
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: TextEditingController(text: publicUrl),
readOnly: true,
decoration: const InputDecoration(
labelText: '공개 URL',
hintText: '해시 생성 시 공개 URL이 표시됩니다',
prefixIcon: Icon(Icons.link),
),
maxLines: 1,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: hasHash
? () {
final url = UrlUtils.getEventPublicUrl(_publicHashController.text.trim());
UrlUtils.copyUrlToClipboard(context, url);
}
: null,
icon: const Icon(Icons.copy),
label: const Text('URL 복사'),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _generatePublicHash,
icon: const Icon(Icons.refresh),
label: Text(hasHash ? '재생성' : '생성'),
),
),
],
),
],
);
},
),
const SizedBox(height: 24),
// 날짜 및 시간 섹션
_buildSectionTitle('날짜 및 시간'),
@@ -721,6 +780,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
);
if (date != null) {
if (!context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_startDate),
@@ -775,6 +835,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
);
if (date != null) {
if (!context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: _endDate != null
@@ -830,6 +891,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
);
if (date != null) {
if (!context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: _registrationDeadline != null
@@ -1087,14 +1149,10 @@ class _EventFormScreenState extends State<EventFormScreen> {
),
Tooltip(
message: _publicHashController.text.isEmpty ? '새 해시 생성' : '해시 재생성',
child: ElevatedButton.icon(
icon: const Icon(Icons.refresh, size: 18),
label: Text(_publicHashController.text.isEmpty ? '생성' : '재생성'),
child: IconButton(
icon: const Icon(Icons.refresh),
color: Colors.blue,
onPressed: _generatePublicHash,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
),
),
],
@@ -1104,9 +1162,9 @@ class _EventFormScreenState extends State<EventFormScreen> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1117,7 +1175,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
children: [
Expanded(
child: Text(
'https://bowling.example.com/events/${_publicHashController.text}',
UrlUtils.getEventPublicUrl(_publicHashController.text),
style: TextStyle(color: Colors.blue[700]),
),
),
@@ -1147,6 +1205,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
key: const ValueKey('access_password_field'),
controller: _accessPasswordController,
decoration: InputDecoration(
labelText: '접근 비밀번호 (선택)',
@@ -1236,6 +1295,7 @@ class _EventFormScreenState extends State<EventFormScreen> {
SizedBox(
width: double.infinity,
child: ElevatedButton(
key: const ValueKey('save_event_button'),
onPressed: _saveEvent,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),