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

View File

@@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart' as intl;
import 'package:superport_v2/features/inventory/inbound/presentation/models/inbound_record.dart';
import 'package:superport_v2/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final currencyFormatter = intl.NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
InboundRecord buildRecord() {
return InboundRecord(
id: 1,
number: 'IN-1',
transactionNumber: 'IN-1',
transactionType: '입고',
processedAt: DateTime(2024, 1, 1, 9),
warehouse: '서울 1창고',
status: '완료',
writer: '홍길동',
remark: '비고',
items: [
InboundLineItem(
id: 10,
product: '테스트 제품',
manufacturer: '테스트 제조사',
unit: 'EA',
quantity: 3,
price: 1000,
remark: '정상',
),
],
);
}
testWidgets('InboundDetailView는 라인 품목과 경고 배지를 렌더링한다', (tester) async {
final record = buildRecord();
await tester.pumpWidget(
buildTestApp(
InboundDetailView(
record: record,
currencyFormatter: currencyFormatter,
transitionsEnabled: false,
),
),
);
await tester.pumpAndSettle();
expect(find.text('재고 상태 전이가 비활성화된 상태입니다.'), findsOneWidget);
expect(find.text('라인 품목'), findsOneWidget);
expect(find.text('테스트 제품'), findsOneWidget);
expect(find.text('테스트 제조사'), findsOneWidget);
expect(find.text('EA'), findsOneWidget);
expect(find.text('3'), findsWidgets);
expect(find.text('₩1,000'), findsOneWidget);
expect(find.text('정상'), findsOneWidget);
});
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart' as intl;
import 'package:superport_v2/features/inventory/outbound/presentation/models/outbound_record.dart';
import 'package:superport_v2/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final currencyFormatter = intl.NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
OutboundRecord buildRecord() {
return OutboundRecord(
id: 1,
number: 'OUT-1',
transactionNumber: 'OUT-1',
transactionType: '출고',
processedAt: DateTime(2024, 2, 10, 11),
warehouse: '서울 1창고',
status: '출고 완료',
writer: '관리자',
remark: '긴급 출고',
items: [
OutboundLineItem(
id: 20,
product: '출고 제품',
manufacturer: '제조사',
unit: 'EA',
quantity: 5,
price: 2000,
remark: '양호',
),
],
customers: [
OutboundCustomer(id: 1, customerId: 7, code: 'CUS-01', name: '테스트 고객'),
],
);
}
testWidgets('OutboundDetailView는 고객 배지와 라인 테이블을 표시한다', (tester) async {
final record = buildRecord();
await tester.pumpWidget(
buildTestApp(
OutboundDetailView(
record: record,
currencyFormatter: currencyFormatter,
transitionsEnabled: true,
),
),
);
await tester.pumpAndSettle();
expect(find.text('출고 고객사'), findsOneWidget);
expect(find.text('테스트 고객 · CUS-01'), findsOneWidget);
expect(find.text('라인 품목'), findsOneWidget);
expect(find.text('출고 제품'), findsOneWidget);
expect(find.text('제조사'), findsWidgets);
expect(find.text('EA'), findsWidgets);
expect(find.text('5'), findsWidgets);
expect(find.text('₩2,000'), findsOneWidget);
expect(find.text('양호'), findsOneWidget);
});
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart' as intl;
import 'package:superport_v2/features/inventory/rental/presentation/models/rental_record.dart';
import 'package:superport_v2/features/inventory/rental/presentation/widgets/rental_detail_view.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final currencyFormatter = intl.NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
);
RentalRecord buildRecord() {
return RentalRecord(
id: 1,
number: 'RENT-1',
transactionNumber: 'RENT-1',
transactionType: '대여',
rentalType: '대여',
processedAt: DateTime(2024, 3, 5, 15),
warehouse: '부산 창고',
status: '반납 대기',
writer: '김운영',
remark: '대여 비고',
returnDueDate: DateTime(2024, 3, 20),
items: [
RentalLineItem(
id: 30,
product: '대여 품목',
manufacturer: '렌탈 제조사',
unit: 'EA',
quantity: 2,
price: 5000,
remark: '',
),
],
customers: [
RentalCustomer(id: 2, customerId: 9, code: 'RC-09', name: '렌탈 고객'),
],
);
}
testWidgets('RentalDetailView는 고객 배지와 라인 테이블을 렌더링한다', (tester) async {
final record = buildRecord();
await tester.pumpWidget(
buildTestApp(
RentalDetailView(
record: record,
currencyFormatter: currencyFormatter,
transitionsEnabled: false,
),
),
);
await tester.pumpAndSettle();
expect(find.text('재고 상태 전이가 비활성화된 상태입니다.'), findsOneWidget);
expect(find.text('연결 고객사'), findsOneWidget);
expect(find.text('렌탈 고객 · RC-09'), findsOneWidget);
expect(find.text('라인 품목'), findsOneWidget);
expect(find.text('대여 품목'), findsOneWidget);
expect(find.text('렌탈 제조사'), findsOneWidget);
expect(find.text('EA'), findsOneWidget);
expect(find.text('2'), findsWidgets);
expect(find.text('₩5,000'), findsOneWidget);
});
}

