feat(flutter): 메일 쓰기 화면 1차를 만든다 (App1Compose)

새 메일 · 답장 · 전체 답장 · 전달 · 이어 쓰기 · mailto 를 연다. 글만 쓰고,
보낼 때 웹과 같은 서식 본문(기본 글꼴 div · 서명 경계 div · "원본 메일"
인용)과 텍스트 본문을 함께 만든다 — 원문 인용은 글로 만든 덩이다.

- 임시 저장은 웹 규칙(3초 · 30초 · 10초 · 한 번에 하나)에 더해 앱이 안
  보이게 되면 바로 저장한다
- 보내기는 같은 Idempotency-Key 규칙(성공 · MAIL_005 에만 새 키), 보낸 뒤
  시작한 화면에 [보내기 취소] 마감 알림 · 남은 시간 막대
- 서명은 따로 된 칸([서명 빼기] · [서명 넣기]), 원본 메일은 접힌 채
- 리뷰 H1: 진행 중인 저장을 기다리는 사이 화면을 떠나면 메일이 나가지
  않던 것 — keepAlive 를 기다림 앞에서 잡고 떠나도 그대로 보낸다
- 리뷰 M1 · M2 · M3: 저장본 읽기는 읽기 화면과 같은 한도 · isolate,
  웹 저장본의 정렬 · 들여쓰기는 남의 저장본 길로, 확인 창의 거짓 "저장했습니다"
