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:
2026-11-06 19:36:31 +09:00
parent 4e779dedd2
commit ad8b126abb
49 changed files with 8684 additions and 59 deletions
+12 -11
View File
@@ -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']);
});
});
}
+306
View File
@@ -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,
];
}
+78
View File
@@ -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();
}
}
}