View File

@@ -0,0 +1,47 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
import '../../../../../helpers/test_app.dart';
void main() {
testWidgets(
'showInventoryTransactionDetailDialog는 summary/metadata/섹션을 렌더링한다',
(tester) async {
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showInventoryTransactionDetailDialog<void>(
context: context,
title: '트랜잭션 상세',
description: '라인 품목과 상태를 확인하세요.',
summary: const Text('TRX-123'),
summaryBadges: const [ShadBadge(child: Text('완료'))],
metadata: [
SuperportDetailMetadata.text(label: '상태', value: '완료'),
SuperportDetailMetadata.text(label: '창고', value: '서울 1창고'),
],
sections: [
SuperportDetailDialogSection(
id: 'lines',
label: '라인 품목',
builder: (_) => const Text('라인 섹션'),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('트랜잭션 상세'), findsOneWidget);
expect(find.text('TRX-123'), findsOneWidget);
expect(find.text('상태'), findsOneWidget);
expect(find.text('라인 섹션'), findsOneWidget);
},
);
}

View File

@@ -0,0 +1,91 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart' as intl;
import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart';
import 'package:superport_v2/features/masters/customer/presentation/dialogs/customer_detail_dialog.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
Future<Customer?> noopCreate(CustomerInput _) async => null;
Future<Customer?> noopUpdate(int _, CustomerInput __) async => null;
Future<bool> noopDelete(int _) async => false;
Future<Customer?> noopRestore(int _) async => null;
testWidgets('showCustomerDetailDialog 등록 모드 폼을 렌더링한다', (tester) async {
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showCustomerDetailDialog(
context: context,
dateFormat: dateFormat,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('고객사 등록'), findsOneWidget);
expect(find.text('고객사코드'), findsOneWidget);
expect(find.text('유형'), findsOneWidget);
expect(find.text('등록'), findsWidgets);
});
testWidgets('showCustomerDetailDialog 상세 모드는 고객 요약을 표시한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final customer = Customer(
id: 10,
customerCode: 'C-001',
customerName: '슈퍼고객',
contactName: '홍길동',
isPartner: true,
isGeneral: false,
email: 'customer@example.com',
mobileNo: '010-1111-2222',
zipcode: CustomerZipcode(
zipcode: '06000',
sido: '서울',
sigungu: '강남구',
roadName: '테헤란로',
),
addressDetail: '101호',
isActive: true,
isDeleted: false,
note: 'VIP',
createdAt: DateTime(2024, 3, 1, 9),
updatedAt: DateTime(2024, 3, 2, 10),
);
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showCustomerDetailDialog(
context: context,
dateFormat: dateFormat,
customer: customer,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('고객사 상세'), findsOneWidget);
expect(find.textContaining('슈퍼고객').first, findsOneWidget);
expect(find.text('고객사 코드'), findsWidgets);
expect(find.text('유형'), findsWidgets);
});
}

View File

