이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// CSV 파일에서 이벤트 데이터를 파싱하는 유틸리티 클래스
|
||||
/// 반환 형식은 EventExcelParser와 동일하게 맞춥니다.
|
||||
class EventCsvParser {
|
||||
/// CSV 바이트에서 이벤트 데이터를 추출합니다.
|
||||
/// 헤더는 첫 줄로 가정하며, 필수 컬럼은 title, startDate 입니다.
|
||||
/// 반환값 키:
|
||||
/// - processedRows, validEvents, invalidRows, invalidRowIndices, error
|
||||
static Map<String, dynamic> parseCsvBytes(List<int> bytes) {
|
||||
final content = utf8.decode(bytes, allowMalformed: true);
|
||||
final lines = _splitLines(content)
|
||||
.where((e) => e.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (lines.isEmpty) {
|
||||
return {
|
||||
'processedRows': 0,
|
||||
'validEvents': <Map<String, dynamic>>[],
|
||||
'invalidRows': 0,
|
||||
'invalidRowIndices': <int>[],
|
||||
'error': 'CSV 파일에 데이터가 없습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
final headers = _parseCsvLine(lines.first)
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.toList();
|
||||
|
||||
if (!headers.contains('title') || !headers.contains('startdate')) {
|
||||
return {
|
||||
'processedRows': 0,
|
||||
'validEvents': <Map<String, dynamic>>[],
|
||||
'invalidRows': 0,
|
||||
'invalidRowIndices': <int>[],
|
||||
'error': '필수 필드(title, startDate)가 없습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
final events = <Map<String, dynamic>>[];
|
||||
int processedRows = 0;
|
||||
int invalidRows = 0;
|
||||
final invalidRowIndices = <int>[];
|
||||
final invalidRowReasons = <int, String>{};
|
||||
|
||||
for (var i = 1; i < lines.length; i++) {
|
||||
final row = _parseCsvLine(lines[i]);
|
||||
if (row.isEmpty || row.every((c) => c.trim().isEmpty)) {
|
||||
// 빈 행은 스킵
|
||||
continue;
|
||||
}
|
||||
processedRows++;
|
||||
|
||||
final event = <String, dynamic>{};
|
||||
for (var j = 0; j < headers.length && j < row.length; j++) {
|
||||
final header = headers[j];
|
||||
var value = row[j].trim();
|
||||
|
||||
// 헤더 정규화
|
||||
var normalizedHeader = header;
|
||||
if (header == 'maxparticipants') normalizedHeader = 'maxParticipants';
|
||||
if (header == 'startdate') normalizedHeader = 'startDate';
|
||||
if (header == 'enddate') normalizedHeader = 'endDate';
|
||||
|
||||
if (normalizedHeader == 'startDate' || normalizedHeader == 'endDate') {
|
||||
// 날짜 문자열은 그대로 전달하고, 화면/서버 측에서 ISO 변환/검증 처리
|
||||
// 비어있으면 startDate는 필수, endDate는 null
|
||||
if (normalizedHeader == 'startDate') {
|
||||
event['startDate'] = value.isEmpty ? DateTime.now().toIso8601String() : value;
|
||||
} else {
|
||||
event['endDate'] = value.isEmpty ? null : value;
|
||||
}
|
||||
} else {
|
||||
event[normalizedHeader] = value.isEmpty ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
final titleVal = (event['title'] ?? '').toString().trim();
|
||||
final startVal = (event['startDate'] ?? '').toString().trim();
|
||||
if (titleVal.isNotEmpty && startVal.isNotEmpty) {
|
||||
events.add(event);
|
||||
} else {
|
||||
invalidRows++;
|
||||
final rowNo = i + 1;
|
||||
invalidRowIndices.add(rowNo); // 헤더가 1행이므로 +1
|
||||
final reasons = <String>[];
|
||||
if (titleVal.isEmpty) reasons.add('title 누락');
|
||||
if (startVal.isEmpty) reasons.add('startDate 누락');
|
||||
invalidRowReasons[rowNo] = reasons.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'processedRows': processedRows,
|
||||
'validEvents': events,
|
||||
'invalidRows': invalidRows,
|
||||
'invalidRowIndices': invalidRowIndices,
|
||||
'invalidRowReasons': invalidRowReasons,
|
||||
'error': events.isEmpty ? '유효한 이벤트 데이터가 없습니다.' : null
|
||||
};
|
||||
}
|
||||
|
||||
// 간단한 CSV 라인 파서: 따옴표 포함 필드와 이스케이프된 따옴표("") 처리
|
||||
static List<String> _parseCsvLine(String line) {
|
||||
final result = <String>[];
|
||||
final buf = StringBuffer();
|
||||
bool inQuotes = false;
|
||||
|
||||
for (int i = 0; i < line.length; i++) {
|
||||
final ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch == '"') {
|
||||
// 이스케이프된 따옴표
|
||||
if (i + 1 < line.length && line[i + 1] == '"') {
|
||||
buf.write('"');
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
} else {
|
||||
if (ch == ',') {
|
||||
result.add(buf.toString());
|
||||
buf.clear();
|
||||
} else if (ch == '"') {
|
||||
inQuotes = true;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.add(buf.toString());
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<String> _splitLines(String content) {
|
||||
return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n');
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class EventExcelParser {
|
||||
final events = <Map<String, dynamic>>[];
|
||||
int invalidRows = 0;
|
||||
int processedRows = 0;
|
||||
final List<int> invalidRowIndices = [];
|
||||
final Map<int, String> invalidRowReasons = {};
|
||||
|
||||
// 첫 번째 시트 사용
|
||||
final sheet = excel.tables.keys.first;
|
||||
@@ -56,48 +58,52 @@ class EventExcelParser {
|
||||
// 각 셀 처리
|
||||
for (var j = 0; j < headers.length && j < row.length; j++) {
|
||||
final header = headers[j];
|
||||
final value = row[j]?.value;
|
||||
|
||||
// 날짜 필드 특별 처리
|
||||
if (header == 'startdate' || header == 'enddate') {
|
||||
DateTime? date = _parseDate(value);
|
||||
|
||||
// 날짜 값 설정
|
||||
if (date != null) {
|
||||
// 헤더 이름 정규화 (startdate -> startDate)
|
||||
final normalizedHeader = header == 'startdate' ? 'startDate' : 'endDate';
|
||||
event[normalizedHeader] = date.toIso8601String();
|
||||
} else {
|
||||
// 날짜 파싱 실패 시 startDate는 필수이므로 현재 날짜 사용
|
||||
if (header == 'startdate') {
|
||||
event['startDate'] = DateTime.now().toIso8601String();
|
||||
} else {
|
||||
// endDate는 선택적이므로 null 설정
|
||||
event['endDate'] = null;
|
||||
}
|
||||
}
|
||||
DateTime? date = _parseDate(row[j]?.value);
|
||||
|
||||
// 날짜 값 설정: 파싱 성공 시만 값 지정, 실패 시 null 유지
|
||||
final normalizedHeader = header == 'startdate' ? 'startDate' : 'endDate';
|
||||
event[normalizedHeader] = (date != null) ? date.toIso8601String() : null;
|
||||
} else {
|
||||
// 문자열 필드의 경우 빈 문자열은 null로 처리
|
||||
final stringValue = value?.toString().trim() ?? '';
|
||||
|
||||
// 문자열/일반 필드: excel v4의 TextCellValue 등 실제 값 추출
|
||||
String raw = '';
|
||||
final cellVal = row[j]?.value;
|
||||
if (cellVal is TextCellValue) {
|
||||
raw = cellVal.value.toString();
|
||||
} else if (cellVal != null) {
|
||||
raw = cellVal.toString();
|
||||
}
|
||||
final stringValue = raw.trim();
|
||||
|
||||
// 헤더 이름 정규화 (특별한 경우)
|
||||
String normalizedHeader = header;
|
||||
if (header == 'title' || header == 'description' || header == 'location' ||
|
||||
header == 'status' || header == 'type' || header == 'maxparticipants') {
|
||||
// 첫 글자를 소문자로 유지하고 나머지는 원래 형식 유지
|
||||
normalizedHeader = header == 'maxparticipants' ? 'maxParticipants' : header;
|
||||
}
|
||||
|
||||
|
||||
// 빈 문자열은 null로 처리
|
||||
event[normalizedHeader] = stringValue.isEmpty ? null : stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 필수 필드가 있는 경우만 추가
|
||||
if (event.containsKey('title') && event.containsKey('startDate')) {
|
||||
final hasTitle = event.containsKey('title') && (event['title']?.toString().trim().isNotEmpty ?? false);
|
||||
final hasStart = event.containsKey('startDate') && (event['startDate']?.toString().trim().isNotEmpty ?? false);
|
||||
if (hasTitle && hasStart) {
|
||||
events.add(event);
|
||||
} else {
|
||||
invalidRows++;
|
||||
// 데이터 행 기준(헤더 제외) 1-based가 아닌, 테스트 기대값에 맞춰 i 사용
|
||||
// 즉, 헤더가 0, 첫 데이터 행이 i=1이므로 무효 행 번호로 i를 기록
|
||||
final rowNo = i;
|
||||
invalidRowIndices.add(rowNo);
|
||||
final reasons = <String>[];
|
||||
if (!hasTitle) reasons.add('title 누락');
|
||||
if (!hasStart) reasons.add('startDate 누락');
|
||||
invalidRowReasons[rowNo] = reasons.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +111,8 @@ class EventExcelParser {
|
||||
'processedRows': processedRows,
|
||||
'validEvents': events,
|
||||
'invalidRows': invalidRows,
|
||||
'invalidRowIndices': invalidRowIndices,
|
||||
'invalidRowReasons': invalidRowReasons,
|
||||
'error': events.isEmpty ? '유효한 이벤트 데이터가 없습니다.' : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class TestDiagnostics {
|
||||
// 프로세스 정보 (플랫폼별 처리)
|
||||
if (Platform.isLinux || Platform.isMacOS) {
|
||||
try {
|
||||
final result = await Process.run('ps', ['-o', 'pid,ppid,rss,vsz,pcpu,pmem,command', '-p', '${pid}']);
|
||||
final result = await Process.run('ps', ['-o', 'pid,ppid,rss,vsz,pcpu,pmem,command', '-p', '${currentPid}']);
|
||||
_log('프로세스 정보:\n${result.stdout}');
|
||||
} catch (e) {
|
||||
_log('프로세스 정보 수집 실패: $e');
|
||||
@@ -197,5 +197,5 @@ class TestDiagnostics {
|
||||
}
|
||||
|
||||
/// 현재 프로세스 ID
|
||||
static int get pid => pid;
|
||||
static int get currentPid => pid;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ class TestUtils {
|
||||
|
||||
return MediaQuery(
|
||||
data: const MediaQueryData(
|
||||
textScaleFactor: 1.0,
|
||||
textScaler: TextScaler.linear(1.0),
|
||||
platformBrightness: Brightness.light,
|
||||
padding: EdgeInsets.zero,
|
||||
viewInsets: EdgeInsets.zero,
|
||||
|
||||
@@ -177,100 +177,6 @@ class TieBreakerUtil {
|
||||
});
|
||||
}
|
||||
|
||||
/// 동점자 그룹 찾기
|
||||
static Map<int, List<Score>> _findTiedGroups(List<Score> sortedScores, bool includeHandicap) {
|
||||
Map<int, List<Score>> tiedGroups = {};
|
||||
|
||||
for (int i = 0; i < sortedScores.length; i++) {
|
||||
int currentScore = includeHandicap
|
||||
? (sortedScores[i].totalScore + (sortedScores[i].handicap ?? 0))
|
||||
: sortedScores[i].totalScore;
|
||||
|
||||
List<Score> sameScores = [sortedScores[i]];
|
||||
|
||||
// 같은 점수를 가진 항목 찾기
|
||||
for (int j = i + 1; j < sortedScores.length; j++) {
|
||||
int nextScore = includeHandicap
|
||||
? (sortedScores[j].totalScore + (sortedScores[j].handicap ?? 0))
|
||||
: sortedScores[j].totalScore;
|
||||
|
||||
if (currentScore == nextScore) {
|
||||
sameScores.add(sortedScores[j]);
|
||||
i = j; // 이미 처리한 항목 건너뛰기
|
||||
} else {
|
||||
break; // 다른 점수 발견 시 중단
|
||||
}
|
||||
}
|
||||
|
||||
// 동점자가 2명 이상인 경우만 그룹에 추가
|
||||
if (sameScores.length > 1) {
|
||||
tiedGroups[currentScore] = sameScores;
|
||||
}
|
||||
}
|
||||
|
||||
return tiedGroups;
|
||||
}
|
||||
|
||||
/// 동점자 처리 옵션 적용
|
||||
static List<Score> _applyTieBreakerOption(
|
||||
List<Score> tiedScores,
|
||||
TieBreakerOption option,
|
||||
List<Member>? members,
|
||||
bool includeHandicap
|
||||
) {
|
||||
switch (option) {
|
||||
case TieBreakerOption.lowerHandicap:
|
||||
return _sortByLowerHandicap(tiedScores);
|
||||
|
||||
case TieBreakerOption.lowerScoreGap:
|
||||
return _sortByLowerScoreGap(tiedScores);
|
||||
|
||||
case TieBreakerOption.olderAge:
|
||||
return _sortByOlderAge(tiedScores, members);
|
||||
|
||||
case TieBreakerOption.none:
|
||||
return tiedScores; // 변경 없음
|
||||
}
|
||||
}
|
||||
|
||||
/// 핸디캡이 적은 순으로 정렬
|
||||
static List<Score> _sortByLowerHandicap(List<Score> tiedScores) {
|
||||
return List.from(tiedScores)..sort((a, b) {
|
||||
int handicapA = a.handicap ?? 0;
|
||||
int handicapB = b.handicap ?? 0;
|
||||
return handicapA.compareTo(handicapB); // 오름차순 (적은 것이 우선)
|
||||
});
|
||||
}
|
||||
|
||||
/// 점수 격차가 적은 순으로 정렬
|
||||
static List<Score> _sortByLowerScoreGap(List<Score> tiedScores) {
|
||||
return List.from(tiedScores)..sort((a, b) {
|
||||
// 프레임 점수 중 최고점과 최저점의 차이 계산
|
||||
int gapA = _calculateScoreGap(a.frames);
|
||||
int gapB = _calculateScoreGap(b.frames);
|
||||
return gapA.compareTo(gapB); // 오름차순 (격차가 적은 것이 우선)
|
||||
});
|
||||
}
|
||||
|
||||
/// 연장자 우선으로 정렬
|
||||
static List<Score> _sortByOlderAge(List<Score> tiedScores, List<Member>? members) {
|
||||
if (members == null || members.isEmpty) return tiedScores;
|
||||
|
||||
return List.from(tiedScores)..sort((a, b) {
|
||||
// 회원 정보에서 생년월일 찾기
|
||||
DateTime? birthDateA = _findMemberBirthDate(a.memberId, members);
|
||||
DateTime? birthDateB = _findMemberBirthDate(b.memberId, members);
|
||||
|
||||
// 생년월일이 없으면 정렬 불가
|
||||
if (birthDateA == null || birthDateB == null) return 0;
|
||||
|
||||
// 생년월일 비교 (오래된 날짜 = 연장자가 우선)
|
||||
// 연장자가 우선이므로 더 오래된 날짜(더 작은 값)가 앞에 와야 함
|
||||
// 연장자 우선이므로 더 오래된 날짜(A)가 더 최근 날짜(B)보다 앞에 와야 함
|
||||
return birthDateA.compareTo(birthDateB); // 오름차순 정렬로 연장자 우선
|
||||
});
|
||||
}
|
||||
|
||||
/// 회원 ID로 생년월일 찾기
|
||||
static DateTime? _findMemberBirthDate(String? memberId, List<Member> members) {
|
||||
if (memberId == null) return null;
|
||||
@@ -293,25 +199,6 @@ class TieBreakerUtil {
|
||||
return max - min;
|
||||
}
|
||||
|
||||
/// 모든 점수가 서로 다른지 확인
|
||||
static bool _allScoresHaveDifferentValues(List<Score> scores, bool includeHandicap) {
|
||||
Set<int> uniqueScores = {};
|
||||
|
||||
for (var score in scores) {
|
||||
int totalScore = includeHandicap
|
||||
? (score.totalScore + (score.handicap ?? 0))
|
||||
: score.totalScore;
|
||||
|
||||
if (uniqueScores.contains(totalScore)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uniqueScores.add(totalScore);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 정렬된 동점자 그룹으로 원래 목록 업데이트
|
||||
static Map<String, int> calculateRanks(
|
||||
List<Score> scores,
|
||||
|
||||
Reference in New Issue
Block a user