import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:superport/data/datasources/remote/api_client.dart'; import 'package:superport/models/company_model.dart'; import 'package:superport/models/warehouse_location_model.dart'; import 'package:superport/services/auth_service.dart'; import 'package:superport/data/models/auth/login_request.dart'; import 'package:superport/services/company_service.dart'; import 'package:superport/services/warehouse_service.dart'; import 'package:superport/services/equipment_service.dart'; import 'package:superport/services/license_service.dart'; import 'package:superport/services/user_service.dart'; import '../../framework/core/screen_test_framework.dart'; import '../../framework/models/test_models.dart'; import '../../framework/models/report_models.dart' as report_models; import '../../framework/models/error_models.dart'; import 'package:dio/dio.dart'; /// 모든 화면 테스트의 기본 클래스 /// /// 이 클래스는 다음과 같은 기능을 제공합니다: /// - 공통 CRUD 테스트 패턴의 표준화된 구현 /// - 에러 자동 진단 및 수정 플로우 /// - 테스트 데이터 자동 생성/정리 /// - 병렬 테스트 실행을 위한 격리 보장 /// - 화면별 특수 기능 테스트를 위한 확장 포인트 abstract class BaseScreenTest extends ScreenTestFramework { final ApiClient apiClient; final GetIt getIt; // 테스트 격리를 위한 고유 식별자 late final String testSessionId; // 병렬 실행을 위한 잠금 메커니즘 static final Map> _resourceLocks = {}; // 자동 재시도 설정 static const int maxRetryAttempts = 3; static const Duration retryDelay = Duration(seconds: 1); BaseScreenTest({ required this.apiClient, required this.getIt, required super.testContext, required super.errorDiagnostics, required super.autoFixer, required super.dataGenerator, required super.reportCollector, }) { // 테스트 세션 ID 생성 (병렬 실행 시 격리 보장) testSessionId = '${getScreenMetadata().screenName}_${DateTime.now().millisecondsSinceEpoch}'; } /// 화면 메타데이터 가져오기 ScreenMetadata getScreenMetadata(); /// 서비스 초기화 Future initializeServices(); /// 테스트 환경 설정 Future setupTestEnvironment() async { _log('테스트 환경 설정 시작 (세션: $testSessionId)'); try { // 서비스 초기화 await initializeServices(); // 인증 확인 (재시도 로직 포함) await _retryWithBackoff( () => _ensureAuthenticated(), '인증 확인', ); // 기본 데이터 설정 await _setupBaseData(); // 화면별 추가 설정 await performAdditionalSetup(); _log('테스트 환경 설정 완료'); } catch (e) { _log('테스트 환경 설정 실패: $e'); throw TestSetupError( message: '테스트 환경 설정 실패', details: {'error': e.toString(), 'sessionId': testSessionId}, ); } } /// 테스트 환경 정리 Future teardownTestEnvironment() async { _log('테스트 환경 정리 시작'); try { // 화면별 추가 정리 await performAdditionalCleanup(); // 생성된 데이터 정리 (역순으로 삭제) await _cleanupTestData(); // 서비스 정리 await _cleanupServices(); // 잠금 해제 _releaseAllLocks(); _log('테스트 환경 정리 완료'); } catch (e) { _log('테스트 환경 정리 중 오류 (무시): $e'); } } /// 테스트 실행 Future runTests() async { final metadata = getScreenMetadata(); testContext.currentScreen = metadata.screenName; final startTime = DateTime.now(); _log('\n${'=' * 60}'); _log('${metadata.screenName} 테스트 시작'); _log('${'=' * 60}\n'); try { // 환경 설정 await setupTestEnvironment(); // 기능 감지 final features = await detectFeatures(metadata); _log('감지된 기능: ${features.items.map((f) => f.featureName).join(', ')}'); // 테스트 실행 final result = await executeTests(features); final duration = DateTime.now().difference(startTime); _log('\n테스트 완료 (소요시간: ${duration.inSeconds}초)'); _log('결과: 총 ${result.totalTests}개, 성공 ${result.passedTests}개, 실패 ${result.failedTests}개\n'); return result; } catch (e, stackTrace) { _log('테스트 실행 중 치명적 오류: $e'); _log('스택 트레이스: $stackTrace'); // 오류 리포트 생성 return report_models.TestResult( totalTests: 0, passedTests: 0, failedTests: 1, skippedTests: 0, failures: [ report_models.TestFailure( feature: metadata.screenName, message: '테스트 실행 중 치명적 오류: $e', stackTrace: stackTrace.toString(), ), ], ); } finally { // 환경 정리 await teardownTestEnvironment(); } } /// 인증 확인 Future _ensureAuthenticated() async { try { final authService = getIt.get(); final isAuthenticated = await authService.isLoggedIn(); if (!isAuthenticated) { // 로그인 시도 final loginRequest = LoginRequest( email: testContext.getConfig('testEmail') ?? 'admin@superport.kr', password: testContext.getConfig('testPassword') ?? 'admin123!', ); await authService.login(loginRequest); } } catch (e) { throw TestError( message: '인증 실패: $e', timestamp: DateTime.now(), feature: 'Authentication', ); } } /// 기본 데이터 설정 Future _setupBaseData() async { // 회사 데이터 확인/생성 await _ensureCompanyExists(); // 창고 데이터 확인/생성 await _ensureWarehouseExists(); } /// 회사 데이터 확인/생성 Future _ensureCompanyExists() async { try { final companyService = getIt.get(); final companies = await companyService.getCompanies(page: 1, perPage: 1); if (companies.isEmpty) { // 테스트용 회사 생성 final companyData = await dataGenerator.generate( GenerationStrategy( dataType: Company, fields: [], relationships: [], constraints: {}, ), ); final company = await companyService.createCompany(companyData.data); testContext.setData('testCompanyId', company.id); } else { testContext.setData('testCompanyId', companies.first.id); } } catch (e) { // 회사 생성은 선택사항이므로 에러 무시 debugPrint('회사 데이터 설정 실패: $e'); } } /// 창고 데이터 확인/생성 Future _ensureWarehouseExists() async { try { final warehouseService = getIt.get(); final companyId = testContext.getData('testCompanyId'); if (companyId != null) { final warehouses = await warehouseService.getWarehouseLocations( page: 1, perPage: 1, ); if (warehouses.isEmpty) { // 테스트용 창고 생성 final warehouseData = await dataGenerator.generate( GenerationStrategy( dataType: WarehouseLocation, fields: [], relationships: [], constraints: {}, ), ); warehouseData.data['company_id'] = companyId; final warehouse = await warehouseService.createWarehouseLocation(warehouseData.data); testContext.setData('testWarehouseId', warehouse.id); } else { testContext.setData('testWarehouseId', warehouses.first.id); } } } catch (e) { // 창고 생성은 선택사항이므로 에러 무시 debugPrint('창고 데이터 설정 실패: $e'); } } /// 테스트 데이터 정리 Future _cleanupTestData() async { final createdIds = testContext.getAllCreatedResourceIds(); final resourcesByType = >{}; // createdIds를 resourceType별로 분류 for (final id in createdIds) { final parts = id.split(':'); if (parts.items.length == 2) { final resourceType = parts[0]; final resourceId = parts[1]; resourcesByType.putIfAbsent(resourceType, () => []).add(resourceId); } } for (final entry in resourcesByType.entries) { final resourceType = entry.key; final ids = entry.value; for (final id in ids) { try { await _deleteResource(resourceType, id); } catch (e) { // 삭제 실패는 무시 debugPrint('리소스 삭제 실패: $resourceType/$id - $e'); } } } } /// 리소스 삭제 Future _deleteResource(String resourceType, String id) async { switch (resourceType) { case 'equipment': final service = getIt.get(); await service.deleteEquipment(int.parse(id)); break; case 'license': final service = getIt.get(); await service.deleteLicense(int.parse(id)); break; case 'user': final service = getIt.get(); await service.deleteUser(int.parse(id)); break; case 'warehouse': final service = getIt.get(); await service.deleteWarehouseLocation(int.parse(id)); break; case 'company': final service = getIt.get(); await service.deleteCompany(int.parse(id)); break; } } /// 서비스 정리 Future _cleanupServices() async { // 필요시 서비스 정리 로직 추가 } /// 공통 CRUD 작업 구현 - Create @override Future performCreate(TestData data) async { _log('[CREATE] 시작: ${getResourceType()}'); try { // 생성 전 데이터 검증 await validateDataBeforeCreate(data); // 서비스 호출 (재시도 로직 포함) final result = await _retryWithBackoff( () => performCreateOperation(data), 'CREATE 작업', ); // 생성된 리소스 ID 저장 final resourceId = extractResourceId(result); testContext.addCreatedResourceId(getResourceType(), resourceId.toString()); testContext.setData('lastCreatedId', resourceId); testContext.setData('lastCreatedResource', result); _log('[CREATE] 성공: ID=$resourceId'); } catch (e) { _log('[CREATE] 실패: $e'); // 에러 자동 진단 및 수정 시도 final fixed = await _handleCrudError(e, 'CREATE', data); if (!fixed) { rethrow; } } } @override Future verifyCreate(TestData data) async { final lastCreatedId = testContext.getData('lastCreatedId'); expect(lastCreatedId, isNotNull, reason: '리소스 생성 실패'); // 생성된 리소스 조회하여 검증 final service = getService(); final result = await service.getById(lastCreatedId); expect(result, isNotNull, reason: '생성된 리소스를 찾을 수 없음'); } @override Future performRead(TestData data) async { _log('[READ] 시작: ${getResourceType()}'); try { // 읽기 작업 수행 (재시도 로직 포함) final results = await _retryWithBackoff( () => performReadOperation(data), 'READ 작업', ); testContext.setData('readResults', results); testContext.setData('readCount', results is List ? results.items.length : 1); _log('[READ] 성공: ${results is List ? results.items.length : 1}개 항목'); } catch (e) { _log('[READ] 실패: $e'); // 에러 자동 진단 및 수정 시도 final fixed = await _handleCrudError(e, 'READ', data); if (!fixed) { rethrow; } } } @override Future verifyRead(TestData data) async { final readResults = testContext.getData('readResults'); expect(readResults, isNotNull, reason: '목록 조회 실패'); expect(readResults, isA(), reason: '올바른 목록 형식이 아님'); } @override Future performUpdate(TestData data) async { _log('[UPDATE] 시작: ${getResourceType()}'); try { // 업데이트할 리소스 확보 final resourceId = await _ensureResourceForUpdate(data); // 업데이트 데이터 준비 final updateData = await prepareUpdateData(data, resourceId); // 업데이트 수행 (재시도 로직 포함) final result = await _retryWithBackoff( () => performUpdateOperation(resourceId, updateData), 'UPDATE 작업', ); testContext.setData('updateResult', result); testContext.setData('lastUpdatedId', resourceId); _log('[UPDATE] 성공: ID=$resourceId'); } catch (e) { _log('[UPDATE] 실패: $e'); // 에러 자동 진단 및 수정 시도 final fixed = await _handleCrudError(e, 'UPDATE', data); if (!fixed) { rethrow; } } } @override Future verifyUpdate(TestData data) async { final updateResult = testContext.getData('updateResult'); expect(updateResult, isNotNull, reason: '업데이트 실패'); // 업데이트된 내용 확인 final lastCreatedId = testContext.getData('lastCreatedId'); final service = getService(); final result = await service.getById(lastCreatedId); expect(result.name, contains('Updated'), reason: '업데이트가 반영되지 않음'); } @override Future performDelete(TestData data) async { _log('[DELETE] 시작: ${getResourceType()}'); try { // 삭제할 리소스 확보 final resourceId = await _ensureResourceForDelete(data); // 삭제 수행 (재시도 로직 포함) await _retryWithBackoff( () => performDeleteOperation(resourceId), 'DELETE 작업', ); testContext.setData('deleteCompleted', true); testContext.setData('lastDeletedId', resourceId); // 생성된 리소스 목록에서 제거 testContext.removeCreatedResourceId(getResourceType(), resourceId.toString()); _log('[DELETE] 성공: ID=$resourceId'); } catch (e) { _log('[DELETE] 실패: $e'); // 에러 자동 진단 및 수정 시도 final fixed = await _handleCrudError(e, 'DELETE', data); if (!fixed) { rethrow; } } } @override Future verifyDelete(TestData data) async { final deleteCompleted = testContext.getData('deleteCompleted'); expect(deleteCompleted, isTrue, reason: '삭제 작업이 완료되지 않음'); // 삭제된 리소스 조회 시도 final lastCreatedId = testContext.getData('lastCreatedId'); final service = getService(); try { await service.getById(lastCreatedId); fail('삭제된 리소스가 여전히 존재함'); } catch (e) { // 예상된 에러 - 리소스를 찾을 수 없음 } } @override Future performSearch(TestData data) async { // 검색할 데이터 먼저 생성 await performCreate(data); final service = getService(); final searchKeyword = data.data['name']?.toString().split(' ').items.first ?? 'test'; final results = await service.search(searchKeyword); testContext.setData('searchResults', results); testContext.setData('searchKeyword', searchKeyword); } @override Future verifySearch(TestData data) async { final searchResults = testContext.getData('searchResults'); final searchKeyword = testContext.getData('searchKeyword'); expect(searchResults, isNotNull, reason: '검색 결과가 없음'); expect(searchResults, isA(), reason: '올바른 검색 결과 형식이 아님'); if (searchResults.items.isNotEmpty) { // 검색 결과가 키워드를 포함하는지 확인 final firstResult = searchResults.items.first; expect( firstResult.toString().toLowerCase(), contains(searchKeyword.toLowerCase()), reason: '검색 결과가 키워드를 포함하지 않음', ); } } @override Future performFilter(TestData data) async { final service = getService(); // 필터 조건 설정 final filters = getDefaultFilters(); final results = await service.getListWithFilters(filters); testContext.setData('filterResults', results); testContext.setData('appliedFilters', filters); } @override Future verifyFilter(TestData data) async { final filterResults = testContext.getData('filterResults'); expect(filterResults, isNotNull, reason: '필터 결과가 없음'); expect(filterResults, isA(), reason: '올바른 필터 결과 형식이 아님'); } @override Future performPagination(TestData data) async { final service = getService(); // 첫 페이지 조회 final page1 = await service.getList(page: 1, perPage: 5); testContext.setData('page1Results', page1); // 두 번째 페이지 조회 final page2 = await service.getList(page: 2, perPage: 5); testContext.setData('page2Results', page2); } @override Future verifyPagination(TestData data) async { final page1Results = testContext.getData('page1Results'); final page2Results = testContext.getData('page2Results'); expect(page1Results, isNotNull, reason: '첫 페이지 결과가 없음'); expect(page2Results, isNotNull, reason: '두 번째 페이지 결과가 없음'); // 페이지별 결과가 다른지 확인 (데이터가 충분한 경우) if (page1Results.items.isNotEmpty && page2Results.items.isNotEmpty) { expect( page1Results.items.first.id != page2Results.items.first.id, isTrue, reason: '페이지네이션이 올바르게 작동하지 않음', ); } } // ===== 하위 클래스에서 구현해야 할 추상 메서드들 ===== /// 서비스 인스턴스 가져오기 dynamic getService(); /// 리소스 타입 가져오기 String getResourceType(); /// 기본 필터 설정 가져오기 Map getDefaultFilters(); // ===== CRUD 작업 구현을 위한 추상 메서드들 ===== /// 실제 생성 작업 수행 Future performCreateOperation(TestData data); /// 실제 읽기 작업 수행 Future performReadOperation(TestData data); /// 실제 업데이트 작업 수행 Future performUpdateOperation(dynamic resourceId, Map updateData); /// 실제 삭제 작업 수행 Future performDeleteOperation(dynamic resourceId); /// 생성된 객체에서 ID 추출 dynamic extractResourceId(dynamic resource); // ===== 선택적 구현 메서드들 (기본 구현 제공) ===== /// 생성 전 데이터 검증 Future validateDataBeforeCreate(TestData data) async { // 기본적으로 검증 없음, 필요시 오버라이드 } /// 업데이트 데이터 준비 Future> prepareUpdateData(TestData data, dynamic resourceId) async { // 기본 구현: 이름에 'Updated' 추가 final updateData = Map.from(data.data); if (updateData.containsKey('name')) { updateData['name'] = '${updateData['name']} - Updated'; } return updateData; } /// 추가 설정 수행 (setupTestEnvironment에서 호출) Future performAdditionalSetup() async { // 기본적으로 추가 설정 없음, 필요시 오버라이드 } /// 추가 정리 수행 (teardownTestEnvironment에서 호출) Future performAdditionalCleanup() async { // 기본적으로 추가 정리 없음, 필요시 오버라이드 } // ===== 에러 처리 및 자동 수정 메서드들 ===== /// CRUD 작업 중 발생한 에러 처리 Future _handleCrudError(dynamic error, String operation, TestData data) async { _log('에러 자동 처리 시작: $operation'); try { // DioException으로 변환 final dioError = _convertToDioException(error); // API 에러로 변환 final apiError = ApiError( originalError: dioError, requestUrl: dioError.requestOptions.path, requestMethod: dioError.requestOptions.method, statusCode: dioError.response?.statusCode, message: error.toString(), requestBody: data.data, timestamp: DateTime.now(), ); // 에러 진단 final diagnosis = await errorDiagnostics.diagnose(apiError); _log('진단 결과: ${diagnosis.errorType} - ${diagnosis.description}'); // 자동 수정 시도 if (diagnosis.confidence > 0.7) { final fixResult = await autoFixer.attemptAutoFix(diagnosis); if (fixResult.success) { _log('자동 수정 성공: ${fixResult.executedActions.items.length}개 액션 적용'); // 수정 액션 적용 (AutoFixResult는 String 액션을 반환) // TODO: String 액션을 FixAction으로 변환하거나 별도 처리 필요 // for (final action in fixResult.executedActions) { // await _applyFixAction(action, data); // } return true; } else { _log('자동 수정 실패: $fixResult.error'); } } } catch (e) { _log('에러 처리 중 예외 발생: $e'); } return false; } /// 수정 액션 적용 Future _applyFixAction(FixAction action, TestData data) async { switch (action.type) { case FixActionType.updateField: final field = action.parameters['field'] as String?; final value = action.parameters['value']; if (field != null && value != null) { data.data[field] = value; _log('필드 업데이트: $field = $value'); } break; case FixActionType.createMissingResource: final resourceType = action.parameters['resourceType'] as String?; if (resourceType != null) { await _createMissingResource(resourceType, action.parameters); } break; case FixActionType.retryWithDelay: final delay = action.parameters['delay'] as int? ?? 1000; await Future.delayed(Duration(milliseconds: delay)); _log('${delay}ms 대기 후 재시도'); break; default: _log('알 수 없는 수정 액션: $action.type'); } } /// 누락된 리소스 생성 Future _createMissingResource(String resourceType, Map metadata) async { _log('누락된 리소스 자동 생성: $resourceType'); switch (resourceType.toLowerCase()) { case 'company': await _ensureCompanyExists(); break; case 'warehouse': await _ensureCompanyExists(); await _ensureWarehouseExists(); break; default: _log('자동 생성을 지원하지 않는 리소스 타입: $resourceType'); } } /// 일반 에러를 DioException으로 변환 DioException _convertToDioException(dynamic error) { if (error is DioException) { return error; } return DioException( requestOptions: RequestOptions( path: '/api/v1/${getResourceType()}', method: 'POST', ), message: error.toString(), type: DioExceptionType.unknown, ); } // ===== 재시도 및 병렬 실행 지원 메서드들 ===== /// 백오프를 포함한 재시도 로직 Future _retryWithBackoff( Future Function() operation, String operationName, ) async { int attempt = 0; dynamic lastError; while (attempt < maxRetryAttempts) { try { return await operation(); } catch (e) { lastError = e; attempt++; if (attempt < maxRetryAttempts) { final delay = retryDelay * attempt; _log('$operationName 실패 (시도 $attempt/$maxRetryAttempts), ${delay.inSeconds}초 후 재시도...'); await Future.delayed(delay); } } } _log('$operationName 최종 실패 ($maxRetryAttempts회 시도)'); throw lastError; } /// 모든 잠금 해제 void _releaseAllLocks() { for (final entry in _resourceLocks.entries) { if (entry.key.contains(testSessionId)) { entry.value.complete(); } } _resourceLocks.removeWhere((key, _) => key.contains(testSessionId)); } // ===== 헬퍼 메서드들 ===== /// 업데이트를 위한 리소스 확보 Future _ensureResourceForUpdate(TestData data) async { var resourceId = testContext.getData('lastCreatedId'); if (resourceId == null) { _log('업데이트할 리소스가 없어 새로 생성'); await performCreate(data); resourceId = testContext.getData('lastCreatedId'); } return resourceId; } /// 삭제를 위한 리소스 확보 Future _ensureResourceForDelete(TestData data) async { var resourceId = testContext.getData('lastCreatedId'); if (resourceId == null) { _log('삭제할 리소스가 없어 새로 생성'); await performCreate(data); resourceId = testContext.getData('lastCreatedId'); } return resourceId; } /// 로깅 메서드 void _log(String message) { final screenName = getScreenMetadata().screenName; // 리포트 수집기에 로그 추가 (print 대신 사용) reportCollector.addStep( report_models.StepReport( stepName: screenName, timestamp: DateTime.now(), success: !message.contains('실패') && !message.contains('에러'), message: message, details: {'sessionId': testSessionId}, ), ); } } /// 테스트 설정 오류 class TestSetupError implements Exception { final String message; final Map details; TestSetupError({ required this.message, required this.details, }); @override String toString() => 'TestSetupError: $message ($details)'; }