@@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../helpers/tester_extensions.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart';
import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart';
@@ -131,7 +132,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('고객사코드를 입력하세요.'), findsOneWidget);
@@ -174,7 +175,7 @@ void main() {
await tester.enterText(fields.at(1), '검색 필요 고객');
await tester.enterText(fields.at(4), '06000');
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('검색 버튼을 눌러 주소를 선택하세요.'), findsOneWidget);
@@ -251,11 +252,10 @@ void main() {
await tester.enterText(editableTexts.at(4), '02-0000-0000');
// 유형 체크박스: 기본값 partner=false, general=true. partner on 추가
await tester.tap(find.text('파트너'));
await tester.tap(find.text('파트너'), warnIfMissed: false);
await tester.pump();
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
await tester.tapShadButton('등록');
expect(capturedInput, isNotNull);
expect(capturedInput?.customerCode, 'C-100');

View File

@@ -0,0 +1,135 @@
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/features/masters/group/domain/entities/group.dart';
import 'package:superport_v2/features/masters/group/presentation/dialogs/group_detail_dialog.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
Future<Group?> noopCreate(GroupInput _) async => null;
Future<Group?> noopUpdate(int _, GroupInput __) async => null;
Future<bool> noopDelete(int _) async => false;
Future<Group?> noopRestore(int _) async => null;
testWidgets('showGroupDetailDialog 등록 모드 폼을 노출한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(960, 720));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showGroupDetailDialog(
context: context,
dateFormat: dateFormat,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('그룹 등록'), findsOneWidget);
expect(find.text('그룹명'), findsOneWidget);
expect(find.text('설명'), findsOneWidget);
expect(find.widgetWithText(ShadButton, '등록'), findsWidgets);
});
testWidgets('showGroupDetailDialog 상세 모드는 요약과 메타데이터를 제공한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final group = Group(
id: 12,
groupName: '운영팀',
description: '운영 담당',
isDefault: false,
isActive: true,
isDeleted: false,
note: '연중무휴',
createdAt: DateTime(2024, 2, 1, 9),
updatedAt: DateTime(2024, 3, 1, 10),
);
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showGroupDetailDialog(
context: context,
dateFormat: dateFormat,
group: group,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('그룹 상세'), findsWidgets);
expect(find.text('운영팀'), findsWidgets);
expect(find.text('운영 담당'), findsWidgets);
expect(find.text('기본 여부'), findsWidgets);
expect(find.text('사용 여부'), findsWidgets);
expect(find.text('삭제'), findsOneWidget);
});
testWidgets('삭제 섹션에서 경고와 버튼 상태를 노출한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final group = Group(
id: 20,
groupName: '삭제 대상',
isDefault: false,
isActive: true,
isDeleted: false,
createdAt: DateTime(2024, 1, 10, 8),
updatedAt: DateTime(2024, 1, 12, 9),
);
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showGroupDetailDialog(
context: context,
dateFormat: dateFormat,
group: group,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: (_) async => true,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
final deleteTab = find.text('삭제').first;
await tester.tap(deleteTab, warnIfMissed: false);
await tester.pumpAndSettle();
expect(find.textContaining('삭제하면'), findsOneWidget);
final dialog = find.byType(Dialog);
final deleteButton = find
.descendant(
of: dialog,
matching: find.widgetWithText(ShadButton, '삭제', skipOffstage: false),
)
.first;
final buttonWidget = tester.widget<ShadButton>(deleteButton);
expect(buttonWidget.onPressed, isNotNull);
});
}

View File

