feat(dialog): 상세 팝업 SuperportDetailDialog 통합

- SuperportDetailDialog 위젯과 showSuperportDetailDialog 헬퍼를 추가하고 metadata/섹션 패턴을 표준화

- 결재/재고/마스터 각 상세 다이얼로그를 dialogs 디렉터리에 신설하고 기존 페이지를 신규 팝업으로 전환

- SuperportTable 행 선택과 우편번호 검색 다이얼로그 onRowTap 보정을 통해 헤더 오프셋 버그를 제거

- 상세 다이얼로그 및 트랜잭션/상세 뷰 전용 위젯 테스트와 tester_extensions 유틸을 추가하여 회귀를 방지

- detail_dialog_unification_plan.md로 작업 배경과 필드 통합 계획을 문서화
This commit is contained in:
JiWoong Sul
2025-11-07 19:02:43 +09:00
parent 1f78171294
commit 2f8b529506
64 changed files with 13721 additions and 7545 deletions

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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();
});
}

View File

@@ -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();
}
}

View File

@@ -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);
});
}

View File

@@ -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);
});
}

View File

@@ -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;
}
}

View File

@@ -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);
});
});
}