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

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