@@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../helpers/tester_extensions.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart';
@@ -120,7 +121,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('그룹명을 입력하세요.'), findsOneWidget);
@@ -179,8 +180,7 @@ void main() {
await tester.enterText(editableTexts.at(0), '운영팀');
await tester.enterText(editableTexts.at(1), '운영 담당');
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
await tester.tapShadButton('등록');
expect(capturedInput, isNotNull);
expect(capturedInput?.groupName, '운영팀');

View File

@@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../helpers/tester_extensions.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
@@ -179,7 +180,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('그룹을 선택하세요.'), findsOneWidget);
@@ -260,7 +261,7 @@ void main() {
final dialog = find.byType(Dialog);
final selects = find.descendant(
of: dialog,
matching: find.byType(ShadSelect<int?>),
matching: find.byWidgetPredicate((widget) => widget is ShadSelect),
);
// 그룹 선택
@@ -287,8 +288,7 @@ void main() {
await tester.tap(switches.at(3));
await tester.pump();
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
await tester.tapShadButton('등록');
expect(capturedInput, isNotNull);
expect(capturedInput?.groupId, 1);

View File

@@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../helpers/tester_extensions.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart';
import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart';
@@ -107,7 +108,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('메뉴코드를 입력하세요.'), findsOneWidget);
@@ -168,8 +169,7 @@ void main() {
await tester.enterText(editableTexts.at(0), 'MENU010');
await tester.enterText(editableTexts.at(1), '신규 메뉴');
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
await tester.tapShadButton('등록');
expect(capturedInput, isNotNull);
expect(capturedInput?.menuCode, 'MENU010');

View File

@@ -0,0 +1,102 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart' as intl;
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
import 'package:superport_v2/features/masters/product/presentation/dialogs/product_detail_dialog.dart';
import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart';
import 'package:superport_v2/features/masters/uom/domain/entities/uom.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
final vendorOptions = [
Vendor(id: 1, vendorCode: 'V-001', vendorName: '슈퍼벤더'),
];
final uomOptions = [Uom(id: 1, uomName: 'EA')];
Future<Product?> noopCreate(ProductInput _) async => null;
Future<Product?> noopUpdate(int _, ProductInput __) async => null;
Future<bool> noopDelete(int _) async => false;
Future<Product?> noopRestore(int _) async => null;
testWidgets('showProductDetailDialog 등록 모드는 입력 폼을 표시한다', (tester) async {
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showProductDetailDialog(
context: context,
dateFormat: dateFormat,
vendorOptions: vendorOptions,
uomOptions: uomOptions,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('제품 등록'), findsOneWidget);
expect(find.text('제품코드'), findsOneWidget);
expect(find.text('제조사'), findsOneWidget);
expect(find.text('단위'), findsOneWidget);
expect(find.text('등록'), findsWidgets);
});
testWidgets('showProductDetailDialog 상세 모드는 기본/연결/히스토리 정보를 제공한다', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final product = Product(
id: 42,
productCode: 'P-100',
productName: '테스트 제품',
vendor: ProductVendor(id: 1, vendorCode: 'V-001', vendorName: '슈퍼벤더'),
uom: ProductUom(id: 1, uomName: 'EA'),
isActive: true,
isDeleted: false,
note: '비고 메모',
createdAt: DateTime(2024, 1, 1, 9),
updatedAt: DateTime(2024, 2, 2, 10),
);
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showProductDetailDialog(
context: context,
dateFormat: dateFormat,
product: product,
vendorOptions: vendorOptions,
uomOptions: uomOptions,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('제품 상세'), findsOneWidget);
expect(find.text('테스트 제품'), findsWidgets);
expect(find.text('제품코드'), findsOneWidget);
expect(find.text('연결 관계'), findsOneWidget);
await tester.tap(find.text('연결 관계'));
await tester.pumpAndSettle();
expect(find.textContaining('슈퍼벤더'), findsWidgets);
await tester.tap(find.text('히스토리'));
await tester.pumpAndSettle();
expect(find.textContaining('변경 이력 데이터는 준비 중입니다.'), findsOneWidget);
});
}

View File

