feat(dialog): 상세 팝업 SuperportDetailDialog 통합
- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화 - 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환 - SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거 - 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지 - detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
@@ -16,6 +16,7 @@ import 'package:superport_v2/features/approvals/domain/repositories/approval_tem
|
||||
import 'package:superport_v2/features/approvals/presentation/pages/approval_page.dart';
|
||||
import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart';
|
||||
import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_detail_dialog.dart';
|
||||
|
||||
import '../../helpers/test_app.dart';
|
||||
import '../../helpers/fixture_loader.dart';
|
||||
@@ -43,6 +44,19 @@ void main() {
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Future<void> selectDetailTab(WidgetTester tester, String id) async {
|
||||
final tabsFinder = find.descendant(
|
||||
of: find.byType(SuperportDetailDialog),
|
||||
matching: find.byType(ShadTabs<String>),
|
||||
);
|
||||
final tabsState = tester.state(tabsFinder);
|
||||
final controller =
|
||||
(tabsState as dynamic).controller as ShadTabsController<String>;
|
||||
controller.select(id);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true');
|
||||
await Environment.initialize();
|
||||
@@ -95,10 +109,7 @@ void main() {
|
||||
await tester.tap(rowFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tabContext = tester.element(find.byType(TabBar));
|
||||
final tabController = DefaultTabController.of(tabContext);
|
||||
tabController.animateTo(1);
|
||||
await tester.pumpAndSettle();
|
||||
await selectDetailTab(tester, 'steps');
|
||||
|
||||
final approveButton = tester.widget<ShadButton>(
|
||||
find.byKey(const ValueKey('step_action_100_approve')),
|
||||
@@ -128,10 +139,7 @@ void main() {
|
||||
await tester.tap(rowFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tabContext = tester.element(find.byType(TabBar));
|
||||
final tabController = DefaultTabController.of(tabContext);
|
||||
tabController.animateTo(1);
|
||||
await tester.pumpAndSettle();
|
||||
await selectDetailTab(tester, 'steps');
|
||||
|
||||
final approveButton = tester.widget<ShadButton>(
|
||||
find.byKey(const ValueKey('step_action_100_approve')),
|
||||
@@ -161,10 +169,7 @@ void main() {
|
||||
await tester.tap(rowFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tabContext = tester.element(find.byType(TabBar));
|
||||
final tabController = DefaultTabController.of(tabContext);
|
||||
tabController.animateTo(1);
|
||||
await tester.pumpAndSettle();
|
||||
await selectDetailTab(tester, 'steps');
|
||||
|
||||
expect(find.textContaining('선행 단계가 완료되지 않았습니다.'), findsWidgets);
|
||||
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
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.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart';
|
||||
import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart';
|
||||
import 'package:superport_v2/features/approvals/history/presentation/dialogs/approval_history_detail_dialog.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
|
||||
import '../../../../../helpers/test_app.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
||||
|
||||
late _FakeApprovalRepository approvalRepository;
|
||||
late _FakeApprovalHistoryRepository historyRepository;
|
||||
late ApprovalHistoryController controller;
|
||||
late ApprovalHistoryRecord record;
|
||||
late AuthenticatedUser user;
|
||||
late Approval sampleApproval;
|
||||
late ApprovalStatus statusInProgress;
|
||||
late ApprovalStatus statusCompleted;
|
||||
late ApprovalStatus statusRejected;
|
||||
|
||||
setUp(() {
|
||||
approvalRepository = _FakeApprovalRepository();
|
||||
historyRepository = _FakeApprovalHistoryRepository();
|
||||
|
||||
statusInProgress = ApprovalStatus(
|
||||
id: 1,
|
||||
name: '진행중',
|
||||
color: '#0EA5E9',
|
||||
isTerminal: false,
|
||||
);
|
||||
statusCompleted = ApprovalStatus(
|
||||
id: 2,
|
||||
name: '완료',
|
||||
color: '#22C55E',
|
||||
isTerminal: true,
|
||||
);
|
||||
statusRejected = ApprovalStatus(
|
||||
id: 3,
|
||||
name: '반려',
|
||||
color: '#F97316',
|
||||
isTerminal: true,
|
||||
);
|
||||
|
||||
final requester = ApprovalRequester(
|
||||
id: 901,
|
||||
employeeNo: 'E901',
|
||||
name: '상신자',
|
||||
);
|
||||
final approver1 = ApprovalApprover(
|
||||
id: 801,
|
||||
employeeNo: 'E801',
|
||||
name: '선결자',
|
||||
);
|
||||
final approver2 = ApprovalApprover(
|
||||
id: 802,
|
||||
employeeNo: 'E802',
|
||||
name: '최종자',
|
||||
);
|
||||
|
||||
final steps = [
|
||||
ApprovalStep(
|
||||
id: 1001,
|
||||
stepOrder: 1,
|
||||
approver: approver1,
|
||||
status: statusInProgress,
|
||||
assignedAt: DateTime(2024, 1, 10, 9),
|
||||
note: '1단계',
|
||||
),
|
||||
ApprovalStep(
|
||||
id: 1002,
|
||||
stepOrder: 2,
|
||||
approver: approver2,
|
||||
status: statusCompleted,
|
||||
assignedAt: DateTime(2024, 1, 10, 10),
|
||||
decidedAt: DateTime(2024, 1, 10, 12),
|
||||
note: '2단계',
|
||||
),
|
||||
];
|
||||
|
||||
final histories = [
|
||||
ApprovalHistory(
|
||||
id: 5001,
|
||||
action: ApprovalAction(id: 11, name: '상신', code: 'submit'),
|
||||
fromStatus: null,
|
||||
toStatus: statusInProgress,
|
||||
approver: approver1,
|
||||
actionAt: DateTime(2024, 1, 10, 9, 5),
|
||||
note: '상신 완료',
|
||||
),
|
||||
ApprovalHistory(
|
||||
id: 5002,
|
||||
action: ApprovalAction(id: 12, name: '승인', code: 'approve'),
|
||||
fromStatus: statusInProgress,
|
||||
toStatus: statusCompleted,
|
||||
approver: approver2,
|
||||
actionAt: DateTime(2024, 1, 10, 12, 30),
|
||||
note: '승인 완료',
|
||||
),
|
||||
];
|
||||
|
||||
sampleApproval = Approval(
|
||||
id: 300,
|
||||
approvalNo: 'APP-2024-0300',
|
||||
transactionNo: 'TRX-0300',
|
||||
transactionUpdatedAt: DateTime(2024, 1, 10, 12, 45),
|
||||
status: statusInProgress,
|
||||
requester: requester,
|
||||
requestedAt: DateTime(2024, 1, 10, 8, 30),
|
||||
steps: steps,
|
||||
histories: histories,
|
||||
updatedAt: DateTime(2024, 1, 10, 12, 50),
|
||||
);
|
||||
|
||||
approvalRepository
|
||||
..detail = sampleApproval
|
||||
..recallResult = sampleApproval
|
||||
..resubmitResult = sampleApproval.copyWith(
|
||||
status: statusInProgress,
|
||||
updatedAt: DateTime(2024, 1, 10, 13),
|
||||
)
|
||||
..historyResult = PaginatedResult<ApprovalHistory>(
|
||||
items: histories,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: histories.length,
|
||||
);
|
||||
|
||||
historyRepository.listResult = PaginatedResult<ApprovalHistoryRecord>(
|
||||
items: [
|
||||
ApprovalHistoryRecord(
|
||||
id: 700,
|
||||
approvalId: sampleApproval.id!,
|
||||
approvalNo: sampleApproval.approvalNo,
|
||||
stepOrder: 1,
|
||||
action: ApprovalAction(id: 21, name: '상신 완료', code: 'submit'),
|
||||
fromStatus: null,
|
||||
toStatus: statusInProgress,
|
||||
approver: approver1,
|
||||
actionAt: DateTime(2024, 1, 10, 9, 5),
|
||||
note: '상신 후 대기 중',
|
||||
),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
);
|
||||
|
||||
controller = ApprovalHistoryController(
|
||||
repository: historyRepository,
|
||||
approvalRepository: approvalRepository,
|
||||
recallUseCase: RecallApprovalUseCase(repository: approvalRepository),
|
||||
resubmitUseCase: ResubmitApprovalUseCase(repository: approvalRepository),
|
||||
);
|
||||
|
||||
record = historyRepository.listResult!.items.first;
|
||||
|
||||
user = const AuthenticatedUser(
|
||||
id: 42,
|
||||
name: '결재자',
|
||||
employeeNo: 'E042',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
Future<void> openDialog(WidgetTester tester) async {
|
||||
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
|
||||
unawaited(
|
||||
showApprovalHistoryDetailDialog(
|
||||
context: context,
|
||||
controller: controller,
|
||||
record: record,
|
||||
dateFormat: dateFormat,
|
||||
currentUser: user,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'showApprovalHistoryDetailDialog 결재 요약과 타임라인을 표시한다',
|
||||
(tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
expect(find.text('결재 이력 상세'), findsOneWidget);
|
||||
expect(find.textContaining('결재번호 ${record.approvalNo}'), findsWidgets);
|
||||
expect(find.text('상태 타임라인'), findsOneWidget);
|
||||
expect(find.text('감사 로그'), findsOneWidget);
|
||||
|
||||
expect(
|
||||
find.textContaining(
|
||||
'상신자 ${sampleApproval.requester.name} (${sampleApproval.requester.employeeNo})',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('총 ${sampleApproval.steps.length}단계'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('승인'), findsWidgets);
|
||||
|
||||
expect(approvalRepository.listHistoryCalls, isNotEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'회수 버튼을 누르면 recallApproval이 호출되어 감사 로그가 새로고침된다',
|
||||
(tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
final recallButton = find.widgetWithText(ShadButton, '회수');
|
||||
expect(recallButton, findsOneWidget);
|
||||
await tester.ensureVisible(recallButton);
|
||||
await tester.tap(recallButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 회수'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '긴급 회수');
|
||||
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '회수'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(approvalRepository.recallInputs, hasLength(1));
|
||||
expect(approvalRepository.recallInputs.first.note, '긴급 회수');
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'재상신 버튼을 누르면 resubmitApproval이 호출되어 최신 단계 정보가 전달된다',
|
||||
(tester) async {
|
||||
final rejectedApproval = sampleApproval.copyWith(
|
||||
status: statusRejected,
|
||||
steps: sampleApproval.steps
|
||||
.map(
|
||||
(step) => step.stepOrder == 1
|
||||
? step.copyWith(
|
||||
status: statusRejected,
|
||||
decidedAt: DateTime(2024, 1, 10, 11),
|
||||
)
|
||||
: step,
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
final resubmittedStatus = ApprovalStatus(
|
||||
id: 4,
|
||||
name: '재상신',
|
||||
color: '#6366F1',
|
||||
isTerminal: false,
|
||||
);
|
||||
final resubmittedApproval = rejectedApproval.copyWith(
|
||||
status: resubmittedStatus,
|
||||
updatedAt: DateTime(2024, 1, 10, 13, 10),
|
||||
);
|
||||
|
||||
approvalRepository
|
||||
..detail = rejectedApproval
|
||||
..resubmitResult = resubmittedApproval;
|
||||
record = record.copyWith(
|
||||
action: ApprovalAction(id: 33, name: '반려', code: 'reject'),
|
||||
toStatus: statusRejected,
|
||||
stepOrder: 2,
|
||||
);
|
||||
|
||||
await openDialog(tester);
|
||||
|
||||
final resubmitButton = find.widgetWithText(ShadButton, '재상신');
|
||||
expect(resubmitButton, findsOneWidget);
|
||||
await tester.ensureVisible(resubmitButton);
|
||||
await tester.tap(resubmitButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dialogFinder = find.ancestor(
|
||||
of: find.text('결재 재상신'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
);
|
||||
expect(dialogFinder, findsOneWidget);
|
||||
|
||||
final memoField = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.byType(ShadTextarea),
|
||||
);
|
||||
expect(memoField, findsOneWidget);
|
||||
await tester.enterText(memoField, '재상신 메모');
|
||||
|
||||
final confirmButton = find.descendant(
|
||||
of: dialogFinder,
|
||||
matching: find.widgetWithText(ShadButton, '재상신'),
|
||||
);
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(approvalRepository.resubmitInputs, hasLength(1));
|
||||
final input = approvalRepository.resubmitInputs.first;
|
||||
expect(input.note, '재상신 메모');
|
||||
expect(input.submission.steps.length, rejectedApproval.steps.length);
|
||||
expect(
|
||||
input.submission.steps.first.stepOrder,
|
||||
rejectedApproval.steps.first.stepOrder,
|
||||
);
|
||||
expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeApprovalRepository implements ApprovalRepository {
|
||||
Approval? detail;
|
||||
Approval? recallResult;
|
||||
Approval? resubmitResult;
|
||||
PaginatedResult<ApprovalHistory>? historyResult;
|
||||
|
||||
final List<ApprovalRecallInput> recallInputs = [];
|
||||
final List<ApprovalResubmissionInput> resubmitInputs = [];
|
||||
final List<int> fetchDetailIds = [];
|
||||
final List<_ListHistoryCall> listHistoryCalls = [];
|
||||
|
||||
@override
|
||||
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> fetchDetail(
|
||||
int id, {
|
||||
bool includeSteps = true,
|
||||
bool includeHistories = true,
|
||||
}) async {
|
||||
fetchDetailIds.add(id);
|
||||
final result = detail;
|
||||
if (result == null) {
|
||||
throw StateError('detail이 설정되지 않았습니다.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalProceedStatus> canProceed(int id) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> performStepAction(ApprovalStepActionInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<Approval>> list({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
int? transactionId,
|
||||
int? approvalStatusId,
|
||||
int? requestedById,
|
||||
List<String>? statusCodes,
|
||||
bool includePending = false,
|
||||
bool includeHistories = false,
|
||||
bool includeSteps = false,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> submit(ApprovalSubmissionInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> resubmit(ApprovalResubmissionInput input) async {
|
||||
resubmitInputs.add(input);
|
||||
final result = resubmitResult ?? detail;
|
||||
if (result == null) {
|
||||
throw StateError('resubmitResult가 설정되지 않았습니다.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> approve(ApprovalDecisionInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> reject(ApprovalDecisionInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> recall(ApprovalRecallInput input) async {
|
||||
recallInputs.add(input);
|
||||
final result = recallResult ?? detail;
|
||||
if (result == null) {
|
||||
throw StateError('recallResult가 설정되지 않았습니다.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<ApprovalHistory>> listHistory({
|
||||
required int approvalId,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int? actorId,
|
||||
int? approvalActionId,
|
||||
}) async {
|
||||
listHistoryCalls.add(
|
||||
_ListHistoryCall(
|
||||
approvalId: approvalId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
actorId: actorId,
|
||||
approvalActionId: approvalActionId,
|
||||
),
|
||||
);
|
||||
return historyResult ??
|
||||
PaginatedResult<ApprovalHistory>(
|
||||
items: const [],
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
total: 0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> create(ApprovalCreateInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> update(ApprovalUpdateInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int id) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> restore(int id) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeApprovalHistoryRepository implements ApprovalHistoryRepository {
|
||||
PaginatedResult<ApprovalHistoryRecord>? listResult;
|
||||
final List<_HistoryListCall> listCalls = [];
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<ApprovalHistoryRecord>> list({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? query,
|
||||
int? approvalActionId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
}) async {
|
||||
listCalls.add(
|
||||
_HistoryListCall(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
query: query,
|
||||
approvalActionId: approvalActionId,
|
||||
from: from,
|
||||
to: to,
|
||||
),
|
||||
);
|
||||
return listResult ??
|
||||
PaginatedResult<ApprovalHistoryRecord>(
|
||||
items: const [],
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
total: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ListHistoryCall {
|
||||
_ListHistoryCall({
|
||||
required this.approvalId,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
this.actorId,
|
||||
this.approvalActionId,
|
||||
});
|
||||
|
||||
final int approvalId;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int? actorId;
|
||||
final int? approvalActionId;
|
||||
}
|
||||
|
||||
class _HistoryListCall {
|
||||
_HistoryListCall({
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
this.query,
|
||||
this.approvalActionId,
|
||||
this.from,
|
||||
this.to,
|
||||
});
|
||||
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final String? query;
|
||||
final int? approvalActionId;
|
||||
final DateTime? from;
|
||||
final DateTime? to;
|
||||
}
|
||||
@@ -395,9 +395,10 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final recallButton = find.widgetWithText(ShadButton, '회수').first;
|
||||
await tester.ensureVisible(recallButton);
|
||||
await tester.tap(recallButton);
|
||||
await tester.tap(find.widgetWithText(ShadButton, '회수').first);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('결재 회수'), findsOneWidget);
|
||||
@@ -406,8 +407,120 @@ void main() {
|
||||
await tester.tap(confirmButton);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
expect(fetchCount, equals(2));
|
||||
expect(find.text('결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'), findsOneWidget);
|
||||
expect(find.textContaining('결재 상세를 새로고침하지 못했습니다'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('닫기'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('회수 성공 시 토스트를 노출한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
|
||||
when(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<ApprovalHistoryRecord>(
|
||||
items: [record],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
final recallable = recallableFlow();
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => recallable.approval);
|
||||
|
||||
when(() => recallUseCase.call(any())).thenAnswer((_) async => recallable);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final table = tester.widget<SuperportTable>(find.byType(SuperportTable));
|
||||
table.onRowTap?.call(0);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(ShadButton, '회수').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('결재 회수'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(ShadButton, '회수').last);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('결재(APP-2024-0001) 회수를 완료했습니다.'), findsOneWidget);
|
||||
verify(() => recallUseCase.call(any())).called(1);
|
||||
|
||||
await tester.tap(find.byTooltip('닫기'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('재상신 성공 시 토스트를 노출한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
|
||||
when(
|
||||
() => historyRepository.list(
|
||||
page: any(named: 'page'),
|
||||
pageSize: any(named: 'pageSize'),
|
||||
query: any(named: 'query'),
|
||||
approvalActionId: any(named: 'approvalActionId'),
|
||||
from: any(named: 'from'),
|
||||
to: any(named: 'to'),
|
||||
),
|
||||
).thenAnswer(
|
||||
(_) async => PaginatedResult<ApprovalHistoryRecord>(
|
||||
items: [record],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
final flow = resubmittableFlow();
|
||||
when(
|
||||
() => approvalRepository.fetchDetail(
|
||||
any(),
|
||||
includeSteps: any(named: 'includeSteps'),
|
||||
includeHistories: any(named: 'includeHistories'),
|
||||
),
|
||||
).thenAnswer((_) async => flow.approval);
|
||||
|
||||
when(() => resubmitUseCase.call(any())).thenAnswer((_) async => flow);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalHistoryPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final table = tester.widget<SuperportTable>(find.byType(SuperportTable));
|
||||
table.onRowTap?.call(0);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(ShadButton, '재상신').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('결재 재상신'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(ShadButton, '재상신').last);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('결재(APP-2024-0001) 재상신을 완료했습니다.'), findsOneWidget);
|
||||
verify(() => resubmitUseCase.call(any())).called(1);
|
||||
|
||||
await tester.tap(find.byTooltip('닫기'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
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.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart';
|
||||
import 'package:superport_v2/features/approvals/presentation/dialogs/approval_detail_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_dialog.dart';
|
||||
|
||||
import '../../../../helpers/test_app.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
||||
|
||||
late _FakeApprovalRepository approvalRepository;
|
||||
late _FakeApprovalTemplateRepository templateRepository;
|
||||
late ApprovalController controller;
|
||||
late Approval sampleApproval;
|
||||
late ApprovalTemplate sampleTemplate;
|
||||
|
||||
setUp(() async {
|
||||
approvalRepository = _FakeApprovalRepository();
|
||||
templateRepository = _FakeApprovalTemplateRepository();
|
||||
|
||||
controller = ApprovalController(
|
||||
approvalRepository: approvalRepository,
|
||||
templateRepository: templateRepository,
|
||||
);
|
||||
|
||||
final statusInProgress = ApprovalStatus(id: 1, name: '진행중');
|
||||
final requester = ApprovalRequester(
|
||||
id: 99,
|
||||
employeeNo: 'E099',
|
||||
name: '요청자',
|
||||
);
|
||||
final approver = ApprovalApprover(id: 77, employeeNo: 'E077', name: '승인자');
|
||||
|
||||
final step = ApprovalStep(
|
||||
id: 501,
|
||||
stepOrder: 1,
|
||||
approver: approver,
|
||||
status: statusInProgress,
|
||||
assignedAt: DateTime(2024, 1, 1, 9),
|
||||
);
|
||||
|
||||
sampleApproval = Approval(
|
||||
id: 100,
|
||||
approvalNo: 'APP-2024-0100',
|
||||
transactionNo: 'TRX-100',
|
||||
status: statusInProgress,
|
||||
requester: requester,
|
||||
requestedAt: DateTime(2024, 1, 1, 9),
|
||||
steps: [step],
|
||||
histories: const [],
|
||||
);
|
||||
|
||||
approvalRepository.detail = sampleApproval;
|
||||
approvalRepository.updatedAfterAssign = sampleApproval;
|
||||
approvalRepository.updatedAfterAction = sampleApproval;
|
||||
|
||||
sampleTemplate = ApprovalTemplate(
|
||||
id: 200,
|
||||
code: 'TEMP-200',
|
||||
name: '샘플 템플릿',
|
||||
isActive: true,
|
||||
steps: [
|
||||
ApprovalTemplateStep(
|
||||
id: null,
|
||||
stepOrder: 1,
|
||||
approver: ApprovalTemplateApprover(
|
||||
id: 42,
|
||||
employeeNo: 'E042',
|
||||
name: '템플 승인자',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
templateRepository
|
||||
..listResult = [sampleTemplate]
|
||||
..detailResult = sampleTemplate;
|
||||
|
||||
approvalRepository.actions = [
|
||||
ApprovalAction(id: 1, name: '승인', code: 'approve'),
|
||||
ApprovalAction(id: 2, name: '반려', code: 'reject'),
|
||||
ApprovalAction(id: 3, name: '코멘트', code: 'comment'),
|
||||
];
|
||||
|
||||
expect(controller.templates, isEmpty);
|
||||
|
||||
await controller.loadTemplates(force: true);
|
||||
await controller.loadActionOptions(force: true);
|
||||
await controller.selectApproval(sampleApproval.id!);
|
||||
expect(controller.templates, isNotEmpty);
|
||||
expect(controller.selected, isNotNull);
|
||||
expect(controller.canProceedSelected, isTrue);
|
||||
expect(controller.hasActionOptions, isTrue);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
Future<void> openDialog(WidgetTester tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1280, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
|
||||
unawaited(
|
||||
showApprovalDetailDialog(
|
||||
context: context,
|
||||
controller: controller,
|
||||
dateFormat: dateFormat,
|
||||
canPerformStepActions: true,
|
||||
canApplyTemplate: true,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('showApprovalDetailDialog 결재 요약과 템플릿 선택을 표시한다', (tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
expect(
|
||||
find.textContaining('결재번호 ${sampleApproval.approvalNo}'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining(sampleApproval.transactionNo), findsWidgets);
|
||||
|
||||
final stepsTab = find.text('단계');
|
||||
await tester.tap(stepsTab, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Step 1'), findsOneWidget);
|
||||
expect(find.text('템플릿 적용'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('템플릿 적용 버튼을 누르면 assignSteps가 호출되고 성공 토스트를 노출한다', (tester) async {
|
||||
await openDialog(tester);
|
||||
|
||||
await tester.tap(find.text('단계'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final applyButton = find.byKey(
|
||||
const ValueKey('approval_apply_template_button'),
|
||||
);
|
||||
expect(applyButton, findsOneWidget);
|
||||
await tester.ensureVisible(applyButton);
|
||||
await tester.tap(applyButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
|
||||
expect(approvalRepository.assignmentInputs, hasLength(1));
|
||||
expect(
|
||||
approvalRepository.assignmentInputs.first.steps.first.approverId,
|
||||
sampleTemplate.steps.first.approver.id,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('승인 버튼을 누르면 performStepAction이 호출되고 성공 메시지를 노출한다', (
|
||||
tester,
|
||||
) async {
|
||||
await openDialog(tester);
|
||||
|
||||
await tester.tap(find.text('단계'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final stepText = find.text('Step 1').first;
|
||||
final stepCard = find
|
||||
.ancestor(of: stepText, matching: find.byType(ShadCard))
|
||||
.first;
|
||||
expect(stepCard, findsOneWidget);
|
||||
|
||||
final approveButtonFinder = find.byKey(
|
||||
const ValueKey('step_action_501_approve'),
|
||||
);
|
||||
expect(approveButtonFinder, findsOneWidget);
|
||||
final approveButton = tester.widget<ShadButton>(approveButtonFinder);
|
||||
expect(approveButton.onPressed, isNotNull);
|
||||
await tester.ensureVisible(approveButtonFinder);
|
||||
await tester.tap(approveButtonFinder, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final confirmDialog = find
|
||||
.ancestor(
|
||||
of: find.text('결재 승인'),
|
||||
matching: find.byType(SuperportDialog),
|
||||
)
|
||||
.first;
|
||||
|
||||
final noteField = find.descendant(
|
||||
of: confirmDialog,
|
||||
matching: find.byType(EditableText),
|
||||
);
|
||||
await tester.enterText(noteField, '승인 메모');
|
||||
|
||||
final confirmButton = find
|
||||
.descendant(
|
||||
of: confirmDialog,
|
||||
matching: find.widgetWithText(ShadButton, '승인'),
|
||||
)
|
||||
.first;
|
||||
await tester.tap(confirmButton, warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(approvalRepository.actionInputs, hasLength(1));
|
||||
expect(approvalRepository.actionInputs.first.actionId, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeApprovalRepository implements ApprovalRepository {
|
||||
Approval? detail;
|
||||
Approval? updatedAfterAssign;
|
||||
Approval? updatedAfterAction;
|
||||
bool canProceedResult = true;
|
||||
List<ApprovalAction> actions = const [];
|
||||
|
||||
final List<ApprovalStepAssignmentInput> assignmentInputs = [];
|
||||
final List<ApprovalStepActionInput> actionInputs = [];
|
||||
|
||||
@override
|
||||
Future<Approval> assignSteps(ApprovalStepAssignmentInput input) async {
|
||||
assignmentInputs.add(input);
|
||||
return updatedAfterAssign ?? detail!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> fetchDetail(
|
||||
int id, {
|
||||
bool includeSteps = true,
|
||||
bool includeHistories = true,
|
||||
}) async {
|
||||
return detail!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalProceedStatus> canProceed(int id) async {
|
||||
return ApprovalProceedStatus(approvalId: id, canProceed: canProceedResult);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ApprovalAction>> listActions({bool activeOnly = true}) async {
|
||||
return actions;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> performStepAction(ApprovalStepActionInput input) async {
|
||||
actionInputs.add(input);
|
||||
return updatedAfterAction ?? detail!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<Approval>> list({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
int? transactionId,
|
||||
int? approvalStatusId,
|
||||
int? requestedById,
|
||||
List<String>? statusCodes,
|
||||
bool includePending = false,
|
||||
bool includeHistories = false,
|
||||
bool includeSteps = false,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> submit(ApprovalSubmissionInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> resubmit(ApprovalResubmissionInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> approve(ApprovalDecisionInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> reject(ApprovalDecisionInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> recall(ApprovalRecallInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<ApprovalHistory>> listHistory({
|
||||
required int approvalId,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int? actorId,
|
||||
int? approvalActionId,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> create(ApprovalCreateInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> update(ApprovalUpdateInput input) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int id) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Approval> restore(int id) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeApprovalTemplateRepository implements ApprovalTemplateRepository {
|
||||
List<ApprovalTemplate> listResult = const [];
|
||||
ApprovalTemplate? detailResult;
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<ApprovalTemplate>> list({
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
String? query,
|
||||
bool? isActive,
|
||||
}) async {
|
||||
return PaginatedResult(
|
||||
items: listResult,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
total: listResult.length,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalTemplate> fetchDetail(
|
||||
int id, {
|
||||
bool includeSteps = true,
|
||||
}) async {
|
||||
return detailResult!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalTemplate> create(
|
||||
ApprovalTemplateInput input, {
|
||||
List<ApprovalTemplateStepInput> steps = const [],
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalTemplate> update(
|
||||
int id,
|
||||
ApprovalTemplateInput input, {
|
||||
List<ApprovalTemplateStepInput>? steps,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int id) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ApprovalTemplate> restore(int id) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval.dart';
|
||||
import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart';
|
||||
import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart';
|
||||
import 'package:superport_v2/features/approvals/step/presentation/dialogs/approval_step_detail_dialog.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_detail_dialog.dart';
|
||||
|
||||
import '../../../../../helpers/test_app.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
||||
|
||||
late ApprovalStepRecord sampleRecord;
|
||||
late ApprovalApprover approver;
|
||||
|
||||
setUp(() {
|
||||
final statusPending = ApprovalStatus(
|
||||
id: 1,
|
||||
name: '대기',
|
||||
color: '#0EA5E9',
|
||||
isTerminal: false,
|
||||
);
|
||||
approver = ApprovalApprover(id: 800, employeeNo: 'E800', name: '김승인');
|
||||
|
||||
final step = ApprovalStep(
|
||||
id: 900,
|
||||
stepOrder: 1,
|
||||
approver: approver,
|
||||
status: statusPending,
|
||||
assignedAt: DateTime(2024, 1, 10, 9),
|
||||
);
|
||||
|
||||
sampleRecord = ApprovalStepRecord(
|
||||
approvalId: 400,
|
||||
approvalNo: 'APP-2024-0400',
|
||||
transactionNo: 'TRX-400',
|
||||
templateName: '일반 결재선',
|
||||
step: step,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('결재 단계 상세 다이얼로그는 요약과 메타 정보를 노출한다', (tester) async {
|
||||
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
|
||||
final resultFuture = showApprovalStepDetailDialog(
|
||||
context: context,
|
||||
record: sampleRecord,
|
||||
dateFormat: dateFormat,
|
||||
onUpdate: (_, __) => Future.value(sampleRecord),
|
||||
onDelete: (_) => Future.value(true),
|
||||
onRestore: (_) => Future.value(sampleRecord),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('결재 단계 상세'), findsAtLeastNWidgets(1));
|
||||
expect(find.text('결재 단계 1'), findsAtLeastNWidgets(1));
|
||||
expect(find.textContaining(approver.name), findsAtLeastNWidgets(1));
|
||||
expect(find.text('단계 순서'), findsWidgets);
|
||||
expect(find.text('상태'), findsWidgets);
|
||||
expect(find.text('승인자 사번'), findsWidgets);
|
||||
|
||||
await tester.tap(find.byTooltip('닫기'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
expect(await resultFuture, isNull);
|
||||
});
|
||||
|
||||
testWidgets('수정 탭에서 저장을 누르면 onUpdate 콜백이 입력값과 함께 호출된다', (tester) async {
|
||||
ApprovalStepInput? capturedInput;
|
||||
|
||||
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
|
||||
final resultFuture = showApprovalStepDetailDialog(
|
||||
context: context,
|
||||
record: sampleRecord,
|
||||
dateFormat: dateFormat,
|
||||
onUpdate: (_, input) async {
|
||||
capturedInput = input;
|
||||
return sampleRecord.copyWith(
|
||||
step: sampleRecord.step.copyWith(
|
||||
stepOrder: input.stepOrder,
|
||||
approver: ApprovalApprover(
|
||||
id: input.approverId,
|
||||
employeeNo: 'E${input.approverId}',
|
||||
name: '변경 승인자',
|
||||
),
|
||||
note: input.note,
|
||||
),
|
||||
);
|
||||
},
|
||||
onDelete: (_) => Future.value(false),
|
||||
onRestore: (_) => Future.value(sampleRecord),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final tabsFinder = find.descendant(
|
||||
of: find.byType(SuperportDetailDialog),
|
||||
matching: find.byType(ShadTabs<String>),
|
||||
);
|
||||
final tabsState = tester.state(tabsFinder);
|
||||
final controller =
|
||||
(tabsState as dynamic).controller as ShadTabsController<String>;
|
||||
controller.select('edit');
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('approval_step_detail_step_order')),
|
||||
'3',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('approval_step_detail_approver_id')),
|
||||
'901',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('approval_step_detail_note')),
|
||||
'조정된 결재 단계',
|
||||
);
|
||||
|
||||
final submitButtonFinder = find.byKey(
|
||||
const ValueKey('approval_step_detail_submit'),
|
||||
);
|
||||
final submitButton = tester.widget<ShadButton>(submitButtonFinder);
|
||||
expect(submitButton.onPressed, isNotNull);
|
||||
submitButton.onPressed!();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final result = await resultFuture;
|
||||
expect(result, isNotNull);
|
||||
expect(result!.action, ApprovalStepDetailAction.updated);
|
||||
expect(capturedInput, isNotNull);
|
||||
expect(capturedInput!.stepOrder, 3);
|
||||
expect(capturedInput!.approverId, 901);
|
||||
expect(capturedInput!.note, '조정된 결재 단계');
|
||||
});
|
||||
|
||||
testWidgets('삭제 탭에서 삭제 버튼을 누르면 onDelete 콜백 성공 시 결과가 반환된다', (tester) async {
|
||||
var deleteCalled = false;
|
||||
|
||||
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
|
||||
final resultFuture = showApprovalStepDetailDialog(
|
||||
context: context,
|
||||
record: sampleRecord,
|
||||
dateFormat: dateFormat,
|
||||
onUpdate: (_, __) => Future.value(sampleRecord),
|
||||
onDelete: (_) async {
|
||||
deleteCalled = true;
|
||||
return true;
|
||||
},
|
||||
onRestore: (_) => Future.value(sampleRecord),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tabsFinder = find.descendant(
|
||||
of: find.byType(SuperportDetailDialog),
|
||||
matching: find.byType(ShadTabs<String>),
|
||||
);
|
||||
final tabsState = tester.state(tabsFinder);
|
||||
final controller =
|
||||
(tabsState as dynamic).controller as ShadTabsController<String>;
|
||||
controller.select('delete');
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final deleteButtonFinder = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is ShadButton &&
|
||||
widget.variant == ShadButtonVariant.destructive &&
|
||||
widget.child is Text &&
|
||||
(widget.child as Text).data == '삭제',
|
||||
);
|
||||
expect(deleteButtonFinder, findsOneWidget);
|
||||
final deleteButton = tester.widget<ShadButton>(deleteButtonFinder);
|
||||
expect(deleteButton.onPressed, isNotNull);
|
||||
deleteButton.onPressed!();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final result = await resultFuture;
|
||||
expect(deleteCalled, isTrue);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.action, ApprovalStepDetailAction.deleted);
|
||||
});
|
||||
}
|
||||
@@ -108,20 +108,18 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('APP-2024-0001'), findsOneWidget);
|
||||
expect(find.text('최승인'), findsOneWidget);
|
||||
expect(find.text('APP-2024-0001'), findsWidgets);
|
||||
expect(find.text('최승인'), findsWidgets);
|
||||
|
||||
final detailButtonFinder = find.byKey(const ValueKey('step_detail_501_1'));
|
||||
final detailButton = tester.widget<ShadButton>(detailButtonFinder);
|
||||
detailButton.onPressed?.call();
|
||||
await tester.tap(find.text('APP-2024-0001'));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('결재 단계 상세'), findsOneWidget);
|
||||
expect(find.text('검토 필요'), findsOneWidget);
|
||||
expect(find.text('결재 단계 상세'), findsWidgets);
|
||||
expect(find.text('검토 필요'), findsWidgets);
|
||||
verify(() => repository.fetchDetail(501)).called(1);
|
||||
|
||||
await tester.tap(find.text('닫기'));
|
||||
await tester.tap(find.byTooltip('닫기'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
@@ -198,7 +196,7 @@ void main() {
|
||||
expect(find.text('결재번호 APP-2024-0012 단계가 추가되었습니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('단계 수정 다이얼로그에서 저장을 호출한다', (tester) async {
|
||||
testWidgets('상세 다이얼로그에서 단계를 수정한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
repository = _MockApprovalStepRepository();
|
||||
GetIt.I.registerLazySingleton<ApprovalStepRepository>(() => repository);
|
||||
@@ -237,6 +235,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
when(() => repository.fetchDetail(any())).thenAnswer((_) async => record);
|
||||
when(
|
||||
() => repository.update(any(), any()),
|
||||
).thenAnswer((_) async => updatedRecord);
|
||||
@@ -245,36 +244,43 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final editButtonFinder = find.byKey(
|
||||
ValueKey('step_edit_${record.step.id}_${record.step.stepOrder}'),
|
||||
);
|
||||
final editButton = tester.widget<ShadButton>(editButtonFinder);
|
||||
editButton.onPressed?.call();
|
||||
await tester.tap(find.text('APP-2024-0001'));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final editTabsState =
|
||||
tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs))
|
||||
as dynamic;
|
||||
editTabsState.controller.select('edit');
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('step_form_step_order')),
|
||||
find.byKey(const ValueKey('approval_step_detail_step_order')),
|
||||
'2',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('step_form_approver_id')),
|
||||
find.byKey(const ValueKey('approval_step_detail_approver_id')),
|
||||
'30',
|
||||
);
|
||||
await tester.enterText(find.byKey(const ValueKey('step_form_note')), '수정됨');
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('approval_step_detail_note')),
|
||||
'수정됨',
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('step_form_submit')));
|
||||
final submitButtonFinder = find.byKey(
|
||||
const ValueKey('approval_step_detail_submit'),
|
||||
);
|
||||
final submitButton = tester.widget<ShadButton>(submitButtonFinder);
|
||||
submitButton.onPressed?.call();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => repository.update(record.step.id!, any())).called(1);
|
||||
expect(
|
||||
find.text('결재번호 ${record.approvalNo} 단계 정보를 수정했습니다.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('결재 단계 정보를 수정했습니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('삭제 버튼 확인 후 저장소 삭제를 호출한다', (tester) async {
|
||||
testWidgets('상세 다이얼로그에서 단계를 삭제한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
repository = _MockApprovalStepRepository();
|
||||
GetIt.I.registerLazySingleton<ApprovalStepRepository>(() => repository);
|
||||
@@ -297,26 +303,37 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
when(() => repository.fetchDetail(any())).thenAnswer((_) async => record);
|
||||
when(() => repository.delete(any())).thenAnswer((_) async {});
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalStepPage()));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final deleteFinder = find.byKey(const ValueKey('step_delete_501_1'));
|
||||
final deleteButton = tester.widget<ShadButton>(deleteFinder);
|
||||
await tester.tap(find.text('APP-2024-0001'));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final deleteTabsState =
|
||||
tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs))
|
||||
as dynamic;
|
||||
deleteTabsState.controller.select('delete');
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final deleteButtonFinder = find.byKey(
|
||||
const ValueKey('approval_step_detail_delete'),
|
||||
);
|
||||
final deleteButton = tester.widget<ShadButton>(deleteButtonFinder);
|
||||
deleteButton.onPressed?.call();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(ShadButton, '삭제').last);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => repository.delete(501)).called(1);
|
||||
expect(find.text('결재 단계를 삭제했습니다.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('복구 버튼 확인 후 저장소 복구를 호출한다', (tester) async {
|
||||
testWidgets('상세 다이얼로그에서 삭제된 단계를 복구한다', (tester) async {
|
||||
dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n');
|
||||
repository = _MockApprovalStepRepository();
|
||||
GetIt.I.registerLazySingleton<ApprovalStepRepository>(() => repository);
|
||||
@@ -343,22 +360,35 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
when(
|
||||
() => repository.fetchDetail(any()),
|
||||
).thenAnswer((_) async => deletedRecord);
|
||||
when(() => repository.restore(any())).thenAnswer((_) async => record);
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalStepPage()));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final restoreFinder = find.byKey(const ValueKey('step_restore_501_1'));
|
||||
final restoreButton = tester.widget<ShadButton>(restoreFinder);
|
||||
await tester.tap(find.text('APP-2024-0001'));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tabsState =
|
||||
tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs))
|
||||
as dynamic;
|
||||
tabsState.controller.select('restore');
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final restoreButtonFinder = find.byKey(
|
||||
const ValueKey('approval_step_detail_restore'),
|
||||
);
|
||||
final restoreButton = tester.widget<ShadButton>(restoreButtonFinder);
|
||||
restoreButton.onPressed?.call();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(ShadButton, '복구').last);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(() => repository.restore(501)).called(1);
|
||||
expect(find.text('결재 단계를 복구했습니다.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'package:superport_v2/core/services/token_storage.dart';
|
||||
import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart';
|
||||
import 'package:superport_v2/features/approvals/template/presentation/dialogs/approval_template_detail_dialog.dart';
|
||||
import 'package:superport_v2/features/auth/application/auth_service.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/login_request.dart';
|
||||
import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart';
|
||||
import 'package:superport_v2/widgets/components/superport_detail_dialog.dart';
|
||||
|
||||
import '../../../../../helpers/test_app.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
|
||||
|
||||
late ApprovalTemplate sampleTemplate;
|
||||
late ApprovalTemplateApprover approver1;
|
||||
late ApprovalTemplateApprover approver2;
|
||||
|
||||
setUp(() {
|
||||
final getIt = GetIt.I;
|
||||
if (!getIt.isRegistered<AuthService>()) {
|
||||
getIt.registerSingleton<AuthService>(
|
||||
_StubAuthService(
|
||||
AuthSession(
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
user: const AuthenticatedUser(
|
||||
id: 1,
|
||||
name: '테스터',
|
||||
employeeNo: 'E001',
|
||||
),
|
||||
permissions: const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
approver1 = ApprovalTemplateApprover(
|
||||
id: 501,
|
||||
employeeNo: 'E501',
|
||||
name: '1차 승인자',
|
||||
);
|
||||
approver2 = ApprovalTemplateApprover(
|
||||
id: 502,
|
||||
employeeNo: 'E502',
|
||||
name: '2차 승인자',
|
||||
);
|
||||
|
||||
sampleTemplate = ApprovalTemplate(
|
||||
id: 10,
|
||||
code: 'TEMP-10',
|
||||
name: '자산 구매 결재',
|
||||
description: '자산 구매 시 사용하는 기본 결재선',
|
||||
note: '월별로 재검토 필요',
|
||||
isActive: true,
|
||||
createdAt: DateTime(2024, 1, 1, 9),
|
||||
updatedAt: DateTime(2024, 1, 10, 10),
|
||||
steps: [
|
||||
ApprovalTemplateStep(
|
||||
id: 1001,
|
||||
stepOrder: 1,
|
||||
approver: approver1,
|
||||
note: '팀 리더 확인',
|
||||
),
|
||||
ApprovalTemplateStep(
|
||||
id: 1002,
|
||||
stepOrder: 2,
|
||||
approver: approver2,
|
||||
note: '경영진 승인',
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
final getIt = GetIt.I;
|
||||
if (getIt.isRegistered<AuthService>()) {
|
||||
getIt.unregister<AuthService>();
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('템플릿 상세 다이얼로그는 요약과 메타데이터를 info panel에 표시한다', (tester) async {
|
||||
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
|
||||
final resultFuture = showApprovalTemplateDetailDialog(
|
||||
context: context,
|
||||
dateFormat: dateFormat,
|
||||
template: sampleTemplate,
|
||||
onCreate: (_, __) => Future.value(null),
|
||||
onUpdate: (_, __, ___) => Future.value(sampleTemplate),
|
||||
onDelete: (_) => Future.value(false),
|
||||
onRestore: (_) => Future.value(null),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('결재 템플릿 상세'), findsAtLeastNWidgets(1));
|
||||
expect(find.text(sampleTemplate.name), findsAtLeastNWidgets(1));
|
||||
expect(find.text('단계'), findsOneWidget);
|
||||
expect(find.text('코드'), findsWidgets);
|
||||
expect(find.text('TEMP-10'), findsWidgets);
|
||||
expect(find.text('상태'), findsWidgets);
|
||||
expect(find.text('생성일시'), findsWidgets);
|
||||
|
||||
expect(find.byType(SuperportDetailDialog), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('닫기'), warnIfMissed: false);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(await resultFuture, isNull);
|
||||
});
|
||||
|
||||
testWidgets('생성 모드에서 필수 정보를 입력하면 onCreate 콜백이 호출된다', (tester) async {
|
||||
final createdTemplates = <ApprovalTemplateInput>[];
|
||||
final createdSteps = <List<ApprovalTemplateStepInput>>[];
|
||||
|
||||
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
|
||||
final resultFuture = showApprovalTemplateDetailDialog(
|
||||
context: context,
|
||||
dateFormat: dateFormat,
|
||||
template: null,
|
||||
onCreate: (input, steps) async {
|
||||
createdTemplates.add(input);
|
||||
createdSteps.add(steps);
|
||||
return sampleTemplate.copyWith(name: input.name);
|
||||
},
|
||||
onUpdate: (_, __, ___) => Future.value(null),
|
||||
onDelete: (_) => Future.value(false),
|
||||
onRestore: (_) => Future.value(null),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('template_form_name')),
|
||||
'신규 결재 템플릿',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('template_step_0_order')),
|
||||
'1',
|
||||
);
|
||||
|
||||
final approverFieldFinder = find.byKey(
|
||||
const ValueKey('template_step_0_approver'),
|
||||
);
|
||||
final approverFieldState = tester.state(approverFieldFinder) as dynamic;
|
||||
approverFieldState.widget.idController.text = approver1.id.toString();
|
||||
await tester.pump();
|
||||
|
||||
final submitButton = tester.widget<ShadButton>(
|
||||
find.widgetWithText(ShadButton, '등록'),
|
||||
);
|
||||
expect(submitButton.onPressed, isNotNull);
|
||||
submitButton.onPressed!();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(createdTemplates, isNotEmpty);
|
||||
|
||||
final result = await resultFuture;
|
||||
expect(result, isNotNull);
|
||||
expect(result!.action, ApprovalTemplateDetailAction.created);
|
||||
expect(createdTemplates, hasLength(1));
|
||||
expect(createdTemplates.first.name, '신규 결재 템플릿');
|
||||
expect(createdSteps.single.first.approverId, approver1.id);
|
||||
});
|
||||
|
||||
testWidgets('삭제 탭에서 삭제 버튼을 누르면 onDelete 콜백이 호출되어 결과가 반환된다', (tester) async {
|
||||
var deleteCalled = false;
|
||||
|
||||
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
|
||||
final resultFuture = showApprovalTemplateDetailDialog(
|
||||
context: context,
|
||||
dateFormat: dateFormat,
|
||||
template: sampleTemplate,
|
||||
onCreate: (_, __) => Future.value(null),
|
||||
onUpdate: (_, __, ___) => Future.value(sampleTemplate),
|
||||
onDelete: (_) async {
|
||||
deleteCalled = true;
|
||||
return true;
|
||||
},
|
||||
onRestore: (_) => Future.value(null),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tabsFinder = find.descendant(
|
||||
of: find.byType(SuperportDetailDialog),
|
||||
matching: find.byType(ShadTabs<String>),
|
||||
);
|
||||
final tabsState = tester.state(tabsFinder);
|
||||
final controller =
|
||||
(tabsState as dynamic).controller as ShadTabsController<String>;
|
||||
controller.select('delete');
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final deleteButtonFinder = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is ShadButton &&
|
||||
widget.variant == ShadButtonVariant.destructive &&
|
||||
widget.child is Text &&
|
||||
(widget.child as Text).data == '삭제',
|
||||
);
|
||||
expect(deleteButtonFinder, findsOneWidget);
|
||||
final deleteButton = tester.widget<ShadButton>(deleteButtonFinder);
|
||||
expect(deleteButton.onPressed, isNotNull);
|
||||
deleteButton.onPressed!();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
final result = await resultFuture;
|
||||
expect(deleteCalled, isTrue);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.action, ApprovalTemplateDetailAction.deleted);
|
||||
});
|
||||
}
|
||||
|
||||
class _StubAuthService extends AuthService {
|
||||
_StubAuthService(this._session)
|
||||
: super(
|
||||
repository: _FakeAuthRepository(),
|
||||
tokenStorage: _FakeTokenStorage(),
|
||||
);
|
||||
|
||||
final AuthSession _session;
|
||||
|
||||
@override
|
||||
AuthSession? get session => _session;
|
||||
}
|
||||
|
||||
class _FakeAuthRepository implements AuthRepository {
|
||||
@override
|
||||
Future<AuthSession> login(LoginRequest request) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthSession> refresh(String refreshToken) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeTokenStorage implements TokenStorage {
|
||||
String? access;
|
||||
String? refresh;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
access = null;
|
||||
refresh = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readAccessToken() async => access;
|
||||
|
||||
@override
|
||||
Future<String?> readRefreshToken() async => refresh;
|
||||
|
||||
@override
|
||||
Future<void> writeAccessToken(String? token) async {
|
||||
access = token;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeRefreshToken(String? token) async {
|
||||
refresh = token;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import 'package:superport_v2/features/approvals/domain/usecases/save_approval_te
|
||||
import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart';
|
||||
import 'package:superport_v2/features/approvals/shared/domain/entities/approval_approver_candidate.dart';
|
||||
import 'package:superport_v2/features/approvals/shared/domain/repositories/approval_approver_repository.dart';
|
||||
import 'package:superport_v2/features/approvals/shared/widgets/approver_autocomplete_field.dart';
|
||||
import 'package:superport_v2/features/auth/application/auth_service.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/auth_session.dart';
|
||||
import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart';
|
||||
@@ -169,8 +170,8 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('AP_INBOUND'), findsOneWidget);
|
||||
expect(find.text('입고 템플릿'), findsOneWidget);
|
||||
expect(find.text('AP_INBOUND'), findsWidgets);
|
||||
expect(find.text('입고 템플릿'), findsWidgets);
|
||||
expect(find.textContaining('1. 최승인'), findsOneWidget);
|
||||
|
||||
verify(
|
||||
@@ -198,7 +199,7 @@ void main() {
|
||||
|
||||
when(
|
||||
() => repository.create(any(), steps: any(named: 'steps')),
|
||||
).thenAnswer((_) async => buildTemplate());
|
||||
).thenAnswer((_) async => buildTemplate().copyWith(name: '신규 템플릿'));
|
||||
|
||||
await tester.pumpWidget(_buildApp(const ApprovalTemplatePage()));
|
||||
await tester.pump();
|
||||
@@ -207,43 +208,26 @@ void main() {
|
||||
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),
|
||||
find.byKey(const ValueKey('template_form_name')),
|
||||
'신규 템플릿',
|
||||
);
|
||||
|
||||
final stepFieldsFinder = find.descendant(
|
||||
of: find.byKey(const ValueKey('step_field_0')),
|
||||
matching: find.byType(EditableText),
|
||||
skipOffstage: false,
|
||||
final approverField = tester.widget<ApprovalApproverAutocompleteField>(
|
||||
find.byType(ApprovalApproverAutocompleteField).first,
|
||||
);
|
||||
final stepFieldElements = stepFieldsFinder.evaluate().toList();
|
||||
expect(stepFieldElements.length, greaterThanOrEqualTo(2));
|
||||
|
||||
await tester.enterText(find.byWidget(stepFieldElements[1].widget), '33');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
approverField.idController.text = '33';
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('생성 완료'));
|
||||
final createButtonFinder = find.widgetWithText(ShadButton, '등록').last;
|
||||
final createButton = tester.widget<ShadButton>(createButtonFinder);
|
||||
createButton.onPressed?.call();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => repository.create(any(), steps: any(named: 'steps')),
|
||||
).called(1);
|
||||
expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget);
|
||||
expect(find.textContaining('템플릿 "신규 템플릿"'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('보기 버튼을 눌러 템플릿 단계를 미리본다', (tester) async {
|
||||
@@ -273,25 +257,25 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final previewFinder = find.text('보기', skipOffstage: false);
|
||||
|
||||
await tester.dragUntilVisible(
|
||||
previewFinder,
|
||||
find.text(template.name),
|
||||
const Offset(-200, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(previewFinder);
|
||||
await tester.tap(find.text(template.name));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(template.name), findsWidgets);
|
||||
final detailTabsState =
|
||||
tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs))
|
||||
as dynamic;
|
||||
detailTabsState.controller.select('steps');
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('사번 E001'), findsOneWidget);
|
||||
|
||||
verify(
|
||||
() => repository.fetchDetail(template.id, includeSteps: true),
|
||||
).called(1);
|
||||
|
||||
await tester.tap(find.byTooltip('닫기'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('수정 플로우에서 fetchDetail 후 update를 호출한다', (tester) async {
|
||||
@@ -334,31 +318,25 @@ void main() {
|
||||
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.tap(find.text('입고 템플릿'));
|
||||
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));
|
||||
final tabsState =
|
||||
tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs))
|
||||
as dynamic;
|
||||
tabsState.controller.select('edit');
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byWidget(editFieldElements[0].widget),
|
||||
find.byKey(const ValueKey('template_form_name')),
|
||||
'수정된 템플릿',
|
||||
);
|
||||
|
||||
await tester.tap(find.text('수정 완료'));
|
||||
final saveButtonFinder = find.widgetWithText(ShadButton, '저장').last;
|
||||
final saveButton = tester.widget<ShadButton>(saveButtonFinder);
|
||||
saveButton.onPressed?.call();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -367,7 +345,7 @@ void main() {
|
||||
() => repository.update(10, any(), steps: any(named: 'steps')),
|
||||
).called(1);
|
||||
|
||||
expect(find.text('템플릿 "입고 템플릿"을(를) 수정했습니다.'), findsOneWidget);
|
||||
expect(find.text('템플릿 "수정된 템플릿"을(를) 수정했습니다.'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user