결재 템플릿 단계 적용 구현

- ApprovalTemplate 엔티티·DTO·원격 리포지토리 추가
- ApprovalController에 템플릿 로딩/적용 상태와 assignSteps 호출 연동
- ApprovalPage 단계 탭에 템플릿 선택 UI 및 적용 확인 다이얼로그 구현
- 템플릿 적용 단위 테스트와 IMPLEMENTATION_TASKS 현황 갱신
This commit is contained in:
JiWoong Sul
2025-09-25 00:21:12 +09:00
parent b6e50464d2
commit c3010965ad
63 changed files with 10179 additions and 1436 deletions

View File

@@ -0,0 +1,181 @@
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/masters/menu/domain/entities/menu.dart';
import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart';
import 'package:superport_v2/features/masters/menu/presentation/pages/menu_page.dart';
class _MockMenuRepository extends Mock implements MenuRepository {}
class _FakeMenuInput extends Fake implements MenuInput {}
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(_FakeMenuInput());
});
tearDown(() async {
await GetIt.I.reset();
dotenv.clean();
});
testWidgets('플래그 Off 시 스펙 화면 노출', (tester) async {
dotenv.testLoad(fileInput: 'FEATURE_MENUS_ENABLED=false\n');
await tester.pumpWidget(_buildApp(const MenuPage()));
await tester.pump();
expect(find.text('메뉴 관리'), findsOneWidget);
expect(find.text('비활성화 (백엔드 준비 중)'), findsOneWidget);
});
group('플래그 On', () {
late _MockMenuRepository repository;
setUp(() {
dotenv.testLoad(fileInput: 'FEATURE_MENUS_ENABLED=true\n');
repository = _MockMenuRepository();
GetIt.I.registerLazySingleton<MenuRepository>(() => repository);
});
testWidgets('목록을 조회해 테이블을 렌더링한다', (tester) async {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
parentId: any(named: 'parentId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer(
(_) async => PaginatedResult<MenuItem>(
items: [MenuItem(id: 1, menuCode: 'MENU001', menuName: '대시보드')],
page: 1,
pageSize: 20,
total: 1,
),
);
await tester.pumpWidget(_buildApp(const MenuPage()));
await tester.pumpAndSettle();
expect(find.text('MENU001'), findsOneWidget);
});
testWidgets('신규 등록 폼 검증 에러 표시', (tester) async {
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
parentId: any(named: 'parentId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer(
(_) async => PaginatedResult<MenuItem>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
),
);
await tester.pumpWidget(_buildApp(const MenuPage()));
await tester.pumpAndSettle();
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.pump();
expect(find.text('메뉴코드를 입력하세요.'), findsOneWidget);
expect(find.text('메뉴명을 입력하세요.'), findsOneWidget);
});
testWidgets('신규 등록 성공 시 repository.create 호출', (tester) async {
var listCall = 0;
when(
() => repository.list(
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
query: any(named: 'query'),
parentId: any(named: 'parentId'),
isActive: any(named: 'isActive'),
includeDeleted: any(named: 'includeDeleted'),
),
).thenAnswer((_) async {
listCall += 1;
if (listCall == 1) {
return PaginatedResult<MenuItem>(
items: const [],
page: 1,
pageSize: 20,
total: 0,
);
}
return PaginatedResult<MenuItem>(
items: [MenuItem(id: 10, menuCode: 'MENU010', menuName: '신규 메뉴')],
page: 1,
pageSize: 20,
total: 1,
);
});
MenuInput? capturedInput;
when(() => repository.create(any())).thenAnswer((invocation) async {
capturedInput = invocation.positionalArguments.first as MenuInput;
return MenuItem(
id: 10,
menuCode: capturedInput!.menuCode,
menuName: capturedInput!.menuName,
);
});
await tester.pumpWidget(_buildApp(const MenuPage()));
await tester.pumpAndSettle();
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
final dialog = find.byType(Dialog);
final editableTexts = find.descendant(
of: dialog,
matching: find.byType(EditableText),
);
await tester.enterText(editableTexts.at(0), 'MENU010');
await tester.enterText(editableTexts.at(1), '신규 메뉴');
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
expect(capturedInput, isNotNull);
expect(capturedInput?.menuCode, 'MENU010');
expect(find.byType(Dialog), findsNothing);
expect(find.text('MENU010'), findsOneWidget);
verify(() => repository.create(any())).called(1);
});
});
}