@@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../helpers/tester_extensions.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
@@ -208,7 +209,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('제품코드를 입력하세요.'), findsOneWidget);
@@ -303,8 +304,7 @@ void main() {
await tester.tap(find.text('EA'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
await tester.tapShadButton('등록');
expect(capturedInput, isNotNull);
expect(capturedInput?.productCode, 'NP-001');

View File

@@ -0,0 +1,152 @@
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/features/masters/group/domain/entities/group.dart';
import 'package:superport_v2/features/masters/user/domain/entities/user.dart';
import 'package:superport_v2/features/masters/user/presentation/dialogs/user_detail_dialog.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
final groups = [Group(id: 1, groupName: '관리자')];
Future<UserAccount?> noopCreate(UserInput _) async => null;
Future<UserAccount?> noopUpdate(int _, UserInput __) async => null;
Future<bool> noopDelete(int _) async => false;
Future<UserAccount?> noopRestore(int _) async => null;
Future<UserAccount?> noopReset(int _) async => null;
testWidgets('showUserDetailDialog 등록 모드 폼 렌더링', (tester) async {
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showUserDetailDialog(
context: context,
dateFormat: dateFormat,
groupOptions: groups,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
onResetPassword: noopReset,
),
);
await tester.pumpAndSettle();
expect(find.text('사용자 등록'), findsOneWidget);
expect(find.text('사번'), findsOneWidget);
expect(find.text('그룹'), findsOneWidget);
expect(find.text('등록'), findsWidgets);
});
testWidgets('상세 모드에서 비밀번호 재설정을 실행한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final user = UserAccount(
id: 7,
employeeNo: 'A007',
employeeName: '홍길동',
email: 'hong@superport.com',
mobileNo: '010-1234-5678',
group: UserGroup(id: 1, groupName: '관리자'),
createdAt: DateTime(2024, 1, 1, 9),
updatedAt: DateTime(2024, 1, 2, 10),
passwordUpdatedAt: DateTime(2024, 1, 2, 9),
);
var resetCalled = false;
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
final dialogFuture = showUserDetailDialog(
context: context,
dateFormat: dateFormat,
user: user,
groupOptions: groups,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
onResetPassword: (id) async {
resetCalled = id == 7;
return user;
},
);
await tester.pumpAndSettle();
await tester.tap(find.text('보안'));
await tester.pumpAndSettle();
final resetButton = find.widgetWithText(ShadButton, '비밀번호 재설정').first;
await tester.ensureVisible(resetButton);
await tester.tap(resetButton, warnIfMissed: false);
await tester.pumpAndSettle();
expect(find.text('비밀번호 재설정'), findsWidgets);
final confirmButton = find.widgetWithText(ShadButton, '재설정').last;
await tester.ensureVisible(confirmButton);
await tester.tap(confirmButton, warnIfMissed: false);
await tester.pumpAndSettle();
final result = await dialogFuture;
expect(resetCalled, isTrue);
expect(result?.action, UserDetailDialogAction.passwordReset);
expect(find.byType(SuperportDialog), findsNothing);
});
testWidgets('상세 모드에서 삭제 섹션을 노출한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final user = UserAccount(
id: 9,
employeeNo: 'A009',
employeeName: '삭제 대상',
group: UserGroup(id: 1, groupName: '관리자'),
isActive: true,
isDeleted: false,
createdAt: DateTime(2024, 1, 3, 9),
updatedAt: DateTime(2024, 1, 4, 12),
);
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
final dialogFuture = showUserDetailDialog(
context: context,
dateFormat: dateFormat,
user: user,
groupOptions: groups,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
onResetPassword: noopReset,
);
await tester.pumpAndSettle();
await tester.tap(find.text('삭제').first);
await tester.pumpAndSettle();
expect(find.textContaining('삭제하면'), findsOneWidget);
expect(find.widgetWithText(ShadButton, '삭제'), findsWidgets);
await tester.tap(find.byTooltip('닫기'), warnIfMissed: false);
await tester.pumpAndSettle();
await dialogFuture;
});
}

View File

