generated from ttipo/project-base
feat(flutter): 메일 목록 화면을 만들고 앱 틀이 화면마다 탭 막대를 숨길 수 있게 한다 (App1MailList)
앱 틀(core/router): - 틀을 Scaffold 가 아니라 Column + 탭 막대로 — 탭 안 화면마다 자기 Scaffold 를 두어 전역 알림이 [메일 쓰기] 위에 뜬다. 탭 막대는 위 안전 영역을 빼고(실기기에서 상태 표시줄만큼 두꺼워지지 않게), 화면이 ShellBarHidden 으로 숨길 수 있다(모듈마다 셈 · 보이는지는 TickerMode, 자판이 열리면 뺌) 메일 목록(features/mail): - 메일함 서랍(바꿔 끼움 · 안 읽은 수는 회색 글), 줄(사진 · 보낸 사람 · 시각 / 제목 · 첨부 / 미리 보기 한 줄 · 안 읽음 굵게 · 중요 별), 커서로 이어 받기 · 당겨서 새로고침 · 빈 메일함 · 30일 줄 - 고르기 막대(읽음 · 중요 · 이동 · 삭제) — 먼저 바꾸고 failedIds 만 되돌리기, 이동 · 삭제는 응답 id 만, 100통씩, 휴지통으로 옮기고 [되돌리기], 휴지통에서는 확인 뒤 영구 삭제, 붙잡힌 메일은 고를 수 없음 - 새 메일 확인: 보이고 앱이 앞에 있을 때만 1분마다, 다시 받은 쪽이 끝까지면 지운 옛 메일을 뺀다 - [되돌리기] 알림은 [메일 쓰기]와 한 자리(화면 낭독기를 켠 동안은 닫지 않음), 단추 없는 알림은 전역 스낵바 - 시험 346건(새로 119) 리뷰 반영: 탭 막대 높이, 고르는 중 알림 줄 위치, 이어 받기 중 새로고침 실패, 붙잡힌 메일 다시 보기, 메일함 역할을 알기 전 그리지 않음, 선택 풀기 · 되살리기를 계약에 맞춤, 맞추기 한 번에 하나. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-11
@@ -12,6 +12,7 @@ import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/pages/mail_list_page.dart';
|
||||
import 'package:arasystem_app/features/settings/presentation/settings_texts.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -22,6 +23,11 @@ import 'support/fakes.dart';
|
||||
import 'support/login_fakes.dart';
|
||||
import 'support/provider_harness.dart';
|
||||
|
||||
/// 메일 목록 화면 — [mailboxId] 가 null 이면 받은편지함(주소 `/mail`)
|
||||
Finder mailList(String? mailboxId) => find.byWidgetPredicate(
|
||||
(Widget widget) => widget is MailListPage && widget.mailboxId == mailboxId,
|
||||
);
|
||||
|
||||
void main() {
|
||||
late ProviderHarness h;
|
||||
|
||||
@@ -90,13 +96,7 @@ void main() {
|
||||
|
||||
await pumpApp(tester);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(ListView),
|
||||
matching: find.text(MailTexts.home),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(mailList(null), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ void main() {
|
||||
final GoRouter router = await pumpSignedIn(tester);
|
||||
router.go(MailRoutes.mailbox('archive'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page(MailTexts.mailbox), findsOneWidget);
|
||||
expect(mailList('archive'), findsOneWidget);
|
||||
|
||||
await tester.tap(tab('설정'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -162,7 +162,7 @@ void main() {
|
||||
|
||||
await tester.tap(tab('메일'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page(MailTexts.mailbox), findsOneWidget);
|
||||
expect(mailList('archive'), findsOneWidget);
|
||||
expect(
|
||||
router.routerDelegate.currentConfiguration.uri.path,
|
||||
MailRoutes.mailbox('archive'),
|
||||
@@ -171,7 +171,8 @@ void main() {
|
||||
// 지금 탭을 다시 누르면 그 모듈의 첫 화면으로
|
||||
await tester.tap(tab('메일'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(page(MailTexts.home), findsOneWidget);
|
||||
expect(mailList(null), findsOneWidget);
|
||||
expect(mailList('archive'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('읽기 · 쓰기는 탭 막대 없이 전체 화면으로 연다', (WidgetTester tester) async {
|
||||
@@ -204,7 +205,7 @@ void main() {
|
||||
MailRoutes.home,
|
||||
reason: location,
|
||||
);
|
||||
expect(page(MailTexts.home), findsOneWidget, reason: location);
|
||||
expect(mailList(null), findsOneWidget, reason: location);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:arasystem_app/app.dart';
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/constants/app_constants.dart';
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/router/app_router.dart';
|
||||
import 'package:arasystem_app/core/router/shell_bar.dart';
|
||||
import 'package:arasystem_app/core/theme/app_spacing.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/mail/mail_routes.dart';
|
||||
import 'package:arasystem_app/features/settings/settings_routes.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/provider_harness.dart';
|
||||
|
||||
/// 휴대폰 화면 크기 (보드의 390 × 844)
|
||||
const Size _phone = Size(390, 844);
|
||||
|
||||
/// 로그인 뒤 화면의 틀 — 아래 탭 막대 (앱 보드 `App1Shell`)
|
||||
void main() {
|
||||
late ProviderHarness h;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() => h = ProviderHarness());
|
||||
|
||||
GoRouter router() => container.read(routerProvider);
|
||||
|
||||
/// 되살린 세션으로 앱을 띄운다 — 첫 화면은 메일 탭(메일 API 는 없는 경로라 메일 화면은 받지 못함을 그린다)
|
||||
Future<void> pumpSignedIn(WidgetTester tester) async {
|
||||
tester.view.physicalSize = _phone * 3;
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
await h.prepare(refreshToken: 'refresh-1');
|
||||
h.server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
...sessionJson(),
|
||||
'tokens': tokensJson(2, now: h.clock.now),
|
||||
}),
|
||||
);
|
||||
h.server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
retry: noAutomaticRetry,
|
||||
overrides: h.overrides,
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp)),
|
||||
);
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
}
|
||||
|
||||
testWidgets('탭 막대는 위 안전 영역(상태 표시줄)만큼 커지지 않는다 — 아래 안전 영역만 더한다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// 위 24 · 아래 16 (물리 픽셀은 3배)
|
||||
tester.view.padding = const FakeViewPadding(top: 72, bottom: 48);
|
||||
await pumpSignedIn(tester);
|
||||
|
||||
final Rect bar = tester.getRect(find.byType(NavigationBar));
|
||||
expect(bar.height, moreOrLessEquals(AppSize.navigationBarHeight + 16));
|
||||
expect(bar.bottom, moreOrLessEquals(_phone.height));
|
||||
});
|
||||
|
||||
testWidgets('탭 막대의 화면 낭독기 이름은 "앱 메뉴"', (WidgetTester tester) async {
|
||||
await pumpSignedIn(tester);
|
||||
|
||||
expect(find.bySemanticsLabel(kAppMenuLabel), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('자판이 열리면 탭 막대를 뺀다(화면이 자판 위까지 줄어든다)', (WidgetTester tester) async {
|
||||
await pumpSignedIn(tester);
|
||||
|
||||
tester.view.viewInsets = const FakeViewPadding(bottom: 900);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
|
||||
tester.view.resetViewInsets();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('화면이 숨긴 탭 막대는 그 모듈의 탭에서만 빠진다 — 다른 탭에서는 보인다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await pumpSignedIn(tester);
|
||||
final ShellBarHiding hiding = container.read(
|
||||
shellBarHidingProvider.notifier,
|
||||
);
|
||||
|
||||
hiding.hide(AppModuleId.mail);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
|
||||
router().go(SettingsRoutes.home);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
|
||||
router().go(MailRoutes.home);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
|
||||
hiding.reveal(AppModuleId.mail);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:arasystem_app/core/modules/app_modules.dart';
|
||||
import 'package:arasystem_app/core/router/shell_bar.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// 아래 탭 막대 숨기기 — 화면마다 숨기고 돌려준다(모듈마다 센다)
|
||||
void main() {
|
||||
test('숨긴 화면이 모두 돌려줄 때까지 그 모듈의 탭에서 숨긴다 — 다른 모듈은 그대로', () {
|
||||
final ProviderContainer container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
final ShellBarHiding hiding = container.read(
|
||||
shellBarHidingProvider.notifier,
|
||||
);
|
||||
Map<AppModuleId, int> state() => container.read(shellBarHidingProvider);
|
||||
|
||||
hiding
|
||||
..hide(AppModuleId.mail)
|
||||
..hide(AppModuleId.mail);
|
||||
expect(isShellBarHidden(state(), AppModuleId.mail), isTrue);
|
||||
expect(isShellBarHidden(state(), AppModuleId.settings), isFalse);
|
||||
|
||||
hiding.reveal(AppModuleId.mail);
|
||||
expect(isShellBarHidden(state(), AppModuleId.mail), isTrue);
|
||||
hiding.reveal(AppModuleId.mail);
|
||||
expect(isShellBarHidden(state(), AppModuleId.mail), isFalse);
|
||||
// 숨기지 않은 모듈을 돌려줘도 음수가 되지 않는다
|
||||
hiding.reveal(AppModuleId.settings);
|
||||
expect(state(), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,11 @@ import 'package:arasystem_app/features/auth/presentation/pages/two_factor_setup_
|
||||
import 'package:arasystem_app/features/auth/presentation/providers/session_state.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/widgets/login_fields.dart';
|
||||
import 'package:arasystem_app/features/auth/presentation/widgets/login_parts.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_state.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -1837,7 +1842,15 @@ bool _allowedProviderValue(Object? value) =>
|
||||
value is AuthRepository ||
|
||||
value is TwoFactorRepository ||
|
||||
value is SupportContactRepository ||
|
||||
value is AuthenticatorLauncher;
|
||||
value is AuthenticatorLauncher ||
|
||||
// 로그인을 마치면 메일 목록이 뜬다 — 메일함 · 목록 · 새 메일 확인 · 알림 · 탭 막대 숨김 · 앱이 앞에 있는가
|
||||
value is MailRepository ||
|
||||
value is MailboxesState ||
|
||||
value is MailListState ||
|
||||
value is MailChange ||
|
||||
value is MailToast ||
|
||||
value is Map<AppModuleId, int> ||
|
||||
value is bool;
|
||||
|
||||
/// 프로바이더 값 · 실패를 모두 적어 둔다 — 비밀이 상태에 들어가지 않는지 본다
|
||||
final class _ValueRecorder extends ProviderObserver {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/fakes.dart';
|
||||
import '../../support/mail_fakes.dart';
|
||||
|
||||
/// 메일 저장소 — 계약 2절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다)
|
||||
void main() {
|
||||
late ApiHarness h;
|
||||
late MailRepository repository;
|
||||
|
||||
setUp(() async {
|
||||
h = ApiHarness();
|
||||
await h.prepare();
|
||||
await h.signIn();
|
||||
repository = MailRepository(h.dio);
|
||||
});
|
||||
|
||||
test('메일함 목록 — Bearer 로 부르고 읽는다, 실패는 알리지 않는다', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailboxes,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': standardMailboxes(),
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
}),
|
||||
);
|
||||
|
||||
final List<Mailbox> mailboxes = await repository.listMailboxes();
|
||||
|
||||
expect(mailboxes, hasLength(8));
|
||||
expect(mailboxes.first.role, MailboxRole.inbox);
|
||||
expect(bearerTokenOf(h.server.requests.single), 'access-1');
|
||||
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailboxes,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
expect(
|
||||
apiErrorOf(await errorOf(repository.listMailboxes())).code,
|
||||
'MAIL_001',
|
||||
);
|
||||
expect(h.notifier.shownCodes, isEmpty);
|
||||
});
|
||||
|
||||
test('목록 — 메일함 · 쪽 크기 · 커서를 그대로 보낸다', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[messageJson('m1', mailboxId: kInboxId)],
|
||||
'pageInfo': <String, Object?>{
|
||||
'hasNext': true,
|
||||
'nextCursor': 'opaque==',
|
||||
'total': 21,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
final MailPage<MessageSummary> page = await repository.listMessages(
|
||||
mailboxId: kInboxId,
|
||||
cursor: 'abc',
|
||||
limit: 50,
|
||||
);
|
||||
|
||||
expect(page.items.single.id, 'm1');
|
||||
expect(page.pageInfo.nextCursor, 'opaque==');
|
||||
expect(page.pageInfo.total, 21);
|
||||
expect(h.server.requests.single.queryParameters, <String, Object?>{
|
||||
'mailboxId': kInboxId,
|
||||
'limit': 50,
|
||||
'cursor': 'abc',
|
||||
});
|
||||
});
|
||||
|
||||
test('목록 — 응답의 Date(서버 시각)를 쪽에 싣는다, 없으면 null', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[],
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
}),
|
||||
);
|
||||
final DateTime serverTime = DateTime.utc(2026, 11, 6, 1, 2, 3);
|
||||
h.server.serverTime = serverTime;
|
||||
|
||||
final MailPage<MessageSummary> page = await repository.listMessages(
|
||||
mailboxId: kInboxId,
|
||||
);
|
||||
expect(page.serverTime, serverTime);
|
||||
|
||||
h.server.serverTime = null;
|
||||
final MailPage<MessageSummary> withoutDate = await repository.listMessages(
|
||||
mailboxId: kInboxId,
|
||||
);
|
||||
expect(withoutDate.serverTime, isNull);
|
||||
});
|
||||
|
||||
test('응답 모양이 계약과 다르면 SYS_001', () async {
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'items': <Object?>[
|
||||
<String, Object?>{'id': 'm1'},
|
||||
],
|
||||
'pageInfo': <String, Object?>{'hasNext': false},
|
||||
}),
|
||||
);
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailState,
|
||||
(_) => FakeResponse.ok(<String, Object?>{'state': 3}),
|
||||
);
|
||||
|
||||
expect(
|
||||
apiErrorOf(
|
||||
await errorOf(repository.listMessages(mailboxId: kInboxId)),
|
||||
).code,
|
||||
'SYS_001',
|
||||
);
|
||||
expect(apiErrorOf(await errorOf(repository.mailState())).code, 'SYS_001');
|
||||
});
|
||||
|
||||
test('한 통 · 여러 통 바꾸기 · 영구 삭제의 경로와 본문', () async {
|
||||
h.server.on(
|
||||
'PATCH',
|
||||
MailApiPaths.message('a/b'),
|
||||
(RequestOptions options) => FakeResponse.ok(
|
||||
messageJson('a/b', mailboxId: kInboxId, isRead: true),
|
||||
),
|
||||
);
|
||||
h.server.on(
|
||||
'PATCH',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'updatedIds': <String>['m1'],
|
||||
'failedIds': <String>['m2'],
|
||||
}),
|
||||
);
|
||||
h.server.on(
|
||||
'POST',
|
||||
MailApiPaths.messageDeletions,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
'deletedIds': <String>['t1'],
|
||||
'skippedIds': <String>[],
|
||||
}),
|
||||
);
|
||||
|
||||
final MessageSummary one = await repository.updateMessage(
|
||||
'a/b',
|
||||
const MessageChanges(isRead: true),
|
||||
);
|
||||
final MessagesPatched many = await repository.updateMessages(<String>[
|
||||
'm1',
|
||||
'm2',
|
||||
], const MessageChanges(mailboxId: kTrashId));
|
||||
final MessageDeletions deleted = await repository.deleteMessages(<String>[
|
||||
't1',
|
||||
]);
|
||||
|
||||
expect(MailApiPaths.message('a/b'), '/messages/a%2Fb');
|
||||
expect(one.isRead, isTrue);
|
||||
expect(many.failedIds, <String>['m2']);
|
||||
expect(deleted.deletedIds, <String>['t1']);
|
||||
expect(h.server.requests[0].data, <String, Object?>{'isRead': true});
|
||||
expect(h.server.requests[1].data, <String, Object?>{
|
||||
'ids': <String>['m1', 'm2'],
|
||||
'mailboxId': kTrashId,
|
||||
});
|
||||
expect(h.server.requests[2].data, <String, Object?>{
|
||||
'ids': <String>['t1'],
|
||||
});
|
||||
});
|
||||
|
||||
test('여러 통 바꾸기의 실패는 인터셉터가 알린다(새 메일 확인은 알리지 않는다)', () async {
|
||||
h.server.on(
|
||||
'PATCH',
|
||||
MailApiPaths.messages,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
h.server.on(
|
||||
'GET',
|
||||
MailApiPaths.mailState,
|
||||
(_) => FakeResponse.fail(503, 'MAIL_001'),
|
||||
);
|
||||
|
||||
await errorOf(
|
||||
repository.updateMessages(<String>[
|
||||
'm1',
|
||||
], const MessageChanges(isRead: true)),
|
||||
);
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
h.clock.advance(const Duration(seconds: 5));
|
||||
await errorOf(repository.mailState());
|
||||
expect(h.notifier.shownCodes, <String>['MAIL_001']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import 'package:arasystem_app/core/utils/korean_format.dart';
|
||||
import 'package:arasystem_app/core/utils/korean_text.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
|
||||
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_bulk.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mail_list_merge.dart';
|
||||
import 'package:arasystem_app/features/mail/domain/mailbox_tree.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../support/mail_fakes.dart';
|
||||
|
||||
/// 메일 목록의 순수 규칙 — 표기 · 조사 · 줄 · 메일함 차례 · 병합 · 나눠 보내기 · 모델 읽기 (웹 규칙과 같은지)
|
||||
void main() {
|
||||
/// 기기 시간대의 시각을 UTC 로 — 시험이 CI(UTC) · 개발 PC(KST) 어디서나 같게 (테스트 규칙 "시간대도 입력이다")
|
||||
DateTime local(int y, int m, int d, [int h = 0, int min = 0]) =>
|
||||
DateTime(y, m, d, h, min).toUtc();
|
||||
|
||||
MessageSummary summary(Map<String, Object?> json) =>
|
||||
MessageSummary.tryParse(json)!;
|
||||
|
||||
group('숫자 · 시각 표기 (웹 format.ts 와 같은 글)', () {
|
||||
test('개수에 천 단위 쉼표', () {
|
||||
expect(formatCount(0), '0');
|
||||
expect(formatCount(999), '999');
|
||||
expect(formatCount(1000), '1,000');
|
||||
expect(formatCount(1234567), '1,234,567');
|
||||
});
|
||||
|
||||
test('하루 안의 시각 — 오전 · 오후 · 열두 시', () {
|
||||
expect(koreanTimeOfDay(DateTime(2026, 11, 6, 10, 42)), '오전 10:42');
|
||||
expect(koreanTimeOfDay(DateTime(2026, 11, 6, 15, 5)), '오후 3:05');
|
||||
expect(koreanTimeOfDay(DateTime(2026, 11, 6, 0, 30)), '오전 12:30');
|
||||
expect(koreanTimeOfDay(DateTime(2026, 11, 6, 12)), '오후 12:00');
|
||||
});
|
||||
|
||||
test('목록의 받은 시각 — 오늘 · 어제 · 올해 · 그 전', () {
|
||||
final DateTime now = local(2026, 11, 6, 9, 12);
|
||||
expect(formatMailListTime(local(2026, 11, 6, 8, 31), now), '오전 8:31');
|
||||
expect(formatMailListTime(local(2026, 11, 5, 23, 59), now), '어제');
|
||||
expect(formatMailListTime(local(2026, 11, 3, 10), now), '11월 3일');
|
||||
expect(formatMailListTime(local(2025, 10, 24, 10), now), '2025. 10. 24.');
|
||||
});
|
||||
|
||||
test('어제는 달 · 해의 경계를 넘는다', () {
|
||||
expect(
|
||||
formatMailListTime(local(2025, 12, 31, 18), local(2026, 1, 1, 8)),
|
||||
'어제',
|
||||
);
|
||||
expect(
|
||||
formatMailListTime(local(2026, 2, 28, 18), local(2026, 3, 1, 8)),
|
||||
'어제',
|
||||
);
|
||||
});
|
||||
|
||||
test('붙잡아 둔 발송 — 보내는 중 · 예약 시각(요일 없이)', () {
|
||||
expect(
|
||||
pendingSendLabel(
|
||||
PendingSend(
|
||||
sendAt: local(2026, 11, 6, 9, 13),
|
||||
kind: PendingSendKind.undo,
|
||||
),
|
||||
),
|
||||
'보내는 중',
|
||||
);
|
||||
expect(
|
||||
pendingSendLabel(
|
||||
PendingSend(
|
||||
sendAt: local(2026, 11, 7, 9),
|
||||
kind: PendingSendKind.scheduled,
|
||||
),
|
||||
),
|
||||
'예약 · 11월 7일 오전 9:00',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('조사 (웹 korean.ts 와 같다)', () {
|
||||
test('이/가 — 받침 · 받침 없음 · 한글이 아님', () {
|
||||
expect(subjectParticle('휴지통'), '이');
|
||||
expect(subjectParticle('프로젝트'), '가');
|
||||
expect(subjectParticle('Inbox'), '이(가)');
|
||||
expect(MailListTexts.emptyTitle('휴지통'), '휴지통이 비어 있습니다');
|
||||
expect(MailListTexts.emptyTitle('프로젝트'), '프로젝트가 비어 있습니다');
|
||||
});
|
||||
|
||||
test('으로/로 — 받침 · ㄹ 받침 · 받침 없음 · 한글이 아님', () {
|
||||
expect(directionParticle('휴지통'), '으로');
|
||||
expect(directionParticle('보낸편지함'), '으로');
|
||||
expect(directionParticle('메일'), '로');
|
||||
expect(directionParticle('프로젝트'), '로');
|
||||
expect(directionParticle('Archive'), '(으)로');
|
||||
});
|
||||
});
|
||||
|
||||
group('여러 통 처리의 알림 글 (웹 mail-bulk.ts 와 같다)', () {
|
||||
test('옮김 · 영구 삭제 · 되돌림, 그대로 둔 메일은 뒤에 덧붙인다', () {
|
||||
expect(MailBulkTexts.moved(2, '휴지통'), '메일 2통을 휴지통으로 옮겼습니다.');
|
||||
expect(
|
||||
MailBulkTexts.moved(1, '프로젝트', skipped: 1),
|
||||
'메일 1통을 프로젝트로 옮겼습니다. 1통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
);
|
||||
expect(
|
||||
MailBulkTexts.moved(0, '휴지통', skipped: 2),
|
||||
'2통은 그사이 옮겨졌거나 지워져 그대로 두었습니다.',
|
||||
);
|
||||
expect(MailBulkTexts.purged(1234), '메일 1,234통을 영구 삭제했습니다.');
|
||||
expect(MailBulkTexts.restored(3), '메일 3통을 되돌렸습니다.');
|
||||
expect(MailBulkTexts.purgeTitle(2), '메일 2통을 영구 삭제할까요?');
|
||||
});
|
||||
|
||||
test('위 막대 아래 안 읽은 수', () {
|
||||
expect(MailListTexts.unread(3), '안 읽은 메일 3통');
|
||||
expect(MailListTexts.unread(0), '안 읽은 메일이 없습니다');
|
||||
});
|
||||
});
|
||||
|
||||
group('목록 줄 (웹 mail-list-row.ts 의 분할 보기와 같다)', () {
|
||||
final DateTime now = local(2026, 11, 6, 11);
|
||||
|
||||
test('받은편지함 — 보낸 사람 · 머리글자 · 안 읽음 · 첨부 · 중요 · 미리 보기', () {
|
||||
final MailRowView view = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
'm1',
|
||||
mailboxId: kInboxId,
|
||||
fromName: '(주)한빛솔루션',
|
||||
subject: '견적서',
|
||||
preview: '첨부했습니다.',
|
||||
receivedAt: local(2026, 11, 6, 10, 42),
|
||||
hasAttachment: true,
|
||||
isFlagged: true,
|
||||
),
|
||||
),
|
||||
role: MailboxRole.inbox,
|
||||
now: now,
|
||||
);
|
||||
expect(view.who, '(주)한빛솔루션');
|
||||
expect(view.initial, '한');
|
||||
expect(view.time, '오전 10:42');
|
||||
expect(view.unread, isTrue);
|
||||
expect(view.held, isFalse);
|
||||
expect(view.selectLabel, '고르기: 견적서');
|
||||
expect(
|
||||
view.semanticsLabel,
|
||||
'안 읽음, (주)한빛솔루션, 오전 10:42, 견적서, 첨부 있음, 중요, 첨부했습니다.',
|
||||
);
|
||||
});
|
||||
|
||||
test('이름이 없으면 주소, 보낸 사람이 없으면 "(보낸 사람 없음)" · 사람 아이콘', () {
|
||||
final MailRowView noName = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
'm1',
|
||||
mailboxId: kInboxId,
|
||||
fromName: '',
|
||||
fromEmail: 'bot@example.com',
|
||||
),
|
||||
),
|
||||
role: MailboxRole.inbox,
|
||||
now: now,
|
||||
);
|
||||
expect(noName.who, 'bot@example.com');
|
||||
expect(noName.initial, 'B');
|
||||
final MailRowView noSender = mailRowViewOf(
|
||||
summary(messageJson('m2', mailboxId: kInboxId, noSender: true)),
|
||||
role: MailboxRole.inbox,
|
||||
now: now,
|
||||
);
|
||||
expect(noSender.who, MailListTexts.noSender);
|
||||
expect(noSender.initial, isNull);
|
||||
});
|
||||
|
||||
test('보낸편지함 — "받는 사람: 박준호 외 2명", 받는 사람이 없으면 "받는 사람 없음"', () {
|
||||
final MailRowView view = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
's1',
|
||||
mailboxId: kSentId,
|
||||
to: <Map<String, Object?>>[
|
||||
addressJson('박준호', 'junho@example.com'),
|
||||
addressJson('', 'b@example.com'),
|
||||
addressJson('최서연', 'c@example.com'),
|
||||
],
|
||||
isRead: true,
|
||||
),
|
||||
),
|
||||
role: MailboxRole.sent,
|
||||
now: now,
|
||||
);
|
||||
expect(view.who, '받는 사람: 박준호 외 2명');
|
||||
expect(view.initial, '박');
|
||||
final MailRowView none = mailRowViewOf(
|
||||
summary(messageJson('s2', mailboxId: kSentId)),
|
||||
role: MailboxRole.sent,
|
||||
now: now,
|
||||
);
|
||||
expect(none.who, MailListTexts.noRecipients);
|
||||
expect(none.whoPlaceholder, isFalse);
|
||||
});
|
||||
|
||||
test('임시보관함 — 받는 사람을 쉼표로, 없으면 흐린 "(받는 사람 없음)", 읽음 여부 · 빈 제목', () {
|
||||
final MailRowView view = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
'd1',
|
||||
mailboxId: kDraftsId,
|
||||
to: <Map<String, Object?>>[
|
||||
addressJson('박준호', 'junho@example.com'),
|
||||
addressJson('', 'infra@example.com'),
|
||||
],
|
||||
),
|
||||
),
|
||||
role: MailboxRole.drafts,
|
||||
now: now,
|
||||
);
|
||||
expect(view.who, '박준호, infra@example.com');
|
||||
expect(view.unread, isFalse, reason: '임시 저장본은 읽음 여부를 보이지 않는다');
|
||||
final MailRowView empty = mailRowViewOf(
|
||||
summary(messageJson('d2', mailboxId: kDraftsId, subject: '')),
|
||||
role: MailboxRole.drafts,
|
||||
now: now,
|
||||
);
|
||||
expect(empty.who, MailListTexts.draftNoRecipients);
|
||||
expect(empty.whoPlaceholder, isTrue);
|
||||
expect(empty.subject, MailListTexts.noSubject);
|
||||
expect(empty.subjectPlaceholder, isTrue);
|
||||
});
|
||||
|
||||
test('아직 보내지 않은 메일 — 시각 자리에 표시(청록) · 고를 수 없다', () {
|
||||
final MailRowView view = mailRowViewOf(
|
||||
summary(
|
||||
messageJson(
|
||||
's1',
|
||||
mailboxId: kSentId,
|
||||
isRead: true,
|
||||
pendingSend: pendingJson(local(2026, 11, 7, 9), kind: 'scheduled'),
|
||||
),
|
||||
),
|
||||
role: MailboxRole.sent,
|
||||
now: now,
|
||||
);
|
||||
expect(view.time, '예약 · 11월 7일 오전 9:00');
|
||||
expect(view.timePending, isTrue);
|
||||
expect(view.held, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('모델 읽기', () {
|
||||
test('메일 — 칸이 틀리면 null, 붙잡아 둔 발송만은 틀리면 없는 것으로', () {
|
||||
final Map<String, Object?> json = messageJson('m1', mailboxId: kInboxId);
|
||||
expect(summary(json).pendingSend, isNull);
|
||||
expect(
|
||||
MessageSummary.tryParse(<String, Object?>{...json, 'id': 1}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
MessageSummary.tryParse(<String, Object?>{
|
||||
...json,
|
||||
'mailboxIds': <Object?>[1],
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
MessageSummary.tryParse(<String, Object?>{
|
||||
...json,
|
||||
'receivedAt': 'nope',
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
summary(<String, Object?>{
|
||||
...json,
|
||||
'pendingSend': <String, Object?>{'kind': 'later', 'sendAt': 'x'},
|
||||
}).pendingSend,
|
||||
isNull,
|
||||
);
|
||||
// 9단계 앞 서버 — 칸이 없다
|
||||
expect(
|
||||
summary(<String, Object?>{...json}..remove('pendingSend')).isHeld,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('메일함 — 모르는 역할은 내 메일함, 목록 쪽 — 항목 하나라도 틀리면 null', () {
|
||||
expect(
|
||||
Mailbox.tryParse(mailboxJson('x', '보관', role: 'mystery'))!.role,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
Mailbox.tryParse(mailboxJson('x', '받은편지함', role: 'inbox'))!.role,
|
||||
MailboxRole.inbox,
|
||||
);
|
||||
final MailPage<Mailbox>? page = MailPage.tryParse<Mailbox>(
|
||||
<String, Object?>{
|
||||
'items': <Object?>[
|
||||
mailboxJson('a', 'A'),
|
||||
<String, Object?>{'id': 2},
|
||||
],
|
||||
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
|
||||
},
|
||||
Mailbox.tryParse,
|
||||
);
|
||||
expect(page, isNull);
|
||||
});
|
||||
|
||||
test('다음 쪽은 hasNext 이고 커서가 있을 때만', () {
|
||||
expect(
|
||||
MailPageInfo.tryParse(<String, Object?>{
|
||||
'hasNext': true,
|
||||
'nextCursor': 'c:20',
|
||||
'total': 40,
|
||||
})!.hasMore,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
MailPageInfo.tryParse(<String, Object?>{
|
||||
'hasNext': true,
|
||||
'nextCursor': null,
|
||||
})!.hasMore,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('바꿀 것 — 정한 칸만 싣는다', () {
|
||||
expect(const MessageChanges(isRead: true).toJson(), <String, Object?>{
|
||||
'isRead': true,
|
||||
});
|
||||
expect(
|
||||
const MessageChanges(mailboxId: 'mb-trash', isJunk: false).toJson(),
|
||||
<String, Object?>{'mailboxId': 'mb-trash', 'isJunk': false},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('메일함 차례 · 묶음 · 옮길 곳 (웹 mailbox.ts 와 같다)', () {
|
||||
final List<Mailbox> mailboxes = <Mailbox>[
|
||||
for (final Map<String, Object?> json in standardMailboxes())
|
||||
Mailbox.tryParse(json)!,
|
||||
];
|
||||
|
||||
test('역할 차례 → 내 메일함(sortOrder · 이름) — 하위는 부모 바로 아래 한 단 깊이', () {
|
||||
final List<MailboxNode> nodes = orderMailboxes(mailboxes);
|
||||
expect(
|
||||
<String>[for (final MailboxNode node in nodes) node.mailbox.name],
|
||||
<String>[
|
||||
'받은편지함',
|
||||
'보낸편지함',
|
||||
'임시보관함',
|
||||
'스팸메일함',
|
||||
'휴지통',
|
||||
'거래처',
|
||||
'프로젝트',
|
||||
'설계',
|
||||
],
|
||||
);
|
||||
expect(nodes.last.depth, 1);
|
||||
final ({List<MailboxNode> system, List<MailboxNode> mine}) groups =
|
||||
splitMailboxGroups(nodes);
|
||||
expect(groups.system, hasLength(5));
|
||||
expect(groups.mine, hasLength(3));
|
||||
});
|
||||
|
||||
test('내 메일함 이름은 대소문자를 가리지 않고 글자 값으로 — 영문 · 한글이 섞이면 웹(ko 정렬)과 다를 수 있다', () {
|
||||
final List<Mailbox> mine = <Mailbox>[
|
||||
for (final (String id, String name) in <(String, String)>[
|
||||
('1', '가나다'),
|
||||
('2', 'erp'),
|
||||
('3', 'ARA 프로젝트'),
|
||||
('4', 'ara'),
|
||||
('5', 'Ara'),
|
||||
])
|
||||
Mailbox.tryParse(mailboxJson(id, name))!,
|
||||
];
|
||||
|
||||
expect(
|
||||
<String>[
|
||||
for (final MailboxNode node in orderMailboxes(mine))
|
||||
node.mailbox.name,
|
||||
],
|
||||
// 같은 글(대소문자만 다름)은 원래 글자로 한 번 더 견준다 — 늘 같은 차례
|
||||
<String>['Ara', 'ara', 'ARA 프로젝트', 'erp', '가나다'],
|
||||
);
|
||||
});
|
||||
|
||||
test('부모를 찾을 수 없거나 서로를 가리키는 메일함도 빠뜨리지 않는다', () {
|
||||
final List<Mailbox> broken = <Mailbox>[
|
||||
Mailbox.tryParse(mailboxJson('a', 'A', parentId: 'b'))!,
|
||||
Mailbox.tryParse(mailboxJson('b', 'B', parentId: 'a'))!,
|
||||
Mailbox.tryParse(mailboxJson('c', 'C', parentId: 'gone'))!,
|
||||
];
|
||||
expect(orderMailboxes(broken), hasLength(3));
|
||||
});
|
||||
|
||||
test('옮길 곳은 지금 메일함 · 임시보관함 · 휴지통을 뺀다', () {
|
||||
expect(
|
||||
<String>[
|
||||
for (final MailboxNode node in moveTargetsOf(mailboxes, kInboxId))
|
||||
node.mailbox.id,
|
||||
],
|
||||
<String>[kSentId, kJunkId, kClientsId, kProjectId, kProjectChildId],
|
||||
);
|
||||
});
|
||||
|
||||
test('첫 화면은 받은편지함, 안 읽은 수는 보낸편지함 · 임시보관함 · 휴지통에 없다', () {
|
||||
expect(defaultMailboxOf(mailboxes)!.id, kInboxId);
|
||||
expect(showsUnreadCount(MailboxRole.inbox), isTrue);
|
||||
expect(showsUnreadCount(null), isTrue);
|
||||
expect(showsUnreadCount(MailboxRole.sent), isFalse);
|
||||
expect(showsUnreadCount(MailboxRole.drafts), isFalse);
|
||||
expect(showsUnreadCount(MailboxRole.trash), isFalse);
|
||||
expect(MailListTexts.retentionNotice(MailboxRole.trash), isNotNull);
|
||||
expect(MailListTexts.retentionNotice(MailboxRole.junk), isNotNull);
|
||||
expect(MailListTexts.retentionNotice(MailboxRole.inbox), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('새 메일 확인의 병합 (웹 mail-list-merge.ts 와 같다)', () {
|
||||
MessageSummary m(String id) =>
|
||||
summary(messageJson(id, mailboxId: kInboxId));
|
||||
const MailPageInfo oldInfo = MailPageInfo(
|
||||
hasNext: true,
|
||||
nextCursor: 'c:40',
|
||||
total: 50,
|
||||
);
|
||||
const MailPageInfo newInfo = MailPageInfo(
|
||||
hasNext: true,
|
||||
nextCursor: 'c:20',
|
||||
total: 51,
|
||||
);
|
||||
|
||||
test('다시 받은 범위는 서버 기준, 그 뒤의 옛 메일은 남기고 옛 커서를 쓴다', () {
|
||||
final MailListMerge merged = mergeRefreshedList(
|
||||
current: <MessageSummary>[m('a'), m('b'), m('c'), m('d')],
|
||||
currentPageInfo: oldInfo,
|
||||
fetched: <MessageSummary>[m('new'), m('a'), m('c')],
|
||||
fetchedPageInfo: newInfo,
|
||||
);
|
||||
expect(
|
||||
<String>[for (final MessageSummary x in merged.items) x.id],
|
||||
<String>['new', 'a', 'c', 'd'],
|
||||
reason: '다른 곳에서 지운 b 는 빠지고, 범위 밖의 d 는 남는다',
|
||||
);
|
||||
expect(merged.pageInfo.nextCursor, 'c:40');
|
||||
expect(merged.pageInfo.total, 51);
|
||||
});
|
||||
|
||||
test('다시 받은 쪽이 끝까지였으면 받지 못한 옛 메일은 지워진 것이라 남기지 않는다', () {
|
||||
final MailListMerge merged = mergeRefreshedList(
|
||||
current: <MessageSummary>[m('a'), m('b'), m('c')],
|
||||
currentPageInfo: const MailPageInfo(hasNext: false),
|
||||
fetched: <MessageSummary>[m('new'), m('a'), m('b')],
|
||||
fetchedPageInfo: const MailPageInfo(hasNext: false, total: 3),
|
||||
);
|
||||
expect(
|
||||
<String>[for (final MessageSummary x in merged.items) x.id],
|
||||
<String>['new', 'a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('겹치는 메일이 없으면 다시 받은 것만 쓴다', () {
|
||||
final MailListMerge merged = mergeRefreshedList(
|
||||
current: <MessageSummary>[m('a')],
|
||||
currentPageInfo: oldInfo,
|
||||
fetched: <MessageSummary>[m('x'), m('y')],
|
||||
fetchedPageInfo: newInfo,
|
||||
);
|
||||
expect(
|
||||
<String>[for (final MessageSummary x in merged.items) x.id],
|
||||
<String>['x', 'y'],
|
||||
);
|
||||
expect(merged.pageInfo, newInfo);
|
||||
});
|
||||
});
|
||||
|
||||
group('나눠 보내기 · 되돌리기 묶음', () {
|
||||
test('100통씩 나눈다', () {
|
||||
final List<String> ids = <String>[for (int i = 0; i < 250; i++) '$i'];
|
||||
expect(chunked<String>(ids, 100).map((List<String> c) => c.length), <int>[
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
]);
|
||||
});
|
||||
|
||||
test('요청이 실패하면 그 묶음과 남은 묶음은 보내지 않은 것으로 두고 멈춘다', () async {
|
||||
int calls = 0;
|
||||
final BulkResult result = await sendInChunks(
|
||||
<String>[for (int i = 0; i < 250; i++) '$i'],
|
||||
100,
|
||||
(List<String> chunk) async {
|
||||
calls++;
|
||||
if (calls == 2) {
|
||||
throw DioException(requestOptions: RequestOptions(path: '/x'));
|
||||
}
|
||||
return (done: chunk.sublist(1), skipped: chunk.sublist(0, 1));
|
||||
},
|
||||
);
|
||||
expect(calls, 2);
|
||||
expect(result.doneIds, hasLength(99));
|
||||
expect(result.skippedIds, <String>['0']);
|
||||
expect(result.unsentIds, hasLength(150));
|
||||
expect(result.hasLeftovers, isTrue);
|
||||
});
|
||||
|
||||
test('원래 메일함별로 묶는다 — 모르는 메일은 뺀다', () {
|
||||
final List<BulkOrigin> groups = groupByOrigin(
|
||||
<String>['a', 'b', 'c', 'd'],
|
||||
<String, String>{'a': kInboxId, 'b': kClientsId, 'c': kInboxId},
|
||||
);
|
||||
expect(groups, hasLength(2));
|
||||
expect(groups.first.mailboxId, kInboxId);
|
||||
expect(groups.first.ids, <String>['a', 'c']);
|
||||
expect(groups.last.ids, <String>['b']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'fakes.dart';
|
||||
|
||||
/// 시험 메일함 id
|
||||
const String kInboxId = 'mb-inbox';
|
||||
const String kSentId = 'mb-sent';
|
||||
const String kDraftsId = 'mb-drafts';
|
||||
const String kJunkId = 'mb-junk';
|
||||
const String kTrashId = 'mb-trash';
|
||||
const String kClientsId = 'mb-clients';
|
||||
const String kProjectId = 'mb-project';
|
||||
const String kProjectChildId = 'mb-project-child';
|
||||
|
||||
/// 메일함 한 칸 (계약 2절 `Mailbox`)
|
||||
Map<String, Object?> mailboxJson(
|
||||
String id,
|
||||
String name, {
|
||||
String? role,
|
||||
String? parentId,
|
||||
int sortOrder = 0,
|
||||
int total = 0,
|
||||
int unread = 0,
|
||||
}) => <String, Object?>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'role': role,
|
||||
'parentId': parentId,
|
||||
'sortOrder': sortOrder,
|
||||
'totalCount': total,
|
||||
'unreadCount': unread,
|
||||
};
|
||||
|
||||
/// 메일 한 통 (계약 2절 `MessageSummary`)
|
||||
Map<String, Object?> messageJson(
|
||||
String id, {
|
||||
required String mailboxId,
|
||||
String fromName = '이도윤',
|
||||
String fromEmail = 'doyun.lee@example.com',
|
||||
List<Map<String, Object?>> to = const <Map<String, Object?>>[],
|
||||
String? subject,
|
||||
String preview = '미리 보기 글',
|
||||
DateTime? receivedAt,
|
||||
bool isRead = false,
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
Map<String, Object?>? pendingSend,
|
||||
bool noSender = false,
|
||||
}) => <String, Object?>{
|
||||
'id': id,
|
||||
'threadId': 'thread-$id',
|
||||
'mailboxIds': <String>[mailboxId],
|
||||
'from': noSender
|
||||
? null
|
||||
: <String, Object?>{'name': fromName, 'email': fromEmail},
|
||||
'to': to,
|
||||
'subject': subject ?? '제목 $id',
|
||||
'preview': preview,
|
||||
'receivedAt': (receivedAt ?? kTestNow).toUtc().toIso8601String(),
|
||||
'isRead': isRead,
|
||||
'isFlagged': isFlagged,
|
||||
'hasAttachment': hasAttachment,
|
||||
'size': 1024,
|
||||
'pendingSend': pendingSend,
|
||||
};
|
||||
|
||||
/// 받는 사람 한 사람
|
||||
Map<String, Object?> addressJson(String name, String email) =>
|
||||
<String, Object?>{'name': name, 'email': email};
|
||||
|
||||
/// 붙잡아 둔 발송 (9단계)
|
||||
Map<String, Object?> pendingJson(DateTime sendAt, {String kind = 'undo'}) =>
|
||||
<String, Object?>{'sendAt': sendAt.toUtc().toIso8601String(), 'kind': kind};
|
||||
|
||||
/// 기본 메일함 다섯과 내 메일함 셋(프로젝트 아래에 하나)
|
||||
List<Map<String, Object?>> standardMailboxes({
|
||||
int inboxUnread = 3,
|
||||
}) => <Map<String, Object?>>[
|
||||
mailboxJson(kInboxId, '받은편지함', role: 'inbox', total: 30, unread: inboxUnread),
|
||||
mailboxJson(kSentId, '보낸편지함', role: 'sent', total: 5),
|
||||
mailboxJson(kDraftsId, '임시보관함', role: 'drafts', total: 1),
|
||||
mailboxJson(kJunkId, '스팸메일함', role: 'junk', total: 2, unread: 2),
|
||||
mailboxJson(kTrashId, '휴지통', role: 'trash'),
|
||||
mailboxJson(kClientsId, '거래처', total: 1, unread: 1),
|
||||
mailboxJson(kProjectId, '프로젝트', sortOrder: 1),
|
||||
mailboxJson(kProjectChildId, '설계', parentId: kProjectId),
|
||||
];
|
||||
|
||||
/// 메일 API 를 흉내 내는 가짜 뒤쪽 — 메일함 · 목록(커서) · 여러 통 바꾸기 · 영구 삭제 · 새 메일 확인.
|
||||
///
|
||||
/// 상태는 메모리에 두고 요청대로 바꾼다. [failIds] 는 바꾸기 · 지우기가 그대로 둘 메일(`failedIds` · `skippedIds`),
|
||||
/// [patchFailure] · [listFailure] 는 그 요청을 그 응답으로 실패시킨다. 문([listGate] 등)을 두면 그 응답이 문이 열릴 때까지 기다린다.
|
||||
class FakeMailBackend {
|
||||
FakeMailBackend(this.server, {List<Map<String, Object?>>? mailboxes})
|
||||
: mailboxes = mailboxes ?? standardMailboxes();
|
||||
|
||||
final FakeServer server;
|
||||
List<Map<String, Object?>> mailboxes;
|
||||
|
||||
/// 메일함마다 메일 — 받은 시각 내림차순(앞이 최신)
|
||||
final Map<String, List<Map<String, Object?>>> messages =
|
||||
<String, List<Map<String, Object?>>>{};
|
||||
|
||||
/// 새 메일 확인의 상태 글
|
||||
String state = 'state-1';
|
||||
|
||||
/// 바꾸기 · 지우기가 그대로 둘 메일 id
|
||||
final Set<String> failIds = <String>{};
|
||||
|
||||
/// 있으면 PATCH 본문 · 메일 id 마다 그대로 둘지 묻는다(요청마다 다르게 — 읽음만 실패 등)
|
||||
bool Function(Map<String, Object?> body, String id)? failWhen;
|
||||
|
||||
FakeResponse? mailboxesFailure;
|
||||
FakeResponse? listFailure;
|
||||
FakeResponse? patchFailure;
|
||||
FakeResponse? deletionFailure;
|
||||
FakeResponse? stateFailure;
|
||||
|
||||
/// 있으면 다음 목록 응답들이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다 — [listGateOnce])
|
||||
Completer<void>? listGate;
|
||||
bool listGateOnce = true;
|
||||
Completer<void>? patchGate;
|
||||
Completer<void>? mailboxesGate;
|
||||
|
||||
/// 받은 PATCH /messages 본문
|
||||
final List<Map<String, Object?>> patches = <Map<String, Object?>>[];
|
||||
|
||||
/// 받은 POST /message-deletions 의 ids
|
||||
final List<List<String>> deletions = <List<String>>[];
|
||||
|
||||
int get stateRequests =>
|
||||
server.received('GET', MailApiPaths.mailState).length;
|
||||
|
||||
List<RequestOptions> get listRequests =>
|
||||
server.received('GET', MailApiPaths.messages);
|
||||
|
||||
/// 경로마다 처리기를 건다
|
||||
void install() {
|
||||
server.on('GET', MailApiPaths.mailboxes, (_) async {
|
||||
await mailboxesGate?.future;
|
||||
final FakeResponse? failure = mailboxesFailure;
|
||||
if (failure != null) return failure;
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'items': mailboxes,
|
||||
'pageInfo': <String, Object?>{
|
||||
'hasNext': false,
|
||||
'nextCursor': null,
|
||||
'total': mailboxes.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
server.on('GET', MailApiPaths.messages, (RequestOptions options) async {
|
||||
final Completer<void>? gate = listGate;
|
||||
if (gate != null) {
|
||||
if (listGateOnce) listGate = null;
|
||||
await gate.future;
|
||||
}
|
||||
final FakeResponse? failure = listFailure;
|
||||
if (failure != null) return failure;
|
||||
final Map<String, Object?> query = options.queryParameters;
|
||||
final String mailboxId = query['mailboxId']! as String;
|
||||
if (!mailboxes.any((Map<String, Object?> m) => m['id'] == mailboxId)) {
|
||||
return FakeResponse.fail(404, 'RES_001');
|
||||
}
|
||||
final List<Map<String, Object?>> all =
|
||||
messages[mailboxId] ?? <Map<String, Object?>>[];
|
||||
final int limit = (query['limit'] as int?) ?? kMailPageSize;
|
||||
final Object? cursor = query['cursor'];
|
||||
final int start = cursor is String ? int.parse(cursor.substring(2)) : 0;
|
||||
final int end = (start + limit).clamp(0, all.length);
|
||||
final bool hasNext = end < all.length;
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'items': all.sublist(start.clamp(0, all.length), end),
|
||||
'pageInfo': <String, Object?>{
|
||||
'hasNext': hasNext,
|
||||
'nextCursor': hasNext ? 'c:$end' : null,
|
||||
'total': all.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
server.on('PATCH', MailApiPaths.messages, (RequestOptions options) async {
|
||||
final Map<String, Object?> body = options.data! as Map<String, Object?>;
|
||||
patches.add(body);
|
||||
await patchGate?.future;
|
||||
final FakeResponse? failure = patchFailure;
|
||||
if (failure != null) return failure;
|
||||
final List<String> ids = (body['ids']! as List<Object?>).cast<String>();
|
||||
final List<String> updated = <String>[];
|
||||
final List<String> failed = <String>[];
|
||||
for (final String id in ids) {
|
||||
if (failIds.contains(id) ||
|
||||
(failWhen?.call(body, id) ?? false) ||
|
||||
_find(id) == null) {
|
||||
failed.add(id);
|
||||
continue;
|
||||
}
|
||||
_apply(id, body);
|
||||
updated.add(id);
|
||||
}
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'updatedIds': updated,
|
||||
'failedIds': failed,
|
||||
});
|
||||
});
|
||||
server.on('POST', MailApiPaths.messageDeletions, (
|
||||
RequestOptions options,
|
||||
) async {
|
||||
final Map<String, Object?> body = options.data! as Map<String, Object?>;
|
||||
final List<String> ids = (body['ids']! as List<Object?>).cast<String>();
|
||||
deletions.add(ids);
|
||||
final FakeResponse? failure = deletionFailure;
|
||||
if (failure != null) return failure;
|
||||
final List<String> deleted = <String>[];
|
||||
final List<String> skipped = <String>[];
|
||||
final List<Map<String, Object?>> trash =
|
||||
messages[kTrashId] ?? <Map<String, Object?>>[];
|
||||
for (final String id in ids) {
|
||||
final int index = trash.indexWhere(
|
||||
(Map<String, Object?> m) => m['id'] == id,
|
||||
);
|
||||
if (failIds.contains(id) || index == -1) {
|
||||
skipped.add(id);
|
||||
continue;
|
||||
}
|
||||
trash.removeAt(index);
|
||||
deleted.add(id);
|
||||
}
|
||||
return FakeResponse.ok(<String, Object?>{
|
||||
'deletedIds': deleted,
|
||||
'skippedIds': skipped,
|
||||
});
|
||||
});
|
||||
server.on('GET', MailApiPaths.mailState, (_) {
|
||||
final FakeResponse? failure = stateFailure;
|
||||
if (failure != null) return failure;
|
||||
return FakeResponse.ok(<String, Object?>{'state': state});
|
||||
});
|
||||
}
|
||||
|
||||
/// [mailboxId] 에 메일 [count] 통을 둔다 — id 는 `<prefix>1` 부터(앞이 최신)
|
||||
void seed(
|
||||
String mailboxId,
|
||||
int count, {
|
||||
String prefix = 'm',
|
||||
bool isRead = false,
|
||||
}) {
|
||||
messages[mailboxId] = <Map<String, Object?>>[
|
||||
for (int n = 1; n <= count; n++)
|
||||
messageJson(
|
||||
'$prefix$n',
|
||||
mailboxId: mailboxId,
|
||||
receivedAt: kTestNow.subtract(Duration(minutes: n)),
|
||||
isRead: isRead,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 맨 앞에 새 메일을 넣는다(새 메일이 왔다)
|
||||
void arrive(String mailboxId, Map<String, Object?> message) {
|
||||
(messages[mailboxId] ??= <Map<String, Object?>>[]).insert(0, message);
|
||||
}
|
||||
|
||||
({String mailboxId, Map<String, Object?> message})? _find(String id) {
|
||||
for (final MapEntry<String, List<Map<String, Object?>>> entry
|
||||
in messages.entries) {
|
||||
for (final Map<String, Object?> message in entry.value) {
|
||||
if (message['id'] == id) {
|
||||
return (mailboxId: entry.key, message: message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _apply(String id, Map<String, Object?> body) {
|
||||
final ({String mailboxId, Map<String, Object?> message})? found = _find(id);
|
||||
if (found == null) return;
|
||||
final Map<String, Object?> message = found.message;
|
||||
if (body['isRead'] case final bool isRead) message['isRead'] = isRead;
|
||||
if (body['isFlagged'] case final bool isFlagged) {
|
||||
message['isFlagged'] = isFlagged;
|
||||
}
|
||||
if (body['mailboxId'] case final String target
|
||||
when target != found.mailboxId) {
|
||||
messages[found.mailboxId]!.remove(message);
|
||||
message['mailboxIds'] = <String>[target];
|
||||
final List<Map<String, Object?>> into = messages[target] ??=
|
||||
<Map<String, Object?>>[];
|
||||
into.add(message);
|
||||
into.sort(
|
||||
(Map<String, Object?> a, Map<String, Object?> b) =>
|
||||
(b['receivedAt']! as String).compareTo(a['receivedAt']! as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [mailboxId] 에 있는 메일 id (차례대로)
|
||||
List<String> idsIn(String mailboxId) => <String>[
|
||||
for (final Map<String, Object?> message
|
||||
in messages[mailboxId] ?? <Map<String, Object?>>[])
|
||||
message['id']! as String,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:arasystem_app/app.dart';
|
||||
import 'package:arasystem_app/core/api/api_paths.dart';
|
||||
import 'package:arasystem_app/core/router/app_router.dart';
|
||||
import 'package:arasystem_app/core/utils/provider_retry.dart';
|
||||
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'fakes.dart';
|
||||
import 'mail_fakes.dart';
|
||||
import 'provider_harness.dart';
|
||||
|
||||
/// 시험 직원 (fakes.dart 의 `sessionJson` 과 같다)
|
||||
const String kMailTestAccount = 'ara.kim@arasystem.it.kr';
|
||||
|
||||
/// 휴대폰 화면 크기 (보드의 390 × 844)
|
||||
const Size kPhoneSize = Size(390, 844);
|
||||
|
||||
/// 메일 화면 시험대 — 로그인한 앱 전체를 가짜 서버(인증 + 메일)로 띄운다
|
||||
class MailAppHarness {
|
||||
MailAppHarness();
|
||||
|
||||
final ProviderHarness base = ProviderHarness();
|
||||
late final FakeMailBackend mail = FakeMailBackend(base.server);
|
||||
|
||||
FakeServer get server => base.server;
|
||||
ManualClock get clock => base.clock;
|
||||
RecordingNotifier get notifier => base.notifier;
|
||||
|
||||
late ProviderContainer container;
|
||||
|
||||
GoRouter get router => container.read(routerProvider);
|
||||
|
||||
/// 지금 주소
|
||||
String location() =>
|
||||
router.routerDelegate.currentConfiguration.uri.toString();
|
||||
|
||||
/// 앱을 띄운다 — 리프레시 토큰으로 세션을 되살리고 메일 첫 화면(또는 [location])까지 그린다
|
||||
Future<void> pump(
|
||||
WidgetTester tester, {
|
||||
String? location,
|
||||
Size size = kPhoneSize,
|
||||
}) async {
|
||||
tester.view.physicalSize = size * 3;
|
||||
tester.view.devicePixelRatio = 3;
|
||||
addTearDown(tester.view.reset);
|
||||
await base.prepare(refreshToken: 'refresh-1');
|
||||
server.on(
|
||||
'POST',
|
||||
ApiPaths.appRefresh,
|
||||
(_) => FakeResponse.ok(<String, Object?>{
|
||||
...sessionJson(),
|
||||
'tokens': tokensJson(2, now: clock.now),
|
||||
}),
|
||||
);
|
||||
server.on('GET', ApiPaths.me, (_) => FakeResponse.ok(sessionJson()));
|
||||
mail.install();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
retry: noAutomaticRetry,
|
||||
overrides: <Override>[...base.overrides],
|
||||
child: const App(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(MaterialApp)),
|
||||
);
|
||||
expect(container.read(mailAccountProvider), kMailTestAccount);
|
||||
if (location != null) {
|
||||
router.go(location);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user