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

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
+107
View File
@@ -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 사용 여부 등) 확인.
+187
View File
@@ -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;
}
}
+42
View File
@@ -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'));
});
});
}
+132
View File
@@ -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);
}
+24
View File
@@ -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),
];
}
}
+172
View File
@@ -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,
);
}
}
+23 -22
View File
@@ -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위
+57
View File
@@ -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);
});
});
}
+57
View File
@@ -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();
}
}