@@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -5,6 +7,7 @@ import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../helpers/tester_extensions.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/core/permissions/permission_manager.dart';
import 'package:superport_v2/features/masters/group/domain/entities/group.dart';
@@ -14,6 +17,7 @@ import 'package:superport_v2/features/masters/group_permission/domain/repositori
import 'package:superport_v2/features/masters/user/domain/entities/user.dart';
import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart';
import 'package:superport_v2/features/masters/user/presentation/pages/user_page.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
class _MockUserRepository extends Mock implements UserRepository {}
@@ -191,7 +195,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('사번을 입력하세요.'), findsOneWidget);
@@ -259,7 +263,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
final dialog = find.byType(Dialog);
final dialog = find.byType(SuperportDialog);
await tester.enterText(
find.byKey(const ValueKey('user_form_employee')),
'A010',
@@ -298,8 +302,7 @@ void main() {
await tester.tap(adminOption.first, warnIfMissed: false);
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
await tester.tapShadButton('등록');
expect(capturedInput, isNotNull);
expect(capturedInput?.employeeNo, 'A010');
@@ -307,7 +310,7 @@ void main() {
expect(capturedInput?.forcePasswordChange, isTrue);
expect(capturedInput?.email, 'new@superport.com');
expect(capturedInput?.mobileNo, '010-1111-2222');
expect(find.byType(Dialog), findsNothing);
expect(find.byType(SuperportDialog), findsNothing);
expect(find.text('A010'), findsOneWidget);
verify(() => userRepository.create(any())).called(1);
});
@@ -344,7 +347,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
final dialog = find.byType(Dialog);
final dialog = find.byType(SuperportDialog);
await tester.enterText(
find.byKey(const ValueKey('user_form_employee')),
'A011',
@@ -381,14 +384,14 @@ void main() {
await tester.tap(find.text('관리자', skipOffstage: false).first);
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.textContaining('최소 8자 이상 입력해야 합니다.'), findsOneWidget);
verifyNever(() => userRepository.create(any()));
});
testWidgets('비밀번호 재설정 버튼을 통해 확인 후 API 호출', (tester) async {
testWidgets('상세 팝업에서 비밀번호 재설정 액션 수행', (tester) async {
final view = tester.view;
view.physicalSize = const Size(1280, 800);
view.devicePixelRatio = 1.0;
@@ -432,20 +435,37 @@ void main() {
await tester.pumpWidget(_buildApp(const UserPage()));
await tester.pumpAndSettle();
final resetFinder = find
.widgetWithIcon(ShadButton, LucideIcons.refreshCcw)
.first;
final resetButton = tester.widget<ShadButton>(resetFinder);
resetButton.onPressed?.call();
final rowFinder = find.text('A001');
expect(rowFinder, findsOneWidget);
final rowRect = tester.getRect(rowFinder);
await tester.tapAt(
rowRect.center,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsOneWidget);
expect(find.text('재설정'), findsOneWidget);
await tester.tap(find.text('재설정'));
expect(find.byType(SuperportDialog), findsOneWidget);
await tester.tap(find.text('보안'));
await tester.pumpAndSettle();
final resetButton =
find.widgetWithText(ShadButton, '비밀번호 재설정').first;
await tester.ensureVisible(resetButton);
await tester.tap(resetButton, warnIfMissed: false);
await tester.pumpAndSettle();
expect(find.text('비밀번호 재설정'), findsWidgets);
final confirmButton =
find.widgetWithText(ShadButton, '재설정').last;
await tester.ensureVisible(confirmButton);
await tester.tap(confirmButton, warnIfMissed: false);
await tester.pumpAndSettle();
verify(() => userRepository.resetPassword(1)).called(1);
expect(find.text('비밀번호 재설정'), findsNothing);
expect(find.byType(SuperportDialog), findsNothing);
});
});
}

View File

@@ -0,0 +1,135 @@
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/features/masters/vendor/domain/entities/vendor.dart';
import 'package:superport_v2/features/masters/vendor/presentation/dialogs/vendor_detail_dialog.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
Future<Vendor?> noopCreate(VendorInput _) async => null;
Future<Vendor?> noopUpdate(int _, VendorInput __) async => null;
Future<bool> noopDelete(int _) async => false;
Future<Vendor?> noopRestore(int _) async => null;
testWidgets('showVendorDetailDialog 등록 모드 폼을 노출한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(1080, 720));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showVendorDetailDialog(
context: context,
dateFormat: dateFormat,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('벤더 등록'), findsOneWidget);
expect(find.text('벤더코드'), findsOneWidget);
expect(find.text('벤더명'), findsOneWidget);
expect(find.widgetWithText(ShadButton, '등록'), findsWidgets);
});
testWidgets('showVendorDetailDialog 상세 모드는 요약 정보를 제공한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final vendor = Vendor(
id: 7,
vendorCode: 'V-007',
vendorName: '슈퍼벤더',
isActive: true,
isDeleted: false,
note: '테스트 벤더',
createdAt: DateTime(2024, 3, 1, 9, 30),
updatedAt: DateTime(2024, 3, 5, 12, 0),
);
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showVendorDetailDialog(
context: context,
dateFormat: dateFormat,
vendor: vendor,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: (id) async {
expect(id, equals(7));
return true;
},
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('벤더 상세'), findsWidgets);
expect(find.text('슈퍼벤더'), findsWidgets);
expect(find.text('삭제 상태'), findsOneWidget);
expect(find.text('삭제'), findsOneWidget);
});
testWidgets('삭제 섹션에서 경고 메시지와 버튼을 노출한다', (tester) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final vendor = Vendor(
id: 9,
vendorCode: 'V-009',
vendorName: '삭제 대상',
isActive: true,
isDeleted: false,
createdAt: DateTime(2024, 4, 1, 10),
updatedAt: DateTime(2024, 4, 10, 18),
);
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showVendorDetailDialog(
context: context,
dateFormat: dateFormat,
vendor: vendor,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: (_) async => true,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
final deleteTab = find.text('삭제').first;
await tester.tap(deleteTab, warnIfMissed: false);
await tester.pumpAndSettle();
expect(find.textContaining('삭제하면'), findsOneWidget);
final dialog = find.byType(Dialog);
final deleteButton = find
.descendant(
of: dialog,
matching: find.widgetWithText(ShadButton, '삭제', skipOffstage: false),
)
.first;
final buttonWidget = tester.widget<ShadButton>(deleteButton);
expect(buttonWidget.onPressed, isNotNull);
});
}

