generated from ttipo/project-base
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:
@@ -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:"b"" data-x="1 & 2"><p>글 & <태그><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 < y & <b>z</b></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이도윤 <doyun@x.kr></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>이도윤 <d@x.kr></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(
|
||||
|
||||
Reference in New Issue
Block a user