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