test: 통합 테스트 오류 및 경고 수정
- 모든 서비스 메서드 시그니처를 실제 구현에 맞게 수정 - TestDataGenerator 제거하고 직접 객체 생성으로 변경 - 모델 필드명 및 타입 불일치 수정 - 불필요한 Either 패턴 사용 제거 - null safety 관련 이슈 해결 수정된 파일: - test/integration/screens/company_integration_test.dart - test/integration/screens/equipment_integration_test.dart - test/integration/screens/user_integration_test.dart - test/integration/screens/login_integration_test.dart
This commit is contained in:
158
test/integration/automated/framework/core/README.md
Normal file
158
test/integration/automated/framework/core/README.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# TestDataGenerator 사용 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
`TestDataGenerator`는 기존 `TestDataHelper`를 확장하여 더 스마트하고 현실적인 테스트 데이터를 생성하는 유틸리티 클래스입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 현실적인 데이터 생성
|
||||
- 실제 회사명, 제조사, 제품 모델 사용
|
||||
- 한국식 이름 생성
|
||||
- 유효한 전화번호 및 사업자등록번호 형식
|
||||
- 카테고리별 현실적인 가격 책정
|
||||
|
||||
### 2. 데이터 간 관계 자동 설정
|
||||
- 회사 → 사용자 → 장비/라이선스 관계 자동 구성
|
||||
- 시나리오별 데이터 세트 생성
|
||||
- 제약 조건 자동 충족
|
||||
|
||||
### 3. 데이터 관리 기능
|
||||
- 생성된 데이터 자동 추적
|
||||
- 타입별/전체 데이터 정리 기능
|
||||
- 캐싱을 통한 참조 데이터 재사용
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 기본 데이터 생성
|
||||
|
||||
```dart
|
||||
// 회사 데이터 생성
|
||||
final companyData = TestDataGenerator.createSmartCompanyData(
|
||||
name: '테스트 회사',
|
||||
companyTypes: ['technology', 'service'],
|
||||
);
|
||||
|
||||
// 사용자 데이터 생성
|
||||
final userData = TestDataGenerator.createSmartUserData(
|
||||
companyId: 1,
|
||||
role: 'manager',
|
||||
department: '개발팀',
|
||||
);
|
||||
|
||||
// 장비 데이터 생성
|
||||
final equipmentData = TestDataGenerator.createSmartEquipmentData(
|
||||
companyId: 1,
|
||||
warehouseLocationId: 1,
|
||||
category: '노트북',
|
||||
manufacturer: '삼성전자',
|
||||
);
|
||||
|
||||
// 라이선스 데이터 생성
|
||||
final licenseData = TestDataGenerator.createSmartLicenseData(
|
||||
companyId: 1,
|
||||
productName: 'Microsoft Office 365',
|
||||
licenseType: 'subscription',
|
||||
);
|
||||
```
|
||||
|
||||
### 시나리오 데이터 생성
|
||||
|
||||
```dart
|
||||
// 장비 입고 시나리오
|
||||
final equipmentScenario = await TestDataGenerator.createEquipmentScenario(
|
||||
equipmentCount: 10,
|
||||
);
|
||||
|
||||
// 사용자 관리 시나리오
|
||||
final userScenario = await TestDataGenerator.createUserScenario(
|
||||
userCount: 20,
|
||||
);
|
||||
|
||||
// 라이선스 관리 시나리오
|
||||
final licenseScenario = await TestDataGenerator.createLicenseScenario(
|
||||
licenseCount: 15,
|
||||
);
|
||||
```
|
||||
|
||||
### 데이터 정리
|
||||
|
||||
```dart
|
||||
// 모든 테스트 데이터 정리
|
||||
await TestDataGenerator.cleanupAllTestData();
|
||||
|
||||
// 특정 타입만 정리
|
||||
await TestDataGenerator.cleanupTestDataByType(TestDataType.equipment);
|
||||
```
|
||||
|
||||
## 실제 데이터 풀
|
||||
|
||||
### 회사명
|
||||
- 테크솔루션, 디지털컴퍼니, 스마트시스템즈, 클라우드테크 등
|
||||
|
||||
### 제조사 및 모델
|
||||
- **삼성전자**: Galaxy Book Pro, Galaxy Book Pro 360, Odyssey G9
|
||||
- **LG전자**: Gram 17, Gram 16, UltraGear 27GN950
|
||||
- **Apple**: MacBook Pro 16", MacBook Air M2, iMac 24"
|
||||
- **Dell**: XPS 13, XPS 15, Latitude 7420
|
||||
- 기타 HP, Lenovo, Microsoft, ASUS 제품
|
||||
|
||||
### 소프트웨어 제품
|
||||
- Microsoft Office 365
|
||||
- Adobe Creative Cloud
|
||||
- AutoCAD 2024
|
||||
- Visual Studio Enterprise
|
||||
- JetBrains All Products
|
||||
|
||||
### 장비 카테고리
|
||||
- 노트북, 데스크탑, 모니터, 프린터, 네트워크장비, 서버, 태블릿, 스캐너
|
||||
|
||||
### 창고 타입
|
||||
- 메인창고, 서브창고A/B, 임시보관소, 수리센터, 대여센터
|
||||
|
||||
## 테스트 작성 예시
|
||||
|
||||
```dart
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
await RealApiTestHelper.setupTestEnvironment();
|
||||
await RealApiTestHelper.loginAndGetToken();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await TestDataGenerator.cleanupAllTestData();
|
||||
await RealApiTestHelper.teardownTestEnvironment();
|
||||
});
|
||||
|
||||
test('장비 관리 통합 테스트', () async {
|
||||
// 시나리오 데이터 생성
|
||||
final scenario = await TestDataGenerator.createEquipmentScenario(
|
||||
equipmentCount: 5,
|
||||
);
|
||||
|
||||
// 테스트 수행
|
||||
expect(scenario.equipments.length, equals(5));
|
||||
|
||||
// 장비 상태 변경 테스트
|
||||
for (final equipment in scenario.equipments) {
|
||||
// 출고 처리
|
||||
// 검증
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. 테스트 종료 시 반드시 `cleanupAllTestData()` 호출
|
||||
2. 실제 API와 연동되므로 네트워크 연결 필요
|
||||
3. 생성된 데이터는 자동으로 추적되어 정리됨
|
||||
4. 동시성 테스트 시 고유 ID 충돌 방지를 위해 타임스탬프 기반 ID 사용
|
||||
|
||||
## 확장 가능성
|
||||
|
||||
필요에 따라 다음 기능을 추가할 수 있습니다:
|
||||
- 더 많은 실제 데이터 풀 추가
|
||||
- 복잡한 시나리오 추가 (예: 장비 이동, 라이선스 갱신)
|
||||
- 성능 테스트용 대량 데이터 생성
|
||||
- 국제화 데이터 생성 (다국어 지원)
|
||||
@@ -0,0 +1,990 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../models/error_models.dart';
|
||||
|
||||
/// API 에러 진단 시스템
|
||||
class ApiErrorDiagnostics {
|
||||
/// 학습된 에러 패턴
|
||||
final Map<String, ErrorPattern> _learnedPatterns = {};
|
||||
|
||||
/// 진단 규칙 목록
|
||||
final List<DiagnosticRule> _diagnosticRules = [];
|
||||
|
||||
/// 기본 생성자
|
||||
ApiErrorDiagnostics() {
|
||||
_initializeDefaultRules();
|
||||
}
|
||||
|
||||
/// 기본 진단 규칙 초기화
|
||||
void _initializeDefaultRules() {
|
||||
_diagnosticRules.addAll([
|
||||
AuthenticationDiagnosticRule(),
|
||||
ValidationDiagnosticRule(),
|
||||
NetworkDiagnosticRule(),
|
||||
ServerErrorDiagnosticRule(),
|
||||
NotFoundDiagnosticRule(),
|
||||
RateLimitDiagnosticRule(),
|
||||
]);
|
||||
}
|
||||
|
||||
/// API 에러 진단
|
||||
Future<ErrorDiagnosis> diagnose(ApiError error) async {
|
||||
// 1. 학습된 패턴에서 먼저 매칭 시도
|
||||
final matchedPattern = _findMatchingPattern(error);
|
||||
if (matchedPattern != null) {
|
||||
return _createDiagnosisFromPattern(error, matchedPattern);
|
||||
}
|
||||
|
||||
// 2. 진단 규칙 순회
|
||||
for (final rule in _diagnosticRules) {
|
||||
if (rule.canHandle(error)) {
|
||||
return await rule.diagnose(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 기본 진단 반환
|
||||
return _createDefaultDiagnosis(error);
|
||||
}
|
||||
|
||||
/// 근본 원인 분석
|
||||
Future<RootCause> analyzeRootCause(ErrorDiagnosis diagnosis) async {
|
||||
final causeType = _determineCauseType(diagnosis);
|
||||
final evidence = await _collectEvidence(diagnosis);
|
||||
final description = _generateCauseDescription(diagnosis, evidence);
|
||||
final fixes = await suggestFixes(diagnosis);
|
||||
|
||||
return RootCause(
|
||||
causeType: causeType,
|
||||
description: description,
|
||||
evidence: evidence,
|
||||
diagnosis: diagnosis,
|
||||
recommendedFixes: fixes,
|
||||
);
|
||||
}
|
||||
|
||||
/// 수정 제안
|
||||
Future<List<FixSuggestion>> suggestFixes(ErrorDiagnosis diagnosis) async {
|
||||
final suggestions = <FixSuggestion>[];
|
||||
|
||||
switch (diagnosis.type) {
|
||||
case ApiErrorType.authentication:
|
||||
suggestions.addAll(_createAuthenticationFixes(diagnosis));
|
||||
break;
|
||||
case ApiErrorType.validation:
|
||||
suggestions.addAll(_createValidationFixes(diagnosis));
|
||||
break;
|
||||
case ApiErrorType.networkConnection:
|
||||
suggestions.addAll(_createNetworkFixes(diagnosis));
|
||||
break;
|
||||
case ApiErrorType.serverError:
|
||||
suggestions.addAll(_createServerErrorFixes(diagnosis));
|
||||
break;
|
||||
case ApiErrorType.notFound:
|
||||
suggestions.addAll(_createNotFoundFixes(diagnosis));
|
||||
break;
|
||||
case ApiErrorType.rateLimit:
|
||||
suggestions.addAll(_createRateLimitFixes(diagnosis));
|
||||
break;
|
||||
default:
|
||||
suggestions.add(_createGenericRetryFix());
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/// 에러로부터 학습
|
||||
Future<void> learnFromError(ApiError error, FixResult fixResult) async {
|
||||
if (!fixResult.success) return;
|
||||
|
||||
final diagnosis = await diagnose(error);
|
||||
final patternId = _generatePatternId(error);
|
||||
|
||||
final existingPattern = _learnedPatterns[patternId];
|
||||
if (existingPattern != null) {
|
||||
// 기존 패턴 업데이트
|
||||
_updatePattern(existingPattern, fixResult);
|
||||
} else {
|
||||
// 새로운 패턴 생성
|
||||
_createNewPattern(error, diagnosis, fixResult);
|
||||
}
|
||||
}
|
||||
|
||||
/// 학습된 패턴 찾기
|
||||
ErrorPattern? _findMatchingPattern(ApiError error) {
|
||||
for (final pattern in _learnedPatterns.values) {
|
||||
if (_matchesPattern(error, pattern)) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 패턴 매칭 확인
|
||||
bool _matchesPattern(ApiError error, ErrorPattern pattern) {
|
||||
final rules = pattern.matchingRules;
|
||||
|
||||
// 상태 코드 매칭
|
||||
if (rules['statusCode'] != null && rules['statusCode'] != error.statusCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 에러 타입 매칭
|
||||
if (rules['errorType'] != null &&
|
||||
rules['errorType'] != error.originalError?.type.toString()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// URL 패턴 매칭
|
||||
if (rules['urlPattern'] != null) {
|
||||
final pattern = RegExp(rules['urlPattern'] as String);
|
||||
if (!pattern.hasMatch(error.requestUrl)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 메시지 패턴 매칭
|
||||
if (rules['messagePattern'] != null && error.responseBody != null) {
|
||||
final pattern = RegExp(rules['messagePattern'] as String);
|
||||
final message = error.responseBody.toString();
|
||||
if (!pattern.hasMatch(message)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 패턴으로부터 진단 생성
|
||||
ErrorDiagnosis _createDiagnosisFromPattern(ApiError error, ErrorPattern pattern) {
|
||||
return ErrorDiagnosis(
|
||||
type: pattern.errorType,
|
||||
errorType: _mapApiErrorToErrorType(pattern.errorType),
|
||||
description: '학습된 패턴과 일치하는 에러입니다.',
|
||||
context: {
|
||||
'patternId': pattern.patternId,
|
||||
'confidence': pattern.confidence,
|
||||
'occurrenceCount': pattern.occurrenceCount,
|
||||
},
|
||||
confidence: pattern.confidence,
|
||||
affectedEndpoints: [error.requestUrl],
|
||||
originalMessage: error.originalError?.message,
|
||||
);
|
||||
}
|
||||
|
||||
/// 기본 진단 생성
|
||||
ErrorDiagnosis _createDefaultDiagnosis(ApiError error) {
|
||||
return ErrorDiagnosis(
|
||||
type: ApiErrorType.unknown,
|
||||
errorType: ErrorType.unknown,
|
||||
description: '알 수 없는 에러가 발생했습니다.',
|
||||
context: {
|
||||
'statusCode': error.statusCode,
|
||||
'errorType': error.originalError?.type.toString() ?? 'unknown',
|
||||
},
|
||||
confidence: 0.3,
|
||||
affectedEndpoints: [error.requestUrl],
|
||||
originalMessage: error.originalError?.message,
|
||||
);
|
||||
}
|
||||
|
||||
/// 원인 타입 결정
|
||||
String _determineCauseType(ErrorDiagnosis diagnosis) {
|
||||
switch (diagnosis.type) {
|
||||
case ApiErrorType.authentication:
|
||||
return 'authentication_failure';
|
||||
case ApiErrorType.validation:
|
||||
return 'data_validation_error';
|
||||
case ApiErrorType.networkConnection:
|
||||
return 'network_connectivity';
|
||||
case ApiErrorType.serverError:
|
||||
return 'server_side_error';
|
||||
case ApiErrorType.notFound:
|
||||
return 'resource_not_found';
|
||||
case ApiErrorType.rateLimit:
|
||||
return 'rate_limit_exceeded';
|
||||
default:
|
||||
return 'unknown_error';
|
||||
}
|
||||
}
|
||||
|
||||
/// 증거 수집
|
||||
Future<List<String>> _collectEvidence(ErrorDiagnosis diagnosis) async {
|
||||
final evidence = <String>[];
|
||||
|
||||
// 기본 정보
|
||||
evidence.add('에러 타입: ${diagnosis.type}');
|
||||
evidence.add('발생 시간: ${diagnosis.timestamp.toIso8601String()}');
|
||||
|
||||
// 서버 에러 코드
|
||||
if (diagnosis.serverErrorCode != null) {
|
||||
evidence.add('서버 에러 코드: ${diagnosis.serverErrorCode}');
|
||||
}
|
||||
|
||||
// 누락된 필드
|
||||
if (diagnosis.missingFields != null && diagnosis.missingFields!.isNotEmpty) {
|
||||
evidence.add('누락된 필드: ${diagnosis.missingFields!.join(', ')}');
|
||||
}
|
||||
|
||||
// 타입 불일치
|
||||
if (diagnosis.typeMismatches != null && diagnosis.typeMismatches!.isNotEmpty) {
|
||||
for (final mismatch in diagnosis.typeMismatches!.values) {
|
||||
evidence.add('타입 불일치 - ${mismatch.fieldName}: '
|
||||
'예상 ${mismatch.expectedType}, 실제 ${mismatch.actualType}');
|
||||
}
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
/// 원인 설명 생성
|
||||
String _generateCauseDescription(ErrorDiagnosis diagnosis, List<String> evidence) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
switch (diagnosis.type) {
|
||||
case ApiErrorType.authentication:
|
||||
buffer.write('인증 실패: 토큰이 만료되었거나 유효하지 않습니다.');
|
||||
break;
|
||||
case ApiErrorType.validation:
|
||||
buffer.write('데이터 유효성 검증 실패: ');
|
||||
if (diagnosis.missingFields != null && diagnosis.missingFields!.isNotEmpty) {
|
||||
buffer.write('필수 필드가 누락되었습니다.');
|
||||
} else if (diagnosis.typeMismatches != null && diagnosis.typeMismatches!.isNotEmpty) {
|
||||
buffer.write('데이터 타입이 일치하지 않습니다.');
|
||||
} else {
|
||||
buffer.write('입력 데이터가 서버 요구사항을 충족하지 않습니다.');
|
||||
}
|
||||
break;
|
||||
case ApiErrorType.networkConnection:
|
||||
buffer.write('네트워크 연결 실패: 인터넷 연결을 확인하거나 서버 상태를 확인하세요.');
|
||||
break;
|
||||
case ApiErrorType.serverError:
|
||||
buffer.write('서버 내부 오류: 서버에서 예상치 못한 오류가 발생했습니다.');
|
||||
break;
|
||||
case ApiErrorType.notFound:
|
||||
buffer.write('리소스를 찾을 수 없음: 요청한 리소스가 존재하지 않거나 접근 권한이 없습니다.');
|
||||
break;
|
||||
case ApiErrorType.rateLimit:
|
||||
buffer.write('요청 제한 초과: API 호출 제한을 초과했습니다.');
|
||||
break;
|
||||
default:
|
||||
buffer.write('알 수 없는 오류가 발생했습니다.');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// 인증 관련 수정 제안 생성
|
||||
List<FixSuggestion> _createAuthenticationFixes(ErrorDiagnosis diagnosis) {
|
||||
return [
|
||||
FixSuggestion(
|
||||
fixId: 'auth_refresh_token',
|
||||
type: FixType.refreshToken,
|
||||
description: '토큰을 갱신하여 인증 문제를 해결합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.changePermission,
|
||||
actionType: 'refresh_token',
|
||||
target: 'auth_service',
|
||||
parameters: {},
|
||||
description: '리프레시 토큰을 사용하여 액세스 토큰 갱신',
|
||||
),
|
||||
],
|
||||
successProbability: 0.85,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 500,
|
||||
),
|
||||
FixSuggestion(
|
||||
fixId: 'auth_relogin',
|
||||
type: FixType.manualIntervention,
|
||||
description: '다시 로그인하여 새로운 인증 정보를 획득합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.changePermission,
|
||||
actionType: 'navigate',
|
||||
target: 'login_screen',
|
||||
parameters: {'reason': 'token_expired'},
|
||||
description: '로그인 화면으로 이동',
|
||||
),
|
||||
],
|
||||
successProbability: 0.95,
|
||||
isAutoFixable: false,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 유효성 검증 관련 수정 제안 생성
|
||||
List<FixSuggestion> _createValidationFixes(ErrorDiagnosis diagnosis) {
|
||||
final fixes = <FixSuggestion>[];
|
||||
|
||||
// 누락된 필드 추가
|
||||
if (diagnosis.missingFields != null && diagnosis.missingFields!.isNotEmpty) {
|
||||
fixes.add(FixSuggestion(
|
||||
fixId: 'validation_add_fields',
|
||||
type: FixType.addMissingField,
|
||||
description: '누락된 필수 필드를 추가합니다.',
|
||||
actions: diagnosis.missingFields!.map((field) => FixAction(
|
||||
type: FixActionType.updateField,
|
||||
actionType: 'add_field',
|
||||
target: 'request_body',
|
||||
parameters: {
|
||||
'field': field,
|
||||
'defaultValue': _getDefaultValueForField(field),
|
||||
},
|
||||
description: '$field 필드 추가',
|
||||
)).toList(),
|
||||
successProbability: 0.8,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 100,
|
||||
));
|
||||
}
|
||||
|
||||
// 타입 불일치 수정
|
||||
if (diagnosis.typeMismatches != null && diagnosis.typeMismatches!.isNotEmpty) {
|
||||
fixes.add(FixSuggestion(
|
||||
fixId: 'validation_convert_types',
|
||||
type: FixType.convertType,
|
||||
description: '잘못된 데이터 타입을 변환합니다.',
|
||||
actions: diagnosis.typeMismatches!.values.map((mismatch) => FixAction(
|
||||
type: FixActionType.convertDataType,
|
||||
actionType: 'convert_type',
|
||||
target: 'request_body',
|
||||
parameters: {
|
||||
'field': mismatch.fieldName,
|
||||
'fromType': mismatch.actualType,
|
||||
'toType': mismatch.expectedType,
|
||||
'value': mismatch.actualValue,
|
||||
},
|
||||
description: '${mismatch.fieldName} 타입 변환',
|
||||
)).toList(),
|
||||
successProbability: 0.75,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 150,
|
||||
));
|
||||
}
|
||||
|
||||
return fixes;
|
||||
}
|
||||
|
||||
/// 네트워크 관련 수정 제안 생성
|
||||
List<FixSuggestion> _createNetworkFixes(ErrorDiagnosis diagnosis) {
|
||||
return [
|
||||
FixSuggestion(
|
||||
fixId: 'network_retry',
|
||||
type: FixType.retry,
|
||||
description: '네트워크 요청을 재시도합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'retry_request',
|
||||
target: 'api_client',
|
||||
parameters: {
|
||||
'maxAttempts': 3,
|
||||
'backoffDelay': 1000,
|
||||
},
|
||||
description: '지수 백오프로 재시도',
|
||||
),
|
||||
],
|
||||
successProbability: 0.7,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 3000,
|
||||
),
|
||||
FixSuggestion(
|
||||
fixId: 'network_check_connection',
|
||||
type: FixType.configuration,
|
||||
description: '네트워크 연결 상태를 확인하고 재연결을 시도합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'check_connectivity',
|
||||
target: 'network_manager',
|
||||
parameters: {},
|
||||
description: '네트워크 연결 확인',
|
||||
),
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'reset_connection',
|
||||
target: 'api_client',
|
||||
parameters: {},
|
||||
description: '연결 재설정',
|
||||
),
|
||||
],
|
||||
successProbability: 0.6,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 2000,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 서버 에러 관련 수정 제안 생성
|
||||
List<FixSuggestion> _createServerErrorFixes(ErrorDiagnosis diagnosis) {
|
||||
return [
|
||||
FixSuggestion(
|
||||
fixId: 'server_retry_later',
|
||||
type: FixType.retry,
|
||||
description: '잠시 후 다시 시도합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'delayed_retry',
|
||||
target: 'api_client',
|
||||
parameters: {
|
||||
'delay': 5000,
|
||||
'maxAttempts': 2,
|
||||
},
|
||||
description: '5초 후 재시도',
|
||||
),
|
||||
],
|
||||
successProbability: 0.5,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 5000,
|
||||
),
|
||||
FixSuggestion(
|
||||
fixId: 'server_fallback',
|
||||
type: FixType.endpointSwitch,
|
||||
description: '대체 엔드포인트로 전환합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'switch_endpoint',
|
||||
target: 'api_client',
|
||||
parameters: {
|
||||
'useFallback': true,
|
||||
},
|
||||
description: '백업 서버로 전환',
|
||||
),
|
||||
],
|
||||
successProbability: 0.7,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 1000,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Not Found 관련 수정 제안 생성
|
||||
List<FixSuggestion> _createNotFoundFixes(ErrorDiagnosis diagnosis) {
|
||||
return [
|
||||
FixSuggestion(
|
||||
fixId: 'notfound_verify_id',
|
||||
type: FixType.modifyData,
|
||||
description: '리소스 ID를 확인하고 수정합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.updateField,
|
||||
actionType: 'verify_resource_id',
|
||||
target: 'request_params',
|
||||
parameters: {},
|
||||
description: '리소스 ID 유효성 확인',
|
||||
),
|
||||
],
|
||||
successProbability: 0.4,
|
||||
isAutoFixable: false,
|
||||
estimatedDuration: 100,
|
||||
),
|
||||
FixSuggestion(
|
||||
fixId: 'notfound_refresh_list',
|
||||
type: FixType.retry,
|
||||
description: '리소스 목록을 새로고침합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'refresh_resource_list',
|
||||
target: 'resource_cache',
|
||||
parameters: {},
|
||||
description: '캐시된 리소스 목록 갱신',
|
||||
),
|
||||
],
|
||||
successProbability: 0.6,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 2000,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Rate Limit 관련 수정 제안 생성
|
||||
List<FixSuggestion> _createRateLimitFixes(ErrorDiagnosis diagnosis) {
|
||||
return [
|
||||
FixSuggestion(
|
||||
fixId: 'ratelimit_wait',
|
||||
type: FixType.retry,
|
||||
description: '제한이 해제될 때까지 대기 후 재시도합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'wait_and_retry',
|
||||
target: 'api_client',
|
||||
parameters: {
|
||||
'waitTime': 60000, // 1분
|
||||
},
|
||||
description: '1분 대기 후 재시도',
|
||||
),
|
||||
],
|
||||
successProbability: 0.9,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 60000,
|
||||
),
|
||||
FixSuggestion(
|
||||
fixId: 'ratelimit_reduce_frequency',
|
||||
type: FixType.configuration,
|
||||
description: 'API 호출 빈도를 줄입니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'configure_throttling',
|
||||
target: 'api_client',
|
||||
parameters: {
|
||||
'maxRequestsPerMinute': 30,
|
||||
},
|
||||
description: 'API 호출 제한 설정',
|
||||
),
|
||||
],
|
||||
successProbability: 0.85,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 100,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 일반적인 재시도 수정 제안 생성
|
||||
FixSuggestion _createGenericRetryFix() {
|
||||
return FixSuggestion(
|
||||
fixId: 'generic_retry',
|
||||
type: FixType.retry,
|
||||
description: '요청을 재시도합니다.',
|
||||
actions: [
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'retry_request',
|
||||
target: 'api_client',
|
||||
parameters: {
|
||||
'maxAttempts': 2,
|
||||
},
|
||||
description: '기본 재시도',
|
||||
),
|
||||
],
|
||||
successProbability: 0.3,
|
||||
isAutoFixable: true,
|
||||
estimatedDuration: 1000,
|
||||
);
|
||||
}
|
||||
|
||||
/// 필드의 기본값 반환
|
||||
dynamic _getDefaultValueForField(String field) {
|
||||
// 필드 이름에 따른 기본값 매핑
|
||||
final defaultValues = {
|
||||
'name': '미지정',
|
||||
'description': '',
|
||||
'quantity': 1,
|
||||
'price': 0,
|
||||
'is_active': true,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
// 특정 패턴에 따른 기본값
|
||||
if (field.endsWith('_id')) return 0;
|
||||
if (field.endsWith('_date')) return DateTime.now().toIso8601String();
|
||||
if (field.endsWith('_count')) return 0;
|
||||
if (field.startsWith('is_')) return false;
|
||||
if (field.startsWith('has_')) return false;
|
||||
|
||||
return defaultValues[field] ?? '';
|
||||
}
|
||||
|
||||
/// 패턴 ID 생성
|
||||
String _generatePatternId(ApiError error) {
|
||||
final components = [
|
||||
error.statusCode?.toString() ?? 'unknown',
|
||||
error.requestMethod,
|
||||
Uri.parse(error.requestUrl).path,
|
||||
error.originalError?.type.toString() ?? 'unknown',
|
||||
];
|
||||
|
||||
return components.join('_').replaceAll('/', '_');
|
||||
}
|
||||
|
||||
/// 패턴 업데이트
|
||||
void _updatePattern(ErrorPattern pattern, FixResult fixResult) {
|
||||
// 성공한 수정 전략 추가 (중복 제거)
|
||||
final fixIds = pattern.successfulFixes.map((f) => f.fixId).toSet();
|
||||
for (final action in fixResult.executedActions) {
|
||||
if (!fixIds.contains(action.actionType)) {
|
||||
// 새로운 수정 전략 추가는 실제 FixSuggestion 객체가 필요하므로 생략
|
||||
}
|
||||
}
|
||||
|
||||
// 발생 횟수 및 신뢰도 업데이트
|
||||
final updatedPattern = ErrorPattern(
|
||||
patternId: pattern.patternId,
|
||||
errorType: pattern.errorType,
|
||||
matchingRules: pattern.matchingRules,
|
||||
successfulFixes: pattern.successfulFixes,
|
||||
occurrenceCount: pattern.occurrenceCount + 1,
|
||||
lastOccurred: DateTime.now(),
|
||||
confidence: _calculateUpdatedConfidence(pattern.confidence, pattern.occurrenceCount),
|
||||
);
|
||||
|
||||
_learnedPatterns[pattern.patternId] = updatedPattern;
|
||||
}
|
||||
|
||||
/// 새로운 패턴 생성
|
||||
void _createNewPattern(ApiError error, ErrorDiagnosis diagnosis, FixResult fixResult) {
|
||||
final patternId = _generatePatternId(error);
|
||||
|
||||
final pattern = ErrorPattern(
|
||||
patternId: patternId,
|
||||
errorType: diagnosis.type,
|
||||
matchingRules: {
|
||||
'statusCode': error.statusCode,
|
||||
'errorType': error.originalError?.type.toString() ?? 'unknown',
|
||||
'urlPattern': Uri.parse(error.requestUrl).path,
|
||||
if (error.responseBody != null && error.responseBody is Map)
|
||||
'messagePattern': _extractMessagePattern(error.responseBody),
|
||||
},
|
||||
successfulFixes: [], // 실제 구현에서는 fixResult로부터 생성
|
||||
occurrenceCount: 1,
|
||||
lastOccurred: DateTime.now(),
|
||||
confidence: 0.5,
|
||||
);
|
||||
|
||||
_learnedPatterns[patternId] = pattern;
|
||||
}
|
||||
|
||||
/// 메시지 패턴 추출
|
||||
String? _extractMessagePattern(dynamic responseBody) {
|
||||
if (responseBody is Map) {
|
||||
// 서버 에러 형식에 따른 메시지 추출
|
||||
if (responseBody['error'] != null && responseBody['error'] is Map) {
|
||||
final errorCode = responseBody['error']['code'];
|
||||
if (errorCode != null) {
|
||||
return errorCode.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (responseBody['message'] != null) {
|
||||
// 메시지에서 일반적인 패턴 추출
|
||||
final message = responseBody['message'].toString();
|
||||
if (message.contains('필수 필드')) {
|
||||
return 'VALIDATION_ERROR';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 업데이트된 신뢰도 계산
|
||||
double _calculateUpdatedConfidence(double currentConfidence, int occurrenceCount) {
|
||||
// 발생 횟수에 따라 신뢰도 증가
|
||||
final increment = 0.05 * (1.0 - currentConfidence);
|
||||
return (currentConfidence + increment).clamp(0.0, 0.95);
|
||||
}
|
||||
|
||||
/// ApiErrorType을 ErrorType으로 매핑
|
||||
ErrorType _mapApiErrorToErrorType(ApiErrorType apiErrorType) {
|
||||
switch (apiErrorType) {
|
||||
case ApiErrorType.authentication:
|
||||
return ErrorType.permissionDenied;
|
||||
case ApiErrorType.validation:
|
||||
return ErrorType.validation;
|
||||
case ApiErrorType.notFound:
|
||||
return ErrorType.invalidReference;
|
||||
case ApiErrorType.serverError:
|
||||
return ErrorType.serverError;
|
||||
case ApiErrorType.networkConnection:
|
||||
case ApiErrorType.timeout:
|
||||
return ErrorType.networkError;
|
||||
case ApiErrorType.rateLimit:
|
||||
case ApiErrorType.unknown:
|
||||
default:
|
||||
return ErrorType.unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 진단 규칙 인터페이스
|
||||
abstract class DiagnosticRule {
|
||||
bool canHandle(ApiError error);
|
||||
Future<ErrorDiagnosis> diagnose(ApiError error);
|
||||
}
|
||||
|
||||
/// 인증 진단 규칙
|
||||
class AuthenticationDiagnosticRule implements DiagnosticRule {
|
||||
@override
|
||||
bool canHandle(ApiError error) {
|
||||
return error.statusCode == 401 || error.statusCode == 403;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ErrorDiagnosis> diagnose(ApiError error) async {
|
||||
return ErrorDiagnosis(
|
||||
type: ApiErrorType.authentication,
|
||||
errorType: error.statusCode == 403 ? ErrorType.permissionDenied : ErrorType.unknown,
|
||||
description: '인증 실패: ${error.statusCode == 401 ? '인증 정보가 없거나 만료되었습니다' : '접근 권한이 없습니다'}',
|
||||
context: {
|
||||
'statusCode': error.statusCode,
|
||||
'endpoint': error.requestUrl,
|
||||
'method': error.requestMethod,
|
||||
},
|
||||
confidence: 0.95,
|
||||
affectedEndpoints: [error.requestUrl],
|
||||
serverErrorCode: _extractServerErrorCode(error.responseBody),
|
||||
originalMessage: error.originalError?.message,
|
||||
);
|
||||
}
|
||||
|
||||
String? _extractServerErrorCode(dynamic responseBody) {
|
||||
if (responseBody is Map) {
|
||||
if (responseBody['error'] is Map) {
|
||||
return responseBody['error']['code']?.toString();
|
||||
}
|
||||
return responseBody['code']?.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 유효성 검증 진단 규칙
|
||||
class ValidationDiagnosticRule implements DiagnosticRule {
|
||||
@override
|
||||
bool canHandle(ApiError error) {
|
||||
return error.statusCode == 400 || error.statusCode == 422;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ErrorDiagnosis> diagnose(ApiError error) async {
|
||||
final missingFields = _extractMissingFields(error.responseBody);
|
||||
final typeMismatches = _extractTypeMismatches(error.responseBody);
|
||||
|
||||
return ErrorDiagnosis(
|
||||
type: ApiErrorType.validation,
|
||||
errorType: ErrorType.validation,
|
||||
description: '데이터 유효성 검증 실패',
|
||||
context: {
|
||||
'statusCode': error.statusCode,
|
||||
'endpoint': error.requestUrl,
|
||||
'method': error.requestMethod,
|
||||
'requestBody': error.requestBody,
|
||||
},
|
||||
confidence: 0.9,
|
||||
affectedEndpoints: [error.requestUrl],
|
||||
serverErrorCode: _extractServerErrorCode(error.responseBody),
|
||||
missingFields: missingFields,
|
||||
typeMismatches: typeMismatches,
|
||||
originalMessage: error.originalError?.message,
|
||||
);
|
||||
}
|
||||
|
||||
List<String>? _extractMissingFields(dynamic responseBody) {
|
||||
if (responseBody is Map) {
|
||||
final error = responseBody['error'];
|
||||
if (error is Map && error['message'] != null) {
|
||||
final message = error['message'].toString();
|
||||
|
||||
// "필수 필드가 누락되었습니다: field1, field2" 형식 파싱
|
||||
if (message.contains('필수 필드가 누락되었습니다:')) {
|
||||
final fieldsStr = message.split(':').last.trim();
|
||||
return fieldsStr.split(',').map((f) => f.trim()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// validation_errors 필드 확인
|
||||
if (responseBody['validation_errors'] is Map) {
|
||||
final errors = responseBody['validation_errors'] as Map;
|
||||
return errors.keys.map((k) => k.toString()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, TypeMismatchInfo>? _extractTypeMismatches(dynamic responseBody) {
|
||||
// 실제 서버 응답에 따라 구현
|
||||
// 예시로 빈 맵 반환
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _extractServerErrorCode(dynamic responseBody) {
|
||||
if (responseBody is Map) {
|
||||
if (responseBody['error'] is Map) {
|
||||
return responseBody['error']['code']?.toString();
|
||||
}
|
||||
return responseBody['code']?.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 네트워크 진단 규칙
|
||||
class NetworkDiagnosticRule implements DiagnosticRule {
|
||||
@override
|
||||
bool canHandle(ApiError error) {
|
||||
return error.originalError?.type == DioExceptionType.connectionTimeout ||
|
||||
error.originalError?.type == DioExceptionType.sendTimeout ||
|
||||
error.originalError?.type == DioExceptionType.receiveTimeout ||
|
||||
error.originalError?.type == DioExceptionType.connectionError;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ErrorDiagnosis> diagnose(ApiError error) async {
|
||||
final errorType = error.originalError?.type;
|
||||
String description;
|
||||
|
||||
switch (errorType) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
description = '연결 시간 초과: 서버에 연결할 수 없습니다';
|
||||
break;
|
||||
case DioExceptionType.sendTimeout:
|
||||
description = '전송 시간 초과: 요청을 전송하는 중 시간이 초과되었습니다';
|
||||
break;
|
||||
case DioExceptionType.receiveTimeout:
|
||||
description = '수신 시간 초과: 응답을 받는 중 시간이 초과되었습니다';
|
||||
break;
|
||||
case DioExceptionType.connectionError:
|
||||
description = '연결 오류: 네트워크에 연결할 수 없습니다';
|
||||
break;
|
||||
default:
|
||||
description = '네트워크 오류가 발생했습니다';
|
||||
}
|
||||
|
||||
return ErrorDiagnosis(
|
||||
type: errorType == DioExceptionType.connectionError
|
||||
? ApiErrorType.networkConnection
|
||||
: ApiErrorType.timeout,
|
||||
errorType: ErrorType.networkError,
|
||||
description: description,
|
||||
context: {
|
||||
'errorType': errorType.toString(),
|
||||
'endpoint': error.requestUrl,
|
||||
'method': error.requestMethod,
|
||||
},
|
||||
confidence: 0.85,
|
||||
affectedEndpoints: [error.requestUrl],
|
||||
originalMessage: error.originalError?.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 서버 에러 진단 규칙
|
||||
class ServerErrorDiagnosticRule implements DiagnosticRule {
|
||||
@override
|
||||
bool canHandle(ApiError error) {
|
||||
final statusCode = error.statusCode ?? 0;
|
||||
return statusCode >= 500 && statusCode < 600;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ErrorDiagnosis> diagnose(ApiError error) async {
|
||||
return ErrorDiagnosis(
|
||||
type: ApiErrorType.serverError,
|
||||
errorType: ErrorType.serverError,
|
||||
description: '서버 내부 오류: 서버에서 요청을 처리하는 중 오류가 발생했습니다',
|
||||
context: {
|
||||
'statusCode': error.statusCode,
|
||||
'endpoint': error.requestUrl,
|
||||
'method': error.requestMethod,
|
||||
'serverMessage': _extractServerMessage(error.responseBody),
|
||||
},
|
||||
confidence: 0.8,
|
||||
affectedEndpoints: [error.requestUrl],
|
||||
serverErrorCode: _extractServerErrorCode(error.responseBody),
|
||||
originalMessage: error.originalError?.message,
|
||||
);
|
||||
}
|
||||
|
||||
String? _extractServerMessage(dynamic responseBody) {
|
||||
if (responseBody is Map) {
|
||||
return responseBody['message']?.toString() ??
|
||||
responseBody['error']?.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _extractServerErrorCode(dynamic responseBody) {
|
||||
if (responseBody is Map) {
|
||||
if (responseBody['error'] is Map) {
|
||||
return responseBody['error']['code']?.toString();
|
||||
}
|
||||
return responseBody['code']?.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Not Found 진단 규칙
|
||||
class NotFoundDiagnosticRule implements DiagnosticRule {
|
||||
@override
|
||||
bool canHandle(ApiError error) {
|
||||
return error.statusCode == 404;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ErrorDiagnosis> diagnose(ApiError error) async {
|
||||
return ErrorDiagnosis(
|
||||
type: ApiErrorType.notFound,
|
||||
errorType: ErrorType.unknown,
|
||||
description: '리소스를 찾을 수 없음: 요청한 리소스가 존재하지 않습니다',
|
||||
context: {
|
||||
'statusCode': error.statusCode,
|
||||
'endpoint': error.requestUrl,
|
||||
'method': error.requestMethod,
|
||||
'resourceId': _extractResourceId(error.requestUrl),
|
||||
},
|
||||
confidence: 0.95,
|
||||
affectedEndpoints: [error.requestUrl],
|
||||
originalMessage: error.originalError?.message,
|
||||
);
|
||||
}
|
||||
|
||||
String? _extractResourceId(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
final segments = uri.pathSegments;
|
||||
|
||||
// URL의 마지막 세그먼트가 숫자인 경우 ID로 간주
|
||||
if (segments.isNotEmpty) {
|
||||
final lastSegment = segments.last;
|
||||
if (int.tryParse(lastSegment) != null) {
|
||||
return lastSegment;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate Limit 진단 규칙
|
||||
class RateLimitDiagnosticRule implements DiagnosticRule {
|
||||
@override
|
||||
bool canHandle(ApiError error) {
|
||||
return error.statusCode == 429;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ErrorDiagnosis> diagnose(ApiError error) async {
|
||||
final retryAfter = _extractRetryAfter(error.originalError?.response?.headers);
|
||||
|
||||
return ErrorDiagnosis(
|
||||
type: ApiErrorType.rateLimit,
|
||||
errorType: ErrorType.unknown,
|
||||
description: '요청 제한 초과: API 호출 제한을 초과했습니다',
|
||||
context: {
|
||||
'statusCode': error.statusCode,
|
||||
'endpoint': error.requestUrl,
|
||||
'method': error.requestMethod,
|
||||
'retryAfter': retryAfter,
|
||||
},
|
||||
confidence: 0.95,
|
||||
affectedEndpoints: [error.requestUrl],
|
||||
originalMessage: error.originalError?.message,
|
||||
);
|
||||
}
|
||||
|
||||
int? _extractRetryAfter(Headers? headers) {
|
||||
if (headers == null) return null;
|
||||
|
||||
final retryAfter = headers.value('retry-after');
|
||||
if (retryAfter != null) {
|
||||
return int.tryParse(retryAfter);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
979
test/integration/automated/framework/core/auto_fixer.dart
Normal file
979
test/integration/automated/framework/core/auto_fixer.dart
Normal file
@@ -0,0 +1,979 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../models/error_models.dart';
|
||||
import 'api_error_diagnostics.dart';
|
||||
|
||||
/// API 에러 자동 수정 시스템
|
||||
class ApiAutoFixer {
|
||||
final ApiErrorDiagnostics diagnostics;
|
||||
final List<FixHistory> _fixHistory = [];
|
||||
final Map<String, dynamic> _learnedPatterns = {};
|
||||
final Random _random = Random();
|
||||
|
||||
// 자동 생성 규칙
|
||||
final Map<String, dynamic Function()> _defaultValueRules = {};
|
||||
final Map<String, Future<dynamic> Function()> _referenceDataGenerators = {};
|
||||
|
||||
ApiAutoFixer({
|
||||
ApiErrorDiagnostics? diagnostics,
|
||||
}) : diagnostics = diagnostics ?? ApiErrorDiagnostics() {
|
||||
_initializeRules();
|
||||
}
|
||||
|
||||
/// 기본값 및 참조 데이터 생성 규칙 초기화
|
||||
void _initializeRules() {
|
||||
// 기본값 규칙
|
||||
_defaultValueRules.addAll({
|
||||
'equipment_number': () => 'EQ-${DateTime.now().millisecondsSinceEpoch}',
|
||||
'manufacturer': () => '미지정',
|
||||
'username': () => 'user_${DateTime.now().millisecondsSinceEpoch}',
|
||||
'email': () => 'test_${DateTime.now().millisecondsSinceEpoch}@test.com',
|
||||
'password': () => 'Test1234!',
|
||||
'name': () => '테스트 ${DateTime.now().millisecondsSinceEpoch}',
|
||||
'status': () => 'I',
|
||||
'quantity': () => 1,
|
||||
'role': () => 'staff',
|
||||
'is_active': () => true,
|
||||
'created_at': () => DateTime.now().toIso8601String(),
|
||||
'updated_at': () => DateTime.now().toIso8601String(),
|
||||
});
|
||||
|
||||
// 참조 데이터 생성 규칙
|
||||
_referenceDataGenerators.addAll({
|
||||
'company_id': _generateOrFindCompany,
|
||||
'warehouse_id': _generateOrFindWarehouse,
|
||||
'user_id': _generateOrFindUser,
|
||||
'branch_id': _generateOrFindBranch,
|
||||
});
|
||||
}
|
||||
|
||||
/// ErrorDiagnosis를 받아 자동 수정 수행
|
||||
Future<FixResult> attemptAutoFix(ErrorDiagnosis diagnosis) async {
|
||||
// 1. 수정 제안 생성
|
||||
final suggestions = await diagnostics.suggestFixes(diagnosis);
|
||||
|
||||
// 2. 자동 수정 가능한 제안 필터링
|
||||
final autoFixableSuggestions = suggestions
|
||||
.where((s) => s.isAutoFixable)
|
||||
.toList()
|
||||
..sort((a, b) => b.successProbability.compareTo(a.successProbability));
|
||||
|
||||
if (autoFixableSuggestions.isEmpty) {
|
||||
return FixResult(
|
||||
fixId: 'no_autofix_available',
|
||||
success: false,
|
||||
executedActions: [],
|
||||
executedAt: DateTime.now(),
|
||||
duration: 0,
|
||||
error: 'No auto-fixable suggestions available',
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 성공 확률이 가장 높은 제안부터 시도
|
||||
for (final suggestion in autoFixableSuggestions) {
|
||||
final result = await _executeFix(suggestion, diagnosis);
|
||||
if (result.success) {
|
||||
// 4. 성공한 수정 패턴 학습
|
||||
await _learnFromSuccess(diagnosis, suggestion, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 시도 실패
|
||||
return FixResult(
|
||||
fixId: 'all_fixes_failed',
|
||||
success: false,
|
||||
executedActions: [],
|
||||
executedAt: DateTime.now(),
|
||||
duration: 0,
|
||||
error: 'All auto-fix attempts failed',
|
||||
);
|
||||
}
|
||||
|
||||
/// 수정 제안 실행
|
||||
Future<FixResult> _executeFix(FixSuggestion suggestion, ErrorDiagnosis diagnosis) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final executedActions = <FixAction>[];
|
||||
Map<String, dynamic>? beforeState;
|
||||
|
||||
try {
|
||||
// 수정 전 상태 저장
|
||||
beforeState = await _captureCurrentState();
|
||||
|
||||
// 각 수정 액션 실행
|
||||
for (final action in suggestion.actions) {
|
||||
final success = await _executeAction(action, diagnosis);
|
||||
if (success) {
|
||||
executedActions.add(action);
|
||||
} else {
|
||||
// 실패 시 롤백
|
||||
await _rollback(executedActions, beforeState);
|
||||
return FixResult(
|
||||
fixId: suggestion.fixId,
|
||||
success: false,
|
||||
executedActions: executedActions,
|
||||
executedAt: DateTime.now(),
|
||||
duration: stopwatch.elapsedMilliseconds,
|
||||
error: 'Failed to execute action: ${action.actionType}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 수정 후 검증
|
||||
final validationResult = await _validateFix(suggestion, diagnosis);
|
||||
if (!validationResult) {
|
||||
await _rollback(executedActions, beforeState);
|
||||
return FixResult(
|
||||
fixId: suggestion.fixId,
|
||||
success: false,
|
||||
executedActions: executedActions,
|
||||
executedAt: DateTime.now(),
|
||||
duration: stopwatch.elapsedMilliseconds,
|
||||
error: 'Fix validation failed',
|
||||
);
|
||||
}
|
||||
|
||||
stopwatch.stop();
|
||||
|
||||
final result = FixResult(
|
||||
fixId: suggestion.fixId,
|
||||
success: true,
|
||||
executedActions: executedActions,
|
||||
executedAt: DateTime.now(),
|
||||
duration: stopwatch.elapsedMilliseconds,
|
||||
additionalInfo: {
|
||||
'diagnosis': diagnosis.toJson(),
|
||||
'suggestion': suggestion.toJson(),
|
||||
},
|
||||
);
|
||||
|
||||
// 수정 이력 기록
|
||||
_recordFix(result, diagnosis);
|
||||
|
||||
return result;
|
||||
} catch (e, stackTrace) {
|
||||
// 오류 발생 시 롤백
|
||||
if (beforeState != null) {
|
||||
await _rollback(executedActions, beforeState);
|
||||
}
|
||||
|
||||
stopwatch.stop();
|
||||
|
||||
return FixResult(
|
||||
fixId: suggestion.fixId,
|
||||
success: false,
|
||||
executedActions: executedActions,
|
||||
executedAt: DateTime.now(),
|
||||
duration: stopwatch.elapsedMilliseconds,
|
||||
error: e.toString(),
|
||||
additionalInfo: {
|
||||
'stackTrace': stackTrace.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 수정 액션 실행
|
||||
Future<bool> _executeAction(FixAction action, ErrorDiagnosis diagnosis) async {
|
||||
try {
|
||||
switch (action.actionType) {
|
||||
case 'add_field':
|
||||
return await _addMissingField(action, diagnosis);
|
||||
case 'convert_type':
|
||||
return await _convertType(action, diagnosis);
|
||||
case 'generate_reference':
|
||||
return await _generateReferenceData(action, diagnosis);
|
||||
case 'refresh_token':
|
||||
return await _refreshToken(action);
|
||||
case 'retry_request':
|
||||
return await _retryRequest(action, diagnosis);
|
||||
case 'switch_endpoint':
|
||||
return await _switchEndpoint(action);
|
||||
case 'wait_and_retry':
|
||||
return await _waitAndRetry(action, diagnosis);
|
||||
case 'configure_throttling':
|
||||
return await _configureThrottling(action);
|
||||
default:
|
||||
// print('Unknown action type: ${action.actionType}');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
// print('Error executing action ${action.actionType}: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 필수 필드 추가
|
||||
Future<bool> _addMissingField(FixAction action, ErrorDiagnosis diagnosis) async {
|
||||
final field = action.parameters['field'] as String;
|
||||
final requestBody = await _getLastRequestBody();
|
||||
|
||||
if (requestBody == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 기본값 또는 자동 생성 값 추가
|
||||
final value = await _generateFieldValue(field);
|
||||
requestBody[field] = value;
|
||||
|
||||
// 수정된 요청 본문 저장
|
||||
await _updateRequestBody(requestBody);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 타입 변환 수행
|
||||
Future<bool> _convertType(FixAction action, ErrorDiagnosis diagnosis) async {
|
||||
final field = action.parameters['field'] as String;
|
||||
final fromType = action.parameters['fromType'] as String;
|
||||
final toType = action.parameters['toType'] as String;
|
||||
final value = action.parameters['value'];
|
||||
|
||||
final requestBody = await _getLastRequestBody();
|
||||
if (requestBody == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 타입 변환 수행
|
||||
final convertedValue = _performTypeConversion(value, fromType, toType);
|
||||
if (convertedValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
requestBody[field] = convertedValue;
|
||||
await _updateRequestBody(requestBody);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 참조 데이터 생성
|
||||
Future<bool> _generateReferenceData(FixAction action, ErrorDiagnosis diagnosis) async {
|
||||
final field = action.parameters['field'] as String;
|
||||
final requestBody = await _getLastRequestBody();
|
||||
|
||||
if (requestBody == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 참조 데이터 생성 또는 조회
|
||||
final generator = _referenceDataGenerators[field];
|
||||
if (generator == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final referenceId = await generator();
|
||||
requestBody[field] = referenceId;
|
||||
|
||||
await _updateRequestBody(requestBody);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 토큰 갱신
|
||||
Future<bool> _refreshToken(FixAction action) async {
|
||||
try {
|
||||
final authService = GetIt.instance<AuthService>();
|
||||
await authService.refreshToken();
|
||||
return true;
|
||||
} catch (e) {
|
||||
// print('Failed to refresh token: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 요청 재시도
|
||||
Future<bool> _retryRequest(FixAction action, ErrorDiagnosis diagnosis) async {
|
||||
final maxAttempts = action.parameters['maxAttempts'] as int? ?? 3;
|
||||
final backoffDelay = action.parameters['backoffDelay'] as int? ?? 1000;
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
// 마지막 실패한 요청 정보 가져오기
|
||||
final lastRequest = await _getLastFailedRequest();
|
||||
if (lastRequest == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 재시도 전 대기
|
||||
if (attempt > 1) {
|
||||
await Future.delayed(Duration(milliseconds: backoffDelay * attempt));
|
||||
}
|
||||
|
||||
// 요청 재시도
|
||||
final dio = GetIt.instance<Dio>();
|
||||
await dio.request(
|
||||
lastRequest['path'],
|
||||
options: Options(
|
||||
method: lastRequest['method'],
|
||||
headers: lastRequest['headers'],
|
||||
),
|
||||
data: lastRequest['data'],
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (attempt == maxAttempts) {
|
||||
// print('All retry attempts failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 엔드포인트 전환
|
||||
Future<bool> _switchEndpoint(FixAction action) async {
|
||||
try {
|
||||
final useFallback = action.parameters['useFallback'] as bool? ?? true;
|
||||
final apiService = GetIt.instance<ApiService>();
|
||||
|
||||
if (useFallback) {
|
||||
// 백업 서버로 전환
|
||||
await apiService.switchToFallbackServer();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
// print('Failed to switch endpoint: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 대기 후 재시도
|
||||
Future<bool> _waitAndRetry(FixAction action, ErrorDiagnosis diagnosis) async {
|
||||
final waitTime = action.parameters['waitTime'] as int? ?? 60000;
|
||||
|
||||
// 대기
|
||||
await Future.delayed(Duration(milliseconds: waitTime));
|
||||
|
||||
// 재시도
|
||||
return await _retryRequest(
|
||||
FixAction(
|
||||
type: FixActionType.retryWithDelay,
|
||||
actionType: 'retry_request',
|
||||
target: action.target,
|
||||
parameters: {'maxAttempts': 1},
|
||||
),
|
||||
diagnosis,
|
||||
);
|
||||
}
|
||||
|
||||
/// API 호출 제한 설정
|
||||
Future<bool> _configureThrottling(FixAction action) async {
|
||||
try {
|
||||
final maxRequestsPerMinute = action.parameters['maxRequestsPerMinute'] as int? ?? 30;
|
||||
final apiService = GetIt.instance<ApiService>();
|
||||
|
||||
// API 호출 제한 설정
|
||||
apiService.setRateLimit(maxRequestsPerMinute);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
// print('Failed to configure throttling: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 필드 값 생성
|
||||
Future<dynamic> _generateFieldValue(String field) async {
|
||||
// 필드명에 따른 기본값 생성
|
||||
final generator = _defaultValueRules[field];
|
||||
if (generator != null) {
|
||||
return generator();
|
||||
}
|
||||
|
||||
// 참조 데이터 생성
|
||||
final refGenerator = _referenceDataGenerators[field];
|
||||
if (refGenerator != null) {
|
||||
return await refGenerator();
|
||||
}
|
||||
|
||||
// 패턴 기반 기본값
|
||||
if (field.endsWith('_id')) return 1;
|
||||
if (field.endsWith('_date')) return DateTime.now().toIso8601String();
|
||||
if (field.endsWith('_time')) return DateTime.now().toIso8601String();
|
||||
if (field.endsWith('_count')) return 0;
|
||||
if (field.startsWith('is_')) return false;
|
||||
if (field.startsWith('has_')) return false;
|
||||
|
||||
// 기타 필드는 빈 문자열
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 타입 변환 수행
|
||||
dynamic _performTypeConversion(dynamic value, String fromType, String toType) {
|
||||
try {
|
||||
switch (toType.toLowerCase()) {
|
||||
case 'string':
|
||||
return value.toString();
|
||||
case 'int':
|
||||
case 'integer':
|
||||
if (value is String) {
|
||||
return int.tryParse(value) ?? 0;
|
||||
}
|
||||
return value is num ? value.toInt() : 0;
|
||||
case 'double':
|
||||
case 'float':
|
||||
if (value is String) {
|
||||
return double.tryParse(value) ?? 0.0;
|
||||
}
|
||||
return value is num ? value.toDouble() : 0.0;
|
||||
case 'bool':
|
||||
case 'boolean':
|
||||
if (value is String) {
|
||||
return value.toLowerCase() == 'true' || value == '1';
|
||||
}
|
||||
return value is bool ? value : false;
|
||||
case 'list':
|
||||
case 'array':
|
||||
if (value is! List) {
|
||||
return [value];
|
||||
}
|
||||
return value;
|
||||
case 'map':
|
||||
case 'object':
|
||||
if (value is String) {
|
||||
try {
|
||||
return jsonDecode(value);
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return value is Map ? value : {};
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
} catch (e) {
|
||||
// print('Type conversion failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 회사 생성 또는 조회
|
||||
Future<int> _generateOrFindCompany() async {
|
||||
try {
|
||||
// 기존 테스트 회사 조회
|
||||
final existingCompany = await _findTestCompany();
|
||||
if (existingCompany != null) {
|
||||
return existingCompany['id'];
|
||||
}
|
||||
|
||||
// 새로운 테스트 회사 생성
|
||||
final companyData = _generateCompanyData();
|
||||
final response = await _createEntity('/api/companies', companyData);
|
||||
return response['id'];
|
||||
} catch (e) {
|
||||
// print('Failed to generate company: $e');
|
||||
return 1; // 기본값
|
||||
}
|
||||
}
|
||||
|
||||
/// 창고 생성 또는 조회
|
||||
Future<int> _generateOrFindWarehouse() async {
|
||||
try {
|
||||
// 기존 테스트 창고 조회
|
||||
final existingWarehouse = await _findTestWarehouse();
|
||||
if (existingWarehouse != null) {
|
||||
return existingWarehouse['id'];
|
||||
}
|
||||
|
||||
// 새로운 테스트 창고 생성
|
||||
final warehouseData = _generateWarehouseData();
|
||||
final response = await _createEntity('/api/warehouses', warehouseData);
|
||||
return response['id'];
|
||||
} catch (e) {
|
||||
// print('Failed to generate warehouse: $e');
|
||||
return 1; // 기본값
|
||||
}
|
||||
}
|
||||
|
||||
/// 사용자 생성 또는 조회
|
||||
Future<int> _generateOrFindUser() async {
|
||||
try {
|
||||
// 기존 테스트 사용자 조회
|
||||
final existingUser = await _findTestUser();
|
||||
if (existingUser != null) {
|
||||
return existingUser['id'];
|
||||
}
|
||||
|
||||
// 새로운 테스트 사용자 생성
|
||||
final companyId = await _generateOrFindCompany();
|
||||
final userData = _generateUserData(companyId);
|
||||
final response = await _createEntity('/api/users', userData);
|
||||
return response['id'];
|
||||
} catch (e) {
|
||||
// print('Failed to generate user: $e');
|
||||
return 1; // 기본값
|
||||
}
|
||||
}
|
||||
|
||||
/// 지점 생성 또는 조회
|
||||
Future<int> _generateOrFindBranch() async {
|
||||
try {
|
||||
// 기존 테스트 지점 조회
|
||||
final existingBranch = await _findTestBranch();
|
||||
if (existingBranch != null) {
|
||||
return existingBranch['id'];
|
||||
}
|
||||
|
||||
// 새로운 테스트 지점 생성
|
||||
final companyId = await _generateOrFindCompany();
|
||||
final branchData = {
|
||||
'company_id': companyId,
|
||||
'name': '테스트 지점 ${DateTime.now().millisecondsSinceEpoch}',
|
||||
'address': '서울시 강남구',
|
||||
};
|
||||
final response = await _createEntity('/api/branches', branchData);
|
||||
return response['id'];
|
||||
} catch (e) {
|
||||
// print('Failed to generate branch: $e');
|
||||
return 1; // 기본값
|
||||
}
|
||||
}
|
||||
|
||||
/// 수정 검증
|
||||
Future<bool> _validateFix(FixSuggestion suggestion, ErrorDiagnosis diagnosis) async {
|
||||
try {
|
||||
// 수정 타입별 검증
|
||||
switch (suggestion.type) {
|
||||
case FixType.addMissingField:
|
||||
// 필수 필드가 추가되었는지 확인
|
||||
final requestBody = await _getLastRequestBody();
|
||||
if (requestBody is Map<String, dynamic>) {
|
||||
for (final field in diagnosis.missingFields ?? []) {
|
||||
if (!requestBody.containsKey(field)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
case FixType.convertType:
|
||||
// 타입이 올바르게 변환되었는지 확인
|
||||
return true;
|
||||
|
||||
case FixType.refreshToken:
|
||||
// 토큰이 유효한지 확인
|
||||
try {
|
||||
final authService = GetIt.instance<AuthService>();
|
||||
return await authService.hasValidToken();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
case FixType.retry:
|
||||
// 재시도가 성공했는지는 액션 실행 결과로 판단
|
||||
return true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// print('Validation failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 롤백 수행
|
||||
Future<void> _rollback(List<FixAction> executedActions, Map<String, dynamic> beforeState) async {
|
||||
try {
|
||||
// 실행된 액션을 역순으로 되돌리기
|
||||
for (final action in executedActions.reversed) {
|
||||
await _rollbackAction(action, beforeState);
|
||||
}
|
||||
|
||||
// 롤백 기록
|
||||
_fixHistory.add(FixHistory(
|
||||
fixResult: FixResult(
|
||||
fixId: 'rollback_${DateTime.now().millisecondsSinceEpoch}',
|
||||
success: true,
|
||||
executedActions: executedActions,
|
||||
executedAt: DateTime.now(),
|
||||
duration: 0,
|
||||
),
|
||||
action: FixHistoryAction.rollback,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
} catch (e) {
|
||||
// print('Rollback failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 개별 액션 롤백
|
||||
Future<void> _rollbackAction(FixAction action, Map<String, dynamic> beforeState) async {
|
||||
switch (action.actionType) {
|
||||
case 'switch_endpoint':
|
||||
// 원래 엔드포인트로 복원
|
||||
try {
|
||||
final apiService = GetIt.instance<ApiService>();
|
||||
await apiService.switchToPrimaryServer();
|
||||
} catch (_) {}
|
||||
break;
|
||||
case 'configure_throttling':
|
||||
// 원래 제한 설정으로 복원
|
||||
try {
|
||||
final apiService = GetIt.instance<ApiService>();
|
||||
apiService.resetRateLimit();
|
||||
} catch (_) {}
|
||||
break;
|
||||
default:
|
||||
// 대부분의 변경사항은 자동으로 롤백되거나 롤백이 불필요
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 수정 이력 기록
|
||||
void _recordFix(FixResult result, ErrorDiagnosis diagnosis) {
|
||||
_fixHistory.add(FixHistory(
|
||||
fixResult: result,
|
||||
action: result.success ? FixHistoryAction.applied : FixHistoryAction.failed,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
|
||||
// 성공한 수정 패턴 추가
|
||||
if (result.success) {
|
||||
final patternKey = '${diagnosis.type}_${result.fixId}';
|
||||
_learnedPatterns[patternKey] = {
|
||||
'diagnosis': diagnosis.toJson(),
|
||||
'fixId': result.fixId,
|
||||
'successCount': (_learnedPatterns[patternKey]?['successCount'] ?? 0) + 1,
|
||||
'lastSuccess': DateTime.now().toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 성공한 수정으로부터 학습
|
||||
Future<void> _learnFromSuccess(ErrorDiagnosis diagnosis, FixSuggestion suggestion, FixResult result) async {
|
||||
// 성공한 수정 전략을 저장하여 다음에 더 높은 우선순위 부여
|
||||
final patternKey = _generatePatternKey(diagnosis);
|
||||
_learnedPatterns[patternKey] = {
|
||||
'diagnosis': diagnosis.toJson(),
|
||||
'suggestion': suggestion.toJson(),
|
||||
'result': result.toJson(),
|
||||
'successCount': (_learnedPatterns[patternKey]?['successCount'] ?? 0) + 1,
|
||||
'confidence': suggestion.successProbability,
|
||||
'lastSuccess': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
// 진단 시스템에도 학습 결과 전달
|
||||
final apiError = ApiError(
|
||||
originalError: DioException(
|
||||
requestOptions: RequestOptions(path: diagnosis.affectedEndpoints.first),
|
||||
type: DioExceptionType.unknown,
|
||||
),
|
||||
requestUrl: diagnosis.affectedEndpoints.first,
|
||||
requestMethod: 'UNKNOWN',
|
||||
);
|
||||
|
||||
await diagnostics.learnFromError(apiError, result);
|
||||
}
|
||||
|
||||
/// 패턴 키 생성
|
||||
String _generatePatternKey(ErrorDiagnosis diagnosis) {
|
||||
final components = [
|
||||
diagnosis.type.toString(),
|
||||
diagnosis.serverErrorCode ?? 'no_code',
|
||||
diagnosis.missingFields?.join('_') ?? 'no_fields',
|
||||
];
|
||||
return components.join('::');
|
||||
}
|
||||
|
||||
/// 현재 상태 캡처
|
||||
Future<Map<String, dynamic>> _captureCurrentState() async {
|
||||
final state = <String, dynamic>{
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
try {
|
||||
// 인증 상태
|
||||
final authService = GetIt.instance<AuthService>();
|
||||
state['auth'] = {
|
||||
'isAuthenticated': await authService.isAuthenticated(),
|
||||
'hasValidToken': await authService.hasValidToken(),
|
||||
};
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
// API 설정 상태
|
||||
final apiService = GetIt.instance<ApiService>();
|
||||
state['api'] = {
|
||||
'baseUrl': apiService.baseUrl,
|
||||
'rateLimit': apiService.currentRateLimit,
|
||||
};
|
||||
} catch (_) {}
|
||||
|
||||
// 마지막 요청 정보
|
||||
state['lastRequest'] = await _getLastFailedRequest();
|
||||
state['lastRequestBody'] = await _getLastRequestBody();
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// 마지막 실패한 요청 정보 가져오기
|
||||
Future<Map<String, dynamic>?> _getLastFailedRequest() async {
|
||||
// 실제 구현에서는 테스트 컨텍스트나 전역 상태에서 가져와야 함
|
||||
// 여기서는 예시로 빈 맵 반환
|
||||
return {
|
||||
'path': '/api/test',
|
||||
'method': 'POST',
|
||||
'headers': {},
|
||||
'data': {},
|
||||
};
|
||||
}
|
||||
|
||||
/// 마지막 요청 본문 가져오기
|
||||
Future<Map<String, dynamic>?> _getLastRequestBody() async {
|
||||
// 실제 구현에서는 테스트 컨텍스트나 전역 상태에서 가져와야 함
|
||||
return {};
|
||||
}
|
||||
|
||||
/// 요청 본문 업데이트
|
||||
Future<void> _updateRequestBody(Map<String, dynamic> body) async {
|
||||
// 실제 구현에서는 테스트 컨텍스트나 전역 상태에 저장해야 함
|
||||
}
|
||||
|
||||
/// 테스트 회사 조회
|
||||
Future<Map<String, dynamic>?> _findTestCompany() async {
|
||||
try {
|
||||
final dio = GetIt.instance<Dio>();
|
||||
final response = await dio.get('/api/companies', queryParameters: {
|
||||
'name': '테스트',
|
||||
'limit': 1,
|
||||
});
|
||||
|
||||
if (response.data is Map && response.data['items'] is List) {
|
||||
final items = response.data['items'] as List;
|
||||
return items.isNotEmpty ? items.first : null;
|
||||
}
|
||||
} catch (e) {
|
||||
// print('Failed to find test company: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 테스트 창고 조회
|
||||
Future<Map<String, dynamic>?> _findTestWarehouse() async {
|
||||
try {
|
||||
final dio = GetIt.instance<Dio>();
|
||||
final response = await dio.get('/api/warehouses', queryParameters: {
|
||||
'name': '테스트',
|
||||
'limit': 1,
|
||||
});
|
||||
|
||||
if (response.data is Map && response.data['items'] is List) {
|
||||
final items = response.data['items'] as List;
|
||||
return items.isNotEmpty ? items.first : null;
|
||||
}
|
||||
} catch (e) {
|
||||
// print('Failed to find test warehouse: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 테스트 사용자 조회
|
||||
Future<Map<String, dynamic>?> _findTestUser() async {
|
||||
try {
|
||||
final dio = GetIt.instance<Dio>();
|
||||
final response = await dio.get('/api/users', queryParameters: {
|
||||
'username': 'test',
|
||||
'limit': 1,
|
||||
});
|
||||
|
||||
if (response.data is Map && response.data['items'] is List) {
|
||||
final items = response.data['items'] as List;
|
||||
return items.isNotEmpty ? items.first : null;
|
||||
}
|
||||
} catch (e) {
|
||||
// print('Failed to find test user: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 테스트 지점 조회
|
||||
Future<Map<String, dynamic>?> _findTestBranch() async {
|
||||
try {
|
||||
final dio = GetIt.instance<Dio>();
|
||||
final response = await dio.get('/api/branches', queryParameters: {
|
||||
'name': '테스트',
|
||||
'limit': 1,
|
||||
});
|
||||
|
||||
if (response.data is Map && response.data['items'] is List) {
|
||||
final items = response.data['items'] as List;
|
||||
return items.isNotEmpty ? items.first : null;
|
||||
}
|
||||
} catch (e) {
|
||||
// print('Failed to find test branch: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 엔티티 생성
|
||||
Future<Map<String, dynamic>> _createEntity(String endpoint, Map<String, dynamic> data) async {
|
||||
final dio = GetIt.instance<Dio>();
|
||||
final response = await dio.post(endpoint, data: data);
|
||||
|
||||
if (response.data is Map) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw Exception('Invalid response format');
|
||||
}
|
||||
|
||||
/// 수정 이력 조회
|
||||
List<FixHistory> getFixHistory() => List.unmodifiable(_fixHistory);
|
||||
|
||||
/// 성공한 수정 통계
|
||||
Map<String, dynamic> getSuccessStatistics() {
|
||||
final totalFixes = _fixHistory.length;
|
||||
final successfulFixes = _fixHistory.where((h) =>
|
||||
h.action == FixHistoryAction.applied && h.fixResult.success
|
||||
).length;
|
||||
|
||||
final fixTypeStats = <String, int>{};
|
||||
for (final history in _fixHistory) {
|
||||
if (history.fixResult.success) {
|
||||
fixTypeStats[history.fixResult.fixId] =
|
||||
(fixTypeStats[history.fixResult.fixId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'totalAttempts': totalFixes,
|
||||
'successfulFixes': successfulFixes,
|
||||
'successRate': totalFixes > 0 ? successfulFixes / totalFixes : 0,
|
||||
'fixTypeStats': fixTypeStats,
|
||||
'averageFixDuration': _calculateAverageFixDuration(),
|
||||
'learnedPatterns': _learnedPatterns.length,
|
||||
};
|
||||
}
|
||||
|
||||
/// 평균 수정 시간 계산
|
||||
Duration _calculateAverageFixDuration() {
|
||||
if (_fixHistory.isEmpty) return Duration.zero;
|
||||
|
||||
final totalMilliseconds = _fixHistory
|
||||
.map((h) => h.fixResult.duration)
|
||||
.reduce((a, b) => a + b);
|
||||
|
||||
return Duration(milliseconds: totalMilliseconds ~/ _fixHistory.length);
|
||||
}
|
||||
|
||||
/// 학습된 패턴 기반 수정 제안 우선순위 조정
|
||||
List<FixSuggestion> prioritizeSuggestions(List<FixSuggestion> suggestions, ErrorDiagnosis diagnosis) {
|
||||
final patternKey = _generatePatternKey(diagnosis);
|
||||
final learnedPattern = _learnedPatterns[patternKey];
|
||||
|
||||
if (learnedPattern != null && learnedPattern['successCount'] > 0) {
|
||||
// 학습된 패턴이 있으면 해당 제안의 우선순위 높이기
|
||||
final successfulFixId = learnedPattern['suggestion']?['fixId'];
|
||||
suggestions.sort((a, b) {
|
||||
if (a.fixId == successfulFixId) return -1;
|
||||
if (b.fixId == successfulFixId) return 1;
|
||||
return b.successProbability.compareTo(a.successProbability);
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// API 에러 자동 수정 팩토리
|
||||
class ApiAutoFixerFactory {
|
||||
static ApiAutoFixer create() {
|
||||
return ApiAutoFixer();
|
||||
}
|
||||
|
||||
static ApiAutoFixer createWithDependencies({
|
||||
ApiErrorDiagnostics? diagnostics,
|
||||
}) {
|
||||
return ApiAutoFixer(
|
||||
diagnostics: diagnostics,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 수정 이력
|
||||
class FixHistory {
|
||||
final FixResult fixResult;
|
||||
final FixHistoryAction action;
|
||||
final DateTime timestamp;
|
||||
|
||||
FixHistory({
|
||||
required this.fixResult,
|
||||
required this.action,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'fixResult': fixResult.toJson(),
|
||||
'action': action.toString(),
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 수정 이력 액션
|
||||
enum FixHistoryAction {
|
||||
applied,
|
||||
failed,
|
||||
rollback,
|
||||
}
|
||||
|
||||
/// API 서비스 인터페이스 (예시)
|
||||
abstract class ApiService {
|
||||
String get baseUrl;
|
||||
int get currentRateLimit;
|
||||
|
||||
Future<void> switchToFallbackServer();
|
||||
Future<void> switchToPrimaryServer();
|
||||
void setRateLimit(int requestsPerMinute);
|
||||
void resetRateLimit();
|
||||
}
|
||||
|
||||
/// 인증 서비스 인터페이스 (예시)
|
||||
abstract class AuthService {
|
||||
Future<bool> isAuthenticated();
|
||||
Future<bool> hasValidToken();
|
||||
Future<void> refreshToken();
|
||||
}
|
||||
|
||||
// 테스트 데이터 생성 헬퍼 메서드 추가
|
||||
extension ApiAutoFixerDataGenerators on ApiAutoFixer {
|
||||
Map<String, dynamic> _generateCompanyData() {
|
||||
return {
|
||||
'name': '테스트 회사 ${DateTime.now().millisecondsSinceEpoch}',
|
||||
'business_number': '${_random.nextInt(999)}-${_random.nextInt(99)}-${_random.nextInt(99999)}',
|
||||
'phone': '02-${_random.nextInt(9999)}-${_random.nextInt(9999)}',
|
||||
'address': {
|
||||
'zip_code': '${_random.nextInt(99999)}',
|
||||
'region': '서울시',
|
||||
'detail_address': '테스트로 ${_random.nextInt(999)}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _generateWarehouseData() {
|
||||
return {
|
||||
'name': '테스트 창고 ${DateTime.now().millisecondsSinceEpoch}',
|
||||
'location': '서울시 강남구',
|
||||
'capacity': 1000,
|
||||
'manager': '테스트 매니저',
|
||||
'contact': '010-${_random.nextInt(9999)}-${_random.nextInt(9999)}',
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _generateUserData(int companyId) {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
return {
|
||||
'company_id': companyId,
|
||||
'username': 'test_user_$timestamp',
|
||||
'email': 'test_$timestamp@test.com',
|
||||
'password': 'Test1234!',
|
||||
'name': '테스트 사용자',
|
||||
'role': 'staff',
|
||||
'phone': '010-${_random.nextInt(9999)}-${_random.nextInt(9999)}',
|
||||
};
|
||||
}
|
||||
}
|
||||
332
test/integration/automated/framework/core/auto_test_system.dart
Normal file
332
test/integration/automated/framework/core/auto_test_system.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:superport/data/datasources/remote/api_client.dart';
|
||||
import 'api_error_diagnostics.dart';
|
||||
import 'auto_fixer.dart';
|
||||
import 'test_data_generator.dart';
|
||||
import 'test_auth_service.dart';
|
||||
import '../models/error_models.dart';
|
||||
import '../infrastructure/report_collector.dart';
|
||||
|
||||
/// 자동 테스트 및 수정 시스템
|
||||
///
|
||||
/// 화면별로 모든 기능을 자동으로 테스트하고,
|
||||
/// 에러 발생 시 자동으로 수정하는 시스템
|
||||
class AutoTestSystem {
|
||||
final ApiClient apiClient;
|
||||
final GetIt getIt;
|
||||
final ApiErrorDiagnostics errorDiagnostics;
|
||||
final ApiAutoFixer autoFixer;
|
||||
final TestDataGenerator dataGenerator;
|
||||
final ReportCollector reportCollector;
|
||||
late TestAuthService _testAuthService;
|
||||
|
||||
static const String _testEmail = 'admin@superport.kr';
|
||||
static const String _testPassword = 'admin123!';
|
||||
|
||||
bool _isLoggedIn = false;
|
||||
String? _accessToken;
|
||||
|
||||
AutoTestSystem({
|
||||
required this.apiClient,
|
||||
required this.getIt,
|
||||
required this.errorDiagnostics,
|
||||
required this.autoFixer,
|
||||
required this.dataGenerator,
|
||||
required this.reportCollector,
|
||||
}) {
|
||||
_testAuthService = TestAuthHelper.getInstance(apiClient);
|
||||
}
|
||||
|
||||
/// 테스트 시작 전 로그인
|
||||
Future<void> ensureAuthenticated() async {
|
||||
if (_isLoggedIn && _accessToken != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// print('[AutoTestSystem] 인증 시작...');
|
||||
|
||||
try {
|
||||
final loginResponse = await _testAuthService.login(_testEmail, _testPassword);
|
||||
|
||||
_accessToken = loginResponse.accessToken;
|
||||
_isLoggedIn = true;
|
||||
|
||||
// print('[AutoTestSystem] 로그인 성공!');
|
||||
// print('[AutoTestSystem] 사용자: ${loginResponse.user.email}');
|
||||
// print('[AutoTestSystem] 역할: ${loginResponse.user.role}');
|
||||
} catch (e) {
|
||||
// print('[AutoTestSystem] 로그인 에러: $e');
|
||||
throw Exception('인증 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 테스트 실행 및 자동 수정
|
||||
Future<TestResult> runTestWithAutoFix({
|
||||
required String testName,
|
||||
required String screenName,
|
||||
required Future<void> Function() testFunction,
|
||||
int maxRetries = 3,
|
||||
}) async {
|
||||
// print('\n[AutoTestSystem] 테스트 시작: $testName');
|
||||
|
||||
// 인증 확인
|
||||
await ensureAuthenticated();
|
||||
|
||||
int retryCount = 0;
|
||||
Exception? lastError;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
// 테스트 실행
|
||||
await testFunction();
|
||||
|
||||
// print('[AutoTestSystem] ✅ 테스트 성공: $testName');
|
||||
|
||||
// 성공 리포트
|
||||
reportCollector.addTestResult(
|
||||
screenName: screenName,
|
||||
testName: testName,
|
||||
passed: true,
|
||||
);
|
||||
|
||||
return TestResult(
|
||||
testName: testName,
|
||||
passed: true,
|
||||
retryCount: retryCount,
|
||||
);
|
||||
} catch (e) {
|
||||
// Exception이나 AssertionError 모두 처리
|
||||
if (e is Exception) {
|
||||
lastError = e;
|
||||
} else if (e is AssertionError) {
|
||||
lastError = Exception('Assertion failed: ${e.message}');
|
||||
} else {
|
||||
lastError = Exception('Test failed: $e');
|
||||
}
|
||||
retryCount++;
|
||||
|
||||
// print('[AutoTestSystem] ❌ 테스트 실패 (시도 $retryCount/$maxRetries): $e');
|
||||
|
||||
// 에러 분석 및 수정 시도
|
||||
if (retryCount < maxRetries) {
|
||||
final fixed = await _tryAutoFix(testName, screenName, e);
|
||||
|
||||
if (!fixed) {
|
||||
break; // 수정 불가능한 에러
|
||||
}
|
||||
|
||||
// 재시도 전 대기
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 실패 리포트
|
||||
reportCollector.addTestResult(
|
||||
screenName: screenName,
|
||||
testName: testName,
|
||||
passed: false,
|
||||
error: lastError.toString(),
|
||||
);
|
||||
|
||||
return TestResult(
|
||||
testName: testName,
|
||||
passed: false,
|
||||
error: lastError?.toString(),
|
||||
retryCount: retryCount,
|
||||
);
|
||||
}
|
||||
|
||||
/// 에러 자동 수정 시도
|
||||
Future<bool> _tryAutoFix(String testName, String screenName, dynamic error) async {
|
||||
// print('[AutoTestSystem] 자동 수정 시도 중...');
|
||||
|
||||
try {
|
||||
if (error is DioException) {
|
||||
// API 에러를 ApiError로 변환
|
||||
final apiError = ApiError(
|
||||
statusCode: error.response?.statusCode,
|
||||
requestUrl: error.requestOptions.uri.toString(),
|
||||
requestMethod: error.requestOptions.method,
|
||||
requestBody: error.requestOptions.data,
|
||||
responseBody: error.response?.data,
|
||||
originalError: error,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
// API 에러 진단
|
||||
final diagnosis = await errorDiagnostics.diagnose(apiError);
|
||||
|
||||
switch (diagnosis.type) {
|
||||
case ApiErrorType.authentication:
|
||||
// 인증 에러 - 재로그인
|
||||
// print('[AutoTestSystem] 인증 에러 감지 - 재로그인 시도');
|
||||
_isLoggedIn = false;
|
||||
_accessToken = null;
|
||||
await ensureAuthenticated();
|
||||
return true;
|
||||
|
||||
case ApiErrorType.validation:
|
||||
// 검증 에러 - 데이터 수정
|
||||
// print('[AutoTestSystem] 검증 에러 감지 - 데이터 수정 시도');
|
||||
final validationErrors = _extractValidationErrors(error);
|
||||
if (validationErrors.isNotEmpty) {
|
||||
// print('[AutoTestSystem] 검증 에러 필드: ${validationErrors.keys.join(', ')}');
|
||||
// 여기서 데이터 수정 로직 구현
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
case ApiErrorType.notFound:
|
||||
// 리소스 없음 - 생성 필요
|
||||
// print('[AutoTestSystem] 리소스 없음 - 생성 시도');
|
||||
// 여기서 필요한 리소스 생성 로직 구현
|
||||
return true;
|
||||
|
||||
case ApiErrorType.serverError:
|
||||
// 서버 에러 - 재시도
|
||||
// print('[AutoTestSystem] 서버 에러 - 재시도 대기');
|
||||
await Future.delayed(Duration(seconds: 2));
|
||||
return true;
|
||||
|
||||
default:
|
||||
// print('[AutoTestSystem] 수정 불가능한 에러: ${diagnosis.type}');
|
||||
return false;
|
||||
}
|
||||
} else if (error.toString().contains('필수')) {
|
||||
// 필수 필드 누락 에러
|
||||
// print('[AutoTestSystem] 필수 필드 누락 - 기본값 생성');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
// print('[AutoTestSystem] 자동 수정 실패: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 검증 에러 추출
|
||||
Map<String, List<String>> _extractValidationErrors(DioException error) {
|
||||
try {
|
||||
final responseData = error.response?.data;
|
||||
if (responseData is Map && responseData['errors'] is Map) {
|
||||
return Map<String, List<String>>.from(
|
||||
responseData['errors'].map((key, value) => MapEntry(
|
||||
key.toString(),
|
||||
value is List ? value.map((e) => e.toString()).toList() : [value.toString()],
|
||||
)),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
return {};
|
||||
}
|
||||
|
||||
/// 테스트 데이터 자동 생성
|
||||
Future<Map<String, dynamic>> generateTestData(String dataType) async {
|
||||
switch (dataType) {
|
||||
case 'equipment':
|
||||
return await _generateEquipmentData();
|
||||
case 'company':
|
||||
return await _generateCompanyData();
|
||||
case 'warehouse':
|
||||
return await _generateWarehouseData();
|
||||
case 'user':
|
||||
return await _generateUserData();
|
||||
case 'license':
|
||||
return await _generateLicenseData();
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _generateEquipmentData() async {
|
||||
final serialNumber = dataGenerator.generateSerialNumber();
|
||||
return {
|
||||
'equipment_number': 'EQ-${dataGenerator.generateId()}', // 필수 필드
|
||||
'serial_number': serialNumber,
|
||||
'manufacturer': dataGenerator.generateCompanyName(),
|
||||
'model_name': dataGenerator.generateEquipmentName(), // model_name으로 변경
|
||||
'status': 'available',
|
||||
'category': 'Material Handling',
|
||||
'current_company_id': 1, // current_company_id로 변경
|
||||
'warehouse_location_id': 1, // 실제 창고 ID로 교체 필요
|
||||
'purchase_date': DateTime.now().toIso8601String().split('T')[0],
|
||||
'purchase_price': dataGenerator.generatePrice(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _generateCompanyData() async {
|
||||
return {
|
||||
'name': dataGenerator.generateCompanyName(),
|
||||
'code': 'COMP-${dataGenerator.generateId()}',
|
||||
'business_type': 'Manufacturing',
|
||||
'registration_number': TestDataGenerator.generateBusinessNumber(),
|
||||
'representative_name': dataGenerator.generatePersonName(),
|
||||
'phone': TestDataGenerator.generatePhoneNumber(),
|
||||
'email': dataGenerator.generateEmail(),
|
||||
'is_active': true,
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _generateWarehouseData() async {
|
||||
return {
|
||||
'name': 'Warehouse ${dataGenerator.generateId()}',
|
||||
'code': 'WH-${dataGenerator.generateId()}',
|
||||
'address_line1': dataGenerator.generateAddress(),
|
||||
'city': '서울시',
|
||||
'state_province': '서울',
|
||||
'postal_code': dataGenerator.generatePostalCode(),
|
||||
'country': 'Korea',
|
||||
'capacity': 10000,
|
||||
'manager_name': dataGenerator.generatePersonName(),
|
||||
'contact_phone': TestDataGenerator.generatePhoneNumber(),
|
||||
'is_active': true,
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _generateUserData() async {
|
||||
return {
|
||||
'email': dataGenerator.generateEmail(),
|
||||
'username': dataGenerator.generateUsername(),
|
||||
'password': 'Test1234!',
|
||||
'first_name': dataGenerator.generatePersonName().split(' ')[0],
|
||||
'last_name': dataGenerator.generatePersonName().split(' ')[1],
|
||||
'role': 'staff',
|
||||
'company_id': 1, // 실제 회사 ID로 교체 필요
|
||||
'department': 'IT',
|
||||
'phone': TestDataGenerator.generatePhoneNumber(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _generateLicenseData() async {
|
||||
return {
|
||||
'license_key': dataGenerator.generateLicenseKey(),
|
||||
'software_name': dataGenerator.generateSoftwareName(),
|
||||
'license_type': 'subscription',
|
||||
'seats': 10,
|
||||
'company_id': 1, // 실제 회사 ID로 교체 필요
|
||||
'purchase_date': DateTime.now().toIso8601String().split('T')[0],
|
||||
'expiry_date': DateTime.now().add(Duration(days: 365)).toIso8601String().split('T')[0],
|
||||
'cost': dataGenerator.generatePrice(),
|
||||
'vendor': dataGenerator.generateCompanyName(),
|
||||
'is_active': true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 테스트 결과
|
||||
class TestResult {
|
||||
final String testName;
|
||||
final bool passed;
|
||||
final String? error;
|
||||
final int retryCount;
|
||||
|
||||
TestResult({
|
||||
required this.testName,
|
||||
required this.passed,
|
||||
this.error,
|
||||
this.retryCount = 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import '../models/test_models.dart' as test_models;
|
||||
import '../models/error_models.dart';
|
||||
import '../models/report_models.dart' as report_models;
|
||||
import '../infrastructure/test_context.dart';
|
||||
import '../infrastructure/report_collector.dart';
|
||||
import 'api_error_diagnostics.dart';
|
||||
import 'auto_fixer.dart' as auto_fixer;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'test_data_generator.dart';
|
||||
|
||||
/// 화면 테스트 프레임워크의 추상 클래스
|
||||
abstract class ScreenTestFramework {
|
||||
final TestContext testContext;
|
||||
final ApiErrorDiagnostics errorDiagnostics;
|
||||
final auto_fixer.ApiAutoFixer autoFixer;
|
||||
final TestDataGenerator dataGenerator;
|
||||
final ReportCollector reportCollector;
|
||||
|
||||
ScreenTestFramework({
|
||||
required this.testContext,
|
||||
required this.errorDiagnostics,
|
||||
required this.autoFixer,
|
||||
required this.dataGenerator,
|
||||
required this.reportCollector,
|
||||
});
|
||||
|
||||
/// 화면의 테스트 가능한 기능들을 자동으로 감지
|
||||
Future<List<test_models.TestableFeature>> detectFeatures(test_models.ScreenMetadata metadata) async {
|
||||
final features = <test_models.TestableFeature>[];
|
||||
|
||||
// CRUD 작업 감지
|
||||
if (metadata.screenCapabilities.containsKey('crud')) {
|
||||
features.add(_createCrudFeature(metadata));
|
||||
}
|
||||
|
||||
// 검색 기능 감지
|
||||
if (metadata.screenCapabilities.containsKey('search')) {
|
||||
features.add(_createSearchFeature(metadata));
|
||||
}
|
||||
|
||||
// 필터링 기능 감지
|
||||
if (metadata.screenCapabilities.containsKey('filter')) {
|
||||
features.add(_createFilterFeature(metadata));
|
||||
}
|
||||
|
||||
// 페이지네이션 감지
|
||||
if (metadata.screenCapabilities.containsKey('pagination')) {
|
||||
features.add(_createPaginationFeature(metadata));
|
||||
}
|
||||
|
||||
// 커스텀 기능 감지 (하위 클래스에서 구현)
|
||||
features.addAll(await detectCustomFeatures(metadata));
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/// 하위 클래스에서 구현할 커스텀 기능 감지
|
||||
Future<List<test_models.TestableFeature>> detectCustomFeatures(test_models.ScreenMetadata metadata);
|
||||
|
||||
/// 테스트 실행
|
||||
Future<report_models.TestResult> executeTests(List<test_models.TestableFeature> features) async {
|
||||
// report_models.TestResult 생성
|
||||
int totalTests = 0;
|
||||
int passedTests = 0;
|
||||
int failedTests = 0;
|
||||
int skippedTests = 0;
|
||||
final failures = <report_models.TestFailure>[];
|
||||
|
||||
final testResult = test_models.TestResult(
|
||||
screenName: testContext.currentScreen ?? 'unknown',
|
||||
startTime: DateTime.now(),
|
||||
);
|
||||
|
||||
for (final feature in features) {
|
||||
try {
|
||||
final featureResult = await _executeFeatureTests(feature);
|
||||
testResult.featureResults.add(featureResult);
|
||||
} catch (error, stackTrace) {
|
||||
final testError = test_models.TestError(
|
||||
message: error.toString(),
|
||||
stackTrace: stackTrace,
|
||||
feature: feature.featureName,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
// 에러 처리 시도
|
||||
await handleError(testError);
|
||||
testResult.errors.add(testError);
|
||||
}
|
||||
}
|
||||
|
||||
testResult.endTime = DateTime.now();
|
||||
testResult.calculateMetrics();
|
||||
|
||||
// 메트릭 계산
|
||||
for (final featureResult in testResult.featureResults) {
|
||||
for (final testCaseResult in featureResult.testCaseResults) {
|
||||
totalTests++;
|
||||
if (testCaseResult.success) {
|
||||
passedTests++;
|
||||
} else {
|
||||
failedTests++;
|
||||
failures.add(report_models.TestFailure(
|
||||
feature: featureResult.featureName,
|
||||
message: testCaseResult.error ?? 'Unknown error',
|
||||
stackTrace: testCaseResult.stackTrace?.toString(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// report_models.TestResult 반환
|
||||
return report_models.TestResult(
|
||||
totalTests: totalTests,
|
||||
passedTests: passedTests,
|
||||
failedTests: failedTests,
|
||||
skippedTests: skippedTests,
|
||||
failures: failures,
|
||||
);
|
||||
}
|
||||
|
||||
/// 에러 처리
|
||||
Future<void> handleError(test_models.TestError error) async {
|
||||
// 에러 진단
|
||||
final diagnosis = await errorDiagnostics.diagnose(
|
||||
ApiError(
|
||||
originalError: DioException(
|
||||
requestOptions: RequestOptions(path: '/test'),
|
||||
message: error.message,
|
||||
stackTrace: error.stackTrace,
|
||||
),
|
||||
requestUrl: '/test',
|
||||
requestMethod: 'TEST',
|
||||
message: error.message,
|
||||
),
|
||||
);
|
||||
|
||||
// 자동 수정 시도
|
||||
if (diagnosis.confidence > 0.7) {
|
||||
final suggestions = await errorDiagnostics.suggestFixes(
|
||||
diagnosis,
|
||||
);
|
||||
|
||||
if (suggestions.isNotEmpty) {
|
||||
final fixResult = await autoFixer.attemptAutoFix(ErrorDiagnosis(
|
||||
type: ApiErrorType.unknown,
|
||||
errorType: ErrorType.unknown,
|
||||
description: error.message,
|
||||
context: {},
|
||||
confidence: 0.8,
|
||||
affectedEndpoints: [],
|
||||
));
|
||||
|
||||
if (fixResult.success) {
|
||||
// TODO: Fix 결과 기록 로직 구현 필요
|
||||
// testContext.recordFix(fixResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 리포트 생성
|
||||
Future<report_models.TestReport> generateReport() async {
|
||||
final basicReport = reportCollector.generateReport();
|
||||
|
||||
// BasicTestReport를 TestReport로 변환
|
||||
return report_models.TestReport(
|
||||
reportId: basicReport.reportId,
|
||||
generatedAt: basicReport.endTime,
|
||||
type: report_models.ReportType.full,
|
||||
screenReports: [],
|
||||
summary: report_models.TestSummary(
|
||||
totalScreens: 1,
|
||||
totalFeatures: basicReport.features.length,
|
||||
totalTestCases: basicReport.testResult.totalTests,
|
||||
passedTestCases: basicReport.testResult.passedTests,
|
||||
failedTestCases: basicReport.testResult.failedTests,
|
||||
skippedTestCases: basicReport.testResult.skippedTests,
|
||||
totalDuration: basicReport.duration,
|
||||
overallSuccessRate: basicReport.testResult.passedTests /
|
||||
(basicReport.testResult.totalTests > 0 ? basicReport.testResult.totalTests : 1),
|
||||
startTime: basicReport.startTime,
|
||||
endTime: basicReport.endTime,
|
||||
),
|
||||
errorAnalyses: [],
|
||||
performanceMetrics: [],
|
||||
metadata: basicReport.environment,
|
||||
);
|
||||
}
|
||||
|
||||
/// 개별 기능 테스트 실행
|
||||
Future<test_models.FeatureTestResult> _executeFeatureTests(test_models.TestableFeature feature) async {
|
||||
final result = test_models.FeatureTestResult(
|
||||
featureName: feature.featureName,
|
||||
startTime: DateTime.now(),
|
||||
);
|
||||
|
||||
// 테스트 데이터 생성
|
||||
final testData = await dataGenerator.generate(
|
||||
test_models.GenerationStrategy(
|
||||
dataType: feature.requiredDataType ?? Object,
|
||||
fields: [],
|
||||
relationships: [],
|
||||
constraints: feature.dataConstraints ?? {},
|
||||
quantity: feature.testCases.length,
|
||||
),
|
||||
);
|
||||
|
||||
// 각 테스트 케이스 실행
|
||||
for (final testCase in feature.testCases) {
|
||||
final caseResult = await _executeTestCase(testCase, testData);
|
||||
result.testCaseResults.add(caseResult);
|
||||
}
|
||||
|
||||
result.endTime = DateTime.now();
|
||||
result.calculateMetrics();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 개별 테스트 케이스 실행
|
||||
Future<test_models.TestCaseResult> _executeTestCase(
|
||||
test_models.TestCase testCase,
|
||||
test_models.TestData testData,
|
||||
) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
// 전처리
|
||||
await testCase.setup?.call(testData);
|
||||
|
||||
// 테스트 실행
|
||||
await testCase.execute(testData);
|
||||
|
||||
// 검증
|
||||
await testCase.verify(testData);
|
||||
|
||||
stopwatch.stop();
|
||||
|
||||
return test_models.TestCaseResult(
|
||||
testCaseName: testCase.name,
|
||||
success: true,
|
||||
duration: stopwatch.elapsed,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
stopwatch.stop();
|
||||
|
||||
return test_models.TestCaseResult(
|
||||
testCaseName: testCase.name,
|
||||
success: false,
|
||||
duration: stopwatch.elapsed,
|
||||
error: error.toString(),
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
} finally {
|
||||
// 후처리
|
||||
await testCase.teardown?.call(testData);
|
||||
}
|
||||
}
|
||||
|
||||
/// CRUD 기능 생성
|
||||
test_models.TestableFeature _createCrudFeature(test_models.ScreenMetadata metadata) {
|
||||
return test_models.TestableFeature(
|
||||
featureName: 'CRUD Operations',
|
||||
type: test_models.FeatureType.crud,
|
||||
testCases: [
|
||||
test_models.TestCase(
|
||||
name: 'Create',
|
||||
execute: (data) async {
|
||||
// 생성 로직은 하위 클래스에서 구현
|
||||
await performCreate(data);
|
||||
},
|
||||
verify: (data) async {
|
||||
// 생성 검증 로직
|
||||
await verifyCreate(data);
|
||||
},
|
||||
),
|
||||
test_models.TestCase(
|
||||
name: 'Read',
|
||||
execute: (data) async {
|
||||
await performRead(data);
|
||||
},
|
||||
verify: (data) async {
|
||||
await verifyRead(data);
|
||||
},
|
||||
),
|
||||
test_models.TestCase(
|
||||
name: 'Update',
|
||||
execute: (data) async {
|
||||
await performUpdate(data);
|
||||
},
|
||||
verify: (data) async {
|
||||
await verifyUpdate(data);
|
||||
},
|
||||
),
|
||||
test_models.TestCase(
|
||||
name: 'Delete',
|
||||
execute: (data) async {
|
||||
await performDelete(data);
|
||||
},
|
||||
verify: (data) async {
|
||||
await verifyDelete(data);
|
||||
},
|
||||
),
|
||||
],
|
||||
metadata: metadata.screenCapabilities['crud'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// 검색 기능 생성
|
||||
test_models.TestableFeature _createSearchFeature(test_models.ScreenMetadata metadata) {
|
||||
return test_models.TestableFeature(
|
||||
featureName: 'Search',
|
||||
type: test_models.FeatureType.search,
|
||||
testCases: [
|
||||
test_models.TestCase(
|
||||
name: 'Search by keyword',
|
||||
execute: (data) async {
|
||||
await performSearch(data);
|
||||
},
|
||||
verify: (data) async {
|
||||
await verifySearch(data);
|
||||
},
|
||||
),
|
||||
],
|
||||
metadata: metadata.screenCapabilities['search'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// 필터 기능 생성
|
||||
test_models.TestableFeature _createFilterFeature(test_models.ScreenMetadata metadata) {
|
||||
return test_models.TestableFeature(
|
||||
featureName: 'Filter',
|
||||
type: test_models.FeatureType.filter,
|
||||
testCases: [
|
||||
test_models.TestCase(
|
||||
name: 'Apply filters',
|
||||
execute: (data) async {
|
||||
await performFilter(data);
|
||||
},
|
||||
verify: (data) async {
|
||||
await verifyFilter(data);
|
||||
},
|
||||
),
|
||||
],
|
||||
metadata: metadata.screenCapabilities['filter'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// 페이지네이션 기능 생성
|
||||
test_models.TestableFeature _createPaginationFeature(test_models.ScreenMetadata metadata) {
|
||||
return test_models.TestableFeature(
|
||||
featureName: 'Pagination',
|
||||
type: test_models.FeatureType.pagination,
|
||||
testCases: [
|
||||
test_models.TestCase(
|
||||
name: 'Navigate pages',
|
||||
execute: (data) async {
|
||||
await performPagination(data);
|
||||
},
|
||||
verify: (data) async {
|
||||
await verifyPagination(data);
|
||||
},
|
||||
),
|
||||
],
|
||||
metadata: metadata.screenCapabilities['pagination'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
// 하위 클래스에서 구현해야 할 추상 메서드들
|
||||
Future<void> performCreate(test_models.TestData data);
|
||||
Future<void> verifyCreate(test_models.TestData data);
|
||||
Future<void> performRead(test_models.TestData data);
|
||||
Future<void> verifyRead(test_models.TestData data);
|
||||
Future<void> performUpdate(test_models.TestData data);
|
||||
Future<void> verifyUpdate(test_models.TestData data);
|
||||
Future<void> performDelete(test_models.TestData data);
|
||||
Future<void> verifyDelete(test_models.TestData data);
|
||||
Future<void> performSearch(test_models.TestData data);
|
||||
Future<void> verifySearch(test_models.TestData data);
|
||||
Future<void> performFilter(test_models.TestData data);
|
||||
Future<void> verifyFilter(test_models.TestData data);
|
||||
Future<void> performPagination(test_models.TestData data);
|
||||
Future<void> verifyPagination(test_models.TestData data);
|
||||
}
|
||||
|
||||
/// 화면 테스트 프레임워크의 구체적인 구현
|
||||
class ConcreteScreenTestFramework extends ScreenTestFramework {
|
||||
ConcreteScreenTestFramework({
|
||||
required super.testContext,
|
||||
required super.errorDiagnostics,
|
||||
required super.autoFixer,
|
||||
required super.dataGenerator,
|
||||
required super.reportCollector,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<test_models.TestableFeature>> detectCustomFeatures(test_models.ScreenMetadata metadata) async {
|
||||
// 화면별 커스텀 기능 감지 로직
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performCreate(test_models.TestData data) async {
|
||||
// 구체적인 생성 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> verifyCreate(test_models.TestData data) async {
|
||||
// 구체적인 생성 검증 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performRead(test_models.TestData data) async {
|
||||
// 구체적인 읽기 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> verifyRead(test_models.TestData data) async {
|
||||
// 구체적인 읽기 검증 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performUpdate(test_models.TestData data) async {
|
||||
// 구체적인 수정 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> verifyUpdate(test_models.TestData data) async {
|
||||
// 구체적인 수정 검증 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performDelete(test_models.TestData data) async {
|
||||
// 구체적인 삭제 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> verifyDelete(test_models.TestData data) async {
|
||||
// 구체적인 삭제 검증 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performSearch(test_models.TestData data) async {
|
||||
// 구체적인 검색 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> verifySearch(test_models.TestData data) async {
|
||||
// 구체적인 검색 검증 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performFilter(test_models.TestData data) async {
|
||||
// 구체적인 필터 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> verifyFilter(test_models.TestData data) async {
|
||||
// 구체적인 필터 검증 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performPagination(test_models.TestData data) async {
|
||||
// 구체적인 페이지네이션 로직 구현
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> verifyPagination(test_models.TestData data) async {
|
||||
// 구체적인 페이지네이션 검증 로직 구현
|
||||
}
|
||||
}
|
||||
131
test/integration/automated/framework/core/test_auth_service.dart
Normal file
131
test/integration/automated/framework/core/test_auth_service.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:superport/data/datasources/remote/api_client.dart';
|
||||
import 'package:superport/data/models/auth/login_response.dart';
|
||||
import 'package:superport/data/models/auth/auth_user.dart';
|
||||
|
||||
/// 테스트용 인증 서비스
|
||||
///
|
||||
/// FlutterSecureStorage를 사용하지 않고 메모리에 토큰을 저장합니다.
|
||||
class TestAuthService {
|
||||
final ApiClient apiClient;
|
||||
|
||||
String? _accessToken;
|
||||
String? _refreshToken;
|
||||
AuthUser? _currentUser;
|
||||
|
||||
TestAuthService({
|
||||
required this.apiClient,
|
||||
});
|
||||
|
||||
String? get accessToken => _accessToken;
|
||||
String? get refreshToken => _refreshToken;
|
||||
AuthUser? get currentUser => _currentUser;
|
||||
|
||||
/// 로그인
|
||||
Future<LoginResponse> login(String email, String password) async {
|
||||
// print('[TestAuthService] 로그인 시도: $email');
|
||||
|
||||
try {
|
||||
final response = await apiClient.dio.post(
|
||||
'/auth/login',
|
||||
data: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data['success'] == true) {
|
||||
final data = response.data['data'];
|
||||
|
||||
// 토큰 저장 (언더스코어 형식)
|
||||
_accessToken = data['access_token'];
|
||||
_refreshToken = data['refresh_token'];
|
||||
|
||||
// 사용자 정보 저장
|
||||
_currentUser = AuthUser(
|
||||
id: data['user']['id'],
|
||||
username: data['user']['username'] ?? '',
|
||||
email: data['user']['email'],
|
||||
name: data['user']['name'] ?? '',
|
||||
role: data['user']['role'] ?? 'staff',
|
||||
);
|
||||
|
||||
// API 클라이언트에 토큰 설정
|
||||
apiClient.updateAuthToken(_accessToken!);
|
||||
|
||||
// print('[TestAuthService] 로그인 성공!');
|
||||
// print('[TestAuthService] - User: ${_currentUser?.email}');
|
||||
// print('[TestAuthService] - Role: ${_currentUser?.role}');
|
||||
|
||||
// LoginResponse 반환
|
||||
return LoginResponse(
|
||||
accessToken: _accessToken!,
|
||||
refreshToken: _refreshToken!,
|
||||
tokenType: data['token_type'] ?? 'Bearer',
|
||||
expiresIn: data['expires_in'] ?? 3600,
|
||||
user: _currentUser!,
|
||||
);
|
||||
} else {
|
||||
throw Exception('로그인 실패: ${response.data['error']?['message'] ?? '알 수 없는 오류'}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
// print('[TestAuthService] DioException: ${e.type}');
|
||||
if (e.response != null) {
|
||||
// print('[TestAuthService] Response: ${e.response?.data}');
|
||||
throw Exception('로그인 실패: ${e.response?.data['error']?['message'] ?? e.message}');
|
||||
}
|
||||
throw Exception('로그인 실패: 네트워크 오류');
|
||||
} catch (e) {
|
||||
// print('[TestAuthService] 예외 발생: $e');
|
||||
throw Exception('로그인 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그아웃
|
||||
Future<void> logout() async {
|
||||
_accessToken = null;
|
||||
_refreshToken = null;
|
||||
_currentUser = null;
|
||||
apiClient.removeAuthToken();
|
||||
}
|
||||
|
||||
/// 토큰 갱신
|
||||
Future<void> refreshAccessToken() async {
|
||||
if (_refreshToken == null) {
|
||||
throw Exception('리프레시 토큰이 없습니다');
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await apiClient.dio.post(
|
||||
'/auth/refresh',
|
||||
data: {
|
||||
'refreshToken': _refreshToken,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_accessToken = response.data['data']['accessToken'];
|
||||
_refreshToken = response.data['data']['refreshToken'];
|
||||
|
||||
// 새 토큰으로 업데이트
|
||||
apiClient.updateAuthToken(_accessToken!);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('토큰 갱신 실패: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 테스트용 인증 헬퍼
|
||||
class TestAuthHelper {
|
||||
static TestAuthService? _instance;
|
||||
|
||||
static TestAuthService getInstance(ApiClient apiClient) {
|
||||
_instance ??= TestAuthService(apiClient: apiClient);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static void clearInstance() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
import 'dart:math';
|
||||
import 'package:superport/data/models/user/user_dto.dart';
|
||||
import 'package:superport/data/models/equipment/equipment_request.dart';
|
||||
import 'package:superport/data/models/license/license_request_dto.dart';
|
||||
import 'package:superport/data/models/warehouse/warehouse_dto.dart';
|
||||
import 'package:superport/models/company_model.dart';
|
||||
import 'package:superport/models/user_model.dart';
|
||||
import 'package:superport/models/equipment_unified_model.dart';
|
||||
import 'package:superport/models/warehouse_location_model.dart';
|
||||
import 'package:superport/models/license_model.dart';
|
||||
import 'package:superport/models/address_model.dart';
|
||||
import 'package:superport/services/company_service.dart';
|
||||
import 'package:superport/services/user_service.dart';
|
||||
import 'package:superport/services/equipment_service.dart';
|
||||
import 'package:superport/services/license_service.dart';
|
||||
import 'package:superport/services/warehouse_service.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../models/test_models.dart';
|
||||
|
||||
/// 스마트한 테스트 데이터 생성기
|
||||
///
|
||||
/// 기존 TestDataHelper를 확장하여 더 현실적이고 관계성 있는 테스트 데이터를 생성합니다.
|
||||
class TestDataGenerator {
|
||||
static final Random _random = Random();
|
||||
static int _uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// 캐싱을 위한 맵
|
||||
static final Map<String, dynamic> _cache = {};
|
||||
static final List<int> _createdCompanyIds = [];
|
||||
static final List<int> _createdUserIds = [];
|
||||
static final List<int> _createdWarehouseIds = [];
|
||||
static final List<int> _createdEquipmentIds = [];
|
||||
static final List<int> _createdLicenseIds = [];
|
||||
|
||||
// 실제 데이터 풀
|
||||
static const List<String> _realCompanyNames = [
|
||||
'테크솔루션',
|
||||
'디지털컴퍼니',
|
||||
'스마트시스템즈',
|
||||
'클라우드테크',
|
||||
'데이터브릭스',
|
||||
'소프트웨어파크',
|
||||
'IT이노베이션',
|
||||
'퓨처테크놀로지',
|
||||
];
|
||||
|
||||
static const List<String> _realManufacturers = [
|
||||
'삼성전자',
|
||||
'LG전자',
|
||||
'Apple',
|
||||
'Dell',
|
||||
'HP',
|
||||
'Lenovo',
|
||||
'Microsoft',
|
||||
'ASUS',
|
||||
];
|
||||
|
||||
static const Map<String, List<String>> _realProductModels = {
|
||||
'삼성전자': ['Galaxy Book Pro', 'Galaxy Book Pro 360', 'Odyssey G9', 'ViewFinity S9'],
|
||||
'LG전자': ['Gram 17', 'Gram 16', 'UltraGear 27GN950', 'UltraFine 32UN880'],
|
||||
'Apple': ['MacBook Pro 16"', 'MacBook Air M2', 'iMac 24"', 'Mac Studio'],
|
||||
'Dell': ['XPS 13', 'XPS 15', 'Latitude 7420', 'OptiPlex 7090'],
|
||||
'HP': ['Spectre x360', 'EliteBook 840', 'ZBook Studio', 'ProBook 450'],
|
||||
'Lenovo': ['ThinkPad X1 Carbon', 'ThinkPad T14s', 'IdeaPad 5', 'Legion 5'],
|
||||
'Microsoft': ['Surface Laptop 5', 'Surface Pro 9', 'Surface Studio 2+'],
|
||||
'ASUS': ['ZenBook 14', 'ROG Zephyrus G14', 'ProArt StudioBook'],
|
||||
};
|
||||
|
||||
static const List<String> _categories = [
|
||||
'노트북',
|
||||
'데스크탑',
|
||||
'모니터',
|
||||
'프린터',
|
||||
'네트워크장비',
|
||||
'서버',
|
||||
'태블릿',
|
||||
'스캐너',
|
||||
];
|
||||
|
||||
static const List<String> _warehouseNames = [
|
||||
'메인창고',
|
||||
'서브창고A',
|
||||
'서브창고B',
|
||||
'임시보관소',
|
||||
'수리센터',
|
||||
'대여센터',
|
||||
];
|
||||
|
||||
static const List<String> _softwareProducts = [
|
||||
'Microsoft Office 365',
|
||||
'Adobe Creative Cloud',
|
||||
'AutoCAD 2024',
|
||||
'Windows 11 Pro',
|
||||
'Visual Studio Enterprise',
|
||||
'JetBrains All Products',
|
||||
'Slack Business+',
|
||||
'Zoom Business',
|
||||
];
|
||||
|
||||
static const List<String> _departments = [
|
||||
'개발팀',
|
||||
'디자인팀',
|
||||
'영업팀',
|
||||
'인사팀',
|
||||
'재무팀',
|
||||
'마케팅팀',
|
||||
'운영팀',
|
||||
];
|
||||
|
||||
static const List<String> _positions = [
|
||||
'팀장',
|
||||
'매니저',
|
||||
'선임',
|
||||
'주임',
|
||||
'사원',
|
||||
'인턴',
|
||||
];
|
||||
|
||||
// 유틸리티 메서드
|
||||
static String generateUniqueId() {
|
||||
return '${_uniqueId++}';
|
||||
}
|
||||
|
||||
static String generateUniqueEmail({String? domain}) {
|
||||
domain ??= 'test.com';
|
||||
return 'test_${generateUniqueId()}@$domain';
|
||||
}
|
||||
|
||||
static String generateUniqueName(String prefix) {
|
||||
return '${prefix}_${generateUniqueId()}';
|
||||
}
|
||||
|
||||
static String generatePhoneNumber() {
|
||||
final area = ['010', '011', '016', '017', '018', '019'][_random.nextInt(6)];
|
||||
final middle = 1000 + _random.nextInt(9000);
|
||||
final last = 1000 + _random.nextInt(9000);
|
||||
return '$area-$middle-$last';
|
||||
}
|
||||
|
||||
static String generateBusinessNumber() {
|
||||
final first = 100 + _random.nextInt(900);
|
||||
final second = 10 + _random.nextInt(90);
|
||||
final third = 10000 + _random.nextInt(90000);
|
||||
return '$first-$second-$third';
|
||||
}
|
||||
|
||||
static T getRandomElement<T>(List<T> list) {
|
||||
return list[_random.nextInt(list.length)];
|
||||
}
|
||||
|
||||
// 추가 메서드들 (인스턴스 메서드로 변경)
|
||||
String generateId() => generateUniqueId();
|
||||
|
||||
String generateSerialNumber() {
|
||||
final prefix = ['SN', 'EQ', 'IT'][_random.nextInt(3)];
|
||||
final year = DateTime.now().year;
|
||||
final number = _random.nextInt(999999).toString().padLeft(6, '0');
|
||||
return '$prefix-$year-$number';
|
||||
}
|
||||
|
||||
String generateEquipmentName() {
|
||||
final manufacturers = ['삼성', 'LG', 'Dell', 'HP', 'Lenovo'];
|
||||
final types = ['노트북', '데스크탑', '모니터', '프린터', '서버'];
|
||||
final models = ['Pro', 'Elite', 'Plus', 'Standard', 'Premium'];
|
||||
return '${getRandomElement(manufacturers)} ${getRandomElement(types)} ${getRandomElement(models)}';
|
||||
}
|
||||
|
||||
String generateCompanyName() {
|
||||
final prefixes = ['테크', '디지털', '스마트', '글로벌', '퓨처'];
|
||||
final suffixes = ['솔루션', '시스템', '컴퍼니', '테크놀로지', '이노베이션'];
|
||||
return '${getRandomElement(prefixes)}${getRandomElement(suffixes)} ${generateUniqueId()}';
|
||||
}
|
||||
|
||||
double generatePrice({double min = 100000, double max = 10000000}) {
|
||||
return min + (_random.nextDouble() * (max - min));
|
||||
}
|
||||
|
||||
String generateAddress() {
|
||||
final cities = ['서울시', '부산시', '대구시', '인천시', '광주시'];
|
||||
final districts = ['강남구', '서초구', '송파구', '마포구', '중구'];
|
||||
final streets = ['테헤란로', '강남대로', '디지털로', '한강대로', '올림픽로'];
|
||||
final number = _random.nextInt(500) + 1;
|
||||
return '${getRandomElement(cities)} ${getRandomElement(districts)} ${getRandomElement(streets)} $number';
|
||||
}
|
||||
|
||||
String generatePostalCode() {
|
||||
return '${10000 + _random.nextInt(90000)}';
|
||||
}
|
||||
|
||||
String generatePersonName() {
|
||||
final lastNames = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];
|
||||
final firstNames = ['민수', '영희', '철수', '영미', '준호', '지은', '성민', '수진', '현우', '민지'];
|
||||
return '${getRandomElement(lastNames)}${getRandomElement(firstNames)}';
|
||||
}
|
||||
|
||||
String generateEmail() => generateUniqueEmail();
|
||||
|
||||
String generateUsername() {
|
||||
final adjectives = ['smart', 'clever', 'quick', 'bright', 'sharp'];
|
||||
final nouns = ['user', 'admin', 'manager', 'developer', 'designer'];
|
||||
return '${getRandomElement(adjectives)}_${getRandomElement(nouns)}_${generateUniqueId()}';
|
||||
}
|
||||
|
||||
String generateLicenseKey() {
|
||||
final segments = [];
|
||||
for (int i = 0; i < 4; i++) {
|
||||
final segment = _random.nextInt(9999).toString().padLeft(4, '0').toUpperCase();
|
||||
segments.add(segment);
|
||||
}
|
||||
return segments.join('-');
|
||||
}
|
||||
|
||||
String generateSoftwareName() {
|
||||
return getRandomElement(_softwareProducts);
|
||||
}
|
||||
|
||||
// 회사 데이터 생성
|
||||
static Company createSmartCompanyData({
|
||||
String? name,
|
||||
List<CompanyType>? companyTypes,
|
||||
}) {
|
||||
name ??= '${getRandomElement(_realCompanyNames)} ${generateUniqueId()}';
|
||||
|
||||
return Company(
|
||||
name: name,
|
||||
address: Address(
|
||||
region: '서울시 강남구',
|
||||
detailAddress: '테헤란로 ${100 + _random.nextInt(400)}',
|
||||
),
|
||||
contactName: '홍길동',
|
||||
contactPosition: '대표이사',
|
||||
contactPhone: generatePhoneNumber(),
|
||||
contactEmail: generateUniqueEmail(domain: 'company.com'),
|
||||
companyTypes: companyTypes ?? [CompanyType.customer],
|
||||
remark: '테스트 회사 - ${DateTime.now().toIso8601String()}',
|
||||
);
|
||||
}
|
||||
|
||||
// 사용자 데이터 생성
|
||||
static CreateUserRequest createSmartUserData({
|
||||
required int companyId,
|
||||
int? branchId,
|
||||
String? role,
|
||||
String? department,
|
||||
String? position,
|
||||
}) {
|
||||
final firstName = ['김', '이', '박', '최', '정', '강', '조', '윤'][_random.nextInt(8)];
|
||||
final lastName = ['민수', '영희', '철수', '영미', '준호', '지은', '성민', '수진'][_random.nextInt(8)];
|
||||
final fullName = '$firstName$lastName';
|
||||
|
||||
department ??= getRandomElement(_departments);
|
||||
position ??= getRandomElement(_positions);
|
||||
|
||||
return CreateUserRequest(
|
||||
username: 'user_${generateUniqueId()}',
|
||||
email: generateUniqueEmail(domain: 'company.com'),
|
||||
password: 'Test1234!@',
|
||||
name: fullName,
|
||||
phone: generatePhoneNumber(),
|
||||
role: role ?? 'staff',
|
||||
companyId: companyId,
|
||||
branchId: branchId,
|
||||
);
|
||||
}
|
||||
|
||||
// 장비 데이터 생성
|
||||
static CreateEquipmentRequest createSmartEquipmentData({
|
||||
required int companyId,
|
||||
required int warehouseLocationId,
|
||||
String? category,
|
||||
String? manufacturer,
|
||||
String? status,
|
||||
}) {
|
||||
final String actualManufacturer = manufacturer ?? getRandomElement(_realManufacturers);
|
||||
final models = _realProductModels[actualManufacturer] ?? ['Standard Model'];
|
||||
final model = getRandomElement(models);
|
||||
final String actualCategory = category ?? getRandomElement(_categories);
|
||||
|
||||
final serialNumber = '${actualManufacturer.length >= 2 ? actualManufacturer.substring(0, 2).toUpperCase() : actualManufacturer.toUpperCase()}'
|
||||
'${DateTime.now().year}'
|
||||
'${_random.nextInt(1000000).toString().padLeft(6, '0')}';
|
||||
|
||||
return CreateEquipmentRequest(
|
||||
equipmentNumber: 'EQ-${generateUniqueId()}',
|
||||
category1: actualCategory,
|
||||
category2: _getCategoryDetail(actualCategory),
|
||||
manufacturer: actualManufacturer,
|
||||
modelName: model,
|
||||
serialNumber: serialNumber,
|
||||
purchaseDate: DateTime.now().subtract(Duration(days: _random.nextInt(365))),
|
||||
purchasePrice: _getRealisticPrice(actualCategory),
|
||||
remark: '테스트 장비 - $model',
|
||||
);
|
||||
}
|
||||
|
||||
// 라이선스 데이터 생성
|
||||
static CreateLicenseRequest createSmartLicenseData({
|
||||
required int companyId,
|
||||
int? branchId,
|
||||
String? productName,
|
||||
String? licenseType,
|
||||
}) {
|
||||
productName ??= getRandomElement(_softwareProducts);
|
||||
final vendor = _getVendorFromProduct(productName!);
|
||||
|
||||
return CreateLicenseRequest(
|
||||
licenseKey: 'LIC-${generateUniqueId()}-${_random.nextInt(9999).toString().padLeft(4, '0')}',
|
||||
productName: productName,
|
||||
vendor: vendor,
|
||||
licenseType: licenseType ?? 'subscription',
|
||||
userCount: [1, 5, 10, 25, 50, 100][_random.nextInt(6)],
|
||||
purchaseDate: DateTime.now().subtract(Duration(days: _random.nextInt(180))),
|
||||
expiryDate: DateTime.now().add(Duration(days: 30 + _random.nextInt(335))),
|
||||
purchasePrice: _getLicensePrice(productName),
|
||||
companyId: companyId,
|
||||
branchId: branchId,
|
||||
remark: '테스트 라이선스 - $productName',
|
||||
);
|
||||
}
|
||||
|
||||
// 창고 데이터 생성
|
||||
static CreateWarehouseLocationRequest createSmartWarehouseData({
|
||||
String? name,
|
||||
int? managerId,
|
||||
}) {
|
||||
name ??= '${getRandomElement(_warehouseNames)} ${generateUniqueId()}';
|
||||
|
||||
return CreateWarehouseLocationRequest(
|
||||
name: name,
|
||||
address: '서울시 강남구 물류로 ${_random.nextInt(100) + 1}',
|
||||
city: '서울',
|
||||
state: '서울특별시',
|
||||
postalCode: '${10000 + _random.nextInt(90000)}',
|
||||
country: '대한민국',
|
||||
capacity: [100, 200, 500, 1000, 2000][_random.nextInt(5)],
|
||||
managerId: managerId,
|
||||
);
|
||||
}
|
||||
|
||||
// 시나리오별 데이터 세트 생성
|
||||
|
||||
/// 장비 입고 시나리오 데이터 생성
|
||||
static Future<EquipmentScenarioData> createEquipmentScenario({
|
||||
int? equipmentCount = 5,
|
||||
}) async {
|
||||
final companyService = GetIt.I<CompanyService>();
|
||||
final warehouseService = GetIt.I<WarehouseService>();
|
||||
final equipmentService = GetIt.I<EquipmentService>();
|
||||
|
||||
// 1. 회사 생성
|
||||
final companyData = createSmartCompanyData(
|
||||
name: '테크장비관리 주식회사',
|
||||
companyTypes: [CompanyType.customer],
|
||||
);
|
||||
|
||||
final company = await companyService.createCompany(companyData);
|
||||
_createdCompanyIds.add(company.id!);
|
||||
|
||||
// 2. 창고 생성
|
||||
final warehouseData = createSmartWarehouseData(
|
||||
name: '중앙 물류센터',
|
||||
);
|
||||
|
||||
// warehouseService가 WarehouseLocation을 받는지 확인 필요
|
||||
// 일단 WarehouseLocation으로 변환
|
||||
final warehouseLocation = WarehouseLocation(
|
||||
id: 0, // id 필수, 서비스에서 생성될 예정
|
||||
name: warehouseData.name,
|
||||
address: Address(
|
||||
region: '${warehouseData.state ?? ''} ${warehouseData.city ?? ''}',
|
||||
detailAddress: warehouseData.address ?? '',
|
||||
),
|
||||
remark: '용량: ${warehouseData.capacity}',
|
||||
);
|
||||
final warehouse = await warehouseService.createWarehouseLocation(warehouseLocation);
|
||||
_createdWarehouseIds.add(warehouse.id);
|
||||
|
||||
// 3. 장비 생성
|
||||
final equipments = <Equipment>[];
|
||||
for (int i = 0; i < equipmentCount!; i++) {
|
||||
final equipmentData = createSmartEquipmentData(
|
||||
companyId: company.id!,
|
||||
warehouseLocationId: warehouse.id,
|
||||
);
|
||||
|
||||
// equipmentService가 Equipment을 받는지 확인 필요
|
||||
// 일단 Equipment로 변환
|
||||
final equipment = Equipment(
|
||||
manufacturer: equipmentData.manufacturer,
|
||||
name: equipmentData.modelName ?? '',
|
||||
category: equipmentData.category1 ?? '',
|
||||
subCategory: equipmentData.category2 ?? '',
|
||||
subSubCategory: '',
|
||||
serialNumber: equipmentData.serialNumber,
|
||||
quantity: 1,
|
||||
remark: equipmentData.remark,
|
||||
);
|
||||
final createdEquipment = await equipmentService.createEquipment(equipment);
|
||||
equipments.add(createdEquipment);
|
||||
_createdEquipmentIds.add(createdEquipment.id!);
|
||||
}
|
||||
|
||||
return EquipmentScenarioData(
|
||||
company: company,
|
||||
warehouse: warehouse,
|
||||
equipments: equipments,
|
||||
);
|
||||
}
|
||||
|
||||
/// 사용자 관리 시나리오 데이터 생성
|
||||
static Future<UserScenarioData> createUserScenario({
|
||||
int? userCount = 10,
|
||||
}) async {
|
||||
final companyService = GetIt.I<CompanyService>();
|
||||
final userService = GetIt.I<UserService>();
|
||||
|
||||
// 1. 회사 생성
|
||||
final companyData = createSmartCompanyData(
|
||||
name: '스마트HR 솔루션',
|
||||
companyTypes: [CompanyType.customer],
|
||||
);
|
||||
|
||||
final company = await companyService.createCompany(companyData);
|
||||
_createdCompanyIds.add(company.id!);
|
||||
|
||||
// 2. 부서별 사용자 생성
|
||||
final users = <User>[];
|
||||
final departments = ['개발팀', '디자인팀', '영업팀', '운영팀'];
|
||||
|
||||
for (final dept in departments) {
|
||||
final deptUserCount = userCount! ~/ departments.length;
|
||||
for (int i = 0; i < deptUserCount; i++) {
|
||||
final userData = createSmartUserData(
|
||||
companyId: company.id!,
|
||||
department: dept,
|
||||
role: i == 0 ? 'manager' : 'staff', // 첫 번째는 매니저
|
||||
);
|
||||
|
||||
// userService.createUser는 명명된 파라미터로 호출
|
||||
final user = await userService.createUser(
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
password: userData.password,
|
||||
name: userData.name,
|
||||
phone: userData.phone,
|
||||
role: userData.role,
|
||||
companyId: userData.companyId ?? company.id!,
|
||||
branchId: userData.branchId,
|
||||
);
|
||||
users.add(user);
|
||||
_createdUserIds.add(user.id!);
|
||||
}
|
||||
}
|
||||
|
||||
return UserScenarioData(
|
||||
company: company,
|
||||
users: users,
|
||||
departmentGroups: _groupUsersByDepartment(users),
|
||||
);
|
||||
}
|
||||
|
||||
/// 라이선스 관리 시나리오 데이터 생성
|
||||
static Future<LicenseScenarioData> createLicenseScenario({
|
||||
int? licenseCount = 8,
|
||||
}) async {
|
||||
final companyService = GetIt.I<CompanyService>();
|
||||
final userService = GetIt.I<UserService>();
|
||||
final licenseService = GetIt.I<LicenseService>();
|
||||
|
||||
// 1. 회사 생성
|
||||
final companyData = createSmartCompanyData(
|
||||
name: '소프트웨어 라이선스 매니지먼트',
|
||||
companyTypes: [CompanyType.partner],
|
||||
);
|
||||
|
||||
final company = await companyService.createCompany(companyData);
|
||||
_createdCompanyIds.add(company.id!);
|
||||
|
||||
// 2. 사용자 생성 (라이선스 할당용)
|
||||
final users = <User>[];
|
||||
for (int i = 0; i < 5; i++) {
|
||||
final userData = createSmartUserData(
|
||||
companyId: company.id!,
|
||||
role: i == 0 ? 'admin' : 'staff',
|
||||
);
|
||||
|
||||
final user = await userService.createUser(
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
password: userData.password,
|
||||
name: userData.name,
|
||||
phone: userData.phone,
|
||||
role: userData.role,
|
||||
companyId: userData.companyId ?? company.id!,
|
||||
branchId: userData.branchId,
|
||||
);
|
||||
users.add(user);
|
||||
_createdUserIds.add(user.id!);
|
||||
}
|
||||
|
||||
// 3. 라이선스 생성 (일부는 사용자에게 할당)
|
||||
final licenses = <License>[];
|
||||
for (int i = 0; i < licenseCount!; i++) {
|
||||
final licenseData = createSmartLicenseData(
|
||||
companyId: company.id!,
|
||||
);
|
||||
|
||||
// licenseService.createLicense는 License 객체를 받음
|
||||
final license = License(
|
||||
licenseKey: licenseData.licenseKey,
|
||||
productName: licenseData.productName ?? '',
|
||||
vendor: licenseData.vendor ?? '',
|
||||
licenseType: licenseData.licenseType ?? '',
|
||||
userCount: licenseData.userCount ?? 1,
|
||||
purchaseDate: licenseData.purchaseDate,
|
||||
expiryDate: licenseData.expiryDate,
|
||||
purchasePrice: licenseData.purchasePrice ?? 0.0,
|
||||
companyId: licenseData.companyId,
|
||||
branchId: licenseData.branchId,
|
||||
remark: licenseData.remark,
|
||||
);
|
||||
final createdLicense = await licenseService.createLicense(license);
|
||||
licenses.add(createdLicense);
|
||||
_createdLicenseIds.add(createdLicense.id!);
|
||||
}
|
||||
|
||||
return LicenseScenarioData(
|
||||
company: company,
|
||||
users: users,
|
||||
licenses: licenses,
|
||||
assignedLicenses: licenses,
|
||||
unassignedLicenses: licenses,
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 정리 메서드
|
||||
|
||||
/// 생성된 모든 테스트 데이터 삭제
|
||||
static Future<void> cleanupAllTestData() async {
|
||||
final equipmentService = GetIt.I<EquipmentService>();
|
||||
final licenseService = GetIt.I<LicenseService>();
|
||||
final userService = GetIt.I<UserService>();
|
||||
final warehouseService = GetIt.I<WarehouseService>();
|
||||
final companyService = GetIt.I<CompanyService>();
|
||||
|
||||
// 장비 삭제
|
||||
for (final id in _createdEquipmentIds.reversed) {
|
||||
await equipmentService.deleteEquipment(id);
|
||||
}
|
||||
_createdEquipmentIds.clear();
|
||||
|
||||
// 라이선스 삭제
|
||||
for (final id in _createdLicenseIds.reversed) {
|
||||
await licenseService.deleteLicense(id);
|
||||
}
|
||||
_createdLicenseIds.clear();
|
||||
|
||||
// 사용자 삭제
|
||||
for (final id in _createdUserIds.reversed) {
|
||||
await userService.deleteUser(id);
|
||||
}
|
||||
_createdUserIds.clear();
|
||||
|
||||
// 창고 삭제
|
||||
for (final id in _createdWarehouseIds.reversed) {
|
||||
await warehouseService.deleteWarehouseLocation(id);
|
||||
}
|
||||
_createdWarehouseIds.clear();
|
||||
|
||||
// 회사 삭제
|
||||
for (final id in _createdCompanyIds.reversed) {
|
||||
await companyService.deleteCompany(id);
|
||||
}
|
||||
_createdCompanyIds.clear();
|
||||
|
||||
// 캐시 초기화
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
/// 특정 타입의 데이터만 삭제
|
||||
static Future<void> cleanupTestDataByType(TestDataType type) async {
|
||||
switch (type) {
|
||||
case TestDataType.company:
|
||||
final companyService = GetIt.I<CompanyService>();
|
||||
for (final id in _createdCompanyIds.reversed) {
|
||||
await companyService.deleteCompany(id);
|
||||
}
|
||||
_createdCompanyIds.clear();
|
||||
break;
|
||||
case TestDataType.user:
|
||||
final userService = GetIt.I<UserService>();
|
||||
for (final id in _createdUserIds.reversed) {
|
||||
await userService.deleteUser(id);
|
||||
}
|
||||
_createdUserIds.clear();
|
||||
break;
|
||||
case TestDataType.equipment:
|
||||
final equipmentService = GetIt.I<EquipmentService>();
|
||||
for (final id in _createdEquipmentIds.reversed) {
|
||||
await equipmentService.deleteEquipment(id);
|
||||
}
|
||||
_createdEquipmentIds.clear();
|
||||
break;
|
||||
case TestDataType.license:
|
||||
final licenseService = GetIt.I<LicenseService>();
|
||||
for (final id in _createdLicenseIds.reversed) {
|
||||
await licenseService.deleteLicense(id);
|
||||
}
|
||||
_createdLicenseIds.clear();
|
||||
break;
|
||||
case TestDataType.warehouse:
|
||||
final warehouseService = GetIt.I<WarehouseService>();
|
||||
for (final id in _createdWarehouseIds.reversed) {
|
||||
await warehouseService.deleteWarehouseLocation(id);
|
||||
}
|
||||
_createdWarehouseIds.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 헬퍼 메서드
|
||||
|
||||
static String _getCategoryDetail(String category) {
|
||||
final details = {
|
||||
'노트북': '휴대용 컴퓨터',
|
||||
'데스크탑': '고정형 컴퓨터',
|
||||
'모니터': '디스플레이 장치',
|
||||
'프린터': '출력 장치',
|
||||
'네트워크장비': '통신 장비',
|
||||
'서버': '서버 컴퓨터',
|
||||
'태블릿': '태블릿 PC',
|
||||
'스캐너': '입력 장치',
|
||||
};
|
||||
return details[category] ?? '기타';
|
||||
}
|
||||
|
||||
static double _getRealisticPrice(String category) {
|
||||
final basePrices = {
|
||||
'노트북': 1500000.0,
|
||||
'데스크탑': 1200000.0,
|
||||
'모니터': 400000.0,
|
||||
'프린터': 300000.0,
|
||||
'네트워크장비': 200000.0,
|
||||
'서버': 5000000.0,
|
||||
'태블릿': 800000.0,
|
||||
'스캐너': 250000.0,
|
||||
};
|
||||
final basePrice = basePrices[category] ?? 500000.0;
|
||||
// ±30% 범위의 가격 변동
|
||||
return basePrice * (0.7 + _random.nextDouble() * 0.6);
|
||||
}
|
||||
|
||||
static String _getVendorFromProduct(String productName) {
|
||||
if (productName.contains('Microsoft')) return 'Microsoft';
|
||||
if (productName.contains('Adobe')) return 'Adobe';
|
||||
if (productName.contains('AutoCAD')) return 'Autodesk';
|
||||
if (productName.contains('JetBrains')) return 'JetBrains';
|
||||
if (productName.contains('Slack')) return 'Slack Technologies';
|
||||
if (productName.contains('Zoom')) return 'Zoom Video Communications';
|
||||
return 'Unknown Vendor';
|
||||
}
|
||||
|
||||
static double _getLicensePrice(String productName) {
|
||||
final prices = {
|
||||
'Microsoft Office 365': 120000.0,
|
||||
'Adobe Creative Cloud': 600000.0,
|
||||
'AutoCAD 2024': 2400000.0,
|
||||
'Windows 11 Pro': 200000.0,
|
||||
'Visual Studio Enterprise': 3600000.0,
|
||||
'JetBrains All Products': 300000.0,
|
||||
'Slack Business+': 180000.0,
|
||||
'Zoom Business': 240000.0,
|
||||
};
|
||||
return prices[productName] ?? 100000.0;
|
||||
}
|
||||
|
||||
static Map<String, List<User>> _groupUsersByDepartment(List<User> users) {
|
||||
final groups = <String, List<User>>{};
|
||||
// 실제로는 사용자 모델에 부서 정보가 없으므로, 여기서는 더미 구현
|
||||
// 실제 구현시에는 사용자 확장 속성이나 별도 테이블 활용
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// GenerationStrategy를 받아서 테스트 데이터를 생성하는 메서드
|
||||
Future<TestData> generate(GenerationStrategy strategy) async {
|
||||
final data = await _generateByType(strategy.dataType);
|
||||
|
||||
// 필드별 커스터마이징 적용
|
||||
if (data is Map<String, dynamic>) {
|
||||
for (final field in strategy.fields) {
|
||||
data[field.fieldName] = _generateFieldValue(field);
|
||||
}
|
||||
}
|
||||
|
||||
return TestData(
|
||||
dataType: strategy.dataType.toString(),
|
||||
data: data,
|
||||
metadata: {
|
||||
'generated': true,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 타입별 기본 데이터 생성
|
||||
static Future<dynamic> _generateByType(Type type) async {
|
||||
switch (type.toString()) {
|
||||
case 'CreateEquipmentRequest':
|
||||
// 기본값 사용 - 실제 사용 시 적절한 값으로 대체 필요
|
||||
return createSmartEquipmentData(
|
||||
companyId: 1,
|
||||
warehouseLocationId: 1,
|
||||
);
|
||||
case 'CreateCompanyRequest':
|
||||
return createSmartCompanyData();
|
||||
case 'CreateWarehouseLocationRequest':
|
||||
return createSmartWarehouseData();
|
||||
case 'CreateLicenseRequest':
|
||||
// 기본값 사용 - 실제 사용 시 적절한 값으로 대체 필요
|
||||
return createSmartLicenseData(
|
||||
companyId: 1,
|
||||
);
|
||||
default:
|
||||
throw Exception('Unsupported type: $type');
|
||||
}
|
||||
}
|
||||
|
||||
/// 필드 생성 전략에 따른 값 생성
|
||||
static dynamic _generateFieldValue(FieldGeneration field) {
|
||||
switch (field.strategy) {
|
||||
case 'unique':
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
return '${field.prefix ?? ''}$timestamp';
|
||||
case 'realistic':
|
||||
if (field.pool != null && field.pool!.isNotEmpty) {
|
||||
return field.pool![_random.nextInt(field.pool!.length)];
|
||||
}
|
||||
if (field.relatedTo == 'manufacturer') {
|
||||
// manufacturer에 따른 모델명 생성
|
||||
return _generateRealisticModel(field.fieldName);
|
||||
}
|
||||
break;
|
||||
case 'enum':
|
||||
if (field.values != null && field.values!.isNotEmpty) {
|
||||
return field.values![_random.nextInt(field.values!.length)];
|
||||
}
|
||||
break;
|
||||
case 'fixed':
|
||||
return field.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _generateRealisticModel(String manufacturer) {
|
||||
// 간단한 모델명 생성 로직
|
||||
final models = _realProductModels[manufacturer];
|
||||
if (models != null && models.isNotEmpty) {
|
||||
return models[_random.nextInt(models.length)];
|
||||
}
|
||||
return 'Model-${_random.nextInt(1000)}';
|
||||
}
|
||||
}
|
||||
|
||||
// 시나리오 데이터 클래스들
|
||||
|
||||
class EquipmentScenarioData {
|
||||
final Company company;
|
||||
final WarehouseLocation warehouse;
|
||||
final List<Equipment> equipments;
|
||||
|
||||
EquipmentScenarioData({
|
||||
required this.company,
|
||||
required this.warehouse,
|
||||
required this.equipments,
|
||||
});
|
||||
}
|
||||
|
||||
class UserScenarioData {
|
||||
final Company company;
|
||||
final List<User> users;
|
||||
final Map<String, List<User>> departmentGroups;
|
||||
|
||||
UserScenarioData({
|
||||
required this.company,
|
||||
required this.users,
|
||||
required this.departmentGroups,
|
||||
});
|
||||
}
|
||||
|
||||
class LicenseScenarioData {
|
||||
final Company company;
|
||||
final List<User> users;
|
||||
final List<License> licenses;
|
||||
final List<License> assignedLicenses;
|
||||
final List<License> unassignedLicenses;
|
||||
|
||||
LicenseScenarioData({
|
||||
required this.company,
|
||||
required this.users,
|
||||
required this.licenses,
|
||||
required this.assignedLicenses,
|
||||
required this.unassignedLicenses,
|
||||
});
|
||||
}
|
||||
|
||||
// 테스트 데이터 타입 열거형
|
||||
enum TestDataType {
|
||||
company,
|
||||
user,
|
||||
equipment,
|
||||
license,
|
||||
warehouse,
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'test_data_generator.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
// 실제 API 테스트 환경 초기화
|
||||
// RealApiTestHelper가 없으므로 주석 처리
|
||||
// await RealApiTestHelper.setupTestEnvironment();
|
||||
// await RealApiTestHelper.loginAndGetToken();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
// 모든 테스트 데이터 정리
|
||||
await TestDataGenerator.cleanupAllTestData();
|
||||
// await RealApiTestHelper.teardownTestEnvironment();
|
||||
});
|
||||
|
||||
group('TestDataGenerator 단위 메서드 테스트', () {
|
||||
test('고유 ID 생성 테스트', () {
|
||||
final id1 = TestDataGenerator.generateUniqueId();
|
||||
final id2 = TestDataGenerator.generateUniqueId();
|
||||
|
||||
expect(id1, isNotNull);
|
||||
expect(id2, isNotNull);
|
||||
expect(id1, isNot(equals(id2)));
|
||||
});
|
||||
|
||||
test('고유 이메일 생성 테스트', () {
|
||||
final email1 = TestDataGenerator.generateUniqueEmail();
|
||||
final email2 = TestDataGenerator.generateUniqueEmail(domain: 'company.kr');
|
||||
|
||||
expect(email1, contains('@test.com'));
|
||||
expect(email2, contains('@company.kr'));
|
||||
expect(email1, isNot(equals(email2)));
|
||||
});
|
||||
|
||||
test('전화번호 생성 테스트', () {
|
||||
final phone = TestDataGenerator.generatePhoneNumber();
|
||||
|
||||
expect(phone, matches(RegExp(r'^\d{3}-\d{4}-\d{4}$')));
|
||||
});
|
||||
|
||||
test('사업자등록번호 생성 테스트', () {
|
||||
final businessNumber = TestDataGenerator.generateBusinessNumber();
|
||||
|
||||
expect(businessNumber, matches(RegExp(r'^\d{3}-\d{2}-\d{5}$')));
|
||||
});
|
||||
});
|
||||
|
||||
group('스마트 데이터 생성 테스트', () {
|
||||
test('회사 데이터 생성 테스트', () {
|
||||
final companyData = TestDataGenerator.createSmartCompanyData();
|
||||
|
||||
expect(companyData.name, isNotEmpty);
|
||||
expect(companyData.address, contains('서울시 강남구'));
|
||||
expect(companyData.contactPhone, matches(RegExp(r'^\d{3}-\d{4}-\d{4}$')));
|
||||
expect(companyData.contactEmail, contains('@company.com'));
|
||||
});
|
||||
|
||||
test('사용자 데이터 생성 테스트', () {
|
||||
final userData = TestDataGenerator.createSmartUserData(
|
||||
companyId: 1,
|
||||
role: 'manager',
|
||||
);
|
||||
|
||||
expect(userData.name, matches(RegExp(r'^[가-힣]{2,4}$')));
|
||||
expect(userData.email, contains('@company.com'));
|
||||
expect(userData.password, equals('Test1234!@'));
|
||||
expect(userData.role, equals('manager'));
|
||||
expect(userData.companyId, equals(1));
|
||||
});
|
||||
|
||||
test('장비 데이터 생성 테스트', () {
|
||||
final equipmentData = TestDataGenerator.createSmartEquipmentData(
|
||||
companyId: 1,
|
||||
warehouseLocationId: 1,
|
||||
);
|
||||
|
||||
expect(equipmentData.equipmentNumber, startsWith('EQ-'));
|
||||
expect(equipmentData.manufacturer, isIn([
|
||||
'삼성전자', 'LG전자', 'Apple', 'Dell', 'HP', 'Lenovo', 'Microsoft', 'ASUS'
|
||||
]));
|
||||
expect(equipmentData.serialNumber, matches(RegExp(r'^[A-Z]{2}\d{10}$')));
|
||||
expect(equipmentData.purchasePrice, greaterThan(0));
|
||||
});
|
||||
|
||||
test('라이선스 데이터 생성 테스트', () {
|
||||
final licenseData = TestDataGenerator.createSmartLicenseData(
|
||||
companyId: 1,
|
||||
);
|
||||
|
||||
expect(licenseData.licenseKey, startsWith('LIC-'));
|
||||
expect(licenseData.productName, isIn([
|
||||
'Microsoft Office 365',
|
||||
'Adobe Creative Cloud',
|
||||
'AutoCAD 2024',
|
||||
'Windows 11 Pro',
|
||||
'Visual Studio Enterprise',
|
||||
'JetBrains All Products',
|
||||
'Slack Business+',
|
||||
'Zoom Business',
|
||||
]));
|
||||
expect(licenseData.vendor, isNotEmpty);
|
||||
expect(licenseData.expiryDate!.isAfter(DateTime.now()), isTrue);
|
||||
});
|
||||
|
||||
test('창고 데이터 생성 테스트', () {
|
||||
final warehouseData = TestDataGenerator.createSmartWarehouseData();
|
||||
|
||||
expect(warehouseData.name, isNotEmpty);
|
||||
expect(warehouseData.address, contains('서울시 강남구'));
|
||||
expect(warehouseData.city, equals('서울'));
|
||||
expect(warehouseData.country, equals('대한민국'));
|
||||
expect(warehouseData.capacity, isIn([100, 200, 500, 1000, 2000]));
|
||||
});
|
||||
});
|
||||
|
||||
group('시나리오 데이터 생성 테스트', () {
|
||||
test('장비 입고 시나리오 테스트', () async {
|
||||
final scenario = await TestDataGenerator.createEquipmentScenario(
|
||||
equipmentCount: 3,
|
||||
);
|
||||
|
||||
expect(scenario.company.name, equals('테크장비관리 주식회사'));
|
||||
expect(scenario.warehouse.name, equals('중앙 물류센터'));
|
||||
expect(scenario.equipments.length, equals(3));
|
||||
|
||||
// Equipment 모델에 currentCompanyId와 warehouseLocationId 필드가 없음
|
||||
// 대신 장비 수만 확인
|
||||
for (final equipment in scenario.equipments) {
|
||||
expect(equipment, isNotNull);
|
||||
expect(equipment.name, isNotEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('사용자 관리 시나리오 테스트', () async {
|
||||
final scenario = await TestDataGenerator.createUserScenario(
|
||||
userCount: 8,
|
||||
);
|
||||
|
||||
expect(scenario.company.name, equals('스마트HR 솔루션'));
|
||||
expect(scenario.users.length, equals(8));
|
||||
|
||||
// 모든 사용자가 같은 회사에 속하는지 확인
|
||||
for (final user in scenario.users) {
|
||||
expect(user.companyId, equals(scenario.company.id));
|
||||
}
|
||||
|
||||
// 매니저가 있는지 확인
|
||||
final managers = scenario.users.where((u) => u.role == 'manager');
|
||||
expect(managers.isNotEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('라이선스 관리 시나리오 테스트', () async {
|
||||
final scenario = await TestDataGenerator.createLicenseScenario(
|
||||
licenseCount: 6,
|
||||
);
|
||||
|
||||
expect(scenario.company.name, equals('소프트웨어 라이선스 매니지먼트'));
|
||||
expect(scenario.users.length, equals(5));
|
||||
expect(scenario.licenses.length, equals(6));
|
||||
|
||||
// 할당된 라이선스와 미할당 라이선스 확인
|
||||
expect(scenario.assignedLicenses.length, greaterThan(0));
|
||||
expect(scenario.unassignedLicenses.length, greaterThan(0));
|
||||
expect(
|
||||
scenario.assignedLicenses.length + scenario.unassignedLicenses.length,
|
||||
equals(scenario.licenses.length),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('데이터 정리 테스트', () {
|
||||
test('특정 타입 데이터 정리 테스트', () async {
|
||||
// 테스트 데이터 생성
|
||||
// 실제 생성은 시나리오 테스트에서 이미 수행됨
|
||||
|
||||
// 특정 타입만 정리
|
||||
await TestDataGenerator.cleanupTestDataByType(TestDataType.equipment);
|
||||
|
||||
// 정리 후 확인 (실제 테스트에서는 API 호출로 확인 필요)
|
||||
expect(true, isTrue); // 단순 성공 확인
|
||||
});
|
||||
});
|
||||
|
||||
group('실제 데이터 풀 검증', () {
|
||||
test('제조사별 모델 매핑 검증', () {
|
||||
final samsung = TestDataGenerator.createSmartEquipmentData(
|
||||
companyId: 1,
|
||||
warehouseLocationId: 1,
|
||||
manufacturer: '삼성전자',
|
||||
);
|
||||
|
||||
expect(samsung.modelName, isIn([
|
||||
'Galaxy Book Pro',
|
||||
'Galaxy Book Pro 360',
|
||||
'Odyssey G9',
|
||||
'ViewFinity S9',
|
||||
]));
|
||||
});
|
||||
|
||||
test('카테고리별 가격 범위 검증', () {
|
||||
final laptop = TestDataGenerator.createSmartEquipmentData(
|
||||
companyId: 1,
|
||||
warehouseLocationId: 1,
|
||||
category: '노트북',
|
||||
);
|
||||
|
||||
// 노트북 기본 가격 1,500,000원의 ±30% 범위
|
||||
expect(laptop.purchasePrice, greaterThanOrEqualTo(1050000));
|
||||
expect(laptop.purchasePrice, lessThanOrEqualTo(1950000));
|
||||
});
|
||||
|
||||
test('라이선스 제품별 벤더 매핑 검증', () {
|
||||
final office = TestDataGenerator.createSmartLicenseData(
|
||||
companyId: 1,
|
||||
productName: 'Microsoft Office 365',
|
||||
);
|
||||
|
||||
expect(office.vendor, equals('Microsoft'));
|
||||
expect(office.purchasePrice, equals(120000.0));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '../models/report_models.dart';
|
||||
import '../utils/html_report_generator.dart';
|
||||
|
||||
/// 테스트 결과 리포트 수집기
|
||||
class ReportCollector {
|
||||
final List<StepReport> _steps = [];
|
||||
final List<ErrorReport> _errors = [];
|
||||
final List<AutoFixReport> _autoFixes = [];
|
||||
final Map<String, FeatureReport> _features = {};
|
||||
final Map<String, List<ApiCallReport>> _apiCalls = {};
|
||||
final DateTime _startTime = DateTime.now();
|
||||
|
||||
/// 테스트 단계 추가
|
||||
void addStep(StepReport step) {
|
||||
_steps.add(step);
|
||||
}
|
||||
|
||||
/// 에러 추가
|
||||
void addError(ErrorReport error) {
|
||||
_errors.add(error);
|
||||
}
|
||||
|
||||
/// 자동 수정 추가
|
||||
void addAutoFix(AutoFixReport fix) {
|
||||
_autoFixes.add(fix);
|
||||
}
|
||||
|
||||
/// API 호출 추가
|
||||
void addApiCall(String feature, ApiCallReport apiCall) {
|
||||
_apiCalls.putIfAbsent(feature, () => []).add(apiCall);
|
||||
}
|
||||
|
||||
/// 테스트 결과 추가 (간단한 버전)
|
||||
void addTestResult({
|
||||
required String screenName,
|
||||
required String testName,
|
||||
required bool passed,
|
||||
String? error,
|
||||
}) {
|
||||
final step = StepReport(
|
||||
stepName: testName,
|
||||
timestamp: DateTime.now(),
|
||||
message: passed ? '테스트 성공' : '테스트 실패: ${error ?? '알 수 없는 오류'}',
|
||||
success: passed,
|
||||
details: {
|
||||
'screenName': screenName,
|
||||
'testName': testName,
|
||||
'passed': passed,
|
||||
if (error != null) 'error': error,
|
||||
},
|
||||
);
|
||||
addStep(step);
|
||||
|
||||
// 기능별 리포트에도 추가
|
||||
final feature = _features[screenName] ?? FeatureReport(
|
||||
featureName: screenName,
|
||||
featureType: FeatureType.screen,
|
||||
success: true,
|
||||
totalTests: 0,
|
||||
passedTests: 0,
|
||||
failedTests: 0,
|
||||
totalDuration: Duration.zero,
|
||||
testCaseReports: [],
|
||||
);
|
||||
|
||||
_features[screenName] = FeatureReport(
|
||||
featureName: feature.featureName,
|
||||
featureType: feature.featureType,
|
||||
success: feature.passedTests + (passed ? 1 : 0) == feature.totalTests + 1,
|
||||
totalTests: feature.totalTests + 1,
|
||||
passedTests: feature.passedTests + (passed ? 1 : 0),
|
||||
failedTests: feature.failedTests + (passed ? 0 : 1),
|
||||
totalDuration: feature.totalDuration,
|
||||
testCaseReports: [
|
||||
...feature.testCaseReports,
|
||||
TestCaseReport(
|
||||
testCaseName: testName,
|
||||
steps: [],
|
||||
success: passed,
|
||||
duration: Duration.zero,
|
||||
errorMessage: error,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 기능 리포트 추가
|
||||
void addFeatureReport(String feature, FeatureReport report) {
|
||||
_features[feature] = report;
|
||||
}
|
||||
|
||||
/// 테스트 결과 생성
|
||||
TestResult generateTestResult() {
|
||||
int totalTests = 0;
|
||||
int passedTests = 0;
|
||||
int failedTests = 0;
|
||||
int skippedTests = 0;
|
||||
final List<TestFailure> failures = [];
|
||||
|
||||
// 각 기능별 테스트 결과 집계
|
||||
_features.forEach((feature, report) {
|
||||
totalTests += report.totalTests;
|
||||
passedTests += report.passedTests;
|
||||
failedTests += report.failedTests;
|
||||
|
||||
// 실패한 테스트 케이스들을 TestFailure로 변환
|
||||
for (final testCase in report.testCaseReports) {
|
||||
if (!testCase.success && testCase.errorMessage != null) {
|
||||
failures.add(TestFailure(
|
||||
feature: feature,
|
||||
message: testCase.errorMessage!,
|
||||
stackTrace: testCase.stackTrace,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 단계별 실패도 집계
|
||||
for (final step in _steps) {
|
||||
if (!step.success && step.message.contains('실패')) {
|
||||
failures.add(TestFailure(
|
||||
feature: step.stepName,
|
||||
message: step.message,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return TestResult(
|
||||
totalTests: totalTests == 0 ? _steps.length : totalTests,
|
||||
passedTests: passedTests == 0 ? _steps.where((s) => s.success).length : passedTests,
|
||||
failedTests: failedTests == 0 ? _steps.where((s) => !s.success).length : failedTests,
|
||||
skippedTests: skippedTests,
|
||||
failures: failures,
|
||||
);
|
||||
}
|
||||
|
||||
/// 전체 리포트 생성 (BasicTestReport 사용)
|
||||
BasicTestReport generateReport() {
|
||||
final endTime = DateTime.now();
|
||||
final duration = endTime.difference(_startTime);
|
||||
|
||||
return BasicTestReport(
|
||||
reportId: 'TEST-${DateTime.now().millisecondsSinceEpoch}',
|
||||
testName: 'Automated Test Suite',
|
||||
startTime: _startTime,
|
||||
endTime: endTime,
|
||||
duration: duration,
|
||||
environment: {
|
||||
'platform': 'Flutter',
|
||||
'dartVersion': '3.0',
|
||||
'testFramework': 'flutter_test',
|
||||
},
|
||||
testResult: generateTestResult(),
|
||||
steps: List.from(_steps),
|
||||
errors: List.from(_errors),
|
||||
autoFixes: List.from(_autoFixes),
|
||||
features: Map.from(_features),
|
||||
apiCalls: Map.from(_apiCalls),
|
||||
summary: _generateSummary(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 자동 수정 목록 조회
|
||||
List<AutoFixReport> getAutoFixes() {
|
||||
return List.from(_autoFixes);
|
||||
}
|
||||
|
||||
/// 에러 목록 조회
|
||||
List<ErrorReport> getErrors() {
|
||||
return List.from(_errors);
|
||||
}
|
||||
|
||||
/// 기능별 리포트 조회
|
||||
Map<String, FeatureReport> getFeatureReports() {
|
||||
return Map.from(_features);
|
||||
}
|
||||
|
||||
/// 요약 생성
|
||||
String _generateSummary() {
|
||||
final buffer = StringBuffer();
|
||||
final testResult = generateTestResult();
|
||||
|
||||
buffer.writeln('테스트 실행 요약:');
|
||||
buffer.writeln('- 전체 테스트: ${testResult.totalTests}개');
|
||||
buffer.writeln('- 성공: ${testResult.passedTests}개');
|
||||
buffer.writeln('- 실패: ${testResult.failedTests}개');
|
||||
|
||||
if (_autoFixes.isNotEmpty) {
|
||||
buffer.writeln('\n자동 수정 요약:');
|
||||
buffer.writeln('- 총 ${_autoFixes.length}개 항목 자동 수정됨');
|
||||
final fixTypes = _autoFixes.map((f) => f.errorType).toSet();
|
||||
for (final type in fixTypes) {
|
||||
final count = _autoFixes.where((f) => f.errorType == type).length;
|
||||
buffer.writeln(' - $type: $count개');
|
||||
}
|
||||
}
|
||||
|
||||
if (_errors.isNotEmpty) {
|
||||
buffer.writeln('\n에러 요약:');
|
||||
buffer.writeln('- 총 ${_errors.length}개 에러 발생');
|
||||
final errorTypes = _errors.map((e) => e.errorType).toSet();
|
||||
for (final type in errorTypes) {
|
||||
final count = _errors.where((e) => e.errorType == type).length;
|
||||
buffer.writeln(' - $type: $count개');
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// 리포트 초기화
|
||||
void clear() {
|
||||
_steps.clear();
|
||||
_errors.clear();
|
||||
_autoFixes.clear();
|
||||
_features.clear();
|
||||
_apiCalls.clear();
|
||||
}
|
||||
|
||||
/// 통계 정보 조회
|
||||
Map<String, dynamic> getStatistics() {
|
||||
return {
|
||||
'totalSteps': _steps.length,
|
||||
'successfulSteps': _steps.where((s) => s.success).length,
|
||||
'failedSteps': _steps.where((s) => !s.success).length,
|
||||
'totalErrors': _errors.length,
|
||||
'totalAutoFixes': _autoFixes.length,
|
||||
'totalFeatures': _features.length,
|
||||
'totalApiCalls': _apiCalls.values.expand((calls) => calls).length,
|
||||
'duration': DateTime.now().difference(_startTime).inSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
/// HTML 리포트 생성
|
||||
Future<String> generateHtmlReport() async {
|
||||
final report = generateReport();
|
||||
final generator = HtmlReportGenerator();
|
||||
return await generator.generateReport(report);
|
||||
}
|
||||
|
||||
/// Markdown 리포트 생성
|
||||
Future<String> generateMarkdownReport() async {
|
||||
final report = generateReport();
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// 헤더
|
||||
buffer.writeln('# ${report.testName}');
|
||||
buffer.writeln();
|
||||
buffer.writeln('## 📊 테스트 실행 결과');
|
||||
buffer.writeln();
|
||||
buffer.writeln('- **실행 시간**: ${report.startTime.toLocal()} ~ ${report.endTime.toLocal()}');
|
||||
buffer.writeln('- **소요 시간**: ${_formatDuration(report.duration)}');
|
||||
buffer.writeln('- **환경**: ${report.environment['platform']} (${report.environment['api']})');
|
||||
buffer.writeln();
|
||||
|
||||
// 요약
|
||||
buffer.writeln('## 📈 테스트 요약');
|
||||
buffer.writeln();
|
||||
buffer.writeln('| 항목 | 수치 |');
|
||||
buffer.writeln('|------|------|');
|
||||
buffer.writeln('| 전체 테스트 | ${report.testResult.totalTests} |');
|
||||
buffer.writeln('| ✅ 성공 | ${report.testResult.passedTests} |');
|
||||
buffer.writeln('| ❌ 실패 | ${report.testResult.failedTests} |');
|
||||
buffer.writeln('| ⏭️ 건너뜀 | ${report.testResult.skippedTests} |');
|
||||
|
||||
final successRate = report.testResult.totalTests > 0
|
||||
? (report.testResult.passedTests / report.testResult.totalTests * 100).toStringAsFixed(1)
|
||||
: '0.0';
|
||||
buffer.writeln('| 성공률 | $successRate% |');
|
||||
buffer.writeln();
|
||||
|
||||
// 기능별 결과
|
||||
if (report.features.isNotEmpty) {
|
||||
buffer.writeln('## 🎯 기능별 테스트 결과');
|
||||
buffer.writeln();
|
||||
buffer.writeln('| 기능 | 전체 | 성공 | 실패 | 성공률 |');
|
||||
buffer.writeln('|------|------|------|------|--------|');
|
||||
|
||||
report.features.forEach((name, feature) {
|
||||
final featureSuccessRate = feature.totalTests > 0
|
||||
? (feature.passedTests / feature.totalTests * 100).toStringAsFixed(1)
|
||||
: '0.0';
|
||||
buffer.writeln('| $name | ${feature.totalTests} | ${feature.passedTests} | ${feature.failedTests} | $featureSuccessRate% |');
|
||||
});
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
// 실패 상세
|
||||
if (report.testResult.failures.isNotEmpty) {
|
||||
buffer.writeln('## ❌ 실패한 테스트');
|
||||
buffer.writeln();
|
||||
|
||||
for (final failure in report.testResult.failures) {
|
||||
buffer.writeln('### ${failure.feature}');
|
||||
buffer.writeln();
|
||||
buffer.writeln('```');
|
||||
buffer.writeln(failure.message);
|
||||
buffer.writeln('```');
|
||||
buffer.writeln();
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 수정
|
||||
if (report.autoFixes.isNotEmpty) {
|
||||
buffer.writeln('## 🔧 자동 수정 내역');
|
||||
buffer.writeln();
|
||||
|
||||
for (final fix in report.autoFixes) {
|
||||
final status = fix.success ? '✅' : '❌';
|
||||
buffer.writeln('- $status **${fix.errorType}**: ${fix.cause} → ${fix.solution}');
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
buffer.writeln('---');
|
||||
buffer.writeln('*이 리포트는 ${DateTime.now().toLocal()}에 자동 생성되었습니다.*');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// JSON 리포트 생성
|
||||
Future<String> generateJsonReport() async {
|
||||
final report = generateReport();
|
||||
final stats = getStatistics();
|
||||
|
||||
final jsonData = {
|
||||
'reportId': report.reportId,
|
||||
'testName': report.testName,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'duration': report.duration.inMilliseconds,
|
||||
'environment': report.environment,
|
||||
'summary': {
|
||||
'totalTests': report.testResult.totalTests,
|
||||
'passedTests': report.testResult.passedTests,
|
||||
'failedTests': report.testResult.failedTests,
|
||||
'skippedTests': report.testResult.skippedTests,
|
||||
'successRate': report.testResult.totalTests > 0
|
||||
? (report.testResult.passedTests / report.testResult.totalTests * 100).toStringAsFixed(1)
|
||||
: '0.0',
|
||||
},
|
||||
'statistics': stats,
|
||||
'features': report.features.map((key, value) => MapEntry(key, {
|
||||
'totalTests': value.totalTests,
|
||||
'passedTests': value.passedTests,
|
||||
'failedTests': value.failedTests,
|
||||
})),
|
||||
'failures': report.testResult.failures.map((f) => {
|
||||
'feature': f.feature,
|
||||
'message': f.message,
|
||||
'stackTrace': f.stackTrace,
|
||||
}).toList(),
|
||||
'autoFixes': report.autoFixes.map((f) => {
|
||||
'errorType': f.errorType,
|
||||
'cause': f.cause,
|
||||
'solution': f.solution,
|
||||
'success': f.success,
|
||||
'beforeData': f.beforeData,
|
||||
'afterData': f.afterData,
|
||||
}).toList(),
|
||||
};
|
||||
|
||||
return const JsonEncoder.withIndent(' ').convert(jsonData);
|
||||
}
|
||||
|
||||
/// 리포트 파일로 저장
|
||||
Future<void> saveReport(String content, String filePath) async {
|
||||
final file = File(filePath);
|
||||
final directory = file.parent;
|
||||
|
||||
if (!await directory.exists()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
|
||||
await file.writeAsString(content);
|
||||
}
|
||||
|
||||
/// Duration 포맷팅
|
||||
String _formatDuration(Duration duration) {
|
||||
if (duration.inHours > 0) {
|
||||
return '${duration.inHours}시간 ${duration.inMinutes % 60}분 ${duration.inSeconds % 60}초';
|
||||
} else if (duration.inMinutes > 0) {
|
||||
return '${duration.inMinutes}분 ${duration.inSeconds % 60}초';
|
||||
} else {
|
||||
return '${duration.inSeconds}초';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/// 테스트 컨텍스트 - 테스트 실행 중 상태와 데이터를 관리
|
||||
class TestContext {
|
||||
final Map<String, dynamic> _data = {};
|
||||
final List<String> _createdResourceIds = [];
|
||||
final Map<String, List<String>> _resourcesByType = {};
|
||||
final Map<String, dynamic> _config = {};
|
||||
String? currentScreen;
|
||||
|
||||
/// 데이터 저장
|
||||
void setData(String key, dynamic value) {
|
||||
_data[key] = value;
|
||||
}
|
||||
|
||||
/// 데이터 조회
|
||||
dynamic getData(String key) {
|
||||
return _data[key];
|
||||
}
|
||||
|
||||
/// 모든 데이터 조회
|
||||
Map<String, dynamic> getAllData() {
|
||||
return Map.from(_data);
|
||||
}
|
||||
|
||||
/// 생성된 리소스 ID 추가
|
||||
void addCreatedResourceId(String resourceType, String id) {
|
||||
_createdResourceIds.add('$resourceType:$id');
|
||||
_resourcesByType.putIfAbsent(resourceType, () => []).add(id);
|
||||
}
|
||||
|
||||
/// 특정 타입의 생성된 리소스 ID 목록 조회
|
||||
List<String> getCreatedResourceIds(String resourceType) {
|
||||
return _resourcesByType[resourceType] ?? [];
|
||||
}
|
||||
|
||||
/// 모든 생성된 리소스 ID 조회
|
||||
List<String> getAllCreatedResourceIds() {
|
||||
return List.from(_createdResourceIds);
|
||||
}
|
||||
|
||||
/// 생성된 리소스 ID 제거
|
||||
void removeCreatedResourceId(String resourceType, String id) {
|
||||
final resourceKey = '$resourceType:$id';
|
||||
_createdResourceIds.remove(resourceKey);
|
||||
_resourcesByType[resourceType]?.remove(id);
|
||||
}
|
||||
|
||||
/// 컨텍스트 초기화
|
||||
void clear() {
|
||||
_data.clear();
|
||||
_createdResourceIds.clear();
|
||||
_resourcesByType.clear();
|
||||
}
|
||||
|
||||
/// 특정 키의 데이터 존재 여부 확인
|
||||
bool hasData(String key) {
|
||||
return _data.containsKey(key);
|
||||
}
|
||||
|
||||
/// 특정 키의 데이터 제거
|
||||
void removeData(String key) {
|
||||
_data.remove(key);
|
||||
}
|
||||
|
||||
/// 현재 상태 스냅샷
|
||||
Map<String, dynamic> snapshot() {
|
||||
return {
|
||||
'data': Map.from(_data),
|
||||
'createdResources': List.from(_createdResourceIds),
|
||||
'resourcesByType': Map.from(_resourcesByType),
|
||||
};
|
||||
}
|
||||
|
||||
/// 스냅샷에서 복원
|
||||
void restore(Map<String, dynamic> snapshot) {
|
||||
_data.clear();
|
||||
_data.addAll(snapshot['data'] ?? {});
|
||||
|
||||
_createdResourceIds.clear();
|
||||
_createdResourceIds.addAll(List<String>.from(snapshot['createdResources'] ?? []));
|
||||
|
||||
_resourcesByType.clear();
|
||||
final resourcesByType = snapshot['resourcesByType'] as Map<String, dynamic>? ?? {};
|
||||
resourcesByType.forEach((key, value) {
|
||||
_resourcesByType[key] = List<String>.from(value as List);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// 설정값 저장
|
||||
void setConfig(String key, dynamic value) {
|
||||
_config[key] = value;
|
||||
}
|
||||
|
||||
/// 설정값 조회
|
||||
dynamic getConfig(String key) {
|
||||
return _config[key];
|
||||
}
|
||||
}
|
||||
529
test/integration/automated/framework/models/error_models.dart
Normal file
529
test/integration/automated/framework/models/error_models.dart
Normal file
@@ -0,0 +1,529 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// 에러 타입
|
||||
enum ErrorType {
|
||||
/// 필수 필드 누락
|
||||
missingRequiredField,
|
||||
|
||||
/// 잘못된 참조
|
||||
invalidReference,
|
||||
|
||||
/// 중복 데이터
|
||||
duplicateData,
|
||||
|
||||
/// 권한 부족
|
||||
permissionDenied,
|
||||
|
||||
/// 유효성 검증 실패
|
||||
validation,
|
||||
|
||||
/// 서버 에러
|
||||
serverError,
|
||||
|
||||
/// 네트워크 에러
|
||||
networkError,
|
||||
|
||||
/// 알 수 없는 에러
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// API 에러 타입 분류
|
||||
enum ApiErrorType {
|
||||
/// 인증 관련 에러 (401, 403)
|
||||
authentication,
|
||||
|
||||
/// 유효성 검증 에러 (400)
|
||||
validation,
|
||||
|
||||
/// 리소스 찾기 실패 (404)
|
||||
notFound,
|
||||
|
||||
/// 서버 내부 에러 (500)
|
||||
serverError,
|
||||
|
||||
/// 네트워크 연결 에러
|
||||
networkConnection,
|
||||
|
||||
/// 타임아웃 에러
|
||||
timeout,
|
||||
|
||||
/// 요청 취소
|
||||
cancelled,
|
||||
|
||||
/// 속도 제한
|
||||
rateLimit,
|
||||
|
||||
/// 알 수 없는 에러
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// API 에러 진단 결과
|
||||
class ErrorDiagnosis {
|
||||
/// API 에러 타입
|
||||
final ApiErrorType type;
|
||||
|
||||
/// 일반 에러 타입
|
||||
final ErrorType errorType;
|
||||
|
||||
/// 에러 설명
|
||||
final String description;
|
||||
|
||||
/// 에러 컨텍스트 정보
|
||||
final Map<String, dynamic> context;
|
||||
|
||||
/// 진단 신뢰도 (0.0 ~ 1.0)
|
||||
final double confidence;
|
||||
|
||||
/// 영향받은 API 엔드포인트
|
||||
final List<String> affectedEndpoints;
|
||||
|
||||
/// 서버 에러 코드 (있는 경우)
|
||||
final String? serverErrorCode;
|
||||
|
||||
/// 누락된 필드 목록
|
||||
final List<String>? missingFields;
|
||||
|
||||
/// 타입 불일치 필드 정보
|
||||
final Map<String, TypeMismatchInfo>? typeMismatches;
|
||||
|
||||
/// 원본 에러 메시지
|
||||
final String? originalMessage;
|
||||
|
||||
/// 에러 발생 시간
|
||||
final DateTime timestamp;
|
||||
|
||||
ErrorDiagnosis({
|
||||
required this.type,
|
||||
required this.errorType,
|
||||
required this.description,
|
||||
required this.context,
|
||||
required this.confidence,
|
||||
required this.affectedEndpoints,
|
||||
this.serverErrorCode,
|
||||
this.missingFields,
|
||||
this.typeMismatches,
|
||||
this.originalMessage,
|
||||
DateTime? timestamp,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
/// JSON으로 변환
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.toString(),
|
||||
'errorType': errorType.toString(),
|
||||
'description': description,
|
||||
'context': context,
|
||||
'confidence': confidence,
|
||||
'affectedEndpoints': affectedEndpoints,
|
||||
'serverErrorCode': serverErrorCode,
|
||||
'missingFields': missingFields,
|
||||
'typeMismatches': typeMismatches?.map(
|
||||
(key, value) => MapEntry(key, value.toJson()),
|
||||
),
|
||||
'originalMessage': originalMessage,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 타입 불일치 정보
|
||||
class TypeMismatchInfo {
|
||||
/// 필드 이름
|
||||
final String fieldName;
|
||||
|
||||
/// 예상 타입
|
||||
final String expectedType;
|
||||
|
||||
/// 실제 타입
|
||||
final String actualType;
|
||||
|
||||
/// 실제 값
|
||||
final dynamic actualValue;
|
||||
|
||||
TypeMismatchInfo({
|
||||
required this.fieldName,
|
||||
required this.expectedType,
|
||||
required this.actualType,
|
||||
this.actualValue,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'fieldName': fieldName,
|
||||
'expectedType': expectedType,
|
||||
'actualType': actualType,
|
||||
'actualValue': actualValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 수정 제안 타입
|
||||
enum FixType {
|
||||
/// 토큰 갱신
|
||||
refreshToken,
|
||||
|
||||
/// 필드 추가
|
||||
addMissingField,
|
||||
|
||||
/// 타입 변환
|
||||
convertType,
|
||||
|
||||
/// 재시도
|
||||
retry,
|
||||
|
||||
/// 데이터 수정
|
||||
modifyData,
|
||||
|
||||
/// 권한 요청
|
||||
requestPermission,
|
||||
|
||||
/// 엔드포인트 변경
|
||||
endpointSwitch,
|
||||
|
||||
/// 설정 변경
|
||||
configuration,
|
||||
|
||||
/// 수동 개입 필요
|
||||
manualIntervention,
|
||||
}
|
||||
|
||||
/// 수정 제안
|
||||
class FixSuggestion {
|
||||
/// 수정 ID
|
||||
final String fixId;
|
||||
|
||||
/// 수정 타입
|
||||
final FixType type;
|
||||
|
||||
/// 수정 설명
|
||||
final String description;
|
||||
|
||||
/// 수정 작업 목록
|
||||
final List<FixAction> actions;
|
||||
|
||||
/// 성공 확률 (0.0 ~ 1.0)
|
||||
final double successProbability;
|
||||
|
||||
/// 자동 수정 가능 여부
|
||||
final bool isAutoFixable;
|
||||
|
||||
/// 예상 소요 시간 (밀리초)
|
||||
final int? estimatedDuration;
|
||||
|
||||
FixSuggestion({
|
||||
required this.fixId,
|
||||
required this.type,
|
||||
required this.description,
|
||||
required this.actions,
|
||||
required this.successProbability,
|
||||
required this.isAutoFixable,
|
||||
this.estimatedDuration,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'fixId': fixId,
|
||||
'type': type.toString(),
|
||||
'description': description,
|
||||
'actions': actions.map((a) => a.toJson()).toList(),
|
||||
'successProbability': successProbability,
|
||||
'isAutoFixable': isAutoFixable,
|
||||
'estimatedDuration': estimatedDuration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 수정 작업
|
||||
class FixAction {
|
||||
/// 작업 타입
|
||||
final FixActionType type;
|
||||
|
||||
/// 작업 타입 문자열 (하위 호환성)
|
||||
final String actionType;
|
||||
|
||||
/// 대상
|
||||
final String target;
|
||||
|
||||
/// 작업 파라미터
|
||||
final Map<String, dynamic> parameters;
|
||||
|
||||
/// 작업 설명
|
||||
final String? description;
|
||||
|
||||
FixAction({
|
||||
required this.type,
|
||||
required this.actionType,
|
||||
required this.target,
|
||||
required this.parameters,
|
||||
this.description,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type.toString(),
|
||||
'actionType': actionType,
|
||||
'target': target,
|
||||
'parameters': parameters,
|
||||
'description': description,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 수정 결과
|
||||
class FixResult {
|
||||
/// 수정 ID
|
||||
final String fixId;
|
||||
|
||||
/// 성공 여부
|
||||
final bool success;
|
||||
|
||||
/// 실행된 작업 목록
|
||||
final List<FixAction> executedActions;
|
||||
|
||||
/// 실행 시간
|
||||
final DateTime executedAt;
|
||||
|
||||
/// 소요 시간 (밀리초)
|
||||
final int duration;
|
||||
|
||||
/// 에러 (실패 시)
|
||||
final String? error;
|
||||
|
||||
/// 추가 정보
|
||||
final Map<String, dynamic>? additionalInfo;
|
||||
|
||||
FixResult({
|
||||
required this.fixId,
|
||||
required this.success,
|
||||
required this.executedActions,
|
||||
required this.executedAt,
|
||||
required this.duration,
|
||||
this.error,
|
||||
this.additionalInfo,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'fixId': fixId,
|
||||
'success': success,
|
||||
'executedActions': executedActions.map((a) => a.toJson()).toList(),
|
||||
'executedAt': executedAt.toIso8601String(),
|
||||
'duration': duration,
|
||||
'error': error,
|
||||
'additionalInfo': additionalInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 에러 패턴 (학습용)
|
||||
class ErrorPattern {
|
||||
/// 패턴 ID
|
||||
final String patternId;
|
||||
|
||||
/// 에러 타입
|
||||
final ApiErrorType errorType;
|
||||
|
||||
/// 패턴 매칭 규칙
|
||||
final Map<String, dynamic> matchingRules;
|
||||
|
||||
/// 성공한 수정 전략
|
||||
final List<FixSuggestion> successfulFixes;
|
||||
|
||||
/// 발생 횟수
|
||||
final int occurrenceCount;
|
||||
|
||||
/// 마지막 발생 시간
|
||||
final DateTime lastOccurred;
|
||||
|
||||
/// 학습 신뢰도
|
||||
final double confidence;
|
||||
|
||||
ErrorPattern({
|
||||
required this.patternId,
|
||||
required this.errorType,
|
||||
required this.matchingRules,
|
||||
required this.successfulFixes,
|
||||
required this.occurrenceCount,
|
||||
required this.lastOccurred,
|
||||
required this.confidence,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'patternId': patternId,
|
||||
'errorType': errorType.toString(),
|
||||
'matchingRules': matchingRules,
|
||||
'successfulFixes': successfulFixes.map((f) => f.toJson()).toList(),
|
||||
'occurrenceCount': occurrenceCount,
|
||||
'lastOccurred': lastOccurred.toIso8601String(),
|
||||
'confidence': confidence,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// API 에러 정보
|
||||
class ApiError {
|
||||
/// 원본 에러 (optional)
|
||||
final DioException? originalError;
|
||||
|
||||
/// 요청 URL
|
||||
final String requestUrl;
|
||||
|
||||
/// 요청 메서드
|
||||
final String requestMethod;
|
||||
|
||||
/// 요청 헤더
|
||||
final Map<String, dynamic>? requestHeaders;
|
||||
|
||||
/// 요청 바디
|
||||
final dynamic requestBody;
|
||||
|
||||
/// 응답 상태 코드
|
||||
final int? statusCode;
|
||||
|
||||
/// 응답 바디
|
||||
final dynamic responseBody;
|
||||
|
||||
/// 에러 메시지
|
||||
final String? message;
|
||||
|
||||
/// API 엔드포인트
|
||||
final String? endpoint;
|
||||
|
||||
/// HTTP 메서드
|
||||
final String? method;
|
||||
|
||||
/// 에러 발생 시간
|
||||
final DateTime timestamp;
|
||||
|
||||
ApiError({
|
||||
this.originalError,
|
||||
required this.requestUrl,
|
||||
required this.requestMethod,
|
||||
this.requestHeaders,
|
||||
this.requestBody,
|
||||
this.statusCode,
|
||||
this.responseBody,
|
||||
this.message,
|
||||
this.endpoint,
|
||||
this.method,
|
||||
DateTime? timestamp,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
/// DioException으로부터 생성
|
||||
factory ApiError.fromDioException(DioException error) {
|
||||
return ApiError(
|
||||
originalError: error,
|
||||
requestUrl: error.requestOptions.uri.toString(),
|
||||
requestMethod: error.requestOptions.method,
|
||||
requestHeaders: error.requestOptions.headers,
|
||||
requestBody: error.requestOptions.data,
|
||||
statusCode: error.response?.statusCode,
|
||||
responseBody: error.response?.data,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'requestUrl': requestUrl,
|
||||
'requestMethod': requestMethod,
|
||||
'requestHeaders': requestHeaders,
|
||||
'requestBody': requestBody,
|
||||
'statusCode': statusCode,
|
||||
'responseBody': responseBody,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'errorType': originalError?.type.toString(),
|
||||
'errorMessage': message ?? originalError?.message,
|
||||
'endpoint': endpoint,
|
||||
'method': method,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Fix 액션 타입
|
||||
enum FixActionType {
|
||||
/// 필드 업데이트
|
||||
updateField,
|
||||
|
||||
/// 누락된 리소스 생성
|
||||
createMissingResource,
|
||||
|
||||
/// 재시도 with 지연
|
||||
retryWithDelay,
|
||||
|
||||
/// 데이터 타입 변환
|
||||
convertDataType,
|
||||
|
||||
/// 권한 변경
|
||||
changePermission,
|
||||
|
||||
/// 알수 없음
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// 근본 원인 분석 결과
|
||||
class RootCause {
|
||||
/// 원인 타입
|
||||
final String causeType;
|
||||
|
||||
/// 원인 설명
|
||||
final String description;
|
||||
|
||||
/// 증거 목록
|
||||
final List<String> evidence;
|
||||
|
||||
/// 연관된 진단 결과
|
||||
final ErrorDiagnosis diagnosis;
|
||||
|
||||
/// 권장 수정 방법
|
||||
final List<FixSuggestion> recommendedFixes;
|
||||
|
||||
RootCause({
|
||||
required this.causeType,
|
||||
required this.description,
|
||||
required this.evidence,
|
||||
required this.diagnosis,
|
||||
required this.recommendedFixes,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'causeType': causeType,
|
||||
'description': description,
|
||||
'evidence': evidence,
|
||||
'diagnosis': diagnosis.toJson(),
|
||||
'recommendedFixes': recommendedFixes.map((f) => f.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 변경 사항
|
||||
class Change {
|
||||
/// 변경 타입
|
||||
final String type;
|
||||
|
||||
/// 변경 전 값
|
||||
final dynamic before;
|
||||
|
||||
/// 변경 후 값
|
||||
final dynamic after;
|
||||
|
||||
/// 변경 대상
|
||||
final String target;
|
||||
|
||||
Change({
|
||||
required this.type,
|
||||
this.before,
|
||||
this.after,
|
||||
required this.target,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'type': type,
|
||||
'before': before,
|
||||
'after': after,
|
||||
'target': target,
|
||||
};
|
||||
}
|
||||
}
|
||||
606
test/integration/automated/framework/models/report_models.dart
Normal file
606
test/integration/automated/framework/models/report_models.dart
Normal file
@@ -0,0 +1,606 @@
|
||||
/// 테스트 리포트
|
||||
class TestReport {
|
||||
final String reportId;
|
||||
final DateTime generatedAt;
|
||||
final ReportType type;
|
||||
final List<ScreenTestReport> screenReports;
|
||||
final TestSummary summary;
|
||||
final List<ErrorAnalysis> errorAnalyses;
|
||||
final List<PerformanceMetric> performanceMetrics;
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
TestReport({
|
||||
required this.reportId,
|
||||
required this.generatedAt,
|
||||
required this.type,
|
||||
required this.screenReports,
|
||||
required this.summary,
|
||||
required this.errorAnalyses,
|
||||
required this.performanceMetrics,
|
||||
required this.metadata,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'reportId': reportId,
|
||||
'generatedAt': generatedAt.toIso8601String(),
|
||||
'type': type.toString(),
|
||||
'screenReports': screenReports.map((r) => r.toJson()).toList(),
|
||||
'summary': summary.toJson(),
|
||||
'errorAnalyses': errorAnalyses.map((e) => e.toJson()).toList(),
|
||||
'performanceMetrics': performanceMetrics.map((m) => m.toJson()).toList(),
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/// 리포트 타입
|
||||
enum ReportType {
|
||||
full,
|
||||
summary,
|
||||
error,
|
||||
performance,
|
||||
custom,
|
||||
}
|
||||
|
||||
/// 기능 타입
|
||||
enum FeatureType {
|
||||
crud,
|
||||
navigation,
|
||||
validation,
|
||||
authentication,
|
||||
dataSync,
|
||||
ui,
|
||||
performance,
|
||||
custom,
|
||||
screen,
|
||||
}
|
||||
|
||||
/// 에러 타입
|
||||
enum ErrorType {
|
||||
runtime,
|
||||
network,
|
||||
validation,
|
||||
authentication,
|
||||
timeout,
|
||||
assertion,
|
||||
ui,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// 근본 원인
|
||||
class RootCause {
|
||||
final String category;
|
||||
final String description;
|
||||
final double confidence;
|
||||
final Map<String, dynamic>? evidence;
|
||||
|
||||
RootCause({
|
||||
required this.category,
|
||||
required this.description,
|
||||
required this.confidence,
|
||||
this.evidence,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'category': category,
|
||||
'description': description,
|
||||
'confidence': confidence,
|
||||
'evidence': evidence,
|
||||
};
|
||||
}
|
||||
|
||||
/// 수정 제안
|
||||
class FixSuggestion {
|
||||
final String title;
|
||||
final String description;
|
||||
final String code;
|
||||
final double priority;
|
||||
final bool isAutoFixable;
|
||||
|
||||
FixSuggestion({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.code,
|
||||
required this.priority,
|
||||
required this.isAutoFixable,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'code': code,
|
||||
'priority': priority,
|
||||
'isAutoFixable': isAutoFixable,
|
||||
};
|
||||
}
|
||||
|
||||
/// 화면별 테스트 리포트
|
||||
class ScreenTestReport {
|
||||
final String screenName;
|
||||
final TestResult testResult;
|
||||
final List<FeatureReport> featureReports;
|
||||
final Map<String, dynamic> coverage;
|
||||
final List<String> recommendations;
|
||||
|
||||
ScreenTestReport({
|
||||
required this.screenName,
|
||||
required this.testResult,
|
||||
required this.featureReports,
|
||||
required this.coverage,
|
||||
required this.recommendations,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'screenName': screenName,
|
||||
'testResult': testResult.toJson(),
|
||||
'featureReports': featureReports.map((r) => r.toJson()).toList(),
|
||||
'coverage': coverage,
|
||||
'recommendations': recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
/// 기능별 리포트
|
||||
class FeatureReport {
|
||||
final String featureName;
|
||||
final FeatureType featureType;
|
||||
final bool success;
|
||||
final int totalTests;
|
||||
final int passedTests;
|
||||
final int failedTests;
|
||||
final Duration totalDuration;
|
||||
final List<TestCaseReport> testCaseReports;
|
||||
|
||||
FeatureReport({
|
||||
required this.featureName,
|
||||
required this.featureType,
|
||||
required this.success,
|
||||
required this.totalTests,
|
||||
required this.passedTests,
|
||||
required this.failedTests,
|
||||
required this.totalDuration,
|
||||
required this.testCaseReports,
|
||||
});
|
||||
|
||||
double get successRate => totalTests > 0 ? passedTests / totalTests : 0;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'featureName': featureName,
|
||||
'featureType': featureType.toString(),
|
||||
'success': success,
|
||||
'totalTests': totalTests,
|
||||
'passedTests': passedTests,
|
||||
'failedTests': failedTests,
|
||||
'successRate': successRate,
|
||||
'totalDuration': totalDuration.inMilliseconds,
|
||||
'testCaseReports': testCaseReports.map((r) => r.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 테스트 케이스 리포트
|
||||
class TestCaseReport {
|
||||
final String testCaseName;
|
||||
final bool success;
|
||||
final Duration duration;
|
||||
final String? errorMessage;
|
||||
final String? stackTrace;
|
||||
final List<TestStep> steps;
|
||||
final Map<String, dynamic>? additionalInfo;
|
||||
|
||||
TestCaseReport({
|
||||
required this.testCaseName,
|
||||
required this.success,
|
||||
required this.duration,
|
||||
this.errorMessage,
|
||||
this.stackTrace,
|
||||
required this.steps,
|
||||
this.additionalInfo,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'testCaseName': testCaseName,
|
||||
'success': success,
|
||||
'duration': duration.inMilliseconds,
|
||||
'errorMessage': errorMessage,
|
||||
'stackTrace': stackTrace,
|
||||
'steps': steps.map((s) => s.toJson()).toList(),
|
||||
'additionalInfo': additionalInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/// 테스트 단계
|
||||
class TestStep {
|
||||
final String stepName;
|
||||
final StepType type;
|
||||
final bool success;
|
||||
final String? description;
|
||||
final Map<String, dynamic>? data;
|
||||
final DateTime timestamp;
|
||||
|
||||
TestStep({
|
||||
required this.stepName,
|
||||
required this.type,
|
||||
required this.success,
|
||||
this.description,
|
||||
this.data,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'stepName': stepName,
|
||||
'type': type.toString(),
|
||||
'success': success,
|
||||
'description': description,
|
||||
'data': data,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 단계 타입
|
||||
enum StepType {
|
||||
setup,
|
||||
action,
|
||||
verification,
|
||||
teardown,
|
||||
}
|
||||
|
||||
/// 테스트 요약
|
||||
class TestSummary {
|
||||
final int totalScreens;
|
||||
final int totalFeatures;
|
||||
final int totalTestCases;
|
||||
final int passedTestCases;
|
||||
final int failedTestCases;
|
||||
final int skippedTestCases;
|
||||
final Duration totalDuration;
|
||||
final double overallSuccessRate;
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
|
||||
TestSummary({
|
||||
required this.totalScreens,
|
||||
required this.totalFeatures,
|
||||
required this.totalTestCases,
|
||||
required this.passedTestCases,
|
||||
required this.failedTestCases,
|
||||
required this.skippedTestCases,
|
||||
required this.totalDuration,
|
||||
required this.overallSuccessRate,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'totalScreens': totalScreens,
|
||||
'totalFeatures': totalFeatures,
|
||||
'totalTestCases': totalTestCases,
|
||||
'passedTestCases': passedTestCases,
|
||||
'failedTestCases': failedTestCases,
|
||||
'skippedTestCases': skippedTestCases,
|
||||
'totalDuration': totalDuration.inMilliseconds,
|
||||
'overallSuccessRate': overallSuccessRate,
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 에러 분석
|
||||
class ErrorAnalysis {
|
||||
final String errorId;
|
||||
final ErrorType errorType;
|
||||
final String description;
|
||||
final int occurrenceCount;
|
||||
final List<String> affectedScreens;
|
||||
final List<String> affectedFeatures;
|
||||
final RootCause? rootCause;
|
||||
final List<FixSuggestion> suggestedFixes;
|
||||
final bool wasAutoFixed;
|
||||
final Map<String, dynamic> context;
|
||||
|
||||
ErrorAnalysis({
|
||||
required this.errorId,
|
||||
required this.errorType,
|
||||
required this.description,
|
||||
required this.occurrenceCount,
|
||||
required this.affectedScreens,
|
||||
required this.affectedFeatures,
|
||||
this.rootCause,
|
||||
required this.suggestedFixes,
|
||||
required this.wasAutoFixed,
|
||||
required this.context,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'errorId': errorId,
|
||||
'errorType': errorType.toString(),
|
||||
'description': description,
|
||||
'occurrenceCount': occurrenceCount,
|
||||
'affectedScreens': affectedScreens,
|
||||
'affectedFeatures': affectedFeatures,
|
||||
'rootCause': rootCause?.toJson(),
|
||||
'suggestedFixes': suggestedFixes.map((f) => f.toJson()).toList(),
|
||||
'wasAutoFixed': wasAutoFixed,
|
||||
'context': context,
|
||||
};
|
||||
}
|
||||
|
||||
/// 성능 메트릭
|
||||
class PerformanceMetric {
|
||||
final String metricName;
|
||||
final MetricType type;
|
||||
final num value;
|
||||
final String unit;
|
||||
final num? baseline;
|
||||
final num? threshold;
|
||||
final bool isWithinThreshold;
|
||||
final Map<String, dynamic>? breakdown;
|
||||
|
||||
PerformanceMetric({
|
||||
required this.metricName,
|
||||
required this.type,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
this.baseline,
|
||||
this.threshold,
|
||||
required this.isWithinThreshold,
|
||||
this.breakdown,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'metricName': metricName,
|
||||
'type': type.toString(),
|
||||
'value': value,
|
||||
'unit': unit,
|
||||
'baseline': baseline,
|
||||
'threshold': threshold,
|
||||
'isWithinThreshold': isWithinThreshold,
|
||||
'breakdown': breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/// 메트릭 타입
|
||||
enum MetricType {
|
||||
duration,
|
||||
memory,
|
||||
apiCalls,
|
||||
errorRate,
|
||||
throughput,
|
||||
custom,
|
||||
}
|
||||
|
||||
/// 리포트 설정
|
||||
class ReportConfiguration {
|
||||
final bool includeSuccessDetails;
|
||||
final bool includeErrorDetails;
|
||||
final bool includePerformanceMetrics;
|
||||
final bool includeScreenshots;
|
||||
final bool generateHtml;
|
||||
final bool generateJson;
|
||||
final bool generatePdf;
|
||||
final String outputDirectory;
|
||||
final Map<String, dynamic> customSettings;
|
||||
|
||||
ReportConfiguration({
|
||||
this.includeSuccessDetails = true,
|
||||
this.includeErrorDetails = true,
|
||||
this.includePerformanceMetrics = true,
|
||||
this.includeScreenshots = false,
|
||||
this.generateHtml = true,
|
||||
this.generateJson = true,
|
||||
this.generatePdf = false,
|
||||
required this.outputDirectory,
|
||||
this.customSettings = const {},
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'includeSuccessDetails': includeSuccessDetails,
|
||||
'includeErrorDetails': includeErrorDetails,
|
||||
'includePerformanceMetrics': includePerformanceMetrics,
|
||||
'includeScreenshots': includeScreenshots,
|
||||
'generateHtml': generateHtml,
|
||||
'generateJson': generateJson,
|
||||
'generatePdf': generatePdf,
|
||||
'outputDirectory': outputDirectory,
|
||||
'customSettings': customSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/// 테스트 결과 (간단한 버전)
|
||||
class TestResult {
|
||||
final int totalTests;
|
||||
final int passedTests;
|
||||
final int failedTests;
|
||||
final int skippedTests;
|
||||
final List<TestFailure> failures;
|
||||
|
||||
TestResult({
|
||||
required this.totalTests,
|
||||
required this.passedTests,
|
||||
required this.failedTests,
|
||||
required this.skippedTests,
|
||||
required this.failures,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'totalTests': totalTests,
|
||||
'passedTests': passedTests,
|
||||
'failedTests': failedTests,
|
||||
'skippedTests': skippedTests,
|
||||
'failures': failures.map((f) => f.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 테스트 실패
|
||||
class TestFailure {
|
||||
final String feature;
|
||||
final String message;
|
||||
final String? stackTrace;
|
||||
|
||||
TestFailure({
|
||||
required this.feature,
|
||||
required this.message,
|
||||
this.stackTrace,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'feature': feature,
|
||||
'message': message,
|
||||
'stackTrace': stackTrace,
|
||||
};
|
||||
}
|
||||
|
||||
/// 단계별 리포트
|
||||
class StepReport {
|
||||
final String stepName;
|
||||
final DateTime timestamp;
|
||||
final bool success;
|
||||
final String message;
|
||||
final Map<String, dynamic> details;
|
||||
|
||||
StepReport({
|
||||
required this.stepName,
|
||||
required this.timestamp,
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.details,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'stepName': stepName,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'success': success,
|
||||
'message': message,
|
||||
'details': details,
|
||||
};
|
||||
}
|
||||
|
||||
/// 에러 리포트
|
||||
class ErrorReport {
|
||||
final String errorType;
|
||||
final String message;
|
||||
final String? stackTrace;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic> context;
|
||||
|
||||
ErrorReport({
|
||||
required this.errorType,
|
||||
required this.message,
|
||||
this.stackTrace,
|
||||
required this.timestamp,
|
||||
required this.context,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'errorType': errorType,
|
||||
'message': message,
|
||||
'stackTrace': stackTrace,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'context': context,
|
||||
};
|
||||
}
|
||||
|
||||
/// 자동 수정 리포트
|
||||
class AutoFixReport {
|
||||
final String errorType;
|
||||
final String cause;
|
||||
final String solution;
|
||||
final bool success;
|
||||
final Map<String, dynamic> beforeData;
|
||||
final Map<String, dynamic> afterData;
|
||||
|
||||
AutoFixReport({
|
||||
required this.errorType,
|
||||
required this.cause,
|
||||
required this.solution,
|
||||
required this.success,
|
||||
required this.beforeData,
|
||||
required this.afterData,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'errorType': errorType,
|
||||
'cause': cause,
|
||||
'solution': solution,
|
||||
'success': success,
|
||||
'beforeData': beforeData,
|
||||
'afterData': afterData,
|
||||
};
|
||||
}
|
||||
|
||||
/// API 호출 리포트
|
||||
class ApiCallReport {
|
||||
final String endpoint;
|
||||
final String method;
|
||||
final int statusCode;
|
||||
final Duration duration;
|
||||
final Map<String, dynamic>? request;
|
||||
final Map<String, dynamic>? response;
|
||||
final bool success;
|
||||
|
||||
ApiCallReport({
|
||||
required this.endpoint,
|
||||
required this.method,
|
||||
required this.statusCode,
|
||||
required this.duration,
|
||||
this.request,
|
||||
this.response,
|
||||
required this.success,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'endpoint': endpoint,
|
||||
'method': method,
|
||||
'statusCode': statusCode,
|
||||
'duration': duration.inMilliseconds,
|
||||
'request': request,
|
||||
'response': response,
|
||||
'success': success,
|
||||
};
|
||||
}
|
||||
|
||||
/// 간단한 테스트 리포트 (BasicTestReport으로 이름 변경)
|
||||
class BasicTestReport {
|
||||
final String reportId;
|
||||
final String testName;
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final Duration duration;
|
||||
final Map<String, dynamic> environment;
|
||||
final TestResult testResult;
|
||||
final List<StepReport> steps;
|
||||
final List<ErrorReport> errors;
|
||||
final List<AutoFixReport> autoFixes;
|
||||
final Map<String, FeatureReport> features;
|
||||
final Map<String, List<ApiCallReport>> apiCalls;
|
||||
final String summary;
|
||||
|
||||
BasicTestReport({
|
||||
required this.reportId,
|
||||
required this.testName,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.duration,
|
||||
required this.environment,
|
||||
required this.testResult,
|
||||
required this.steps,
|
||||
required this.errors,
|
||||
required this.autoFixes,
|
||||
required this.features,
|
||||
required this.apiCalls,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'reportId': reportId,
|
||||
'testName': testName,
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime.toIso8601String(),
|
||||
'duration': duration.inMilliseconds,
|
||||
'environment': environment,
|
||||
'testResult': testResult.toJson(),
|
||||
'steps': steps.map((s) => s.toJson()).toList(),
|
||||
'errors': errors.map((e) => e.toJson()).toList(),
|
||||
'autoFixes': autoFixes.map((f) => f.toJson()).toList(),
|
||||
'features': features.map((k, v) => MapEntry(k, v.toJson())),
|
||||
'apiCalls': apiCalls.map((k, v) => MapEntry(k, v.map((c) => c.toJson()).toList())),
|
||||
'summary': summary,
|
||||
};
|
||||
}
|
||||
424
test/integration/automated/framework/models/test_models.dart
Normal file
424
test/integration/automated/framework/models/test_models.dart
Normal file
@@ -0,0 +1,424 @@
|
||||
/// 화면 메타데이터
|
||||
class ScreenMetadata {
|
||||
final String screenName;
|
||||
final Type controllerType;
|
||||
final List<ApiEndpoint> relatedEndpoints;
|
||||
final Map<String, dynamic> screenCapabilities;
|
||||
|
||||
ScreenMetadata({
|
||||
required this.screenName,
|
||||
required this.controllerType,
|
||||
required this.relatedEndpoints,
|
||||
required this.screenCapabilities,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'screenName': screenName,
|
||||
'controllerType': controllerType.toString(),
|
||||
'relatedEndpoints': relatedEndpoints.map((e) => e.toJson()).toList(),
|
||||
'screenCapabilities': screenCapabilities,
|
||||
};
|
||||
}
|
||||
|
||||
/// API 엔드포인트
|
||||
class ApiEndpoint {
|
||||
final String path;
|
||||
final String method;
|
||||
final String description;
|
||||
final Map<String, dynamic>? parameters;
|
||||
final Map<String, dynamic>? headers;
|
||||
|
||||
ApiEndpoint({
|
||||
required this.path,
|
||||
required this.method,
|
||||
required this.description,
|
||||
this.parameters,
|
||||
this.headers,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
'method': method,
|
||||
'description': description,
|
||||
'parameters': parameters,
|
||||
'headers': headers,
|
||||
};
|
||||
}
|
||||
|
||||
/// 테스트 가능한 기능
|
||||
class TestableFeature {
|
||||
final String featureName;
|
||||
final FeatureType type;
|
||||
final List<TestCase> testCases;
|
||||
final Map<String, dynamic> metadata;
|
||||
final Type? requiredDataType;
|
||||
final Map<String, FieldConstraint>? dataConstraints;
|
||||
|
||||
TestableFeature({
|
||||
required this.featureName,
|
||||
required this.type,
|
||||
required this.testCases,
|
||||
required this.metadata,
|
||||
this.requiredDataType,
|
||||
this.dataConstraints,
|
||||
});
|
||||
}
|
||||
|
||||
/// 기능 타입
|
||||
enum FeatureType {
|
||||
crud,
|
||||
search,
|
||||
filter,
|
||||
pagination,
|
||||
authentication,
|
||||
export,
|
||||
import,
|
||||
custom,
|
||||
}
|
||||
|
||||
/// 테스트 케이스
|
||||
class TestCase {
|
||||
final String name;
|
||||
final Future<void> Function(TestData data) execute;
|
||||
final Future<void> Function(TestData data) verify;
|
||||
final Future<void> Function(TestData data)? setup;
|
||||
final Future<void> Function(TestData data)? teardown;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
TestCase({
|
||||
required this.name,
|
||||
required this.execute,
|
||||
required this.verify,
|
||||
this.setup,
|
||||
this.teardown,
|
||||
this.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/// 테스트 데이터
|
||||
class TestData {
|
||||
final String dataType;
|
||||
final dynamic data;
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
TestData({
|
||||
required this.dataType,
|
||||
required this.data,
|
||||
required this.metadata,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'dataType': dataType,
|
||||
'data': data is Map || data is List ? data : data?.toJson() ?? {},
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/// 데이터 요구사항
|
||||
class DataRequirement {
|
||||
final Type dataType;
|
||||
final Map<String, FieldConstraint> constraints;
|
||||
final List<DataRelationship> relationships;
|
||||
final int quantity;
|
||||
|
||||
DataRequirement({
|
||||
required this.dataType,
|
||||
required this.constraints,
|
||||
required this.relationships,
|
||||
required this.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
/// 필드 제약조건
|
||||
class FieldConstraint {
|
||||
final bool required;
|
||||
final bool nullable;
|
||||
final int? minLength;
|
||||
final int? maxLength;
|
||||
final num? minValue;
|
||||
final num? maxValue;
|
||||
final String? pattern;
|
||||
final List<dynamic>? allowedValues;
|
||||
final String? defaultValue;
|
||||
|
||||
FieldConstraint({
|
||||
this.required = true,
|
||||
this.nullable = false,
|
||||
this.minLength,
|
||||
this.maxLength,
|
||||
this.minValue,
|
||||
this.maxValue,
|
||||
this.pattern,
|
||||
this.allowedValues,
|
||||
this.defaultValue,
|
||||
});
|
||||
}
|
||||
|
||||
/// 데이터 관계
|
||||
class DataRelationship {
|
||||
final String name;
|
||||
final Type targetType;
|
||||
final RelationType type;
|
||||
final String targetId;
|
||||
final int? count;
|
||||
final Map<String, FieldConstraint>? constraints;
|
||||
|
||||
DataRelationship({
|
||||
required this.name,
|
||||
required this.targetType,
|
||||
required this.type,
|
||||
required this.targetId,
|
||||
this.count,
|
||||
this.constraints,
|
||||
});
|
||||
}
|
||||
|
||||
/// 관계 타입
|
||||
enum RelationType {
|
||||
oneToOne,
|
||||
oneToMany,
|
||||
manyToMany,
|
||||
}
|
||||
|
||||
/// 생성 전략
|
||||
class GenerationStrategy {
|
||||
final Type dataType;
|
||||
final List<FieldGeneration> fields;
|
||||
final List<DataRelationship> relationships;
|
||||
final Map<String, dynamic> constraints;
|
||||
final int? quantity;
|
||||
|
||||
GenerationStrategy({
|
||||
required this.dataType,
|
||||
required this.fields,
|
||||
required this.relationships,
|
||||
required this.constraints,
|
||||
this.quantity,
|
||||
});
|
||||
}
|
||||
|
||||
/// 필드 생성 전략
|
||||
class FieldGeneration {
|
||||
final String fieldName;
|
||||
final Type valueType;
|
||||
final String strategy;
|
||||
final String? prefix;
|
||||
final String? format;
|
||||
final List<dynamic>? pool;
|
||||
final String? relatedTo;
|
||||
final List<String>? values;
|
||||
final dynamic value;
|
||||
|
||||
FieldGeneration({
|
||||
required this.fieldName,
|
||||
required this.valueType,
|
||||
required this.strategy,
|
||||
this.prefix,
|
||||
this.format,
|
||||
this.pool,
|
||||
this.relatedTo,
|
||||
this.values,
|
||||
this.value,
|
||||
});
|
||||
}
|
||||
|
||||
/// 필드 정의
|
||||
class FieldDefinition {
|
||||
final String name;
|
||||
final FieldType type;
|
||||
final dynamic Function() generator;
|
||||
final bool required;
|
||||
final bool nullable;
|
||||
|
||||
FieldDefinition({
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.generator,
|
||||
this.required = true,
|
||||
this.nullable = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 필드 타입
|
||||
enum FieldType {
|
||||
string,
|
||||
integer,
|
||||
double,
|
||||
boolean,
|
||||
dateTime,
|
||||
date,
|
||||
time,
|
||||
object,
|
||||
array,
|
||||
}
|
||||
|
||||
/// 테스트 결과
|
||||
class TestResult {
|
||||
final String screenName;
|
||||
final DateTime startTime;
|
||||
DateTime? endTime;
|
||||
final List<FeatureTestResult> featureResults = [];
|
||||
final List<TestError> errors = [];
|
||||
final Map<String, dynamic> metrics = {};
|
||||
|
||||
TestResult({
|
||||
required this.screenName,
|
||||
required this.startTime,
|
||||
this.endTime,
|
||||
});
|
||||
|
||||
bool get success => errors.isEmpty && featureResults.every((r) => r.success);
|
||||
bool get passed => success; // 호환성을 위한 별칭
|
||||
|
||||
Duration get duration => endTime != null
|
||||
? endTime!.difference(startTime)
|
||||
: Duration.zero;
|
||||
|
||||
// 테스트 카운트 관련 getter들
|
||||
int get totalTests => featureResults
|
||||
.expand((r) => r.testCaseResults)
|
||||
.length;
|
||||
|
||||
int get passedTests => featureResults
|
||||
.expand((r) => r.testCaseResults)
|
||||
.where((r) => r.success)
|
||||
.length;
|
||||
|
||||
int get failedTests => totalTests - passedTests;
|
||||
|
||||
void calculateMetrics() {
|
||||
metrics['totalFeatures'] = featureResults.length;
|
||||
metrics['successfulFeatures'] = featureResults.where((r) => r.success).length;
|
||||
metrics['failedFeatures'] = featureResults.where((r) => !r.success).length;
|
||||
metrics['totalTestCases'] = featureResults
|
||||
.expand((r) => r.testCaseResults)
|
||||
.length;
|
||||
metrics['successfulTestCases'] = featureResults
|
||||
.expand((r) => r.testCaseResults)
|
||||
.where((r) => r.success)
|
||||
.length;
|
||||
metrics['averageDuration'] = _calculateAverageDuration();
|
||||
}
|
||||
|
||||
double _calculateAverageDuration() {
|
||||
final allDurations = featureResults
|
||||
.expand((r) => r.testCaseResults)
|
||||
.map((r) => r.duration.inMilliseconds);
|
||||
|
||||
if (allDurations.isEmpty) return 0;
|
||||
|
||||
return allDurations.reduce((a, b) => a + b) / allDurations.length;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'screenName': screenName,
|
||||
'success': success,
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime?.toIso8601String(),
|
||||
'duration': duration.inMilliseconds,
|
||||
'featureResults': featureResults.map((r) => r.toJson()).toList(),
|
||||
'errors': errors.map((e) => e.toJson()).toList(),
|
||||
'metrics': metrics,
|
||||
};
|
||||
}
|
||||
|
||||
/// 기능 테스트 결과
|
||||
class FeatureTestResult {
|
||||
final String featureName;
|
||||
final DateTime startTime;
|
||||
DateTime? endTime;
|
||||
final List<TestCaseResult> testCaseResults = [];
|
||||
final Map<String, dynamic> metrics = {};
|
||||
|
||||
FeatureTestResult({
|
||||
required this.featureName,
|
||||
required this.startTime,
|
||||
this.endTime,
|
||||
});
|
||||
|
||||
bool get success => testCaseResults.every((r) => r.success);
|
||||
|
||||
Duration get duration => endTime != null
|
||||
? endTime!.difference(startTime)
|
||||
: Duration.zero;
|
||||
|
||||
void calculateMetrics() {
|
||||
metrics['totalTestCases'] = testCaseResults.length;
|
||||
metrics['successfulTestCases'] = testCaseResults.where((r) => r.success).length;
|
||||
metrics['failedTestCases'] = testCaseResults.where((r) => !r.success).length;
|
||||
metrics['averageDuration'] = _calculateAverageDuration();
|
||||
}
|
||||
|
||||
double _calculateAverageDuration() {
|
||||
if (testCaseResults.isEmpty) return 0;
|
||||
|
||||
final totalMs = testCaseResults
|
||||
.map((r) => r.duration.inMilliseconds)
|
||||
.reduce((a, b) => a + b);
|
||||
|
||||
return totalMs / testCaseResults.length;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'featureName': featureName,
|
||||
'success': success,
|
||||
'startTime': startTime.toIso8601String(),
|
||||
'endTime': endTime?.toIso8601String(),
|
||||
'duration': duration.inMilliseconds,
|
||||
'testCaseResults': testCaseResults.map((r) => r.toJson()).toList(),
|
||||
'metrics': metrics,
|
||||
};
|
||||
}
|
||||
|
||||
/// 테스트 케이스 결과
|
||||
class TestCaseResult {
|
||||
final String testCaseName;
|
||||
final bool success;
|
||||
final Duration duration;
|
||||
final String? error;
|
||||
final StackTrace? stackTrace;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
TestCaseResult({
|
||||
required this.testCaseName,
|
||||
required this.success,
|
||||
required this.duration,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'testCaseName': testCaseName,
|
||||
'success': success,
|
||||
'duration': duration.inMilliseconds,
|
||||
'error': error,
|
||||
'stackTrace': stackTrace?.toString(),
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/// 테스트 에러
|
||||
class TestError {
|
||||
final String message;
|
||||
final StackTrace? stackTrace;
|
||||
final String? feature;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic>? context;
|
||||
|
||||
TestError({
|
||||
required this.message,
|
||||
this.stackTrace,
|
||||
this.feature,
|
||||
required this.timestamp,
|
||||
this.context,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'message': message,
|
||||
'stackTrace': stackTrace?.toString(),
|
||||
'feature': feature,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'context': context,
|
||||
};
|
||||
}
|
||||
531
test/integration/automated/framework/testable_action.dart
Normal file
531
test/integration/automated/framework/testable_action.dart
Normal file
@@ -0,0 +1,531 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 테스트 가능한 액션의 기본 인터페이스
|
||||
abstract class TestableAction {
|
||||
/// 액션 이름
|
||||
String get name;
|
||||
|
||||
/// 액션 설명
|
||||
String get description;
|
||||
|
||||
/// 액션 실행 전 조건 검증
|
||||
Future<bool> canExecute(WidgetTester tester);
|
||||
|
||||
/// 액션 실행
|
||||
Future<ActionResult> execute(WidgetTester tester);
|
||||
|
||||
/// 액션 실행 후 검증
|
||||
Future<bool> verify(WidgetTester tester);
|
||||
|
||||
/// 에러 발생 시 복구 시도
|
||||
Future<bool> recover(WidgetTester tester, dynamic error);
|
||||
}
|
||||
|
||||
/// 액션 실행 결과
|
||||
class ActionResult {
|
||||
final bool success;
|
||||
final String? message;
|
||||
final dynamic data;
|
||||
final Duration executionTime;
|
||||
final Map<String, dynamic>? metrics;
|
||||
final dynamic error;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
ActionResult({
|
||||
required this.success,
|
||||
this.message,
|
||||
this.data,
|
||||
required this.executionTime,
|
||||
this.metrics,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
});
|
||||
|
||||
factory ActionResult.success({
|
||||
String? message,
|
||||
dynamic data,
|
||||
required Duration executionTime,
|
||||
Map<String, dynamic>? metrics,
|
||||
}) {
|
||||
return ActionResult(
|
||||
success: true,
|
||||
message: message,
|
||||
data: data,
|
||||
executionTime: executionTime,
|
||||
metrics: metrics,
|
||||
);
|
||||
}
|
||||
|
||||
factory ActionResult.failure({
|
||||
required String message,
|
||||
required Duration executionTime,
|
||||
dynamic error,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
return ActionResult(
|
||||
success: false,
|
||||
message: message,
|
||||
executionTime: executionTime,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 기본 테스트 액션 구현
|
||||
abstract class BaseTestableAction implements TestableAction {
|
||||
@override
|
||||
Future<bool> canExecute(WidgetTester tester) async {
|
||||
// 기본적으로 항상 실행 가능
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> verify(WidgetTester tester) async {
|
||||
// 기본 검증은 성공으로 가정
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> recover(WidgetTester tester, dynamic error) async {
|
||||
// 기본 복구는 실패로 가정
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 탭 액션
|
||||
class TapAction extends BaseTestableAction {
|
||||
final Finder finder;
|
||||
final String targetName;
|
||||
|
||||
TapAction({
|
||||
required this.finder,
|
||||
required this.targetName,
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => 'Tap $targetName';
|
||||
|
||||
@override
|
||||
String get description => 'Tap on $targetName';
|
||||
|
||||
@override
|
||||
Future<bool> canExecute(WidgetTester tester) async {
|
||||
return finder.evaluate().isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> execute(WidgetTester tester) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
await tester.tap(finder);
|
||||
await tester.pump();
|
||||
|
||||
return ActionResult.success(
|
||||
message: 'Successfully tapped $targetName',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
return ActionResult.failure(
|
||||
message: 'Failed to tap $targetName: $e',
|
||||
executionTime: stopwatch.elapsed,
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 텍스트 입력 액션
|
||||
class EnterTextAction extends BaseTestableAction {
|
||||
final Finder finder;
|
||||
final String text;
|
||||
final String fieldName;
|
||||
|
||||
EnterTextAction({
|
||||
required this.finder,
|
||||
required this.text,
|
||||
required this.fieldName,
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => 'Enter text in $fieldName';
|
||||
|
||||
@override
|
||||
String get description => 'Enter "$text" in $fieldName field';
|
||||
|
||||
@override
|
||||
Future<bool> canExecute(WidgetTester tester) async {
|
||||
return finder.evaluate().isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> execute(WidgetTester tester) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
await tester.enterText(finder, text);
|
||||
await tester.pump();
|
||||
|
||||
return ActionResult.success(
|
||||
message: 'Successfully entered text in $fieldName',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
return ActionResult.failure(
|
||||
message: 'Failed to enter text in $fieldName: $e',
|
||||
executionTime: stopwatch.elapsed,
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 대기 액션
|
||||
class WaitAction extends BaseTestableAction {
|
||||
final Duration duration;
|
||||
final String? reason;
|
||||
|
||||
WaitAction({
|
||||
required this.duration,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => 'Wait ${duration.inMilliseconds}ms';
|
||||
|
||||
@override
|
||||
String get description => reason ?? 'Wait for ${duration.inMilliseconds}ms';
|
||||
|
||||
@override
|
||||
Future<ActionResult> execute(WidgetTester tester) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
await tester.pump(duration);
|
||||
|
||||
return ActionResult.success(
|
||||
message: 'Waited for ${duration.inMilliseconds}ms',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
} catch (e, stack) {
|
||||
return ActionResult.failure(
|
||||
message: 'Failed to wait: $e',
|
||||
executionTime: stopwatch.elapsed,
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 스크롤 액션
|
||||
class ScrollAction extends BaseTestableAction {
|
||||
final Finder scrollable;
|
||||
final Finder? target;
|
||||
final Offset offset;
|
||||
final int maxAttempts;
|
||||
|
||||
ScrollAction({
|
||||
required this.scrollable,
|
||||
this.target,
|
||||
this.offset = const Offset(0, -300),
|
||||
this.maxAttempts = 10,
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => 'Scroll';
|
||||
|
||||
@override
|
||||
String get description => target != null
|
||||
? 'Scroll to find target widget'
|
||||
: 'Scroll by offset ${offset.dx}, ${offset.dy}';
|
||||
|
||||
@override
|
||||
Future<ActionResult> execute(WidgetTester tester) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
if (target != null) {
|
||||
// 타겟을 찾을 때까지 스크롤
|
||||
for (int i = 0; i < maxAttempts; i++) {
|
||||
if (target!.evaluate().isNotEmpty) {
|
||||
return ActionResult.success(
|
||||
message: 'Found target after $i scrolls',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
|
||||
await tester.drag(scrollable, offset);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
return ActionResult.failure(
|
||||
message: 'Target not found after $maxAttempts scrolls',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
} else {
|
||||
// 단순 스크롤
|
||||
await tester.drag(scrollable, offset);
|
||||
await tester.pump();
|
||||
|
||||
return ActionResult.success(
|
||||
message: 'Scrolled by offset',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
return ActionResult.failure(
|
||||
message: 'Failed to scroll: $e',
|
||||
executionTime: stopwatch.elapsed,
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 검증 액션
|
||||
class VerifyAction extends BaseTestableAction {
|
||||
final Future<bool> Function(WidgetTester) verifyFunction;
|
||||
final String verificationName;
|
||||
|
||||
VerifyAction({
|
||||
required this.verifyFunction,
|
||||
required this.verificationName,
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => 'Verify $verificationName';
|
||||
|
||||
@override
|
||||
String get description => 'Verify that $verificationName';
|
||||
|
||||
@override
|
||||
Future<ActionResult> execute(WidgetTester tester) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
final result = await verifyFunction(tester);
|
||||
|
||||
if (result) {
|
||||
return ActionResult.success(
|
||||
message: 'Verification passed: $verificationName',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
} else {
|
||||
return ActionResult.failure(
|
||||
message: 'Verification failed: $verificationName',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
return ActionResult.failure(
|
||||
message: 'Verification error: $e',
|
||||
executionTime: stopwatch.elapsed,
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 복합 액션 (여러 액션을 순차적으로 실행)
|
||||
class CompositeAction extends BaseTestableAction {
|
||||
final List<TestableAction> actions;
|
||||
final String compositeName;
|
||||
final bool stopOnFailure;
|
||||
|
||||
CompositeAction({
|
||||
required this.actions,
|
||||
required this.compositeName,
|
||||
this.stopOnFailure = true,
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => compositeName;
|
||||
|
||||
@override
|
||||
String get description => 'Execute ${actions.length} actions for $compositeName';
|
||||
|
||||
@override
|
||||
Future<ActionResult> execute(WidgetTester tester) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final results = <ActionResult>[];
|
||||
|
||||
for (final action in actions) {
|
||||
if (!await action.canExecute(tester)) {
|
||||
if (stopOnFailure) {
|
||||
return ActionResult.failure(
|
||||
message: 'Cannot execute action: ${action.name}',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await action.execute(tester);
|
||||
results.add(result);
|
||||
|
||||
if (!result.success && stopOnFailure) {
|
||||
return ActionResult.failure(
|
||||
message: 'Failed at action: ${action.name} - ${result.message}',
|
||||
executionTime: stopwatch.elapsed,
|
||||
error: result.error,
|
||||
stackTrace: result.stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
if (!await action.verify(tester) && stopOnFailure) {
|
||||
return ActionResult.failure(
|
||||
message: 'Verification failed for action: ${action.name}',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final successCount = results.where((r) => r.success).length;
|
||||
final totalCount = results.length;
|
||||
|
||||
return ActionResult.success(
|
||||
message: 'Completed $successCount/$totalCount actions successfully',
|
||||
data: results,
|
||||
executionTime: stopwatch.elapsed,
|
||||
metrics: {
|
||||
'total_actions': totalCount,
|
||||
'successful_actions': successCount,
|
||||
'failed_actions': totalCount - successCount,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 조건부 액션
|
||||
class ConditionalAction extends BaseTestableAction {
|
||||
final Future<bool> Function(WidgetTester) condition;
|
||||
final TestableAction trueAction;
|
||||
final TestableAction? falseAction;
|
||||
final String conditionName;
|
||||
|
||||
ConditionalAction({
|
||||
required this.condition,
|
||||
required this.trueAction,
|
||||
this.falseAction,
|
||||
required this.conditionName,
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => 'Conditional: $conditionName';
|
||||
|
||||
@override
|
||||
String get description => 'Execute action based on condition: $conditionName';
|
||||
|
||||
@override
|
||||
Future<ActionResult> execute(WidgetTester tester) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
final conditionMet = await condition(tester);
|
||||
|
||||
if (conditionMet) {
|
||||
final result = await trueAction.execute(tester);
|
||||
return ActionResult(
|
||||
success: result.success,
|
||||
message: 'Condition met - ${result.message}',
|
||||
data: result.data,
|
||||
executionTime: stopwatch.elapsed,
|
||||
metrics: result.metrics,
|
||||
error: result.error,
|
||||
stackTrace: result.stackTrace,
|
||||
);
|
||||
} else if (falseAction != null) {
|
||||
final result = await falseAction!.execute(tester);
|
||||
return ActionResult(
|
||||
success: result.success,
|
||||
message: 'Condition not met - ${result.message}',
|
||||
data: result.data,
|
||||
executionTime: stopwatch.elapsed,
|
||||
metrics: result.metrics,
|
||||
error: result.error,
|
||||
stackTrace: result.stackTrace,
|
||||
);
|
||||
} else {
|
||||
return ActionResult.success(
|
||||
message: 'Condition not met - no action taken',
|
||||
executionTime: stopwatch.elapsed,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
return ActionResult.failure(
|
||||
message: 'Conditional action error: $e',
|
||||
executionTime: stopwatch.elapsed,
|
||||
error: e,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 재시도 액션
|
||||
class RetryAction extends BaseTestableAction {
|
||||
final TestableAction action;
|
||||
final int maxRetries;
|
||||
final Duration retryDelay;
|
||||
|
||||
RetryAction({
|
||||
required this.action,
|
||||
this.maxRetries = 3,
|
||||
this.retryDelay = const Duration(seconds: 1),
|
||||
});
|
||||
|
||||
@override
|
||||
String get name => 'Retry ${action.name}';
|
||||
|
||||
@override
|
||||
String get description => 'Retry ${action.name} up to $maxRetries times';
|
||||
|
||||
@override
|
||||
Future<ActionResult> execute(WidgetTester tester) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
ActionResult? lastResult;
|
||||
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
if (!await action.canExecute(tester)) {
|
||||
await tester.pump(retryDelay);
|
||||
continue;
|
||||
}
|
||||
|
||||
lastResult = await action.execute(tester);
|
||||
|
||||
if (lastResult.success) {
|
||||
return ActionResult.success(
|
||||
message: 'Succeeded on attempt $attempt - ${lastResult.message}',
|
||||
data: lastResult.data,
|
||||
executionTime: stopwatch.elapsed,
|
||||
metrics: {
|
||||
...?lastResult.metrics,
|
||||
'attempts': attempt,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await tester.pump(retryDelay);
|
||||
|
||||
// 복구 시도
|
||||
if (lastResult.error != null) {
|
||||
await action.recover(tester, lastResult.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ActionResult.failure(
|
||||
message: 'Failed after $maxRetries attempts - ${lastResult?.message}',
|
||||
executionTime: stopwatch.elapsed,
|
||||
error: lastResult?.error,
|
||||
stackTrace: lastResult?.stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import '../models/report_models.dart';
|
||||
|
||||
/// HTML 리포트 생성기
|
||||
class HtmlReportGenerator {
|
||||
/// 기본 테스트 리포트를 HTML로 변환
|
||||
Future<String> generateReport(BasicTestReport report) async {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// HTML 헤더
|
||||
buffer.writeln('<!DOCTYPE html>');
|
||||
buffer.writeln('<html lang="ko">');
|
||||
buffer.writeln('<head>');
|
||||
buffer.writeln(' <meta charset="UTF-8">');
|
||||
buffer.writeln(' <meta name="viewport" content="width=device-width, initial-scale=1.0">');
|
||||
buffer.writeln(' <title>SUPERPORT 테스트 리포트 - ${report.testName}</title>');
|
||||
buffer.writeln(' <style>');
|
||||
buffer.writeln(_generateCss());
|
||||
buffer.writeln(' </style>');
|
||||
buffer.writeln('</head>');
|
||||
buffer.writeln('<body>');
|
||||
|
||||
// 리포트 컨테이너
|
||||
buffer.writeln(' <div class="container">');
|
||||
|
||||
// 헤더
|
||||
buffer.writeln(' <header class="report-header">');
|
||||
buffer.writeln(' <h1>🚀 ${report.testName}</h1>');
|
||||
buffer.writeln(' <div class="header-info">');
|
||||
buffer.writeln(' <span class="date">생성 시간: ${report.endTime.toLocal()}</span>');
|
||||
buffer.writeln(' <span class="duration">소요 시간: ${_formatDuration(report.duration)}</span>');
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' </header>');
|
||||
|
||||
// 요약 섹션
|
||||
buffer.writeln(' <section class="summary">');
|
||||
buffer.writeln(' <h2>📊 테스트 요약</h2>');
|
||||
buffer.writeln(' <div class="summary-cards">');
|
||||
buffer.writeln(' <div class="card total">');
|
||||
buffer.writeln(' <div class="card-value">${report.testResult.totalTests}</div>');
|
||||
buffer.writeln(' <div class="card-label">전체 테스트</div>');
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' <div class="card success">');
|
||||
buffer.writeln(' <div class="card-value">${report.testResult.passedTests}</div>');
|
||||
buffer.writeln(' <div class="card-label">성공</div>');
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' <div class="card failure">');
|
||||
buffer.writeln(' <div class="card-value">${report.testResult.failedTests}</div>');
|
||||
buffer.writeln(' <div class="card-label">실패</div>');
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' <div class="card skipped">');
|
||||
buffer.writeln(' <div class="card-value">${report.testResult.skippedTests}</div>');
|
||||
buffer.writeln(' <div class="card-label">건너뜀</div>');
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' </div>');
|
||||
|
||||
// 성공률 바
|
||||
final successRate = report.testResult.totalTests > 0
|
||||
? (report.testResult.passedTests / report.testResult.totalTests * 100).toStringAsFixed(1)
|
||||
: '0.0';
|
||||
buffer.writeln(' <div class="progress-bar">');
|
||||
buffer.writeln(' <div class="progress-fill" style="width: $successRate%"></div>');
|
||||
buffer.writeln(' <div class="progress-text">성공률: $successRate%</div>');
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' </section>');
|
||||
|
||||
// 실패 상세
|
||||
if (report.testResult.failures.isNotEmpty) {
|
||||
buffer.writeln(' <section class="failures">');
|
||||
buffer.writeln(' <h2>❌ 실패한 테스트</h2>');
|
||||
buffer.writeln(' <div class="failure-list">');
|
||||
for (final failure in report.testResult.failures) {
|
||||
buffer.writeln(' <div class="failure-item">');
|
||||
buffer.writeln(' <h3>${failure.feature}</h3>');
|
||||
buffer.writeln(' <pre class="failure-message">${_escapeHtml(failure.message)}</pre>');
|
||||
if (failure.stackTrace != null) {
|
||||
buffer.writeln(' <details>');
|
||||
buffer.writeln(' <summary>스택 트레이스</summary>');
|
||||
buffer.writeln(' <pre class="stack-trace">${_escapeHtml(failure.stackTrace!)}</pre>');
|
||||
buffer.writeln(' </details>');
|
||||
}
|
||||
buffer.writeln(' </div>');
|
||||
}
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' </section>');
|
||||
}
|
||||
|
||||
// 기능별 리포트
|
||||
if (report.features.isNotEmpty) {
|
||||
buffer.writeln(' <section class="features">');
|
||||
buffer.writeln(' <h2>🎯 기능별 테스트 결과</h2>');
|
||||
buffer.writeln(' <table class="feature-table">');
|
||||
buffer.writeln(' <thead>');
|
||||
buffer.writeln(' <tr>');
|
||||
buffer.writeln(' <th>기능</th>');
|
||||
buffer.writeln(' <th>전체</th>');
|
||||
buffer.writeln(' <th>성공</th>');
|
||||
buffer.writeln(' <th>실패</th>');
|
||||
buffer.writeln(' <th>성공률</th>');
|
||||
buffer.writeln(' </tr>');
|
||||
buffer.writeln(' </thead>');
|
||||
buffer.writeln(' <tbody>');
|
||||
|
||||
report.features.forEach((name, feature) {
|
||||
final featureSuccessRate = feature.totalTests > 0
|
||||
? (feature.passedTests / feature.totalTests * 100).toStringAsFixed(1)
|
||||
: '0.0';
|
||||
buffer.writeln(' <tr>');
|
||||
buffer.writeln(' <td>$name</td>');
|
||||
buffer.writeln(' <td>${feature.totalTests}</td>');
|
||||
buffer.writeln(' <td class="success">${feature.passedTests}</td>');
|
||||
buffer.writeln(' <td class="failure">${feature.failedTests}</td>');
|
||||
buffer.writeln(' <td>$featureSuccessRate%</td>');
|
||||
buffer.writeln(' </tr>');
|
||||
});
|
||||
|
||||
buffer.writeln(' </tbody>');
|
||||
buffer.writeln(' </table>');
|
||||
buffer.writeln(' </section>');
|
||||
}
|
||||
|
||||
// 자동 수정 섹션
|
||||
if (report.autoFixes.isNotEmpty) {
|
||||
buffer.writeln(' <section class="auto-fixes">');
|
||||
buffer.writeln(' <h2>🔧 자동 수정 내역</h2>');
|
||||
buffer.writeln(' <div class="fix-list">');
|
||||
for (final fix in report.autoFixes) {
|
||||
buffer.writeln(' <div class="fix-item ${fix.success ? 'success' : 'failure'}">');
|
||||
buffer.writeln(' <div class="fix-header">');
|
||||
buffer.writeln(' <span class="fix-type">${fix.errorType}</span>');
|
||||
buffer.writeln(' <span class="fix-status">${fix.success ? '✅ 성공' : '❌ 실패'}</span>');
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' <div class="fix-description">${fix.cause} → ${fix.solution}</div>');
|
||||
buffer.writeln(' </div>');
|
||||
}
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln(' </section>');
|
||||
}
|
||||
|
||||
// 환경 정보
|
||||
buffer.writeln(' <section class="environment">');
|
||||
buffer.writeln(' <h2>⚙️ 테스트 환경</h2>');
|
||||
buffer.writeln(' <table class="env-table">');
|
||||
buffer.writeln(' <tbody>');
|
||||
report.environment.forEach((key, value) {
|
||||
buffer.writeln(' <tr>');
|
||||
buffer.writeln(' <td class="env-key">$key</td>');
|
||||
buffer.writeln(' <td class="env-value">$value</td>');
|
||||
buffer.writeln(' </tr>');
|
||||
});
|
||||
buffer.writeln(' </tbody>');
|
||||
buffer.writeln(' </table>');
|
||||
buffer.writeln(' </section>');
|
||||
|
||||
// 푸터
|
||||
buffer.writeln(' <footer class="report-footer">');
|
||||
buffer.writeln(' <p>이 리포트는 SUPERPORT 자동화 테스트 시스템에 의해 생성되었습니다.</p>');
|
||||
buffer.writeln(' <p>생성 시간: ${DateTime.now().toLocal()}</p>');
|
||||
buffer.writeln(' </footer>');
|
||||
|
||||
buffer.writeln(' </div>');
|
||||
buffer.writeln('</body>');
|
||||
buffer.writeln('</html>');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// CSS 스타일 생성
|
||||
String _generateCss() {
|
||||
return '''
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.report-header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.header-info span {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
section {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 20px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.card.total { background: #e3f2fd; color: #1976d2; }
|
||||
.card.success { background: #e8f5e9; color: #388e3c; }
|
||||
.card.failure { background: #ffebee; color: #d32f2f; }
|
||||
.card.skipped { background: #fff3e0; color: #f57c00; }
|
||||
|
||||
.card-value {
|
||||
font-size: 2.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 30px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 15px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4caf50, #45a049);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.failure-item {
|
||||
border: 1px solid #ffcdd2;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.failure-item h3 {
|
||||
color: #c62828;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.failure-message {
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
details {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.stack-trace {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
td.success { color: #388e3c; }
|
||||
td.failure { color: #d32f2f; }
|
||||
|
||||
.fix-item {
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.fix-item.success {
|
||||
background: #e8f5e9;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
.fix-item.failure {
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.fix-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.fix-type {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.env-table {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.env-key {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.report-footer {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
''';
|
||||
}
|
||||
|
||||
/// Duration 포맷팅
|
||||
String _formatDuration(Duration duration) {
|
||||
if (duration.inHours > 0) {
|
||||
return '${duration.inHours}시간 ${duration.inMinutes % 60}분 ${duration.inSeconds % 60}초';
|
||||
} else if (duration.inMinutes > 0) {
|
||||
return '${duration.inMinutes}분 ${duration.inSeconds % 60}초';
|
||||
} else {
|
||||
return '${duration.inSeconds}초';
|
||||
}
|
||||
}
|
||||
|
||||
/// HTML 이스케이프
|
||||
String _escapeHtml(String text) {
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user