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