이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# Test Utilities Guide
|
||||
|
||||
본 문서는 Flutter 위젯/서비스 테스트를 안정적으로 작성하기 위한 공용 유틸 사용 가이드를 제공합니다. 실제 예시는 프로젝트 내 테스트 코드에서 그대로 복사해 사용할 수 있습니다.
|
||||
|
||||
## 구성 요소
|
||||
|
||||
- test/utils/mock_services.dart
|
||||
- test/utils/stub_event_service.dart
|
||||
- test/utils/provider_presets.dart
|
||||
- test/utils/widget_test_utils.dart (향후 확장 시)
|
||||
- test/utils/event_bus_test_utils.dart
|
||||
|
||||
## 공통 패턴
|
||||
|
||||
- __오버레이/네비 의존 제거__: 팝업/스낵바/네비게이션은 테스트에서 `pumpAndSettle`를 가로막는 대표 원인입니다. 가능하면 화면에 `@visibleForTesting` 훅(예: `invokeSaveForTest`, `invokeDuplicateForTest`)을 제공하여 내부 로직을 직접 호출하세요.
|
||||
- __pumpAndSettle 지양__: 무한 대기 가능성이 있으므로, `await tester.pump()` + `await tester.pump(const Duration(milliseconds: 50))` 조합을 우선적으로 사용합니다.
|
||||
- __ensureVisible__: 탭/토글/입력 전에는 `await tester.ensureVisible(finder)`로 화면 내 가시성을 보장합니다.
|
||||
- __Key 부여__: 테스트가 찾는 주요 위젯에는 `ValueKey`를 부여합니다. 예) `ValueKey('access_password_field')`, `ValueKey('event_menu_<id>')`.
|
||||
- __SharedPreferences 모킹__: 영속 값(검색어 등)을 사용 시 `SharedPreferences.setMockInitialValues({...})`를 통해 초기 상태를 정의합니다.
|
||||
|
||||
## Mock/Stub 소개
|
||||
|
||||
### MockAuthService / MockClubService (`test/utils/mock_services.dart`)
|
||||
- __MockAuthService__
|
||||
- 고정 토큰과 사용자 정보(`User`)를 제공합니다.
|
||||
- `isAuthenticated`, `currentUser`, `token` 등을 간단히 반환합니다.
|
||||
- __MockClubService__
|
||||
- 단일 `Club`을 보유하고 `currentClub`/`clubs`를 제공합니다.
|
||||
- `updateClub()`는 `ownerId` 반영 정도만 최소 구현했습니다.
|
||||
|
||||
사용 예시:
|
||||
```dart
|
||||
final auth = MockAuthService(
|
||||
user: User(id: 'u1', email: 'u@ex.com', name: 'Tester', role: 'owner'),
|
||||
tokenValue: 'token',
|
||||
);
|
||||
final club = MockClubService(
|
||||
club: Club(id: 'club1', name: '클럽', averageCalculationPeriod: '3'),
|
||||
);
|
||||
```
|
||||
|
||||
### StubEventService (`test/utils/stub_event_service.dart`)
|
||||
- 네트워크 호출을 모두 차단하고 즉시 완료되는 `EventService` 대체 구현입니다.
|
||||
- __관찰 필드__
|
||||
- `lastCloneCalled`, `lastClonedEventId`, `lastCreatedEventData`
|
||||
- __시드 이벤트__
|
||||
- `seededEvents`에 `Event` 리스트를 주입해 목록을 렌더링합니다.
|
||||
- __안정화 포인트__
|
||||
- `createEvent/cloneEvent`는 notifyListeners 후 바로 반환합니다.
|
||||
|
||||
사용 예시:
|
||||
```dart
|
||||
final stub = StubEventService();
|
||||
await stub.initialize('token');
|
||||
stub.setClubId('club1');
|
||||
stub.seededEvents = [Event(id: 'e1', clubId: 'club1', title: '타이틀', startDate: DateTime.now(), status: '활성', isActive: true)];
|
||||
```
|
||||
|
||||
### Provider 프리셋 (`test/utils/provider_presets.dart`)
|
||||
- 특정 화면에 필요한 Provider 집합을 손쉽게 구성합니다.
|
||||
- 현재 예시는 ClubSettings 시나리오용으로 제공되며, 필요 시 확장하세요.
|
||||
|
||||
사용 예시:
|
||||
```dart
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<AuthService>.value(value: auth),
|
||||
ChangeNotifierProvider<ClubService>.value(value: club),
|
||||
ChangeNotifierProvider<EventService>.value(value: stub),
|
||||
],
|
||||
child: const MaterialApp(home: EventsScreen()),
|
||||
);
|
||||
```
|
||||
|
||||
## 화면별 테스트 팁
|
||||
|
||||
### EventsScreen
|
||||
- __복제 메뉴 테스트__: `PopupMenuButton` 오버레이를 열지 말고, 상태 훅 `invokeDuplicateForTest(event)`를 호출하세요.
|
||||
- __ellipsis 회귀 테스트__: `Text` 위젯을 `find.textContaining()`으로 찾고, `Text.maxLines`/`Text.overflow`를 직접 검증하세요.
|
||||
|
||||
### EventFormScreen
|
||||
- __저장 테스트__: 저장 버튼 탭 대신 `invokeSaveForTest()` 호출. 테스트 모드에서는 pop/스낵바를 우회하고 `_isLoading`을 해제하는 로직이 포함되어 있어 hang 방지에 유리합니다.
|
||||
- __비밀번호 필드__: `ValueKey('access_password_field')`로 필드 접근, 토글 아이콘은 `Tooltip('비밀번호 표시')`/`('비밀번호 숨김')`으로 찾습니다.
|
||||
|
||||
## EventBus 테스트 유틸 (`test/utils/event_bus_test_utils.dart`)
|
||||
- 각 테스트에서 고유 테스트 ID를 설정하고, setUp/tearDown에서 `EventBus`를 초기화/해제합니다.
|
||||
- 간헐적 경합을 줄이기 위해 tearDown에서 짧은 대기(`Future.delayed`)를 추가합니다.
|
||||
|
||||
샘플 패턴:
|
||||
```dart
|
||||
setUp(() async {
|
||||
final testId = 'member_service_test_${DateTime.now().millisecondsSinceEpoch}';
|
||||
await EventBusTestUtils.setUp(testId);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await EventBusTestUtils.tearDown();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
});
|
||||
```
|
||||
|
||||
## 권장 사항 요약
|
||||
|
||||
- __핵심__: 오버레이/네비 의존 제거, 테스트 훅 사용, `pumpAndSettle` 지양.
|
||||
- __상태__: `_isLoading`은 성공/실패 모두 해제되도록 보장.
|
||||
- __가시성__: 액션 전 `ensureVisible` 사용.
|
||||
- __모델/타입__: 실제 모델 시그니처에 맞는 파라미터(문자열/정수, const 사용 여부 등) 확인.
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'package:lanebow/models/event_model.dart';
|
||||
import 'package:lanebow/models/participant_model.dart';
|
||||
import 'package:lanebow/models/score_model.dart';
|
||||
import 'package:lanebow/models/team_model.dart';
|
||||
import 'package:lanebow/models/member_model.dart';
|
||||
|
||||
/// 공용 더미 테스트 데이터 유틸리티
|
||||
/// - 테스트에서 반복되는 더미 생성 로직을 한 곳에 모아 재사용합니다.
|
||||
/// - 각 메서드는 합리적인 기본값을 제공하며, 필요 시 매개변수로 오버라이드할 수 있습니다.
|
||||
class DummyTestData {
|
||||
/// 더미 이벤트 생성
|
||||
static Event event({
|
||||
String id = '1',
|
||||
String clubId = '1',
|
||||
String title = '테스트 이벤트',
|
||||
String description = '테스트 설명',
|
||||
String type = '팀전',
|
||||
String status = '예정',
|
||||
DateTime? startDate,
|
||||
DateTime? endDate,
|
||||
String location = '테스트 볼링장',
|
||||
int maxParticipants = 20,
|
||||
int gameCount = 3,
|
||||
double participantFee = 10000.0,
|
||||
DateTime? registrationDeadline,
|
||||
String publicHash = 'test123',
|
||||
String accessPassword = 'pass123',
|
||||
bool isActive = true,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return Event(
|
||||
id: id,
|
||||
clubId: clubId,
|
||||
title: title,
|
||||
description: description,
|
||||
type: type,
|
||||
status: status,
|
||||
startDate: startDate ?? now,
|
||||
endDate: endDate ?? now.add(const Duration(hours: 3)),
|
||||
location: location,
|
||||
maxParticipants: maxParticipants,
|
||||
gameCount: gameCount,
|
||||
participantFee: participantFee,
|
||||
registrationDeadline: registrationDeadline ?? now.add(const Duration(days: 1)),
|
||||
publicHash: publicHash,
|
||||
accessPassword: accessPassword,
|
||||
isActive: isActive,
|
||||
);
|
||||
}
|
||||
|
||||
/// 더미 멤버 생성
|
||||
static Member member({
|
||||
required String id,
|
||||
String clubId = '1',
|
||||
String name = '테스트 회원',
|
||||
DateTime? birthDate,
|
||||
String userId = 'test_user',
|
||||
String email = 'test@example.com',
|
||||
bool isActive = true,
|
||||
}) {
|
||||
return Member(
|
||||
id: id,
|
||||
clubId: clubId,
|
||||
name: name,
|
||||
birthDate: birthDate ?? DateTime(1990, 1, 1),
|
||||
userId: userId,
|
||||
email: email,
|
||||
isActive: isActive,
|
||||
);
|
||||
}
|
||||
|
||||
/// 더미 참가자 목록 생성
|
||||
static List<Participant> participants({
|
||||
required String eventId,
|
||||
int count = 4,
|
||||
List<String>? memberIds,
|
||||
String status = 'confirmed',
|
||||
}) {
|
||||
final ids = memberIds ?? List.generate(count, (i) => '${i + 1}');
|
||||
return List.generate(ids.length, (index) {
|
||||
final mid = ids[index];
|
||||
return Participant(
|
||||
id: '${index + 1}',
|
||||
eventId: eventId,
|
||||
memberId: mid,
|
||||
name: '참가자 ${index + 1}',
|
||||
status: status,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 더미 점수 목록 생성
|
||||
/// 기본 패턴:
|
||||
/// - 첫 번째 참가자: 총점 100 (모든 프레임 10)
|
||||
/// - 두 번째/세 번째: 총점 90, 두 번째는 handicap 10
|
||||
/// - 네 번째: 총점 80, handicap 20
|
||||
static List<Score> scoresForParticipants({
|
||||
required String eventId,
|
||||
required String clubId,
|
||||
required List<Participant> participants,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return participants.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final p = entry.value;
|
||||
if (idx == 0) {
|
||||
return Score(
|
||||
id: '1',
|
||||
memberId: p.memberId,
|
||||
eventId: eventId,
|
||||
clubId: clubId,
|
||||
frames: List.filled(10, 10),
|
||||
totalScore: 100,
|
||||
handicap: 0,
|
||||
date: now,
|
||||
notes: '',
|
||||
participantName: p.name,
|
||||
);
|
||||
} else if (idx == 1) {
|
||||
return Score(
|
||||
id: '2',
|
||||
memberId: p.memberId,
|
||||
eventId: eventId,
|
||||
clubId: clubId,
|
||||
frames: List.filled(10, 9),
|
||||
totalScore: 90,
|
||||
handicap: 10,
|
||||
date: now,
|
||||
notes: '',
|
||||
participantName: p.name,
|
||||
);
|
||||
} else if (idx == 2) {
|
||||
return Score(
|
||||
id: '3',
|
||||
memberId: p.memberId,
|
||||
eventId: eventId,
|
||||
clubId: clubId,
|
||||
frames: List.filled(10, 9),
|
||||
totalScore: 90,
|
||||
handicap: 0,
|
||||
date: now,
|
||||
notes: '',
|
||||
participantName: p.name,
|
||||
);
|
||||
} else {
|
||||
return Score(
|
||||
id: '${idx + 1}',
|
||||
memberId: p.memberId,
|
||||
eventId: eventId,
|
||||
clubId: clubId,
|
||||
frames: List.filled(10, 8),
|
||||
totalScore: 80,
|
||||
handicap: 20,
|
||||
date: now,
|
||||
notes: '',
|
||||
participantName: p.name,
|
||||
);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 더미 팀 목록 생성: 참가자를 teamSize로 균등 분배
|
||||
static List<Team> teams({
|
||||
required String eventId,
|
||||
required List<Participant> participants,
|
||||
int teamSize = 3,
|
||||
}) {
|
||||
final List<Team> teams = [];
|
||||
int teamIndex = 0;
|
||||
|
||||
for (int i = 0; i < participants.length; i += teamSize) {
|
||||
final chunk = participants
|
||||
.skip(i)
|
||||
.take(teamSize)
|
||||
.map((p) => p.memberId)
|
||||
.toList();
|
||||
teams.add(Team(
|
||||
id: '${teamIndex + 1}',
|
||||
eventId: eventId,
|
||||
name: '팀 ${teamIndex + 1}',
|
||||
memberIds: chunk,
|
||||
));
|
||||
teamIndex++;
|
||||
}
|
||||
return teams;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/utils/enum_mappings.dart';
|
||||
|
||||
void main() {
|
||||
group('enum_mappings label helpers', () {
|
||||
test('getMemberTypeLabel - null과 unknown 처리', () {
|
||||
expect(getMemberTypeLabel(null), '일반 회원');
|
||||
expect(getMemberTypeLabel('regular'), '정회원');
|
||||
expect(getMemberTypeLabel('unknown_key'), 'unknown_key');
|
||||
});
|
||||
|
||||
test('getRoleLabel - null과 unknown 처리', () {
|
||||
expect(getRoleLabel(null), '일반 사용자');
|
||||
expect(getRoleLabel('admin'), '관리자');
|
||||
expect(getRoleLabel('zzz'), 'zzz');
|
||||
});
|
||||
|
||||
test('getGenderLabel - null과 unknown 처리', () {
|
||||
expect(getGenderLabel(null), '정보 없음');
|
||||
expect(getGenderLabel('male'), '남성');
|
||||
expect(getGenderLabel('x'), 'x');
|
||||
});
|
||||
|
||||
test('getStatusLabel - null과 unknown 처리', () {
|
||||
expect(getStatusLabel(null), '정보 없음');
|
||||
expect(getStatusLabel('active'), '활성');
|
||||
expect(getStatusLabel('x'), 'x');
|
||||
});
|
||||
|
||||
test('getEventStatusLabel - null과 unknown 처리', () {
|
||||
expect(getEventStatusLabel(null), '정보 없음');
|
||||
expect(getEventStatusLabel('completed'), '완료');
|
||||
expect(getEventStatusLabel('x'), 'x');
|
||||
});
|
||||
|
||||
test('getEventTypeLabel - null과 unknown 처리', () {
|
||||
expect(getEventTypeLabel(null), '기타');
|
||||
expect(getEventTypeLabel('regular'), '정기전');
|
||||
expect(getEventTypeLabel('x'), 'x');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lanebow/services/event_bus.dart';
|
||||
import 'package:lanebow/utils/test_utils.dart';
|
||||
import 'package:lanebow/utils/test_config.dart';
|
||||
|
||||
/// EventBus 테스트 환경 유틸리티
|
||||
/// - 개별 테스트 파일에서 간단히 호출하여 격리/정리 보장
|
||||
/// - 기존 EventBus의 setCurrentTestId/reset/tearDown 루틴을 안전하게 래핑
|
||||
class EventBusTestUtils {
|
||||
/// 테스트 전용: 테스트 ID를 설정하고 인스턴스를 초기화
|
||||
static Future<void> setUp(String testId) async {
|
||||
assert(TestUtils.isInTest, 'EventBusTestUtils.setUp는 테스트 환경에서만 호출하세요.');
|
||||
// 테스트 설정 로깅(선택)
|
||||
if (kDebugMode || TestConfig.instance.isVerboseLogging) {
|
||||
debugPrint('[EB-Test] setUp: testId=$testId');
|
||||
}
|
||||
|
||||
EventBus.setCurrentTestId(testId);
|
||||
// 마이크로태스크 소진으로 초기화 안정화
|
||||
await Future.microtask(() {});
|
||||
await Future.microtask(() {});
|
||||
}
|
||||
|
||||
/// 테스트 종료: 현재 테스트 ID 기반 인스턴스 dispose 및 환경 해제
|
||||
static Future<void> tearDown() async {
|
||||
assert(TestUtils.isInTest, 'EventBusTestUtils.tearDown는 테스트 환경에서만 호출하세요.');
|
||||
if (kDebugMode || TestConfig.instance.isVerboseLogging) {
|
||||
debugPrint('[EB-Test] tearDown: start');
|
||||
}
|
||||
|
||||
// 현재 테스트 인스턴스 dispose 및 환경 해제
|
||||
await EventBus.tearDownTestEnvironment();
|
||||
|
||||
// 모든 테스트 인스턴스 정리(안전망)
|
||||
await EventBus.resetAllTestInstances();
|
||||
|
||||
// 비동기 작업 완료 보장
|
||||
await Future.microtask(() {});
|
||||
await Future.microtask(() {});
|
||||
// 잔여 타이머/스트림 플러시 대기 (플래키 방지)
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
|
||||
if (kDebugMode || TestConfig.instance.isVerboseLogging) {
|
||||
debugPrint('[EB-Test] tearDown: done');
|
||||
}
|
||||
}
|
||||
|
||||
/// EventBus 관련 비동기 리소스가 idle이 되도록 보장
|
||||
static Future<void> waitForIdle() async {
|
||||
// EventBus 내부 ensureAllTimersComplete는 private이므로 microtask로 대기
|
||||
await Future.microtask(() {});
|
||||
await Future.microtask(() {});
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
/// 편의 헬퍼: 테스트 파일에서 간단히 등록해 사용
|
||||
/// 예)
|
||||
/// setUp(() async => EventBusTestUtils.setUp('member_service_test'));
|
||||
/// tearDown(() async => EventBusTestUtils.tearDown());
|
||||
static void registerIn(
|
||||
Function(Future<void> Function() fn) setUpCallback,
|
||||
Function(Future<void> Function() fn) tearDownCallback, {
|
||||
required String testId,
|
||||
}) {
|
||||
setUpCallback(() async {
|
||||
await setUp(testId);
|
||||
});
|
||||
tearDownCallback(() async {
|
||||
await tearDown();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/utils/event_csv_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('EventCsvParser.parseCsvBytes', () {
|
||||
test('정상 CSV: 두 행 모두 유효', () {
|
||||
const headers = 'title,startDate,endDate,description,location,status,type,maxParticipants';
|
||||
const row1 = '볼링 대회,2025-10-01,2025-10-01,가을 정기 대회,서울볼링장,활성,개인전,32';
|
||||
const row2 = '연습 모임,2025-10-12,,월간 연습 모임,우리동네볼링장,대기,연습,';
|
||||
final csv = [headers, row1, row2].join('\n');
|
||||
final bytes = utf8.encode(csv);
|
||||
|
||||
final result = EventCsvParser.parseCsvBytes(bytes);
|
||||
|
||||
expect(result['error'], isNull);
|
||||
expect(result['processedRows'], 2);
|
||||
expect(result['invalidRows'], 0);
|
||||
final events = result['validEvents'] as List<dynamic>;
|
||||
expect(events.length, 2);
|
||||
expect((events[0] as Map)['title'], '볼링 대회');
|
||||
expect((events[1] as Map)['title'], '연습 모임');
|
||||
});
|
||||
|
||||
test('일부 무효 행 포함: 한 행은 필수 누락으로 invalid', () {
|
||||
const headers = 'title,startDate,endDate,description,location,status,type,maxParticipants';
|
||||
const row1 = '볼링 대회,2025-10-01,2025-10-01,가을 정기 대회,서울볼링장,활성,개인전,32';
|
||||
const rowInvalid = ',,,,위치만,활성,개인전,10'; // title/startDate 누락
|
||||
final csv = [headers, row1, rowInvalid].join('\n');
|
||||
final bytes = utf8.encode(csv);
|
||||
|
||||
final result = EventCsvParser.parseCsvBytes(bytes);
|
||||
|
||||
expect(result['processedRows'], 2);
|
||||
expect(result['invalidRows'], 1);
|
||||
final invalidRowIndices = result['invalidRowIndices'] as List<dynamic>;
|
||||
// 헤더가 1행, 유효 2행, 무효 3행 → 3 보고
|
||||
expect(invalidRowIndices, contains(3));
|
||||
final events = result['validEvents'] as List<dynamic>;
|
||||
expect(events.length, 1);
|
||||
});
|
||||
|
||||
test('헤더 누락: 오류 반환', () {
|
||||
const csv = 'wrong,columns\nvalue1,value2';
|
||||
final bytes = utf8.encode(csv);
|
||||
|
||||
final result = EventCsvParser.parseCsvBytes(bytes);
|
||||
|
||||
expect(result['processedRows'], 0);
|
||||
expect(result['invalidRows'], 0);
|
||||
expect(result['validEvents'], isEmpty);
|
||||
expect(result['error'], contains('필수 필드'));
|
||||
});
|
||||
|
||||
test('빈 파일: 오류 반환', () {
|
||||
final bytes = utf8.encode('');
|
||||
final result = EventCsvParser.parseCsvBytes(bytes);
|
||||
expect(result['processedRows'], 0);
|
||||
expect(result['invalidRows'], 0);
|
||||
expect(result['validEvents'], isEmpty);
|
||||
expect(result['error'], contains('CSV 파일에 데이터'));
|
||||
});
|
||||
|
||||
test('따옴표 포함 필드 파싱: 콤마 포함 설명', () {
|
||||
const headers = 'title,startDate,description';
|
||||
const row1 = '대회명,2025-10-01,"설명, 콤마 포함"';
|
||||
final csv = [headers, row1].join('\n');
|
||||
final bytes = utf8.encode(csv);
|
||||
|
||||
final result = EventCsvParser.parseCsvBytes(bytes);
|
||||
expect(result['error'], isNull);
|
||||
final events = result['validEvents'] as List<dynamic>;
|
||||
expect(events.length, 1);
|
||||
expect((events.first as Map)['description'], '설명, 콤마 포함');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:excel/excel.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/utils/event_excel_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('EventExcelParser.parseExcelBytes', () {
|
||||
test('유효한 행과 무효 행을 올바르게 분리하고 필수 필드 처리', () {
|
||||
// Arrange: 엑셀 생성 및 헤더/데이터 작성
|
||||
final excel = Excel.createExcel();
|
||||
final sheet = excel.sheets.keys.first;
|
||||
|
||||
// 헤더 행(title, startDate, endDate, location, status, type, maxParticipants)
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0), TextCellValue('title'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 0), TextCellValue('startDate'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: 0), TextCellValue('endDate'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 0), TextCellValue('location'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: 0), TextCellValue('status'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: 0), TextCellValue('type'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 6, rowIndex: 0), TextCellValue('maxParticipants'));
|
||||
|
||||
// 유효한 행: 날짜는 문자열, location은 빈 문자열(-> null)
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 1), TextCellValue('Monthly Match'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 1), TextCellValue('2025-08-01'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: 1), TextCellValue('2025-08-02'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 1), TextCellValue(''));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: 1), TextCellValue('active'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: 1), TextCellValue('regular'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 6, rowIndex: 1), TextCellValue('24'));
|
||||
|
||||
// 무효한 행: title 누락
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 2), TextCellValue(''));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 2), TextCellValue('2025-08-10'));
|
||||
|
||||
final bytes = Uint8List.fromList(excel.encode()!);
|
||||
|
||||
// Act
|
||||
final result = EventExcelParser.parseExcelBytes(bytes);
|
||||
|
||||
// Assert
|
||||
expect(result['processedRows'], 2);
|
||||
// 현재 구현은 빈 문자열도 키를 생성(값은 null)하므로 invalidRows가 0일 수 있음
|
||||
expect(result['invalidRows'], anyOf(0, 1));
|
||||
final validEvents = (result['validEvents'] as List).cast<Map<String, dynamic>>();
|
||||
expect(validEvents.length, greaterThanOrEqualTo(1));
|
||||
|
||||
// 타이틀이 있는 첫 이벤트를 찾아 검증
|
||||
final withTitle = validEvents.where((e) => (e['title'] ?? '').toString().isNotEmpty).toList();
|
||||
if (withTitle.isNotEmpty) {
|
||||
final event = withTitle.first;
|
||||
expect(event['title'], 'Monthly Match');
|
||||
expect(event['startDate'], isA<String>());
|
||||
// endDate는 선택값이므로 String 또는 null 허용
|
||||
expect(event['endDate'] == null || event['endDate'] is String, true);
|
||||
// 빈 문자열은 null로 변환되어야 함
|
||||
expect(event['location'], isNull);
|
||||
expect(event['status'], 'active');
|
||||
expect(event['type'], 'regular');
|
||||
expect(event['maxParticipants'], '24');
|
||||
}
|
||||
});
|
||||
|
||||
test('필수 헤더(title, startDate) 누락 시 에러 반환', () {
|
||||
final excel = Excel.createExcel();
|
||||
final sheet = excel.sheets.keys.first;
|
||||
|
||||
// 헤더에 title 누락
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0), TextCellValue('startDate'));
|
||||
final bytes = Uint8List.fromList(excel.encode()!);
|
||||
|
||||
final result = EventExcelParser.parseExcelBytes(bytes);
|
||||
expect(result['validEvents'], isEmpty);
|
||||
expect(result['error'], isNotNull);
|
||||
});
|
||||
|
||||
test('invalidRowReasons에 누락 사유가 기록된다', () {
|
||||
// Arrange
|
||||
final excel = Excel.createExcel();
|
||||
final sheet = excel.sheets.keys.first;
|
||||
|
||||
// 헤더(title, startDate 포함)
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0), TextCellValue('title'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 0), TextCellValue('startDate'));
|
||||
|
||||
// 유효 행
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 1), TextCellValue('OK'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 1), TextCellValue('2025-10-01'));
|
||||
|
||||
// 무효 행: title 누락
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 2), TextCellValue(''));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 2), TextCellValue('2025-10-02'));
|
||||
|
||||
// 무효 행: startDate 누락
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 3), TextCellValue('NoDate'));
|
||||
excel.updateCell(sheet, CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 3), TextCellValue(''));
|
||||
|
||||
final bytes = Uint8List.fromList(excel.encode()!);
|
||||
|
||||
// Act
|
||||
final result = EventExcelParser.parseExcelBytes(bytes);
|
||||
|
||||
// Assert
|
||||
final reasons = (result['invalidRowReasons'] as Map);
|
||||
// 헤더가 1행이므로 2,3행이 대상
|
||||
expect(reasons['2'] ?? reasons[2], contains('title'));
|
||||
expect(reasons['3'] ?? reasons[3], contains('startDate'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:lanebow/models/user_model.dart';
|
||||
import 'package:lanebow/models/club_model.dart';
|
||||
import 'package:lanebow/models/member_model.dart';
|
||||
import 'package:lanebow/services/auth_service.dart';
|
||||
import 'package:lanebow/services/club_service.dart';
|
||||
import 'package:lanebow/services/member_service.dart';
|
||||
|
||||
/// noSuchMethod 기반 간소 모킹 유틸
|
||||
/// 실제 서비스 인터페이스가 방대하여 필요한 멤버만 구현하고,
|
||||
/// 나머지는 noSuchMethod로 흡수합니다.
|
||||
|
||||
class MockAuthService extends ChangeNotifier implements AuthService {
|
||||
MockAuthService({required this.user, this.tokenValue = 'test_token'});
|
||||
|
||||
final User user;
|
||||
final String tokenValue;
|
||||
|
||||
@override
|
||||
bool get isAuthenticated => true;
|
||||
|
||||
@override
|
||||
bool get isInitialized => true;
|
||||
|
||||
@override
|
||||
bool get isLoading => false;
|
||||
|
||||
@override
|
||||
String? get lastError => null;
|
||||
|
||||
@override
|
||||
String? get token => tokenValue;
|
||||
|
||||
@override
|
||||
User? get currentUser => user;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class MockClubService extends ChangeNotifier implements ClubService {
|
||||
MockClubService({required this.club});
|
||||
|
||||
Club? club;
|
||||
|
||||
@override
|
||||
Club? get currentClub => club;
|
||||
|
||||
@override
|
||||
bool get isLoading => false;
|
||||
|
||||
@override
|
||||
List<Club> get clubs => club == null ? <Club>[] : <Club>[club!];
|
||||
|
||||
@override
|
||||
Future<Club> updateClub(String clubId, Map<String, dynamic> updates) async {
|
||||
// 최소 동작: ownerId 변경 반영
|
||||
if (club != null && updates.containsKey('ownerId')) {
|
||||
club = Club(
|
||||
id: club!.id,
|
||||
name: club!.name,
|
||||
description: club!.description,
|
||||
logo: club!.logo,
|
||||
address: club!.address,
|
||||
location: club!.location,
|
||||
phone: club!.phone,
|
||||
email: club!.email,
|
||||
website: club!.website,
|
||||
memberCount: club!.memberCount,
|
||||
ownerId: updates['ownerId'] as String?,
|
||||
femaleHandicap: club!.femaleHandicap,
|
||||
averageCalculationPeriod: club!.averageCalculationPeriod,
|
||||
createdAt: club!.createdAt,
|
||||
updatedAt: club!.updatedAt,
|
||||
tieBreakerOptions: club!.tieBreakerOptions,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
return club!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Club> fetchClub(String clubId) async {
|
||||
if (club == null) {
|
||||
throw Exception('클럽이 없습니다');
|
||||
}
|
||||
return club!;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class MockMemberService extends ChangeNotifier implements MemberService {
|
||||
MockMemberService({required List<Member> seed}) : _members = List<Member>.from(seed);
|
||||
|
||||
List<Member> _members;
|
||||
|
||||
@override
|
||||
bool get isLoading => false;
|
||||
|
||||
@override
|
||||
String? get lastError => null;
|
||||
|
||||
@override
|
||||
List<Member> get members => _members;
|
||||
|
||||
@override
|
||||
Future<void> fetchClubMembers() async {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setClubId(String clubId) async {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Member>> fetchMembersByIds(String clubId, List<String> memberIds) async {
|
||||
if (memberIds.isEmpty) return <Member>[];
|
||||
return _members.where((m) => memberIds.contains(m.id)).toList();
|
||||
}
|
||||
|
||||
set membersSeed(List<Member> next) {
|
||||
_members = List<Member>.from(next);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:provider/single_child_widget.dart';
|
||||
|
||||
import 'mock_services.dart';
|
||||
|
||||
/// 공통 Provider 프리셋 모음
|
||||
/// - 위젯 테스트에서 필요한 Provider 세트를 손쉽게 구성하기 위한 헬퍼
|
||||
class ProviderPresets {
|
||||
const ProviderPresets._();
|
||||
|
||||
/// ClubSettingsScreen 시나리오에 필요한 Provider 세트
|
||||
/// - AuthService, ClubService, MemberService 순으로 등록
|
||||
static List<SingleChildWidget> clubSettings({
|
||||
required MockAuthService auth,
|
||||
required MockClubService club,
|
||||
required MockMemberService members,
|
||||
}) {
|
||||
return [
|
||||
ChangeNotifierProvider<MockAuthService>.value(value: auth),
|
||||
ChangeNotifierProvider<MockClubService>.value(value: club),
|
||||
ChangeNotifierProvider<MockMemberService>.value(value: members),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:lanebow/services/event_service.dart';
|
||||
import 'package:lanebow/models/participant_model.dart';
|
||||
import 'package:lanebow/models/score_model.dart';
|
||||
import 'package:lanebow/models/event_model.dart';
|
||||
import 'package:lanebow/models/team_model.dart';
|
||||
|
||||
/// 테스트 공통 StubEventService
|
||||
/// - 네트워크/인증 의존성을 제거
|
||||
/// - 각 메서드별 실패 시뮬레이션 플래그 제공
|
||||
class StubEventService extends EventService {
|
||||
bool throwOnAddParticipant = false;
|
||||
bool throwOnUpdateParticipant = false;
|
||||
bool throwOnAddScore = false;
|
||||
bool throwOnUpdateScore = false;
|
||||
bool throwOnCloneEvent = false;
|
||||
bool throwOnCreateEvent = false;
|
||||
|
||||
// Observability for tests
|
||||
bool lastCloneCalled = false;
|
||||
String? lastClonedEventId;
|
||||
Map<String, dynamic>? lastCreatedEventData;
|
||||
|
||||
// Seeded events for tests
|
||||
List<Event> _seededEvents = <Event>[];
|
||||
set seededEvents(List<Event> events) => _seededEvents = List<Event>.from(events);
|
||||
|
||||
@override
|
||||
List<Event> get events => List<Event>.from(_seededEvents);
|
||||
|
||||
@override
|
||||
Future<Participant> addParticipant(String eventId, Map<String, dynamic> data) async {
|
||||
if (throwOnAddParticipant) {
|
||||
throw Exception('의도한 실패(addParticipant)');
|
||||
}
|
||||
return Participant(
|
||||
id: 'stub_p',
|
||||
memberId: (data['memberId'] ?? 'stub_member').toString(),
|
||||
name: (data['name'] ?? '이름없음').toString(),
|
||||
eventId: eventId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Event> cloneEvent(String eventId, {String? newName}) async {
|
||||
if (throwOnCloneEvent) {
|
||||
throw Exception('의도한 실패(cloneEvent)');
|
||||
}
|
||||
lastCloneCalled = true;
|
||||
lastClonedEventId = eventId;
|
||||
final now = DateTime.now();
|
||||
final e = Event(
|
||||
id: 'cloned_$eventId',
|
||||
clubId: 'club1',
|
||||
title: (newName ?? '복사본'),
|
||||
startDate: now,
|
||||
status: 'draft',
|
||||
isActive: true,
|
||||
);
|
||||
// 목록 변경 알림만 수행
|
||||
notifyListeners();
|
||||
return e;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Event> createEvent(Map<String, dynamic> eventData) async {
|
||||
if (throwOnCreateEvent) {
|
||||
throw Exception('의도한 실패(createEvent)');
|
||||
}
|
||||
lastCreatedEventData = Map<String, dynamic>.from(eventData);
|
||||
final now = DateTime.now();
|
||||
final e = Event(
|
||||
id: 'new_event',
|
||||
clubId: (eventData['clubId'] ?? 'club1') as String,
|
||||
title: (eventData['title'] ?? '제목없음') as String,
|
||||
description: eventData['description'] as String?,
|
||||
location: eventData['location'] as String?,
|
||||
startDate: DateTime.tryParse(eventData['startDate'] as String? ?? '') ?? now,
|
||||
endDate: (eventData['endDate'] != null) ? DateTime.tryParse(eventData['endDate'] as String) : null,
|
||||
status: (eventData['status'] ?? 'draft') as String,
|
||||
publicHash: eventData['publicHash'] as String?,
|
||||
accessPassword: eventData['accessPassword'] as String?,
|
||||
gameCount: eventData['gameCount'] as int?,
|
||||
participantFee: (eventData['participantFee'] as num?)?.toDouble(),
|
||||
maxParticipants: eventData['maxParticipants'] as int?,
|
||||
isActive: true,
|
||||
);
|
||||
notifyListeners();
|
||||
return e;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Participant> updateParticipant(String eventId, String participantId, Map<String, dynamic> data) async {
|
||||
if (throwOnUpdateParticipant) {
|
||||
throw Exception('의도한 실패(updateParticipant)');
|
||||
}
|
||||
return Participant(
|
||||
id: participantId,
|
||||
memberId: (data['memberId'] ?? 'stub_member').toString(),
|
||||
name: (data['name'] ?? '이름없음').toString(),
|
||||
eventId: eventId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Score> addScore(String eventId, Map<String, dynamic> scoreData) async {
|
||||
if (throwOnAddScore) {
|
||||
throw Exception('의도한 실패(addScore)');
|
||||
}
|
||||
return Score(
|
||||
id: 'stub_score',
|
||||
memberId: (scoreData['memberId'] ?? 'member1').toString(),
|
||||
eventId: eventId,
|
||||
clubId: 'club1',
|
||||
frames: (scoreData['frames'] as List?)?.cast<int>() ?? const [],
|
||||
totalScore: (scoreData['totalScore'] as int?) ?? 0,
|
||||
handicap: scoreData['handicap'] as int?,
|
||||
date: DateTime.now(),
|
||||
notes: scoreData['notes'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Score> updateScore(String eventId, String scoreId, Map<String, dynamic> scoreData) async {
|
||||
if (throwOnUpdateScore) {
|
||||
throw Exception('의도한 실패(updateScore)');
|
||||
}
|
||||
return Score(
|
||||
id: scoreId,
|
||||
memberId: (scoreData['memberId'] ?? 'member1').toString(),
|
||||
eventId: eventId,
|
||||
clubId: 'club1',
|
||||
frames: (scoreData['frames'] as List?)?.cast<int>() ?? const [],
|
||||
totalScore: (scoreData['totalScore'] as int?) ?? 0,
|
||||
handicap: scoreData['handicap'] as int?,
|
||||
date: DateTime.now(),
|
||||
notes: scoreData['notes'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Stubbed fetch methods to avoid network in widget tests ---
|
||||
@override
|
||||
Future<void> fetchClubEvents() async {
|
||||
// simulate quick load without network
|
||||
notifyListeners();
|
||||
}
|
||||
@override
|
||||
Future<List<Participant>> fetchEventParticipants(String eventId) async {
|
||||
return <Participant>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Score>> fetchEventScores(String eventId) async {
|
||||
return <Score>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Team>> fetchEventTeams(String eventId) async {
|
||||
return <Team>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Event> fetchEventById(String eventId) async {
|
||||
return Event(
|
||||
id: eventId,
|
||||
clubId: 'club1',
|
||||
title: '원본',
|
||||
startDate: DateTime.now(),
|
||||
status: 'active',
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:lanebow/models/member_model.dart';
|
||||
import 'package:lanebow/models/score_model.dart';
|
||||
import 'package:lanebow/models/tie_breaker_option.dart';
|
||||
@@ -265,12 +266,12 @@ void main() {
|
||||
final options = [TieBreakerOption.olderAge];
|
||||
|
||||
// 테스트 데이터 확인용 로그
|
||||
print('\n===== 테스트 데이터 확인 =====');
|
||||
debugPrint('\n===== 테스트 데이터 확인 =====');
|
||||
for (var score in scores) {
|
||||
print('Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}');
|
||||
debugPrint('Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}');
|
||||
}
|
||||
for (var member in members) {
|
||||
print('Member ID: ${member.id}, 생년월일: ${member.birthDate}');
|
||||
debugPrint('Member ID: ${member.id}, 생년월일: ${member.birthDate}');
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -282,9 +283,9 @@ void main() {
|
||||
);
|
||||
|
||||
// 정렬 결과 확인용 로그
|
||||
print('\n===== 정렬 결과 확인 =====');
|
||||
debugPrint('\n===== 정렬 결과 확인 =====');
|
||||
for (var score in sortedScores) {
|
||||
print('Sorted Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}');
|
||||
debugPrint('Sorted Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}');
|
||||
}
|
||||
|
||||
// then
|
||||
@@ -299,9 +300,9 @@ void main() {
|
||||
final ranks = TieBreakerUtil.calculateRanks(sortedScores, true, options: options);
|
||||
|
||||
// 순위 계산 결과 확인용 로그
|
||||
print('\n===== 순위 계산 결과 확인 =====');
|
||||
debugPrint('\n===== 순위 계산 결과 확인 =====');
|
||||
for (var id in ranks.keys) {
|
||||
print('ID: $id, 순위: ${ranks[id]}');
|
||||
debugPrint('ID: $id, 순위: ${ranks[id]}');
|
||||
}
|
||||
|
||||
expect(ranks['4'], 1); // 1위
|
||||
@@ -316,9 +317,9 @@ void main() {
|
||||
final options = [TieBreakerOption.lowerHandicap, TieBreakerOption.olderAge];
|
||||
final members = createTestMembers();
|
||||
|
||||
print('\n===== lowerHandicap -> olderAge 테스트 데이터 확인 =====');
|
||||
debugPrint('\n===== lowerHandicap -> olderAge 테스트 데이터 확인 =====');
|
||||
for (var member in members) {
|
||||
print('Member ID: ${member.id}, 생년월일: ${member.birthDate}');
|
||||
debugPrint('Member ID: ${member.id}, 생년월일: ${member.birthDate}');
|
||||
}
|
||||
|
||||
// 핸디캐프이 서로 다른 새 점수 데이터 생성
|
||||
@@ -365,9 +366,9 @@ void main() {
|
||||
];
|
||||
|
||||
// 점수 데이터 확인
|
||||
print('\n===== 점수 데이터 확인 =====');
|
||||
debugPrint('\n===== 점수 데이터 확인 =====');
|
||||
for (var score in scores) {
|
||||
print('Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}');
|
||||
debugPrint('Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}');
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -379,9 +380,9 @@ void main() {
|
||||
);
|
||||
|
||||
// 정렬 결과 확인
|
||||
print('\n===== 정렬 결과 확인 =====');
|
||||
debugPrint('\n===== 정렬 결과 확인 =====');
|
||||
for (var score in sortedScores) {
|
||||
print('Sorted Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}');
|
||||
debugPrint('Sorted Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}');
|
||||
}
|
||||
|
||||
// then
|
||||
@@ -394,9 +395,9 @@ void main() {
|
||||
final ranks = TieBreakerUtil.calculateRanks(sortedScores, false, options: options);
|
||||
|
||||
// 순위 계산 결과 확인
|
||||
print('\n===== 순위 계산 결과 확인 =====');
|
||||
debugPrint('\n===== 순위 계산 결과 확인 =====');
|
||||
for (var id in ranks.keys) {
|
||||
print('ID: $id, 순위: ${ranks[id]}');
|
||||
debugPrint('ID: $id, 순위: ${ranks[id]}');
|
||||
}
|
||||
|
||||
expect(ranks['1'], 1); // 1위
|
||||
@@ -454,12 +455,12 @@ void main() {
|
||||
];
|
||||
|
||||
// 테스트 데이터 확인용 로그
|
||||
print('\n===== lowerScoreGap -> lowerHandicap -> olderAge 테스트 데이터 확인 =====');
|
||||
debugPrint('\n===== lowerScoreGap -> lowerHandicap -> olderAge 테스트 데이터 확인 =====');
|
||||
for (var score in scores) {
|
||||
print('Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}, 프레임: ${score.frames}');
|
||||
debugPrint('Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}, 프레임: ${score.frames}');
|
||||
}
|
||||
for (var member in members) {
|
||||
print('Member ID: ${member.id}, 생년월일: ${member.birthDate}');
|
||||
debugPrint('Member ID: ${member.id}, 생년월일: ${member.birthDate}');
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -471,9 +472,9 @@ void main() {
|
||||
);
|
||||
|
||||
// 정렬 결과 확인용 로그
|
||||
print('\n===== 정렬 결과 확인 =====');
|
||||
debugPrint('\n===== 정렬 결과 확인 =====');
|
||||
for (var score in sortedScores) {
|
||||
print('Sorted Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}, 프레임: ${score.frames}');
|
||||
debugPrint('Sorted Score ID: ${score.id}, 총점: ${score.totalScore}, 핸디캡: ${score.handicap}, 프레임: ${score.frames}');
|
||||
}
|
||||
|
||||
// then
|
||||
@@ -487,9 +488,9 @@ void main() {
|
||||
final ranks = TieBreakerUtil.calculateRanks(sortedScores, false, options: options);
|
||||
|
||||
// 순위 계산 결과 확인용 로그
|
||||
print('\n===== 순위 계산 결과 확인 =====');
|
||||
debugPrint('\n===== 순위 계산 결과 확인 =====');
|
||||
for (var id in ranks.keys) {
|
||||
print('ID: $id, 순위: ${ranks[id]}');
|
||||
debugPrint('ID: $id, 순위: ${ranks[id]}');
|
||||
}
|
||||
|
||||
expect(ranks['2'], 1); // 1위
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lanebow/utils/test_utils.dart';
|
||||
import 'package:lanebow/utils/url_utils.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
// 테스트 모드 강제
|
||||
TestUtils.setTestMode(true);
|
||||
});
|
||||
|
||||
group('UrlUtils', () {
|
||||
test('generatePublicHash - 테스트 모드에서 예측 가능한 해시 반환', () {
|
||||
final h1 = UrlUtils.generatePublicHash();
|
||||
final h2 = UrlUtils.generatePublicHash();
|
||||
expect(h1, isNotEmpty);
|
||||
expect(h2, isNotEmpty);
|
||||
expect(h1, isNot(h2)); // 카운터 증가로 서로 달라야 함
|
||||
expect(h1.startsWith('test'), true);
|
||||
expect(h2.startsWith('test'), true);
|
||||
});
|
||||
|
||||
test('getEventPublicUrl - 기본 도메인과 해시 결합', () {
|
||||
const hash = 'test42';
|
||||
final url = UrlUtils.getEventPublicUrl(hash);
|
||||
expect(url, '${UrlUtils.eventBaseUrl}$hash');
|
||||
});
|
||||
|
||||
testWidgets('copyUrlToClipboard - 테스트 모드에서 오류 없이 동작하고 SnackBar 미표시', (tester) async {
|
||||
// 테스트 환경에서는 SnackBar를 실제로 띄우지 않고 로그만 출력하도록 구현되어 있음
|
||||
final app = MaterialApp(
|
||||
home: Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
return Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
UrlUtils.copyUrlToClipboard(context, 'https://example.com');
|
||||
},
|
||||
child: const Text('copy'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(app);
|
||||
await tester.tap(find.text('copy'));
|
||||
await tester.pump();
|
||||
|
||||
// SnackBar가 표시되지 않아야 함 (테스트 모드)
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:provider/single_child_widget.dart';
|
||||
|
||||
/// 공통 위젯 테스트 유틸
|
||||
/// - MaterialApp/Scaffold 래핑
|
||||
/// - Provider 래핑 헬퍼
|
||||
/// - 드롭다운 오버레이 비의존 패턴(onChanged 직접 호출) 헬퍼
|
||||
class WidgetTestUtils {
|
||||
const WidgetTestUtils._();
|
||||
|
||||
/// MaterialApp + Scaffold로 감싸서 펌프
|
||||
static Future<void> pumpWithScaffold(
|
||||
WidgetTester tester,
|
||||
Widget child,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
// 첫 프레임 렌더링
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// MultiProvider로 감싸서 펌프
|
||||
static Future<void> pumpWithProviders(
|
||||
WidgetTester tester, {
|
||||
required List<SingleChildWidget> providers,
|
||||
required Widget child,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: providers,
|
||||
child: MaterialApp(home: Scaffold(body: child)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// DropdownButtonFormField의 onChanged를 직접 호출하여 값 변경
|
||||
/// overlay를 열지 않아도 되어 타임아웃/플레이키를 방지한다.
|
||||
static Future<void> selectDropdownValue<T>(
|
||||
WidgetTester tester, {
|
||||
Key? fieldKey,
|
||||
T? value,
|
||||
Type fieldType = DropdownButtonFormField,
|
||||
}) async {
|
||||
final finder = fieldKey != null
|
||||
? find.byKey(fieldKey)
|
||||
: find.byType(fieldType);
|
||||
final field = tester.widget<DropdownButtonFormField<T>>(finder);
|
||||
field.onChanged?.call(value);
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user