From 2accac85f358f0f4c4c79af847d6d09977371ed6 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 25 Sep 2025 15:23:50 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=B0=EC=9E=AC=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=ED=99=94=EB=A9=B4=20=EC=9C=84=EC=A0=AF=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/approval_template_page.dart | 41 +-- .../pages/approval_template_page_test.dart | 270 ++++++++++++++++++ 2 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 test/features/approvals/template/presentation/pages/approval_template_page_test.dart diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart index aff763c..15bc610 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -212,8 +212,10 @@ class _ApprovalTemplateEnabledPageState ) : Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( + SizedBox( + height: 480, child: ShadTable.list( header: ['ID', '템플릿코드', '템플릿명', '설명', '사용', '변경일시', '동작'] @@ -250,12 +252,11 @@ class _ApprovalTemplateEnabledPageState ), ), ShadTableCell( - child: ShadBadge( - variant: template.isActive - ? ShadBadgeVariant.defaultVariant - : ShadBadgeVariant.outline, - child: Text(template.isActive ? '사용' : '미사용'), - ), + child: template.isActive + ? const ShadBadge(child: Text('사용')) + : const ShadBadge.outline( + child: Text('미사용'), + ), ), ShadTableCell( child: Text( @@ -451,6 +452,7 @@ class _ApprovalTemplateEnabledPageState final statusNotifier = ValueNotifier(template?.isActive ?? true); bool isSaving = false; String? errorText; + StateSetter? modalSetState; final result = await showSuperportDialog( context: context, @@ -458,6 +460,7 @@ class _ApprovalTemplateEnabledPageState barrierDismissible: !isSaving, body: StatefulBuilder( builder: (dialogContext, setModalState) { + modalSetState = setModalState; final theme = ShadTheme.of(dialogContext); return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 640), @@ -482,8 +485,8 @@ class _ApprovalTemplateEnabledPageState label: '설명', child: ShadTextarea( controller: descriptionController, - minLines: 2, - maxLines: 4, + minHeight: 80, + maxHeight: 200, ), ), _FormField( @@ -510,8 +513,8 @@ class _ApprovalTemplateEnabledPageState label: '비고', child: ShadTextarea( controller: noteController, - minLines: 2, - maxLines: 4, + minHeight: 80, + maxHeight: 200, ), ), const SizedBox(height: 16), @@ -594,19 +597,19 @@ class _ApprovalTemplateEnabledPageState final codeValue = codeController.text.trim(); final nameValue = nameController.text.trim(); if (!isEdit && codeValue.isEmpty) { - setModalState(() => errorText = '템플릿 코드를 입력하세요.'); + modalSetState?.call(() => errorText = '템플릿 코드를 입력하세요.'); return; } if (nameValue.isEmpty) { - setModalState(() => errorText = '템플릿명을 입력하세요.'); + modalSetState?.call(() => errorText = '템플릿명을 입력하세요.'); return; } final validation = _validateSteps(steps); if (validation != null) { - setModalState(() => errorText = validation); + modalSetState?.call(() => errorText = validation); return; } - setModalState(() => errorText = null); + modalSetState?.call(() => errorText = null); final stepInputs = steps .map( (field) => ApprovalTemplateStepInput( @@ -634,7 +637,7 @@ class _ApprovalTemplateEnabledPageState : noteController.text.trim(), isActive: statusNotifier.value, ); - setModalState(() => isSaving = true); + modalSetState?.call(() => isSaving = true); final success = isEdit ? await _controller.update( template!.id, @@ -645,7 +648,7 @@ class _ApprovalTemplateEnabledPageState if (success != null && mounted) { Navigator.of(context).pop(true); } else { - setModalState(() => isSaving = false); + modalSetState?.call(() => isSaving = false); } }, child: isSaving @@ -812,8 +815,8 @@ class _StepEditorRow extends StatelessWidget { const SizedBox(height: 8), ShadTextarea( controller: field.noteController, - minLines: 1, - maxLines: 3, + minHeight: 60, + maxHeight: 160, placeholder: const Text('비고 (선택)'), ), if (isEdit && field.id != null) diff --git a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart new file mode 100644 index 0000000..73548d1 --- /dev/null +++ b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart'; + +class _MockApprovalTemplateRepository extends Mock + implements ApprovalTemplateRepository {} + +class _FakeTemplateInput extends Fake implements ApprovalTemplateInput {} + +class _FakeTemplateStepInput extends Fake + implements ApprovalTemplateStepInput {} + +Widget _buildApp(Widget child) { + return MaterialApp( + home: ShadTheme( + data: ShadThemeData( + colorScheme: const ShadSlateColorScheme.light(), + brightness: Brightness.light, + ), + child: Scaffold(body: child), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(_FakeTemplateInput()); + registerFallbackValue(_FakeTemplateStepInput()); + registerFallbackValue([]); + }); + + tearDown(() async { + await GetIt.I.reset(); + dotenv.clean(); + }); + + testWidgets('플래그 Off 시 스펙 페이지를 노출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=false\n'); + + await tester.pumpWidget(_buildApp(const ApprovalTemplatePage())); + await tester.pump(); + + expect(find.text('결재 템플릿 관리'), findsOneWidget); + expect(find.text('반복되는 결재 단계를 템플릿으로 구성합니다.'), findsOneWidget); + }); + + group('플래그 On', () { + late _MockApprovalTemplateRepository repository; + + setUp(() { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + repository = _MockApprovalTemplateRepository(); + GetIt.I.registerLazySingleton( + () => repository, + ); + }); + + ApprovalTemplate _template({bool isActive = true}) { + return ApprovalTemplate( + id: 10, + code: 'AP_INBOUND', + name: '입고 템플릿', + description: '입고 2단계', + note: '기본 템플릿', + isActive: isActive, + createdBy: null, + createdAt: DateTime(2024, 4, 1, 9), + updatedAt: DateTime(2024, 4, 2, 9), + steps: [ + ApprovalTemplateStep( + id: 1, + stepOrder: 1, + approver: ApprovalTemplateApprover( + id: 21, + employeeNo: 'E001', + name: '최승인', + ), + note: '확인', + ), + ], + ); + } + + testWidgets('목록을 조회해 테이블로 렌더링한다', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [_template()], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + await tester.pumpWidget(_buildApp(const ApprovalTemplatePage())); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('AP_INBOUND'), findsOneWidget); + expect(find.text('입고 템플릿'), findsOneWidget); + + verify( + () => + repository.list(page: 1, pageSize: 20, query: null, isActive: null), + ).called(1); + }); + + testWidgets('생성 플로우에서 create 호출 후 목록을 새로고침한다', (tester) async { + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: const [], + page: 1, + pageSize: 20, + total: 0, + ), + ); + + when( + () => repository.create(any(), steps: any(named: 'steps')), + ).thenAnswer((_) async => _template()); + + await tester.pumpWidget(_buildApp(const ApprovalTemplatePage())); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(find.text('템플릿 생성')); + await tester.pumpAndSettle(); + + final dialogFieldsFinder = find.descendant( + of: find.byType(Dialog), + matching: find.byType(EditableText), + skipOffstage: false, + ); + final dialogFieldElements = dialogFieldsFinder.evaluate().toList(); + expect(dialogFieldElements.length, greaterThanOrEqualTo(4)); + + await tester.enterText( + find.byWidget(dialogFieldElements[0].widget), + 'AP_NEW', + ); + await tester.enterText( + find.byWidget(dialogFieldElements[1].widget), + '신규 템플릿', + ); + + final stepFieldsFinder = find.descendant( + of: find.byKey(const ValueKey('step_field_0')), + matching: find.byType(EditableText), + skipOffstage: false, + ); + final stepFieldElements = stepFieldsFinder.evaluate().toList(); + expect(stepFieldElements.length, greaterThanOrEqualTo(2)); + + await tester.enterText(find.byWidget(stepFieldElements[1].widget), '33'); + + await tester.tap(find.text('생성 완료')); + await tester.pump(); + await tester.pumpAndSettle(); + + verify( + () => repository.create(any(), steps: any(named: 'steps')), + ).called(1); + verify( + () => + repository.list(page: 1, pageSize: 20, query: null, isActive: null), + ).called(greaterThanOrEqualTo(2)); + + expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget); + }); + + testWidgets('수정 플로우에서 fetchDetail 후 update를 호출한다', (tester) async { + final activeTemplate = _template(); + + when( + () => repository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + isActive: any(named: 'isActive'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [activeTemplate], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + when( + () => repository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + ), + ).thenAnswer((_) async => activeTemplate); + + when( + () => repository.update(any(), any(), steps: any(named: 'steps')), + ).thenAnswer((_) async => activeTemplate.copyWith(name: '수정된 템플릿')); + + await tester.pumpWidget(_buildApp(const ApprovalTemplatePage())); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('입고 템플릿'), findsOneWidget); + verify( + () => + repository.list(page: 1, pageSize: 20, query: null, isActive: null), + ).called(1); + + await tester.dragUntilVisible( + find.text('수정'), + find.text('입고 템플릿'), + const Offset(-200, 0), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('수정').first); + await tester.pump(); + await tester.pumpAndSettle(); + + final editDialogFields = find.descendant( + of: find.byType(Dialog), + matching: find.byType(EditableText), + skipOffstage: false, + ); + final editFieldElements = editDialogFields.evaluate().toList(); + expect(editFieldElements.length, greaterThanOrEqualTo(1)); + + await tester.enterText( + find.byWidget(editFieldElements[0].widget), + '수정된 템플릿', + ); + + await tester.tap(find.text('수정 완료')); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => repository.fetchDetail(10, includeSteps: true)).called(1); + verify( + () => repository.update(10, any(), steps: any(named: 'steps')), + ).called(1); + + expect(find.text('템플릿 "입고 템플릿"을(를) 수정했습니다.'), findsOneWidget); + }); + }); +}