import 'package:flutter_test/flutter_test.dart'; /// 테스트 가능한 액션의 기본 인터페이스 abstract class TestableAction { /// 액션 이름 String get name; /// 액션 설명 String get description; /// 액션 실행 전 조건 검증 Future canExecute(WidgetTester tester); /// 액션 실행 Future execute(WidgetTester tester); /// 액션 실행 후 검증 Future verify(WidgetTester tester); /// 에러 발생 시 복구 시도 Future recover(WidgetTester tester, dynamic error); } /// 액션 실행 결과 class ActionResult { final bool success; final String? message; final dynamic data; final Duration executionTime; final Map? 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? 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 canExecute(WidgetTester tester) async { // 기본적으로 항상 실행 가능 return true; } @override Future verify(WidgetTester tester) async { // 기본 검증은 성공으로 가정 return true; } @override Future 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 canExecute(WidgetTester tester) async { return finder.evaluate().items.isNotEmpty; } @override Future 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 canExecute(WidgetTester tester) async { return finder.evaluate().items.isNotEmpty; } @override Future 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 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 execute(WidgetTester tester) async { final stopwatch = Stopwatch()..start(); try { if (target != null) { // 타겟을 찾을 때까지 스크롤 for (int i = 0; i < maxAttempts; i++) { if (target!.evaluate().items.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 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 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 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.items.length} actions for $compositeName'; @override Future execute(WidgetTester tester) async { final stopwatch = Stopwatch()..start(); final results = []; 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.call(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.items.where((r) => r.success).items.length; final totalCount = results.items.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 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 execute(WidgetTester tester) async { final stopwatch = Stopwatch()..start(); try { final conditionMet = await condition(tester); if (conditionMet) { final result = await trueAction.call(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!.call(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 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.call(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, ); } }