View File

@@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../helpers/tester_extensions.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart';
import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart';
@@ -142,7 +143,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('벤더코드를 입력하세요.'), findsOneWidget);
@@ -207,14 +208,14 @@ void main() {
await tester.enterText(editableTexts.at(0), 'NV-001');
await tester.enterText(editableTexts.at(1), '신규벤더');
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록');
await tester.pumpAndSettle();
expect(find.byType(Dialog), findsNothing);
verify(() => repository.create(any())).called(1);
expect(capturedInput, isNotNull);
expect(capturedInput?.vendorCode, 'NV-001');
expect(find.byType(Dialog), findsNothing);
expect(find.text('NV-001'), findsOneWidget);
verify(() => repository.create(any())).called(1);
});
testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async {

View File

@@ -0,0 +1,89 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart' as intl;
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import 'package:superport_v2/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog.dart';
import '../../../../../helpers/test_app.dart';
void main() {
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
Future<Warehouse?> noopCreate(WarehouseInput _) async => null;
Future<Warehouse?> noopUpdate(int _, WarehouseInput __) async => null;
Future<bool> noopDelete(int _) async => false;
Future<Warehouse?> noopRestore(int _) async => null;
testWidgets('showWarehouseDetailDialog 등록 모드 UI', (tester) async {
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showWarehouseDetailDialog(
context: context,
dateFormat: dateFormat,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('창고 등록'), findsOneWidget);
expect(find.text('창고코드'), findsOneWidget);
expect(find.text('우편번호'), findsOneWidget);
expect(find.text('등록'), findsWidgets);
});
testWidgets('showWarehouseDetailDialog 상세 모드 metadata/위험 섹션 제공', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
final warehouse = Warehouse(
id: 7,
warehouseCode: 'WH-001',
warehouseName: '서울 1창고',
zipcode: WarehouseZipcode(zipcode: '06000', sido: '서울', sigungu: '강남구'),
addressDetail: '강남대로 123',
note: '비고',
isActive: true,
isDeleted: false,
createdAt: DateTime(2024, 1, 1, 9),
updatedAt: DateTime(2024, 1, 2, 10),
);
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
final context = tester.element(find.byType(SizedBox));
unawaited(
showWarehouseDetailDialog(
context: context,
dateFormat: dateFormat,
warehouse: warehouse,
onCreate: noopCreate,
onUpdate: noopUpdate,
onDelete: noopDelete,
onRestore: noopRestore,
),
);
await tester.pumpAndSettle();
expect(find.text('창고 상세'), findsOneWidget);
expect(find.textContaining('서울 1창고'), findsWidgets);
expect(find.text('기본주소'), findsOneWidget);
expect(find.text('서울 강남구'), findsWidgets);
expect(find.text('수정'), findsOneWidget);
await tester.tap(find.text('삭제'));
await tester.pumpAndSettle();
expect(find.textContaining('삭제하면 창고가'), findsOneWidget);
});
}

View File