- 첨부 고르기 · 올리기는 2차(지금은 전달 원본 · 저장본의 첨부만 싣고 뺀다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 02:11:00 +09:00
parent 695a343878
commit 80e48d318d
39 changed files with 11209 additions and 85 deletions
@@ -0,0 +1,271 @@
import 'package:arasystem_app/core/utils/utf16_length.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_fit.dart';
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
import 'package:arasystem_app/features/mail/domain/html_nodes.dart';
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
const List<QuoteHeaderRow> _header = <QuoteHeaderRow>[
QuoteHeaderRow('보낸 사람', '이도윤 <doyun@x.kr>'),
];
/// [depth] 단으로 겹친 div 안의 글
String _nestedDivs(int depth, String inner) =>
'${'<div>' * depth}$inner${'</div>' * depth}';
/// 원문 인용 하나 — 머리 표 뒤에 [inner]
String _mailQuote(String inner) =>
'<blockquote><p>원본 메일</p><table><tbody><tr><td>보낸 사람</td><td>이도윤</td></tr></tbody></table>$inner</blockquote>';
/// 저장본 읽기(쓰기 화면 1차 리뷰 M1 · M2) · 처음 맞추기(L2) · 제목 길이(L3)
void main() {
group('HTML 노드를 되풀이 없이 — package:html 과 같은 글', () {
const String sample =
'<div style="a:&quot;b&quot;" data-x="1 &amp; 2"><p>글&nbsp;&amp; &lt;태그&gt;<br><img src="x.png" alt="그림"></p>'
'<!-- 주석 --><pre>\n\n앞 줄바꿈</pre><script>if (a < b) {}</script><textarea>\n둘</textarea></div>';
test('직렬화 · 글 · 태그로 찾기가 package:html 과 같다', () {
final dom.Element body = html_parser.parse(sample).body!;
expect(innerHtmlOf(body), body.innerHtml);
final dom.Element div = body.children.single;
expect(outerHtmlOf(div), div.outerHtml);
expect(nodeTextOf(div), div.text);
expect(descendantElements(body, 'p'), body.querySelectorAll('p'));
});
test('2만 단으로 겹친 요소도 스택이 넘치지 않는다', () {
const int depth = 20000;
// 파서는 깊이의 제곱으로 느리므로 요소를 손으로 잇는다
final dom.Element body = dom.Element.tag('body');
dom.Element parent = body;
for (int level = 0; level < depth; level++) {
final dom.Element child = dom.Element.tag('div');
parent.append(child);
parent = child;
}
parent.append(dom.Text('깊은 글'));
final String html = innerHtmlOf(body);
expect(html, startsWith('<div><div>'));
expect(html.length, depth * '<div></div>'.length + '깊은 글'.length);
expect(nodeTextOf(body), '깊은 글');
expect(descendantElements(body, 'div'), hasLength(depth));
});
});
group('저장본 읽기 — 읽기 화면과 같은 한도 · 문턱(M1)', () {
test('읽을 곳 — 짧고 얕으면 그 자리, 길거나 깊으면 isolate, 한도를 넘으면 읽지 않는다', () {
expect(
draftBodyPlaceOf(bodyHtml: '<p>짧은 글</p>', body: ''),
DraftBodyPlace.inline,
);
expect(
draftBodyPlaceOf(bodyHtml: null, body: '글'),
DraftBodyPlace.inline,
);
expect(
draftBodyPlaceOf(
bodyHtml: '<p>${'가' * kMailBodyInlineMaxLength}</p>',
body: '',
),
DraftBodyPlace.isolate,
);
// 짧아도 깊으면(중첩 비용이 그 자리 문턱을 넘으면) isolate
expect(
draftBodyPlaceOf(bodyHtml: _nestedDivs(120, 'x'), body: ''),
DraftBodyPlace.isolate,
);
expect(
draftBodyPlaceOf(bodyHtml: _nestedDivs(20000, 'x'), body: ''),
DraftBodyPlace.oversized,
);
expect(
draftBodyPlaceOf(
bodyHtml: '<p>${'가' * kMaxSanitizedHtmlLength}</p>',
body: '',
),
DraftBodyPlace.oversized,
);
});
test('2만 단 저장본 — 읽지 않고 남의 저장본으로: 본문 칸은 텍스트 본문, 원래 HTML 은 그대로 쥔다', () async {
final String html = _nestedDivs(20000, '깊은 글');
final DraftBody parsed = await buildDraftBody(
bodyHtml: ' $html ',
body: '깊은 글\n',
);
expect(parsed, isA<ForeignDraftBody>());
final ForeignDraftBody foreign = parsed as ForeignDraftBody;
expect(foreign.text, '깊은 글');
expect(foreign.originalHtml, html);
expect(foreign.originalText, '깊은 글\n');
expect(foreign.hasFormatting, isTrue);
expect(foreign.markedHtml, isNull);
expect(foreign.quote, isNull);
});
test('깊은 원문 인용이 든 저장본 — 다른 isolate 에서 읽고 원래 인용 HTML 을 쥔다', () async {
final String html =
'<div><p>앞 글</p>${_mailQuote(_nestedDivs(1500, '깊은 원문'))}</div>';
expect(
draftBodyPlaceOf(bodyHtml: html, body: ''),
DraftBodyPlace.isolate,
);
final DraftBody parsed = await buildDraftBody(bodyHtml: html, body: '');
expect(parsed, isA<AppDraftBody>());
final AppDraftBody app = parsed as AppDraftBody;
expect(app.text, '앞 글');
expect(app.quote!.header.single.value, '이도윤');
expect(plainLinesOf(app.quote!.blocks), <String>['깊은 원문']);
expect(app.quote!.rawHtml, startsWith('<blockquote><p>원본 메일</p>'));
});
test('바로 읽어도(한도 검사를 거치지 않아도) 깊은 인용 · 서명에서 스택이 넘치지 않는다', () {
final String html =
'<div><p>글</p><div>${_nestedDivs(2000, '서명')}</div>'
'${_mailQuote(_nestedDivs(2000, '원문'))}</div>';
final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '');
expect(parsed, isA<AppDraftBody>());
final AppDraftBody app = parsed as AppDraftBody;
expect(app.signature?.parts, const <SignaturePart>[
SignatureTextPart('서명'),
]);
expect(
app.quote!.rawHtml!.length,
greaterThan(2000 * '<div></div>'.length),
);
});
});
group('처음 맞추기 · 줄 자르기(L2)', () {
test('처음 인용은 덩이 만들기와 함께 내 글 몫(5%)을 남기고 맞춘다', () {
final ComposeQuote quote = initialQuoteOf(
ComposeQuoteRequest(
htmlBody: null,
textBody: List<String>.filled(3000, '가' * 50).join('\n'),
isBodyTruncated: false,
header: _header,
summary: '이도윤',
signatureText: '김아라',
signatureHtml: '<p style="margin: 0">김아라</p>',
),
);
expect(quote.omitted, isTrue);
expect(quote.blocks.length, lessThan(3000));
final MailBodyOutput output = buildMailBody(
ComposeBodyInput(
text: '',
signatureHtml: '<p style="margin: 0">김아라</p>',
signatureText: '김아라',
quote: quote,
),
);
expect(BodyLimits.initial.fits(output), isTrue);
final ComposeQuote small = initialQuoteOf(
const ComposeQuoteRequest(
htmlBody: '<p>짧은 원문</p>',
textBody: '짧은 원문',
isBodyTruncated: true,
header: _header,
summary: '이도윤',
),
);
expect(plainLinesOf(small.blocks), <String>['짧은 원문']);
expect(small.omitted, isTrue);
});
for (final (String name, String line) in <(String, String)>[
('이스케이프되는 글자(& 5칸)', '&' * 200000),
('탭(8칸까지 공백)', '${'\t' * 70000}끝'),
('섞인 글', List<String>.filled(40000, 'a&\t<😀 ').join()),
]) {
test('긴 한 줄은 들어가는 만큼만 — 한 글자만 더 담아도 넘친다($name)', () {
final ComposeQuote quote = ComposeQuote(
header: _header,
blocks: <QuoteBlock>[QuoteLine(line)],
summary: '',
);
final BodyFit fit = fitMailBody(
ComposeBodyInput(text: '', quote: quote),
BodyLimits.full,
);
expect(BodyLimits.full.fits(fit.output!), isTrue);
final String kept = (fit.quote!.blocks.single as QuoteLine).text;
expect(kept.length, lessThan(line.length));
final int last = kept.codeUnitAt(kept.length - 1);
expect(last >= 0xD800 && last <= 0xDBFF, isFalse);
// 다음 글자(짝 글자면 둘)까지 담으면 넘친다
final int next =
kept.length +
(line.codeUnitAt(kept.length) >= 0xD800 &&
line.codeUnitAt(kept.length) <= 0xDBFF
? 2
: 1);
final MailBodyOutput longer = buildMailBody(
ComposeBodyInput(
text: '',
quote: quote.withBlocks(<QuoteBlock>[
QuoteLine(line.substring(0, next)),
], omitted: true),
),
);
expect(BodyLimits.full.fits(longer), isFalse);
});
}
});
group('제목 길이 — UTF-16 단위(L3 · 서버 · 웹과 같다)', () {
test('truncateUtf16 — 짝 글자를 가르지 않는다', () {
expect(truncateUtf16('a' * 300, 255), 'a' * 255);
expect(truncateUtf16('${'a' * 254}😀', 255), 'a' * 254);
expect(truncateUtf16('짧은 글', 255), '짧은 글');
});
TextEditingValue typed(
String text, {
TextRange composing = TextRange.empty,
}) => TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: composing,
);
test('포매터 — 붙여 넣은 글은 뒤를 자르고, 이모지는 두 칸으로 센다', () {
const Utf16LengthLimitFormatter formatter = Utf16LengthLimitFormatter(10);
expect(
formatter.formatEditUpdate(typed(''), typed('a' * 30)).text,
'a' * 10,
);
expect(
formatter.formatEditUpdate(typed(''), typed('😀' * 8)).text,
'😀' * 5,
);
expect(
formatter.formatEditUpdate(typed('a' * 9), typed('${'a' * 9}😀')).text,
'a' * 9,
);
});
test('포매터 — 한도만큼 찼으면 더 치는 글을 받지 않고, 한글을 조합하는 동안은 막지 않는다', () {
const Utf16LengthLimitFormatter formatter = Utf16LengthLimitFormatter(5);
final TextEditingValue full = typed('abcde');
expect(formatter.formatEditUpdate(full, typed('abXcde')), full);
final TextEditingValue composing = typed(
'abcde한',
composing: const TextRange(start: 5, end: 6),
);
expect(formatter.formatEditUpdate(full, composing), composing);
// 조합이 끝나면 자른다
expect(
formatter.formatEditUpdate(composing, typed('abcde한')).text,
'abcde',
);
});
});
}
@@ -0,0 +1,390 @@
import 'dart:typed_data';
import 'package:arasystem_app/core/api/api_paths.dart';
import 'package:arasystem_app/core/people/person_address.dart';
import 'package:arasystem_app/core/people/recipient_suggestion.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:arasystem_app/features/mail/data/repositories/compose_repository.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';
const MailComposePayload _payload = MailComposePayload(
to: <PersonAddress>[PersonAddress(email: 'doyun@x.kr', name: '이도윤')],
cc: <PersonAddress>[],
bcc: <PersonAddress>[],
subject: '계약',
body: '본문',
bodyHtml: '<div>본문</div>',
draftId: 'd1',
);
Map<String, Object?> _savedJson(String id) => <String, Object?>{
'id': id,
'attachments': <Object?>[attachmentJson('att-$id')],
'updatedAt': '2026-11-07T00:00:00.000Z',
};
/// 쓰기 화면의 저장소 — 계약 2 · 3 · 4절의 경로 · 본문 · 응답 읽기 (인터셉터 줄을 그대로 탄다)
void main() {
late ApiHarness h;
late ComposeRepository repository;
setUp(() async {
h = ApiHarness();
await h.prepare();
await h.signIn();
repository = ComposeRepository(h.dio);
});
test(
'저장 — 처음은 POST /drafts, 그다음은 PUT /drafts/{id}. 받기 한도 60초 · 알리지 않는다',
() async {
h.server.on(
'POST',
ComposeApiPaths.drafts,
(_) => FakeResponse.ok(_savedJson('d2')),
);
h.server.on(
'PUT',
'/drafts/d%2F1',
(_) => FakeResponse.ok(_savedJson('d3')),
);
final DraftSaved created = await repository.createDraft(
_payload.withoutDraftId(),
);
expect(created.id, 'd2');
expect(created.attachments.single.id, 'att-d2');
final RequestOptions post = h.server.requests.single;
expect(post.data, _payload.withoutDraftId().toJson());
expect(post.receiveTimeout, kMailSendTimeout);
expect(bearerTokenOf(post), 'access-1');
final DraftSaved updated = await repository.updateDraft(
'd/1',
_payload.withoutDraftId(),
);
expect(updated.id, 'd3');
h.server.on(
'PUT',
'/drafts/gone',
(_) => FakeResponse.fail(409, 'MAIL_008'),
);
expect(
apiErrorOf(
await errorOf(repository.updateDraft('gone', _payload)),
).code,
'MAIL_008',
);
expect(h.notifier.shownCodes, isEmpty);
},
);
test('저장본 받기 — 모양이 틀리면 SYS_001, 실패는 알리지 않는다(화면이 [다시 시도]를 그린다)', () async {
h.server.on(
'GET',
'/drafts/d1',
(_) => FakeResponse.ok(<String, Object?>{'id': 'd1'}),
);
expect(
apiErrorOf(await errorOf(repository.getDraft('d1'))).code,
'SYS_001',
);
h.server.on('GET', '/drafts/d2', (_) => FakeResponse.fail(503, 'MAIL_001'));
expect(
apiErrorOf(await errorOf(repository.getDraft('d2'))).code,
'MAIL_001',
);
expect(h.notifier.shownCodes, isEmpty);
});
test(
'보내기 — Idempotency-Key · 받기 한도 60초, 칸 안에 보일 코드만 알리지 않는다, 서버 시각을 준다',
() async {
final DateTime serverNow = DateTime.utc(2026, 11, 7, 9);
h.server.serverTime = serverNow;
h.server.on(
'POST',
MailApiPaths.messages,
(_) => FakeResponse(201, <String, Object?>{
'success': true,
'data': <String, Object?>{
'id': 'sent-1',
'rejectedRecipients': <Object?>[
<String, Object?>{'email': 'a@x.kr', 'reason': 'not-found'},
],
'pendingSend': pendingJson(
serverNow.add(const Duration(seconds: 10)),
),
},
}),
);
final ({SentMessage sent, DateTime? serverTime}) result = await repository
.sendMessage(
_payload,
idempotencyKey: 'key-1',
silentCodes: const <String>{'MAIL_002'},
);
expect(result.sent.id, 'sent-1');
expect(
result.sent.rejectedRecipients.single.reason,
RejectedReason.notFound,
);
expect(
result.sent.pendingSend?.sendAt,
serverNow.add(const Duration(seconds: 10)),
);
expect(result.serverTime, serverNow);
final RequestOptions sent = h.server.requests.single;
expect(sent.headers[kIdempotencyHeader], 'key-1');
expect(sent.receiveTimeout, kMailSendTimeout);
expect(sent.data, _payload.toJson());
h.server.on(
'POST',
MailApiPaths.messages,
(_) => FakeResponse.fail(400, 'MAIL_002'),
);
expect(
apiErrorOf(
await errorOf(
repository.sendMessage(
_payload,
idempotencyKey: 'key-1',
silentCodes: const <String>{'MAIL_002'},
),
),
).code,
'MAIL_002',
);
expect(h.notifier.shownCodes, isEmpty);
h.server.on(
'POST',
MailApiPaths.messages,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
await errorOf(
repository.sendMessage(
_payload,
idempotencyKey: 'key-1',
silentCodes: const <String>{'MAIL_002'},
),
);
expect(h.notifier.shownCodes, <String>['MAIL_001']);
},
);
test('토큰이 끝나 401 이면 갱신한 뒤 같은 키로 다시 보낸다(서버가 두 번 내보내지 않게)', () async {
int next = 2;
h.server.on(
'POST',
ApiPaths.appRefresh,
(_) => FakeResponse.ok(<String, Object?>{
...sessionJson(),
'tokens': tokensJson(next++, now: h.clock.now),
}),
);
h.server.on(
'POST',
MailApiPaths.messages,
(RequestOptions options) => bearerTokenOf(options) == 'access-2'
? const FakeResponse(201, <String, Object?>{
'success': true,
'data': <String, Object?>{'id': 'sent-1'},
})
: FakeResponse.fail(401, 'AUTH_001'),
);
final ({SentMessage sent, DateTime? serverTime}) result = await repository
.sendMessage(_payload, idempotencyKey: 'key-9');
expect(result.sent.id, 'sent-1');
expect(result.sent.pendingSend, isNull);
final List<RequestOptions> sends = h.server.received(
'POST',
MailApiPaths.messages,
);
expect(sends, hasLength(2));
expect(
sends.map((RequestOptions r) => r.headers[kIdempotencyHeader]),
<Object?>['key-9', 'key-9'],
);
});
test('서명 · 서명 그림 · 보내기 설정 — 알리지 않는다, 그림은 한도를 넘으면 끊는다', () async {
h.server.on(
'GET',
ComposeApiPaths.mailSignature,
(_) => FakeResponse.ok(<String, Object?>{
'bodyHtml': '<p>김아라</p>',
'body': '김아라',
'isForNew': true,
'isForReply': false,
'updatedAt': '2026-11-01T00:00:00.000Z',
}),
);
final MailSignature signature = await repository.getSignature();
expect(signature.bodyHtml, '<p>김아라</p>');
expect(signature.isForReply, isFalse);
h.server.on(
'GET',
ComposeApiPaths.signatureImage('img-1'),
(_) => FakeResponse.bytes(kTinyPng, contentType: 'image/png'),
);
final Uint8List bytes = await repository.fetchSignatureImage(
'img-1',
maxBytes: 1024,
);
expect(bytes, kTinyPng);
expect(bearerTokenOf(h.server.requests.last), 'access-1');
await errorOf(repository.fetchSignatureImage('img-1', maxBytes: 4));
h.server.on(
'GET',
ComposeApiPaths.mailSettings,
(_) => FakeResponse.ok(<String, Object?>{'undoSendSeconds': 10}),
);
expect(await repository.undoSendSeconds(), 10);
h.server.on(
'GET',
ComposeApiPaths.mailSettings,
(_) => FakeResponse.ok(<String, Object?>{}),
);
expect(
apiErrorOf(await errorOf(repository.undoSendSeconds())).code,
'SYS_001',
);
expect(h.notifier.shownCodes, isEmpty);
});
test(
'받는 사람 추천 · 그룹 · 구성원(다음 쪽까지) — 추천 · 그룹은 알리지 않는다, 고른 그룹의 실패는 알린다',
() async {
h.server.on(
'GET',
ComposeApiPaths.recipientSuggestions,
(_) => FakeResponse.ok(<String, Object?>{
'items': <Object?>[
<String, Object?>{
'name': '이도윤',
'email': 'doyun@x.kr',
'source': 'directory',
},
<String, Object?>{'email': 'x'},
<String, Object?>{'name': 1, 'email': 'bad@x.kr'},
<String, Object?>{'email': 'new@x.kr', 'source': 'future'},
],
'pageInfo': <String, Object?>{'hasNext': true, 'nextCursor': 'c'},
}),
);
final RecipientSuggestionPage page = await repository
.listRecipientSuggestions(query: '도윤', limit: 16);
expect(page.items.map((RecipientSuggestion s) => s.email), <String>[
'doyun@x.kr',
'x',
'new@x.kr',
]);
expect(page.items.last.source, RecipientSource.recent);
expect(page.hasNext, isTrue);
expect(h.server.requests.last.queryParameters, <String, Object?>{
'q': '도윤',
'limit': 16,
});
await repository.listRecipientSuggestions();
expect(h.server.requests.last.queryParameters, <String, Object?>{
'limit': kRecipientSuggestionPreload,
});
h.server.on(
'GET',
ComposeApiPaths.contactGroups,
(_) => FakeResponse.ok(<String, Object?>{
'items': <Object?>[
<String, Object?>{'id': 'g1', 'name': '거래처', 'memberCount': 3},
],
'pageInfo': <String, Object?>{'hasNext': false, 'nextCursor': null},
}),
);
expect((await repository.listContactGroups()).single.name, '거래처');
h.server.on('GET', ComposeApiPaths.contacts, (RequestOptions options) {
final bool first = options.queryParameters['cursor'] == null;
return FakeResponse.ok(<String, Object?>{
'items': <Object?>[
<String, Object?>{
'name': first ? '박지훈' : '최서연',
'emails': <Object?>[
<String, Object?>{
'address': first ? 'jihoon@x.kr' : 'seoyeon@x.kr',
'label': 'work',
},
],
},
if (!first)
<String, Object?>{'name': '주소 없음', 'emails': <Object?>[]},
],
'pageInfo': <String, Object?>{
'hasNext': first,
'nextCursor': first ? 'p2' : null,
},
});
});
final List<GroupMember> members = await repository.listGroupMembers('g1');
expect(members, <GroupMember>[
(name: '박지훈', email: 'jihoon@x.kr'),
(name: '최서연', email: 'seoyeon@x.kr'),
(name: '주소 없음', email: null),
]);
final List<RequestOptions> pages = h.server.received(
'GET',
ComposeApiPaths.contacts,
);
expect(pages.first.queryParameters, <String, Object?>{
'groupId': 'g1',
'limit': kContactsPageSize,
});
expect(pages.last.queryParameters['cursor'], 'p2');
h.server.on(
'GET',
ComposeApiPaths.contacts,
(_) => FakeResponse.fail(503, 'MAIL_001'),
);
await errorOf(repository.listGroupMembers('g1'));
expect(h.notifier.shownCodes, <String>['MAIL_001']);
},
);
test('대용량 파일 상태 — 없으면 RES_001(알리지 않는다)', () async {
h.server.on(
'GET',
ComposeApiPaths.fileUpload('u1'),
(_) => FakeResponse.ok(<String, Object?>{
'id': 'u1',
'status': 'ready',
'name': 'a.zip',
'size': 5,
'scanStatus': 'clean',
}),
);
final DraftLargeFile file = await repository.getLargeFile('u1');
expect(file.status, DraftLargeFileStatus.ready);
expect(file.infected, isFalse);
expect(
apiErrorOf(await errorOf(repository.getLargeFile('u2'))).code,
'RES_001',
);
expect(h.notifier.shownCodes, isEmpty);
});
}
@@ -0,0 +1,920 @@
import 'dart:math' as math;
import 'package:arasystem_app/core/people/person_address.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/domain/compose_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_draft_body.dart';
import 'package:arasystem_app/features/mail/domain/compose_fit.dart';
import 'package:arasystem_app/features/mail/domain/compose_reply.dart';
import 'package:arasystem_app/features/mail/domain/compose_send.dart';
import 'package:arasystem_app/features/mail/domain/compose_signature.dart';
import 'package:arasystem_app/features/mail/domain/mail_quote.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/mail_fakes.dart';
const String _nbsp = ' ';
const String _wrapper =
'<div style="font-family: \'Malgun Gothic\', \'맑은 고딕\', \'Apple SD Gothic Neo\', sans-serif; font-size: 14px; line-height: 1.75">';
const String _p = '<p style="margin: 0">';
const String _mailQuoteOpen =
'<blockquote style="margin: 24px 0 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">';
const String _nestOpen =
'<blockquote style="margin: 0; padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363">';
const List<QuoteHeaderRow> _header = <QuoteHeaderRow>[
QuoteHeaderRow('보낸 사람', '이도윤 <doyun@x.kr>'),
QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'),
QuoteHeaderRow('제목', '계약'),
];
const ComposeQuote _quote = ComposeQuote(
header: _header,
blocks: <QuoteBlock>[
QuoteLine('안녕하세요'),
QuoteLine(''),
QuoteNest(<QuoteBlock>[QuoteLine('이전 글')]),
],
summary: '이도윤 · 11월 5일 (목) 오전 10:42',
);
/// 시간대와 상관없이 같은 벽시계 — 지역 시각을 UTC 글로(읽으면 다시 그 지역 시각)
String _localIso(int year, int month, int day, int hour, int minute) =>
DateTime(year, month, day, hour, minute).toUtc().toIso8601String();
MessageDetail _detail(Map<String, Object?> overrides) =>
MessageDetail.tryParse(<String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'cc': <Object?>[],
'bcc': <Object?>[],
'replyTo': <Object?>[],
'htmlBody': null,
'textBody': '본문',
'hasBlockedImages': false,
'attachments': <Object?>[],
...overrides,
})!;
void main() {
group('보내는 본문 — 서식 본문 + 텍스트 (짝: 웹 mail-serialize.ts)', () {
test('내 글만 — 줄마다 문단, 빈 줄은 <br>, 끝의 빈 줄은 보내지 않는다', () {
final MailBodyOutput output = buildMailBody(
const ComposeBodyInput(text: 'a\n\nb\n\n'),
);
expect(output.html, '$_wrapper${_p}a</p>$_p<br></p>${_p}b</p></div>');
expect(output.text, 'a\n\nb');
expect(output.quoteStart, isNull);
});
test('쓴 글이 없으면 텍스트 메일(서식 본문 없음)', () {
final MailBodyOutput output = buildMailBody(
const ComposeBodyInput(text: '\n \n'),
);
expect(output.html, isNull);
expect(output.text, '');
});
test('글은 이스케이프한다 — 꺾쇠 · & 가 태그가 되지 않는다', () {
expect(
buildMailBody(const ComposeBodyInput(text: 'x < y & <b>z</b>')).html,
'$_wrapper${_p}x &lt; y &amp; &lt;b&gt;z&lt;/b&gt;</p></div>',
);
});
test('여러 칸 띄어쓰기 · 줄 앞 공백 · 탭은 nbsp 와 번갈아(웹 preserveSpaces 와 같은 글)', () {
expect(
preserveSpaces(' 1. 일시 : 10월 27일'),
'$_nbsp 1. 일시 $_nbsp $_nbsp: 10월 27일',
);
expect(preserveSpaces('\tx'), '$_nbsp $_nbsp $_nbsp $_nbsp x');
expect(preserveSpaces('ab\ty'), 'ab $_nbsp $_nbsp ${_nbsp}y');
expect(preserveSpaces('한 칸 띄어쓰기'), '한 칸 띄어쓰기');
// 텍스트 본문의 공백은 그대로다
expect(
buildMailBody(const ComposeBodyInput(text: ' 들여 쓴 줄')).text,
' 들여 쓴 줄',
);
});
test('서명 — 빈 문단 하나 뒤 속성 없는 경계 div, 텍스트는 빈 줄 뒤 텍스트 서명', () {
final MailBodyOutput output = buildMailBody(
const ComposeBodyInput(
text: 'hi',
signatureHtml: '$_p김아라</p>',
signatureText: '김아라\n경영지원팀',
),
);
expect(
output.html,
'$_wrapper${_p}hi</p>$_p<br></p><div>$_p김아라</p></div></div>',
);
expect(output.text, 'hi\n\n김아라\n경영지원팀');
});
test('원문 인용 — "원본 메일" 문단 + 머리 표 + 문단 · 안쪽 인용, 텍스트는 "> " 줄', () {
final MailBodyOutput output = buildMailBody(
const ComposeBodyInput(text: 'ok', quote: _quote),
);
const String label =
'<td style="padding: 0 12px 0 0; color: #667085; vertical-align: top; white-space: nowrap">';
const String value = '<td style="padding: 0; vertical-align: top">';
expect(
output.html,
'$_wrapper${_p}ok</p>'
'$_mailQuoteOpen'
'<p style="margin: 0; font-size: 12.5px"><strong>원본 메일</strong></p>'
'<table style="margin: 0 0 12px; border-collapse: collapse; font-size: 12.5px; line-height: 1.6"><tbody>'
'<tr>$label보낸 사람</td>$value이도윤 &lt;doyun@x.kr&gt;</td></tr>'
'<tr>$label날짜</td>${value}2026. 11. 5. (목) 오전 10:42</td></tr>'
'<tr>$label제목</td>$value계약</td></tr>'
'</tbody></table>'
'$_p안녕하세요</p>$_p<br></p>$_nestOpen$_p이전 글</p></blockquote>'
'</blockquote></div>',
);
expect(
output.text,
'ok\n'
'> 원본 메일\n'
'> 보낸 사람: 이도윤 <doyun@x.kr>\n'
'> 날짜: 2026. 11. 5. (목) 오전 10:42\n'
'> 제목: 계약\n'
'>\n'
'> 안녕하세요\n'
'>\n'
'> > 이전 글',
);
expect(output.quoteStart, 'ok\n'.length);
expect(output.text.substring(output.quoteStart!), startsWith('> 원본 메일'));
expect(quoteStartInText(output.text), output.quoteStart);
});
test('대용량 파일이 있으면 원문 인용 바로 앞에 인용 경계 표시', () {
final String html = buildMailBody(
const ComposeBodyInput(
text: 'ok',
signatureHtml: '$_p서명</p>',
quote: _quote,
quoteStartMark: true,
),
).html!;
expect(html, contains('</div>$kQuoteStartMarker$_mailQuoteOpen'));
});
test('줄인 인용은 끝에 생략 표시 · 남의 저장본의 인용은 원래 HTML 그대로', () {
final MailBodyOutput omitted = buildMailBody(
ComposeBodyInput(
text: '',
quote: _quote.withBlocks(const <QuoteBlock>[
QuoteLine('앞부분'),
], omitted: true),
),
);
expect(
omitted.html,
endsWith('$_p앞부분</p>$_p$kQuoteOmittedMark</p></blockquote></div>'),
);
expect(omitted.text, endsWith('> 앞부분\n> $kQuoteOmittedMark'));
const String raw = '<blockquote><p>원본 메일</p><table></table></blockquote>';
final MailBodyOutput kept = buildMailBody(
const ComposeBodyInput(
text: 'x',
quote: ComposeQuote(
header: _header,
blocks: <QuoteBlock>[QuoteLine('글')],
summary: '',
rawHtml: raw,
),
),
);
expect(kept.html, '$_wrapper${_p}x</p>$raw</div>');
});
});
group('길이 맞추기 (짝: 웹 fit-draft.ts)', () {
ComposeQuote longQuote(int lines, {int width = 100}) => ComposeQuote(
header: _header,
blocks: <QuoteBlock>[
for (int index = 0; index < lines; index++) QuoteLine('가' * width),
],
summary: '',
);
test('한도 안이면 그대로', () {
final BodyFit fit = fitMailBody(
const ComposeBodyInput(text: 'ok', quote: _quote),
BodyLimits.full,
);
expect(fit.quote, _quote);
expect(fit.output?.text, startsWith('ok\n> 원본 메일'));
});
test('처음 초안은 내 글 몫(5%)을 남긴다 — 95,000 · 475,000', () {
expect(BodyLimits.initial.text, 95000);
expect(BodyLimits.initial.html, 475000);
final BodyFit fit = fitMailBody(
ComposeBodyInput(text: '', quote: longQuote(2000)),
BodyLimits.initial,
);
expect(fit.output!.text.length, lessThanOrEqualTo(95000));
expect(fit.output!.html!.length, lessThanOrEqualTo(475000));
expect(fit.quote!.omitted, isTrue);
expect(fit.quote!.blocks.length, lessThan(2000));
expect(fit.output!.text, endsWith('> $kQuoteOmittedMark'));
});
test('보낼 때는 한도 그대로 다시 잰다 — 넘으면 인용을 뒤에서 덜어 낸다', () {
final BodyFit fit = fitMailBody(
ComposeBodyInput(text: '내 글' * 10000, quote: longQuote(2000)),
BodyLimits.full,
);
expect(fit.output!.text.length, lessThanOrEqualTo(kMailBodyTextMax));
expect(fit.output!.text, startsWith('내 글' * 10000));
expect(fit.quote!.omitted, isTrue);
});
test('내 글만으로 넘으면 보내지 않는다(MAIL_007 — 결과 없음)', () {
expect(
fitMailBody(
ComposeBodyInput(text: 'a' * (kMailBodyTextMax + 1), quote: _quote),
BodyLimits.full,
).output,
isNull,
);
expect(
fitMailBody(
ComposeBodyInput(text: 'a' * (kMailBodyTextMax + 1)),
BodyLimits.full,
).output,
isNull,
);
});
test('긴 한 줄짜리 원문은 들어가는 만큼 글자 단위로 — 짝 글자를 가르지 않는다', () {
final BodyFit fit = fitMailBody(
ComposeBodyInput(text: '', quote: longQuote(1, width: 200000)),
BodyLimits.full,
);
final QuoteLine line = fit.quote!.blocks.single as QuoteLine;
expect(line.text.length, greaterThan(90000));
expect(fit.output!.text.length, lessThanOrEqualTo(kMailBodyTextMax));
final BodyFit emoji = fitMailBody(
ComposeBodyInput(
text: '',
quote: ComposeQuote(
header: _header,
blocks: <QuoteBlock>[QuoteLine('😀' * 60000)],
summary: '',
),
),
BodyLimits.full,
);
final String cut = (emoji.quote!.blocks.single as QuoteLine).text;
final int last = cut.codeUnitAt(cut.length - 1);
expect(last >= 0xD800 && last <= 0xDBFF, isFalse);
});
test('대용량 첨부 덩이 몫 — 파일마다 이름 + 24, 머리 600', () {
expect(largeFileBlockTextReserve(const <String>[]), 0);
expect(
largeFileBlockTextReserve(const <String>['a.zip', '계약.pdf']),
600 + 5 + 24 + 6 + 24,
);
expect(BodyLimits.full.reserveText(1000).text, kMailBodyTextMax - 1000);
});
});
group('원문 → 인용 덩이 (화면에 보이는 글과 같게)', () {
List<String> lines(String html) => plainLinesOf(quoteBlocksFromHtml(html));
test('블록 · <br> 은 줄, 이어진 공백은 한 칸, 앞뒤 빈 줄은 뺀다', () {
expect(lines('<p> 안녕 하세요 </p><div>둘째<br>셋째</div><p></p>'), <String>[
'안녕 하세요',
'둘째',
'셋째',
]);
});
test('목록은 "- " · "1. ", 칸이 여럿인 표 줄은 " | ", 링크는 "글 (주소)"', () {
expect(
lines(
'<ul><li>사과</li><li>배</li></ul><ol start="3"><li>셋</li></ol>'
'<table><tr><td>이름</td><td>값</td></tr></table>'
'<p><a href="https://ara.example/x">문서</a> · <a href="javascript:x">위험</a></p>',
),
<String>[
'- 사과',
'- 배',
'3. 셋',
'이름 | 값',
'문서 (https://ara.example/x) · 위험',
],
);
});
test('원문 속 인용은 안쪽 인용, 스크립트 · 스타일 · 그림의 글은 뺀다', () {
final List<QuoteBlock> blocks = quoteBlocksFromHtml(
'<p>위</p><blockquote><p>안쪽</p></blockquote>'
'<style>p{}</style><script>x()</script><img alt="그림">',
);
expect(blocks, const <QuoteBlock>[
QuoteLine('위'),
QuoteNest(<QuoteBlock>[QuoteLine('안쪽')]),
]);
expect(quoteDisplayLines(blocks), <QuoteDisplayLine>[
(text: '위', depth: 0),
(text: '안쪽', depth: 1),
]);
});
test('읽기 화면과 같은 본문 고르기 — 그림뿐인 서식 본문이면 글 본문, 잘라 보낸 본문이면 생략 표시', () {
final QuoteSource onlyImage = quoteSourceOf(
htmlBody: '<p><img src="cid:a"></p>',
textBody: '글 본문\n둘째 줄',
isBodyTruncated: false,
);
expect(plainLinesOf(onlyImage.blocks), <String>['글 본문', '둘째 줄']);
final QuoteSource truncated = quoteSourceOf(
htmlBody: '<p>서식 본문</p>',
textBody: '글 본문',
isBodyTruncated: true,
);
expect(plainLinesOf(truncated.blocks), <String>['서식 본문']);
expect(truncated.truncated, isTrue);
});
});
group('저장본 다시 열기 (계약 2절 "앱이 만드는 모양")', () {
const String signatureHtml = '$_p김아라</p>$_p경영지원팀</p>';
test('앱이 만든 모양 — 내 글 · 서명 · 인용으로 되살린다(nbsp 는 공백으로)', () {
final MailBodyOutput output = buildMailBody(
const ComposeBodyInput(
text: '첫 줄\n 들여 쓴 줄\n\n끝',
signatureHtml: signatureHtml,
signatureText: '김아라',
quote: _quote,
),
);
final DraftBody parsed = draftBodyOf(
bodyHtml: output.html,
body: output.text,
);
expect(parsed, isA<AppDraftBody>());
final AppDraftBody app = parsed as AppDraftBody;
expect(app.text, '첫 줄\n 들여 쓴 줄\n\n끝');
expect(app.signature?.html, signatureHtml);
expect(app.signature?.parts, const <SignaturePart>[
SignatureTextPart('김아라'),
SignatureTextPart('경영지원팀'),
]);
expect(app.quote!.header, _header);
expect(app.quote!.blocks, _quote.blocks);
expect(app.quote!.rawHtml, isNotNull);
expect(app.quote!.summary, '이도윤 · 2026. 11. 5. (목) 오전 10:42');
});
test('서버 정화로 style 띄어쓰기 · 경계 표시가 바뀌어도 앱 모양으로 알아본다', () {
// 서버의 보내는 정화가 내는 꼴 — style 은 "속성:값" 을 ; 로 잇고 data-* 는 지운다
final String html =
buildMailBody(
const ComposeBodyInput(
text: '글',
signatureHtml: signatureHtml,
quote: _quote,
quoteStartMark: true,
),
).html!
.replaceAll(
kMailBodyWrapperStyle,
"font-family:'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif;font-size:14px;line-height:1.75",
)
.replaceAll('margin: 0', 'margin:0')
.replaceAll(' data-ara-quote-start=""', '');
final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '');
expect(parsed, isA<AppDraftBody>());
expect((parsed as AppDraftBody).text, '글');
expect(parsed.quote, isNotNull);
});
test('서식이 있는 남의 저장본 — 인용 앞은 글로, 안내 줄을 보인다(원래 HTML 은 쥔다)', () {
const String html =
'<div><p>안녕 <strong>굵게</strong></p><p>서명</p>'
'<blockquote><p>원본 메일</p><table><tr><td>보낸 사람</td><td>이도윤 &lt;d@x.kr&gt;</td></tr></table><p>원문</p></blockquote></div>';
final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '원래 텍스트');
expect(parsed, isA<ForeignDraftBody>());
final ForeignDraftBody foreign = parsed as ForeignDraftBody;
expect(foreign.text, '안녕 굵게\n서명');
expect(foreign.hasFormatting, isTrue);
expect(foreign.originalHtml, html);
expect(foreign.originalText, '원래 텍스트');
expect(foreign.quote!.header.single.value, '이도윤 <d@x.kr>');
expect(foreign.quote!.summary, '이도윤');
expect(foreign.quote!.rawHtml, isNotNull);
});
test('서식이 없는 남의 저장본은 안내하지 않는다 · 서식 본문이 없으면 텍스트 그대로', () {
final DraftBody plain = draftBodyOf(
bodyHtml: '<div><div>a</div><p>b</p></div>',
body: '',
);
expect(plain, isA<ForeignDraftBody>());
expect((plain as ForeignDraftBody).hasFormatting, isFalse);
expect(plain.text, 'a\nb');
final DraftBody text = draftBodyOf(bodyHtml: null, body: '글 본문\n\n');
expect(text, isA<AppDraftBody>());
expect((text as AppDraftBody).text, '글 본문');
});
test('남의 저장본은 원문 인용 앞에 인용 경계 표시를 넣은 HTML 을 읽을 때 한 번 만들어 둔다', () {
const String html =
'<div><p>a <b>b</b></p><blockquote><p>원본 메일</p><table></table></blockquote></div>';
final ForeignDraftBody foreign =
draftBodyOf(bodyHtml: html, body: 'a b') as ForeignDraftBody;
expect(
foreign.markedHtml,
'<div><p>a <b>b</b></p><div data-ara-quote-start=""></div><blockquote><p>원본 메일</p><table></table></blockquote></div>',
);
expect(foreign.originalHtml, html);
final ForeignDraftBody noQuote =
draftBodyOf(bodyHtml: '<p><b>인용 없음</b></p>', body: '')
as ForeignDraftBody;
expect(noQuote.markedHtml, isNull);
});
// 웹 저장본 표본 — 문단의 정렬 · 들여쓰기 · 글자 꾸밈 · 다른 감싸개는 앱 모양이 아니다(말없이 지우지 않게 원래 HTML 을 쥐고 안내한다)
for (final (String name, String html) in <(String, String)>[
('가운데 정렬', '<div><p style="margin: 0; text-align: center">가운데</p></div>'),
('들여쓰기', '<div><p style="margin: 0; margin-left: 40px">들여 쓴 줄</p></div>'),
('굵게', '<div><p style="margin: 0">글 <strong>굵게</strong></p></div>'),
('문단의 다른 속성', '<div><p style="margin: 0" dir="rtl">글</p></div>'),
(
'꾸민 감싸개',
'<div style="font-size: 18px"><p style="margin: 0">글</p></div>',
),
(
'꾸민 서명 div',
'<div><p>글</p><div style="color: #0E7C86"><p>서명</p></div></div>',
),
]) {
test('웹 저장본 — $name 은 남의 저장본(원래 HTML 을 쥐고 안내 줄)', () {
final DraftBody parsed = draftBodyOf(bodyHtml: html, body: '글');
expect(parsed, isA<ForeignDraftBody>(), reason: name);
final ForeignDraftBody foreign = parsed as ForeignDraftBody;
expect(foreign.hasFormatting, isTrue, reason: name);
expect(foreign.originalHtml, html, reason: name);
});
}
test('꾸밈 없는 문단(속성 없음 · margin:0 하나)만 든 웹 저장본은 앱 모양으로 연다', () {
final DraftBody parsed = draftBodyOf(
bodyHtml:
'<div style="font-family: \'Malgun Gothic\', \'맑은 고딕\', \'Apple SD Gothic Neo\', sans-serif; font-size: 14px; line-height: 1.75">'
'<p style="margin: 0;">첫 줄</p><p>둘째 줄</p></div>',
body: '',
);
expect(parsed, isA<AppDraftBody>());
expect((parsed as AppDraftBody).text, '첫 줄\n둘째 줄');
});
});
group('서명 (계약 2절 "서명" — 짝: 웹 mail-signature.ts)', () {
test('새 메일은 isForNew, 답장 · 전달은 isForReply 일 때만', () {
const MailSignature signature = MailSignature(
body: '김아라',
isForNew: false,
isForReply: true,
);
expect(composeSignatureOf(signature, forReply: false), isNull);
expect(composeSignatureOf(signature, forReply: true), isNotNull);
expect(composeSignatureOf(null, forReply: true), isNull);
expect(
composeSignatureOf(const MailSignature(body: ' '), forReply: true),
isNull,
);
});
test('텍스트 서명은 줄마다 문단', () {
final ComposeSignature signature = composeSignatureOf(
const MailSignature(body: '김아라\n경영지원팀'),
forReply: false,
)!;
expect(signature.html, '$_p김아라</p>$_p경영지원팀</p>');
expect(signature.text, '김아라\n경영지원팀');
expect(signature.parts, const <SignaturePart>[
SignatureTextPart('김아라'),
SignatureTextPart('경영지원팀'),
]);
});
test('서식 서명 — 문단에 margin: 0 을 앞에, 미리 보기는 글 줄과 서명 그림', () {
const String id = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
final ComposeSignature signature = composeSignatureOf(
const MailSignature(
bodyHtml:
'<p><strong>김아라</strong> 경영지원팀</p>'
'<p style="margin:0"><img src="/api/mail-signature/images/$id" width="320" alt="로고"></p>'
'<p><img src="https://tracker.example/x.png"></p>',
body: '김아라 경영지원팀',
),
forReply: false,
)!;
expect(
signature.html,
startsWith('<p style="margin: 0"><strong>김아라</strong>'),
);
expect(signature.html, contains('<p style="margin:0"><img'));
expect(signature.text, '김아라 경영지원팀');
expect(signature.parts, const <SignaturePart>[
SignatureTextPart('김아라 경영지원팀'),
SignatureImagePart(id: id, width: 320, alt: '로고'),
]);
});
});
group('답장 · 전달 (짝: 웹 mail-reply.ts · mail-forward.ts · mail-draft.ts)', () {
test('제목 — 접두어가 있으면 그대로, 255자에 맞춘다(짝 글자를 가르지 않는다)', () {
expect(buildReplySubject('계약'), 'Re: 계약');
expect(buildReplySubject(' RE : 계약'), 'RE : 계약');
expect(buildReplySubject('회신: 계약'), '회신: 계약');
expect(buildReplySubject(''), 'Re:');
expect(buildForwardSubject('계약'), 'Fwd: 계약');
expect(buildForwardSubject('FW: 계약'), 'FW: 계약');
expect(buildForwardSubject('전달: 계약'), '전달: 계약');
expect(fitDraftSubject('a' * 300), 'a' * 255);
expect(fitDraftSubject('${'a' * 254}😀'), 'a' * 254);
});
const String me = 'ara.kim@arasystem.it.kr';
Map<String, Object?> person(String name, String email) => <String, Object?>{
'name': name,
'email': email,
};
List<String> emails(List<PersonAddress> list) => <String>[
for (final PersonAddress address in list) address.email,
];
test('답장은 보낸 사람(Reply-To 가 있으면 그 주소), 전체 답장은 받는 사람 · 참조까지 — 나는 뺀다', () {
final MessageDetail message = _detail(<String, Object?>{
'from': person('이도윤', 'doyun@x.kr'),
'to': <Object?>[
person('김아라', 'ARA.KIM@arasystem.it.kr'),
person('박지훈', 'jihoon@x.kr'),
],
'cc': <Object?>[
person('최서연', 'seoyeon@x.kr'),
person('', 'jihoon@x.kr'),
],
});
final ({List<PersonAddress> to, List<PersonAddress> cc}) reply =
buildReplyRecipients(message, me, all: false);
expect(emails(reply.to), <String>['doyun@x.kr']);
expect(reply.cc, isEmpty);
final ({List<PersonAddress> to, List<PersonAddress> cc}) all =
buildReplyRecipients(message, me, all: true);
expect(emails(all.to), <String>['doyun@x.kr', 'jihoon@x.kr']);
expect(emails(all.cc), <String>['seoyeon@x.kr']);
final MessageDetail withReplyTo = _detail(<String, Object?>{
'from': person('이도윤', 'doyun@x.kr'),
'replyTo': <Object?>[person('지원팀', 'help@x.kr')],
});
expect(
emails(buildReplyRecipients(withReplyTo, me, all: false).to),
<String>['help@x.kr'],
);
});
test('내가 보낸 메일에 답장하면 원래 받는 사람에게, 나에게만 보냈으면 나에게', () {
final MessageDetail own = _detail(<String, Object?>{
'from': person('김아라', me),
'to': <Object?>[person('이도윤', 'doyun@x.kr')],
'replyTo': <Object?>[person('다른 곳', 'else@x.kr')],
});
expect(emails(buildReplyRecipients(own, me, all: false).to), <String>[
'doyun@x.kr',
]);
final MessageDetail toSelf = _detail(<String, Object?>{
'from': person('김아라', me),
'to': <Object?>[person('김아라', me)],
});
expect(emails(buildReplyRecipients(toSelf, me, all: false).to), <String>[
me,
]);
});
test('Reply-To 가 나뿐이면 원래 받는 사람에게', () {
final MessageDetail message = _detail(<String, Object?>{
'from': person('이도윤', 'doyun@x.kr'),
'replyTo': <Object?>[person('김아라', me)],
'to': <Object?>[person('김아라', me), person('박지훈', 'jihoon@x.kr')],
});
expect(emails(buildReplyRecipients(message, me, all: false).to), <String>[
'jihoon@x.kr',
]);
});
test('인용 머리 — 날짜는 웹과 같은 글, 전달은 받는 사람 · 참조도', () {
expect(
formatQuoteDate(DateTime(2026, 11, 5, 10, 42)),
'2026. 11. 5. (목) 오전 10:42',
);
expect(
formatQuoteDate(DateTime(2026, 11, 5, 15, 7)),
'2026. 11. 5. (목) 오후 3:07',
);
final MessageDetail message = _detail(<String, Object?>{
'from': person('이도윤', 'doyun@x.kr'),
'to': <Object?>[person('김아라', me)],
'cc': <Object?>[person('', 'cc@x.kr')],
'subject': '',
'sentAt': _localIso(2026, 11, 5, 10, 42),
});
expect(replyQuoteRows(message), const <QuoteHeaderRow>[
QuoteHeaderRow('보낸 사람', '이도윤 <doyun@x.kr>'),
QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'),
QuoteHeaderRow('제목', '(제목 없음)'),
]);
expect(forwardQuoteRows(message), const <QuoteHeaderRow>[
QuoteHeaderRow('보낸 사람', '이도윤 <doyun@x.kr>'),
QuoteHeaderRow('날짜', '2026. 11. 5. (목) 오전 10:42'),
QuoteHeaderRow('받는 사람', '김아라 <ara.kim@arasystem.it.kr>'),
QuoteHeaderRow('참조', 'cc@x.kr'),
QuoteHeaderRow('제목', '(제목 없음)'),
]);
// 접힌 줄의 둘째 줄 — 올해면 연도 없이, 아니면 연도까지
expect(
quoteSummaryOf(message, now: DateTime(2026, 12, 1)),
'이도윤 · 11월 5일 (목) 오전 10:42',
);
expect(
quoteSummaryOf(message, now: DateTime(2027, 1, 2)),
'이도윤 · 2026년 11월 5일 (목) 오전 10:42',
);
});
test('이어 쓰는 저장본의 쓰기 종류 — 답장할 메일 · 전달 제목 · 새 메일', () {
expect(draftKindOf(inReplyTo: 'm1', subject: '계약'), ComposeKind.reply);
expect(
draftKindOf(inReplyTo: null, subject: 'Fwd: 계약'),
ComposeKind.forward,
);
expect(draftKindOf(inReplyTo: '', subject: '계약'), ComposeKind.newMail);
});
});
group(
'보내기 규칙 (짝: 웹 idempotency-key.ts · mail-send-result.ts · pending-send.ts · mail-attachment.ts)',
() {
test('발송 키 — UUID v4 꼴, 매번 다르다', () {
final RegExp uuid = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
);
expect(createSendKey(), matches(uuid));
expect(createSendKey(math.Random(1)), matches(uuid));
expect(createSendKey(), isNot(createSendKey()));
});
test('키를 바꾸는 것은 성공과 MAIL_005 뿐 — 그 밖의 실패는 같은 키로 다시 보낸다', () {
expect(shouldRenewSendKey(kSendOutcomeSent), isTrue);
expect(shouldRenewSendKey('MAIL_005'), isTrue);
for (final String code in <String>[
'MAIL_001',
'MAIL_002',
'SYS_001',
'REQ_001',
'MAIL_007',
]) {
expect(shouldRenewSendKey(code), isFalse, reason: code);
}
});
test('받지 못한 사람 — 두 명까지 주소 · 까닭, 나머지는 "외 N명"', () {
expect(partialSendMessage(const <RejectedRecipient>[]), isNull);
expect(
partialSendMessage(const <RejectedRecipient>[
RejectedRecipient(email: 'a@x.kr', reason: RejectedReason.notFound),
RejectedRecipient(email: 'b@x.kr', reason: RejectedReason.unknown),
RejectedRecipient(email: 'c@x.kr', reason: RejectedReason.refused),
]),
'보냈지만 3명은 받지 못했습니다. a@x.kr (없는 주소), b@x.kr 외 1명',
);
expect(
partialSendMessage(const <RejectedRecipient>[
RejectedRecipient(
email: 'a@x.kr',
reason: RejectedReason.temporary,
),
], pending: true),
'보냈지만 1명은 받지 못합니다. a@x.kr (지금은 받을 수 없음)',
);
});
test(
'보내기 취소 마감 — 서버 시각으로 sendAt − 2초, 모르면 설정의 초 − 1초(모르면 30초)와 sendAt − 이 기기 시각 가운데 짧은 것(30초를 넘지 않는다)',
() {
final DateTime sendAt = DateTime.utc(2026, 11, 7, 9, 0, 10);
final DateTime device = DateTime.utc(2026, 11, 7, 9, 0, 0);
expect(
undoSendRemaining(
sendAt: sendAt,
serverNow: DateTime.utc(2026, 11, 7, 9, 0, 0),
undoSendSeconds: 30,
deviceNow: device,
),
const Duration(seconds: 8),
);
expect(
undoSendRemaining(
sendAt: sendAt,
serverNow: null,
undoSendSeconds: 5,
deviceNow: device,
),
const Duration(seconds: 4),
);
// 설정을 모르면 sendAt 까지(이 기기 시각), 다만 30초를 넘지 않게 — 웹 undoToastDeadline 과 같다
expect(
undoSendRemaining(
sendAt: sendAt,
serverNow: null,
undoSendSeconds: null,
deviceNow: device,
),
const Duration(seconds: 10),
);
expect(
undoSendRemaining(
sendAt: sendAt,
serverNow: null,
undoSendSeconds: null,
deviceNow: device.subtract(const Duration(hours: 1)),
),
kUndoSendMax,
);
// 설정을 알아도 sendAt 을 넘지 않는다(이 기기 시계가 빠르면 일찍 닫힌다 — 보낸 뒤에 취소를 보이지 않는 쪽)
expect(
undoSendRemaining(
sendAt: sendAt,
serverNow: null,
undoSendSeconds: 30,
deviceNow: device.add(const Duration(seconds: 7)),
),
const Duration(seconds: 3),
);
expect(
undoSendRemaining(
sendAt: sendAt,
serverNow: DateTime.utc(2026, 11, 7, 8, 0),
undoSendSeconds: null,
deviceNow: device,
),
kUndoSendMax,
);
expect(
undoSendRemaining(
sendAt: sendAt,
serverNow: sendAt.subtract(const Duration(seconds: 1)),
undoSendSeconds: 10,
deviceNow: device,
),
isNull,
);
},
);
test('이미 붙은 첨부의 사전 검사 — 개수 → 형식 → 합계(서버와 같은 차례)', () {
expect(
precheckCarriedAttachment(
name: '계약.PDF',
size: 10,
attachedCount: 0,
attachedBytes: 0,
),
isNull,
);
expect(
precheckCarriedAttachment(
name: 'a.pdf',
size: 10,
attachedCount: 20,
attachedBytes: 0,
),
'MAIL_006',
);
expect(
precheckCarriedAttachment(
name: 'a.exe',
size: 10,
attachedCount: 0,
attachedBytes: 0,
),
'FILE_001',
);
expect(
precheckCarriedAttachment(
name: 'noext',
size: 10,
attachedCount: 0,
attachedBytes: 0,
),
'FILE_001',
);
expect(
precheckCarriedAttachment(
name: 'a.zip',
size: 2,
attachedCount: 1,
attachedBytes: kMailAttachmentTotalMaxBytes - 1,
),
'FILE_002',
);
});
},
);
group('발송 · 저장 본문 (계약 2절)', () {
test('빈 선택 칸은 싣지 않는다 · 저장본으로 남길 때는 draftId 를 뺀다', () {
const MailComposePayload payload = MailComposePayload(
to: <PersonAddress>[PersonAddress(email: 'a@x.kr', name: '가')],
cc: <PersonAddress>[],
bcc: <PersonAddress>[],
subject: '제목',
body: '본문',
draftId: 'd1',
);
expect(payload.toJson(), <String, Object?>{
'to': <Object?>[
<String, Object?>{'name': '가', 'email': 'a@x.kr'},
],
'cc': <Object?>[],
'bcc': <Object?>[],
'subject': '제목',
'body': '본문',
'attachments': <Object?>[],
'draftId': 'd1',
});
expect(payload.withoutDraftId().toJson().containsKey('draftId'), isFalse);
const MailComposePayload full = MailComposePayload(
to: <PersonAddress>[],
cc: <PersonAddress>[],
bcc: <PersonAddress>[],
subject: '',
body: 'x',
bodyHtml: '<div>x</div>',
attachments: <ComposeAttachmentRef>[(id: 'att1', name: '계약.pdf')],
inReplyTo: 'm1',
largeFileIds: <String>['u1'],
bodyQuoteStart: 3,
);
expect(full.toJson(), containsPair('bodyHtml', '<div>x</div>'));
expect(full.toJson(), containsPair('inReplyTo', 'm1'));
expect(full.toJson(), containsPair('largeFileIds', <String>['u1']));
expect(full.toJson(), containsPair('bodyQuoteStart', 3));
expect(full.toJson()['attachments'], <Object?>[
<String, Object?>{'id': 'att1', 'name': '계약.pdf'},
]);
});
test('저장본 응답 — 대용량 파일 상태 · 위험 판정을 읽는다(모양이 틀린 항목만 버린다)', () {
final Draft? draft = Draft.tryParse(<String, Object?>{
'id': 'd1',
'to': <Object?>[],
'subject': '',
'body': '',
'updatedAt': '2026-11-05T01:00:00.000Z',
'largeFiles': <Object?>[
<String, Object?>{
'id': 'u1',
'status': 'ready',
'name': 'a.zip',
'size': 10,
'scanStatus': 'infected',
},
<String, Object?>{'id': 'u2', 'status': 'missing'},
<String, Object?>{'id': 'u3', 'status': 'weird'},
],
});
expect(draft, isNotNull);
expect(draft!.cc, isEmpty);
expect(draft.largeFiles, const <DraftLargeFile>[
DraftLargeFile(
id: 'u1',
status: DraftLargeFileStatus.ready,
name: 'a.zip',
size: 10,
infected: true,
),
DraftLargeFile(id: 'u2', status: DraftLargeFileStatus.missing),
]);
expect(Draft.tryParse(<String, Object?>{'id': 'd1'}), isNull);
});
});
}
@@ -0,0 +1,331 @@
import 'dart:async';
import 'package:arasystem_app/core/api/api_error_mapper.dart';
import 'package:arasystem_app/core/people/person_address.dart';
import 'package:arasystem_app/features/mail/data/models/compose_models.dart';
import 'package:arasystem_app/features/mail/data/models/compose_payload.dart';
import 'package:arasystem_app/features/mail/presentation/providers/draft_autosave.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
/// 저장 한 번의 결과 — 문([gate])을 두면 열릴 때까지 기다린다
class _Server {
final List<String> calls = <String>[];
/// 다음 저장들이 이 코드로 실패한다(차례대로 하나씩 쓴다)
final List<String> failures = <String>[];
Completer<void>? gate;
int _next = 0;
Future<DraftSaved> save(String call) async {
calls.add(call);
final Completer<void>? wait = gate;
if (wait != null) await wait.future;
if (failures.isNotEmpty) {
throw apiFailure(RequestOptions(path: '/drafts'), failures.removeAt(0));
}
_next++;
return DraftSaved(
id: 'd$_next',
attachments: const [],
updatedAt: DateTime.utc(2026, 11, 7),
);
}
}
/// 자동 저장 시험대 — 바뀜 여부는 [changed], 저장이 끝나면 저장한 판으로 삼는다(바뀜 없음)
class _Harness {
_Harness() {
autosave = DraftAutosave(
canSave: () => allowed,
hasChanges: () => changed,
prepare: () {
final int version = this.version;
return rejected == null
? DraftSaveRequest(
payload: _payload,
commit: (DraftSaved _) {
// 저장하는 동안 더 바꿨으면 그대로 바뀜
if (this.version == version) changed = false;
},
)
: DraftSaveRejected(rejected!);
},
create: (MailComposePayload _) => server.save('POST'),
update: (String id, MailComposePayload _) => server.save('PUT $id'),
canFallback: () => fallback,
onSaved: (String? previousId, DraftSaved saved, bool replaced) =>
saved_.add((previousId, saved.id, replaced)),
onFailed: (String code, DraftSaveTrigger trigger) =>
failed.add((code, trigger)),
);
}
final _Server server = _Server();
late final DraftAutosave autosave;
bool changed = false;
bool allowed = true;
bool fallback = true;
String? rejected;
int version = 0;
final List<(String?, String, bool)> saved_ = <(String?, String, bool)>[];
final List<(String, DraftSaveTrigger)> failed =
<(String, DraftSaveTrigger)>[];
static const MailComposePayload _payload = MailComposePayload(
to: <PersonAddress>[],
cc: <PersonAddress>[],
bcc: <PersonAddress>[],
subject: '제목',
body: '본문',
);
/// 사용자가 무언가 바꿨다
void edit() {
changed = true;
version++;
autosave.touch();
}
}
void main() {
// 시간은 Timer 로만 잰다 — 시험 시간(testWidgets 의 가짜 시계)을 넘긴다
testWidgets('입력이 3초 멈추면 저장한다 — 처음은 POST, 그다음은 PUT {직전 id}', (
WidgetTester tester,
) async {
final _Harness h = _Harness();
h.edit();
await tester.pump(const Duration(seconds: 2));
expect(h.server.calls, isEmpty);
await tester.pump(const Duration(seconds: 1));
expect(h.server.calls, <String>['POST']);
expect(h.autosave.draftId, 'd1');
expect(h.autosave.status, DraftSaveStatus.saved);
await tester.pump(const Duration(seconds: 10));
h.edit();
await tester.pump(const Duration(seconds: 3));
expect(h.server.calls, <String>['POST', 'PUT d1']);
expect(h.saved_.last, ('d1', 'd2', false));
h.autosave.dispose();
});
testWidgets('저장은 10초에 한 번까지 — 그사이 바뀐 것은 문이 열릴 때 저장한다', (
WidgetTester tester,
) async {
final _Harness h = _Harness();
h.edit();
await tester.pump(const Duration(seconds: 3));
expect(h.server.calls, <String>['POST']);
h.edit();
await tester.pump(const Duration(seconds: 3));
// 첫 저장을 시작한 지 3초 — 아직 문이 닫혀 있다(시작한 뒤 10초에 열린다)
expect(h.server.calls, <String>['POST']);
await tester.pump(const Duration(seconds: 6));
expect(h.server.calls, <String>['POST']);
await tester.pump(const Duration(seconds: 1));
expect(h.server.calls, <String>['POST', 'PUT d1']);
h.autosave.dispose();
});
testWidgets('쉬지 않고 써도 처음 바꾼 뒤 30초가 지나면 저장한다', (WidgetTester tester) async {
final _Harness h = _Harness();
for (int second = 0; second < 30; second++) {
h.edit();
await tester.pump(const Duration(seconds: 1));
}
expect(h.server.calls, <String>['POST']);
h.autosave.dispose();
});
testWidgets('한 번에 하나 — 저장하는 동안 바뀐 것은 끝난 뒤 다시', (WidgetTester tester) async {
final _Harness h = _Harness();
h.server.gate = Completer<void>();
h.edit();
await tester.pump(const Duration(seconds: 3));
expect(h.autosave.saving, isTrue);
h.edit();
await tester.pump(const Duration(seconds: 15));
expect(h.server.calls, <String>['POST']);
h.server.gate!.complete();
h.server.gate = null;
await tester.pump();
// 끝났지만 그사이 바뀐 것이 남았다 — 문(10초)이 이미 열렸으므로 바로
expect(h.server.calls, <String>['POST', 'PUT d1']);
await tester.pump();
expect(h.changed, isFalse);
h.autosave.dispose();
});
testWidgets('옛 저장본이 다른 곳에서 바뀌었거나 없으면 새로 남긴다(MAIL_008 · RES_001)', (
WidgetTester tester,
) async {
final _Harness h = _Harness();
h.autosave.reset(existingId: 'old');
h.server.failures.add('MAIL_008');
h.edit();
await tester.pump(const Duration(seconds: 3));
expect(h.server.calls, <String>['PUT old', 'POST']);
expect(h.autosave.status, DraftSaveStatus.replaced);
expect(h.saved_.single, ('old', 'd1', true));
// 세션이 바뀌었으면 새로 남기지 않는다
final _Harness other = _Harness()..fallback = false;
other.autosave.reset(existingId: 'old');
other.server.failures.add('RES_001');
other.edit();
await tester.pump(const Duration(seconds: 3));
expect(other.server.calls, <String>['PUT old']);
expect(other.failed.single, ('RES_001', DraftSaveTrigger.auto));
h.autosave.dispose();
other.autosave.dispose();
});
testWidgets('실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본을 지우지 않는다)', (
WidgetTester tester,
) async {
final _Harness h = _Harness();
h.autosave.reset(existingId: 'foreign', keepOriginal: true);
expect(h.autosave.draftId, isNull);
h.edit();
await tester.pump(const Duration(seconds: 3));
expect(h.server.calls, <String>['POST']);
h.autosave.dispose();
});
testWidgets('다시 해 보면 나아질 실패는 15 · 30 · 60초 뒤 저절로, 그 밖은 바뀔 때까지 기다린다', (
WidgetTester tester,
) async {
final _Harness h = _Harness();
h.server.failures.addAll(<String>['MAIL_001', 'SYS_001']);
h.edit();
await tester.pump(const Duration(seconds: 3));
expect(h.server.calls, <String>['POST']);
expect(h.autosave.status, DraftSaveStatus.failed);
await tester.pump(const Duration(seconds: 14));
expect(h.server.calls, hasLength(1));
await tester.pump(const Duration(seconds: 1));
expect(h.server.calls, hasLength(2));
await tester.pump(const Duration(seconds: 29));
expect(h.server.calls, hasLength(2));
await tester.pump(const Duration(seconds: 1));
expect(h.server.calls, hasLength(3));
expect(h.autosave.status, DraftSaveStatus.saved);
final _Harness quota = _Harness();
quota.server.failures.add('MAIL_004');
quota.edit();
await tester.pump(const Duration(seconds: 3));
await tester.pump(const Duration(minutes: 5));
expect(quota.server.calls, <String>['POST']);
expect(quota.failed.single.$1, 'MAIL_004');
h.autosave.dispose();
quota.autosave.dispose();
});
testWidgets('보낼 수 없는 본문(MAIL_007)은 요청 없이 실패로 둔다', (
WidgetTester tester,
) async {
final _Harness h = _Harness()..rejected = 'MAIL_007';
h.edit();
await tester.pump(const Duration(seconds: 3));
expect(h.server.calls, isEmpty);
expect(h.failed.single, ('MAIL_007', DraftSaveTrigger.auto));
h.autosave.dispose();
});
testWidgets('앱이 안 보이게 되면 10초 간격을 기다리지 않고 바로 저장한다', (
WidgetTester tester,
) async {
final _Harness h = _Harness();
h.edit();
await tester.pump(const Duration(seconds: 3));
h.edit();
h.autosave.saveOnHide();
await tester.pump();
expect(h.server.calls, <String>['POST', 'PUT d1']);
// 바꾼 것이 없으면 저장하지 않는다
h.autosave.saveOnHide();
await tester.pump();
expect(h.server.calls, hasLength(2));
h.autosave.dispose();
});
testWidgets('떠나기 전 — 진행 중인 저장을 기다린 뒤 남은 변경을 저장한다', (
WidgetTester tester,
) async {
final _Harness h = _Harness();
h.server.gate = Completer<void>();
h.edit();
await tester.pump(const Duration(seconds: 3));
h.edit();
bool? result;
unawaited(
h.autosave.saveBeforeLeave().then((bool saved) => result = saved),
);
await tester.pump();
expect(h.server.calls, <String>['POST']);
h.server.gate!.complete();
h.server.gate = null;
await tester.pump();
await tester.pump();
expect(h.server.calls, <String>['POST', 'PUT d1']);
expect(result, isTrue);
h.autosave.dispose();
});
testWidgets('멈춘 동안(보내기 · 나가기 확인)은 저장하지 않고, 진행 중인 저장은 settle 로 기다린다', (
WidgetTester tester,
) async {
final _Harness h = _Harness();
h.server.gate = Completer<void>();
h.edit();
await tester.pump(const Duration(seconds: 3));
h.autosave.suspend();
h.edit();
bool settled = false;
unawaited(h.autosave.settle().then((void _) => settled = true));
await tester.pump(const Duration(seconds: 40));
expect(settled, isFalse);
h.server.gate!.complete();
h.server.gate = null;
await tester.pump();
expect(settled, isTrue);
await tester.pump(const Duration(seconds: 40));
expect(h.server.calls, <String>['POST']);
h.autosave.resume();
await tester.pump(const Duration(seconds: 3));
expect(h.server.calls, <String>['POST', 'PUT d1']);
h.autosave.dispose();
expect(h.autosave.hasPendingTimers, isFalse);
});
test('양식 없이 한 번 — PUT 이 막히면 같은 세션일 때만 POST 로 남긴다', () async {
final _Server server = _Server();
server.failures.add('MAIL_008');
const MailComposePayload payload = _Harness._payload;
final DraftSaved? saved = await saveDraftDetached(
payload: payload,
draftId: 'd0',
create: (MailComposePayload _) => server.save('POST'),
update: (String id, MailComposePayload _) => server.save('PUT $id'),
stillValid: () => true,
);
expect(saved?.id, 'd1');
expect(server.calls, <String>['PUT d0', 'POST']);
server.failures.add('RES_001');
expect(
await saveDraftDetached(
payload: payload,
draftId: 'd1',
create: (MailComposePayload _) => server.save('POST'),
update: (String id, MailComposePayload _) => server.save('PUT $id'),
stillValid: () => false,
),
isNull,
);
expect(server.calls, <String>['PUT d0', 'POST', 'PUT d1']);
});
}
@@ -9,6 +9,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da
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/mail/presentation/pages/message_compose_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_list_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
@@ -18,7 +19,6 @@ import 'package:arasystem_app/features/mail/presentation/widgets/mail_list_state
import 'package:arasystem_app/features/mail/presentation/widgets/mailbox_drawer.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
import 'package:arasystem_app/features/settings/settings_routes.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@@ -77,14 +77,14 @@ void main() {
MailListPage currentPage(WidgetTester tester) =>
tester.widget<MailListPage>(find.byType(MailListPage));
/// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 자리)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
/// 위에 쌓아 연 전체 화면(읽기 화면 · 쓰기 화면)의 주소 — `push` 는 라우터의 주소를 바꾸지 않는다
String pushedLocation(WidgetTester tester) {
final Finder reader = find.byType(MessageReaderPage);
return GoRouterState.of(
tester.element(
reader.evaluate().isNotEmpty
? reader.last
: find.byType(PlaceholderPage).last,
: find.byType(MessageComposePage).last,
),
).uri.toString();
}
File diff suppressed because it is too large Load Diff
@@ -13,13 +13,13 @@ import 'package:arasystem_app/features/mail/domain/mail_html.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/message_reader_view.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_compose_page.dart';
import 'package:arasystem_app/features/mail/presentation/pages/message_reader_page.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:arasystem_app/features/mail/presentation/providers/message_reader_controller.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_body_view.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/mail_compose_area.dart';
import 'package:arasystem_app/features/mail/presentation/widgets/message_row.dart';
import 'package:arasystem_app/shared/widgets/placeholder_page.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -94,7 +94,7 @@ void main() {
String? lastDocument() => h.webView.last.lastHtml;
String pushedLocation(WidgetTester tester) => GoRouterState.of(
tester.element(find.byType(PlaceholderPage).last),
tester.element(find.byType(MessageComposePage).last),
).uri.toString();
group('머리 · 본문', () {
@@ -684,7 +684,7 @@ void main() {
// 쓰기 화면 · 바깥 링크 · mailto 를 열지 않는다
await tester.tap(find.text(MailReaderTexts.reply));
await tester.pumpAndSettle();
expect(find.byType(PlaceholderPage), findsNothing);
expect(find.byType(MessageComposePage), findsNothing);
expect(
await h.webView.last.navigate('mailto:kim@example.com'),
NavigationDecision.prevent,
@@ -694,7 +694,7 @@ void main() {
NavigationDecision.prevent,
);
await tester.pumpAndSettle();
expect(find.byType(PlaceholderPage), findsNothing);
expect(find.byType(MessageComposePage), findsNothing);
expect(h.links.launched, isEmpty);
// 그사이 위에 다른 화면이 열렸다 — 옮기기가 끝나도 그 화면을 닫지 않는다
@@ -704,7 +704,7 @@ void main() {
await tester.pumpAndSettle();
expect(h.mail.mailboxOf('m1'), kTrashId);
expect(find.byType(PlaceholderPage), findsOneWidget);
expect(find.byType(MessageComposePage), findsOneWidget);
expect(pushedLocation(tester), MailRoutes.compose);
h.router.pop();
await tester.pumpAndSettle();
@@ -1042,7 +1042,7 @@ void main() {
);
await tester.pumpAndSettle();
final GoRouterState compose = GoRouterState.of(
tester.element(find.byType(PlaceholderPage).last),
tester.element(find.byType(MessageComposePage).last),
);
expect(compose.uri.path, MailRoutes.compose);
expect(