결재 템플릿 화면 위젯 테스트 추가

This commit is contained in:
JiWoong Sul
2025-09-25 15:23:50 +09:00
parent 1fbed565b7
commit 2accac85f3
2 changed files with 292 additions and 19 deletions

View File

@@ -212,8 +212,10 @@ class _ApprovalTemplateEnabledPageState
) )
: Column( : Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Expanded( SizedBox(
height: 480,
child: ShadTable.list( child: ShadTable.list(
header: header:
['ID', '템플릿코드', '템플릿명', '설명', '사용', '변경일시', '동작'] ['ID', '템플릿코드', '템플릿명', '설명', '사용', '변경일시', '동작']
@@ -250,12 +252,11 @@ class _ApprovalTemplateEnabledPageState
), ),
), ),
ShadTableCell( ShadTableCell(
child: ShadBadge( child: template.isActive
variant: template.isActive ? const ShadBadge(child: Text('사용'))
? ShadBadgeVariant.defaultVariant : const ShadBadge.outline(
: ShadBadgeVariant.outline, child: Text('미사용'),
child: Text(template.isActive ? '사용' : '미사용'), ),
),
), ),
ShadTableCell( ShadTableCell(
child: Text( child: Text(
@@ -451,6 +452,7 @@ class _ApprovalTemplateEnabledPageState
final statusNotifier = ValueNotifier<bool>(template?.isActive ?? true); final statusNotifier = ValueNotifier<bool>(template?.isActive ?? true);
bool isSaving = false; bool isSaving = false;
String? errorText; String? errorText;
StateSetter? modalSetState;
final result = await showSuperportDialog<bool>( final result = await showSuperportDialog<bool>(
context: context, context: context,
@@ -458,6 +460,7 @@ class _ApprovalTemplateEnabledPageState
barrierDismissible: !isSaving, barrierDismissible: !isSaving,
body: StatefulBuilder( body: StatefulBuilder(
builder: (dialogContext, setModalState) { builder: (dialogContext, setModalState) {
modalSetState = setModalState;
final theme = ShadTheme.of(dialogContext); final theme = ShadTheme.of(dialogContext);
return ConstrainedBox( return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640), constraints: const BoxConstraints(maxWidth: 640),
@@ -482,8 +485,8 @@ class _ApprovalTemplateEnabledPageState
label: '설명', label: '설명',
child: ShadTextarea( child: ShadTextarea(
controller: descriptionController, controller: descriptionController,
minLines: 2, minHeight: 80,
maxLines: 4, maxHeight: 200,
), ),
), ),
_FormField( _FormField(
@@ -510,8 +513,8 @@ class _ApprovalTemplateEnabledPageState
label: '비고', label: '비고',
child: ShadTextarea( child: ShadTextarea(
controller: noteController, controller: noteController,
minLines: 2, minHeight: 80,
maxLines: 4, maxHeight: 200,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -594,19 +597,19 @@ class _ApprovalTemplateEnabledPageState
final codeValue = codeController.text.trim(); final codeValue = codeController.text.trim();
final nameValue = nameController.text.trim(); final nameValue = nameController.text.trim();
if (!isEdit && codeValue.isEmpty) { if (!isEdit && codeValue.isEmpty) {
setModalState(() => errorText = '템플릿 코드를 입력하세요.'); modalSetState?.call(() => errorText = '템플릿 코드를 입력하세요.');
return; return;
} }
if (nameValue.isEmpty) { if (nameValue.isEmpty) {
setModalState(() => errorText = '템플릿명을 입력하세요.'); modalSetState?.call(() => errorText = '템플릿명을 입력하세요.');
return; return;
} }
final validation = _validateSteps(steps); final validation = _validateSteps(steps);
if (validation != null) { if (validation != null) {
setModalState(() => errorText = validation); modalSetState?.call(() => errorText = validation);
return; return;
} }
setModalState(() => errorText = null); modalSetState?.call(() => errorText = null);
final stepInputs = steps final stepInputs = steps
.map( .map(
(field) => ApprovalTemplateStepInput( (field) => ApprovalTemplateStepInput(
@@ -634,7 +637,7 @@ class _ApprovalTemplateEnabledPageState
: noteController.text.trim(), : noteController.text.trim(),
isActive: statusNotifier.value, isActive: statusNotifier.value,
); );
setModalState(() => isSaving = true); modalSetState?.call(() => isSaving = true);
final success = isEdit final success = isEdit
? await _controller.update( ? await _controller.update(
template!.id, template!.id,
@@ -645,7 +648,7 @@ class _ApprovalTemplateEnabledPageState
if (success != null && mounted) { if (success != null && mounted) {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
} else { } else {
setModalState(() => isSaving = false); modalSetState?.call(() => isSaving = false);
} }
}, },
child: isSaving child: isSaving
@@ -812,8 +815,8 @@ class _StepEditorRow extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
ShadTextarea( ShadTextarea(
controller: field.noteController, controller: field.noteController,
minLines: 1, minHeight: 60,
maxLines: 3, maxHeight: 160,
placeholder: const Text('비고 (선택)'), placeholder: const Text('비고 (선택)'),
), ),
if (isEdit && field.id != null) if (isEdit && field.id != null)

View File

@@ -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(<ApprovalTemplateStepInput>[]);
});
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<ApprovalTemplateRepository>(
() => 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<ApprovalTemplate>(
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<ApprovalTemplate>(
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<ApprovalTemplate>(
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);
});
});
}