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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user