@@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../../../helpers/tester_extensions.dart';
import 'package:superport_v2/core/common/models/paginated_result.dart';
import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart';
import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart';
@@ -152,7 +153,7 @@ void main() {
await tester.tap(find.text('신규 등록'));
await tester.pumpAndSettle();
await tester.tap(find.text('등록'));
await tester.tapShadButton('등록', settle: false);
await tester.pump();
expect(find.text('창고코드를 입력하세요.'), findsOneWidget);
@@ -237,8 +238,7 @@ void main() {
await tester.enterText(updatedFields.at(3), '주소');
await tester.tap(find.text('등록'));
await tester.pumpAndSettle();
await tester.tapShadButton('등록');
expect(capturedInput, isNotNull);
expect(capturedInput?.warehouseCode, 'WH-100');

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
extension WidgetTesterDialogActions on WidgetTester {
/// 다이얼로그 내 `ShadButton`을 텍스트 라벨로 찾아 탭한다.
Future<void> tapShadButton(String label, {bool settle = true}) async {
final buttonFinder = find.widgetWithText(ShadButton, label);
expect(
buttonFinder,
findsWidgets,
reason: '텍스트 "$label" 를 가진 ShadButton을 찾지 못했습니다.',
);
final elements = buttonFinder.evaluate().toList(growable: false);
final targetIndex = elements.lastIndexWhere((element) {
final widget = element.widget;
return widget is ShadButton && widget.onPressed != null;
});
expect(
targetIndex,
isNot(-1),
reason: '눌러야 할 활성화된 "$label" 버튼을 찾을 수 없습니다.',
);
final targetButton = buttonFinder.at(targetIndex);
final targetText = find.descendant(
of: targetButton,
matching: find.byType(Text),
);
await ensureVisible(targetText.first);
await tap(targetText.first, warnIfMissed: false);
if (settle) {
await pumpAndSettle();
}
}
}

View File

@@ -0,0 +1,99 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport_v2/widgets/components/superport_detail_dialog.dart';
import '../helpers/test_app.dart';
void main() {
testWidgets('SuperportDetailDialog은 요약과 메타데이터를 렌더링한다', (tester) async {
final widget = SuperportDetailDialog(
summary: const Text('벤더 요약'),
summaryBadges: const [ShadBadge(child: Text('활성'))],
metadata: [
SuperportDetailMetadata.text(label: 'ID', value: '#42'),
SuperportDetailMetadata.text(label: '상태', value: '사용 중'),
],
sections: [
SuperportDetailDialogSection(
id: 'overview',
label: '개요',
builder: (_) => const Text('개요 섹션'),
),
],
);
await tester.pumpWidget(buildTestApp(widget));
await tester.pumpAndSettle();
expect(find.text('벤더 요약'), findsOneWidget);
expect(find.text('활성'), findsOneWidget);
expect(find.text('ID'), findsOneWidget);
expect(find.text('#42'), findsOneWidget);
expect(find.text('상태'), findsOneWidget);
expect(find.text('사용 중'), findsOneWidget);
expect(find.text('개요 섹션'), findsOneWidget);
expect(find.byType(ShadTabs), findsNothing);
});
testWidgets('SuperportDetailDialog은 여러 섹션 탭을 전환한다', (tester) async {
final widget = SuperportDetailDialog(
sections: [
SuperportDetailDialogSection(
id: 'overview',
label: '개요',
builder: (_) => const Text('개요 내용'),
),
SuperportDetailDialogSection(
id: 'history',
label: '이력',
builder: (_) => const Text('이력 내용'),
),
],
);
await tester.pumpWidget(buildTestApp(widget));
await tester.pumpAndSettle();
final tabsFinder = find.byWidgetPredicate((widget) => widget is ShadTabs);
expect(tabsFinder, findsOneWidget);
expect(find.text('개요 내용'), findsOneWidget);
expect(find.text('이력 내용'), findsNothing);
await tester.tap(find.text('이력'));
await tester.pumpAndSettle();
expect(find.text('개요 내용'), findsNothing);
expect(find.text('이력 내용'), findsOneWidget);
});
testWidgets('showSuperportDetailDialog는 기본 닫기 버튼을 렌더링하지 않는다', (tester) async {
await tester.pumpWidget(buildTestApp(const SizedBox.shrink()));
await tester.pumpAndSettle();
final context = tester.element(find.byType(SizedBox));
unawaited(
showSuperportDetailDialog<void>(
context: context,
title: '테스트 다이얼로그',
sections: [
SuperportDetailDialogSection(
id: 'overview',
label: '개요',
builder: (_) => const Text('본문'),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('테스트 다이얼로그'), findsOneWidget);
expect(find.text('본문'), findsOneWidget);
expect(find.text('닫기'), findsNothing);
});
}