generated from ttipo/project-base
fix(flutter): 본문 없이 첨부만 있는 메일에서 읽기 화면 아래가 비고 첨부가 잘리던 것을 고친다
읽기 화면은 머리(제목 · 보낸 사람 · 안내 · 첨부)가 남은 높이의 절반까지만 쓰고 나머지는 본문 WebView 였다. 본문이 비면 아래 절반이 빈 칸으로 남고 첨부가 많으면 위 절반 안에서 잘렸다(11-12 배포 뒤 사용자 보고). 정화한 본문에 보이는 것(글자 · 주소가 남은 그림 · hr · li · details · q)이 없으면 상태 bodyEmpty 로 본문 칸을 두지 않고 머리가 화면 전체를 한 스크롤로 쓴다. 판정은 정화와 같은 길에서 내고(큰 본문은 isolate), 아래 막대가 없는 예약 메일은 머리 스크롤에 시스템 아래 여백을 더한다. 본문이 있는 메일은 그대로다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -372,6 +372,141 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('본문에 보이는 것이 있는가 (없으면 읽기 화면에 본문 칸이 없다)', () {
|
||||
bool visibleHtml(String html, {String? text, bool imagesShown = false}) =>
|
||||
mailBodyFragment(
|
||||
htmlBody: html,
|
||||
textBody: text,
|
||||
imagesShown: imagesShown,
|
||||
).hasVisibleContent;
|
||||
|
||||
bool visibleText(String? text) => mailBodyFragment(
|
||||
htmlBody: null,
|
||||
textBody: text,
|
||||
imagesShown: false,
|
||||
).hasVisibleContent;
|
||||
|
||||
test('빈 줄 · nbsp · 제로폭 글자뿐인 서식 본문은 비었다', () {
|
||||
expect(visibleHtml('<div><br></div>'), isFalse);
|
||||
expect(visibleHtml('<p> </p>'), isFalse);
|
||||
// Gmail · Outlook 이 본문 없이 보낸 꼴 — 모르는 태그(o:p)는 지우고 안의 nbsp 만 남는다
|
||||
expect(visibleHtml('<div dir="ltr"><br></div><div><br></div>'), isFalse);
|
||||
expect(
|
||||
visibleHtml('<p class="MsoNormal"><o:p> </o:p></p>'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<div style="font-family:Pretendard;color:#1F2733"><p></p></div>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// 제로폭 빈칸 · BOM · WORD JOINER · 한글 채움 문자 · 점자 빈칸
|
||||
expect(
|
||||
visibleHtml('<p>​⁠ㅤ⠀</p>'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('내용째 버리는 것 · 주석 · 주소를 지운 그림은 보이지 않는다', () {
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<style>p{color:red}</style><!-- 주석 --><script>alert(1)</script>'
|
||||
'<title>제목</title>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<p><svg><text>그림 글</text></svg><template><b>숨은 글</b></template></p>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// 막은 바깥 그림 · 외부 이미지를 보이지 않을 때의 중계 주소 — 주소를 지워 문서가 숨긴다(alt 도 보이지 않는다)
|
||||
expect(
|
||||
visibleHtml('<img src="https://t.example/p.png" width="600">'),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleHtml('<img src="/api/mail-images/t1" alt="배너">'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'꾸밈(style · bgcolor · border)만으로 그린 상자 · 선과 빈 인용의 막대는 보이는 것으로 치지 않는다',
|
||||
() {
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<table border="1" bgcolor="#EEEEEE"><tr>'
|
||||
'<td style="height:20px;background:#0E7C86;border:1px solid #000000"></td>'
|
||||
'</tr></table>',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleHtml('<blockquote><br></blockquote>'), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('글 · 그림 · 가로줄 · 목록 표시 · 펼침 · 따옴표가 하나라도 있으면 보인다', () {
|
||||
expect(visibleHtml('<p>안녕하세요</p>'), isTrue);
|
||||
expect(visibleHtml('<o:p>모르는 태그 안의 글</o:p>'), isTrue);
|
||||
expect(visibleHtml('<p> </p><p>.</p>'), isTrue);
|
||||
expect(
|
||||
visibleHtml(
|
||||
'<p><br></p><img src="data:image/png;base64,iVBORw0KGgo=">',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
// 외부 이미지를 보일 때의 중계 그림 — 받기 전에도 보이는 것으로 친다
|
||||
expect(
|
||||
visibleHtml('<img src="/api/mail-images/t1">', imagesShown: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(visibleHtml('<hr>'), isTrue);
|
||||
expect(visibleHtml('<ul><li></li></ul>'), isTrue);
|
||||
expect(visibleHtml('<details></details>'), isTrue);
|
||||
expect(visibleHtml('<q></q>'), isTrue);
|
||||
});
|
||||
|
||||
test('글자 본문 — 없거나 공백 · 보이지 않는 글자뿐이면 비었다', () {
|
||||
expect(visibleText(null), isFalse);
|
||||
expect(visibleText(''), isFalse);
|
||||
expect(visibleText(' \n\t\n'), isFalse);
|
||||
expect(
|
||||
visibleText(String.fromCharCodes(<int>[0x00A0, 0x200B, 0x0A, 0xFEFF])),
|
||||
isFalse,
|
||||
);
|
||||
expect(visibleText('본문'), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'고른 본문으로 본다 — 공백뿐인 서식 본문 · 한도를 넘은 서식 본문은 글자 본문, 비어 보이는 서식 본문은 그대로',
|
||||
() {
|
||||
expect(visibleHtml(' \n ', text: ''), isFalse);
|
||||
expect(visibleHtml(' \n ', text: '글자 본문'), isTrue);
|
||||
// 서식 본문이 비어 보여도 글자 본문으로 바꾸지 않는다(웹 displayedHtmlBody 와 같은 본문을 보인다)
|
||||
expect(visibleHtml('<div><br></div>', text: '글자 본문'), isFalse);
|
||||
|
||||
const int depth = 20000;
|
||||
final String deep = '${'<div>' * depth}${'</div>' * depth}';
|
||||
final SanitizedMailBody omitted = mailBodyFragment(
|
||||
htmlBody: deep,
|
||||
textBody: '',
|
||||
imagesShown: false,
|
||||
);
|
||||
expect(omitted.htmlOmitted, isTrue);
|
||||
expect(omitted.hasVisibleContent, isFalse);
|
||||
expect(visibleHtml(deep, text: '글자 본문'), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('깊거나 큰 본문도 되풀이 없이 정화하며 판정한다', () {
|
||||
const int depth = 1500;
|
||||
expect(visibleHtml('${'<div>' * depth}<br>${'</div>' * depth}'), isFalse);
|
||||
expect(visibleHtml('${'<div>' * depth}끝${'</div>' * depth}'), isTrue);
|
||||
// 맨 끝에만 글이 있는 본문
|
||||
expect(visibleHtml('${'<p> </p>' * 20000}<p>끝</p>'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('본문 조각 · 문서', () {
|
||||
test('HTML 이 없거나 공백뿐이면 글자 본문을 이스케이프해 보인다', () {
|
||||
final SanitizedMailBody text = mailBodyFragment(
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:arasystem_app/features/mail/presentation/providers/message_reade
|
||||
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/features/mail/presentation/widgets/reader_bars.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -246,6 +247,232 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('빈 본문 (첨부만 있는 메일)', () {
|
||||
const int fileCount = 12;
|
||||
|
||||
/// 첨부 [fileCount] 개 — 본문 칸은 [body] 로 덮는다(없으면 비었다)
|
||||
Map<String, Object?> manyFiles([
|
||||
Map<String, Object?> body = const <String, Object?>{},
|
||||
]) => <String, Object?>{
|
||||
'htmlBody': null,
|
||||
'textBody': '',
|
||||
...body,
|
||||
'attachments': <Object?>[
|
||||
for (int n = 1; n <= fileCount; n++)
|
||||
attachmentJson('a$n', name: 'file-$n.pdf', size: 2048),
|
||||
],
|
||||
};
|
||||
|
||||
/// 머리(제목 · 안내 줄 · 첨부)의 스크롤 칸
|
||||
Finder head() => find.descendant(
|
||||
of: find.byType(MessageReaderPage),
|
||||
matching: find.byType(SingleChildScrollView),
|
||||
);
|
||||
|
||||
Rect actionBar(WidgetTester tester) =>
|
||||
tester.getRect(find.byType(ReaderActionBar));
|
||||
|
||||
testWidgets('보이는 것이 없으면 본문 칸 없이 머리가 화면 전체를 한 스크롤로 — 마지막 첨부까지 닿는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Gmail 이 본문 없이 보낸 서식 본문 — 빈 줄뿐이다
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
detail: manyFiles(<String, Object?>{
|
||||
'htmlBody': '<div dir="ltr"><br></div>',
|
||||
}),
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(h.webView.controllers, isEmpty);
|
||||
expect(
|
||||
find.text(MailReaderTexts.attachmentsTitle(fileCount)),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 머리가 아래 막대 위까지 다 쓴다(본문이 있으면 절반까지)
|
||||
final Rect scroll = tester.getRect(head());
|
||||
expect(scroll.bottom, moreOrLessEquals(actionBar(tester).top));
|
||||
|
||||
// 마지막 첨부는 처음에는 화면 밖 — 머리를 스크롤해 닿는다
|
||||
final Finder last = find.text('file-$fileCount.pdf');
|
||||
expect(last.hitTestable(), findsNothing);
|
||||
await tester.drag(head(), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final Rect row = tester.getRect(last);
|
||||
expect(row.top, greaterThanOrEqualTo(scroll.top));
|
||||
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
|
||||
// 본문 칸이 있었다면 머리가 닿지 못하는 아래 절반이다
|
||||
expect(
|
||||
row.center.dy,
|
||||
greaterThan(scroll.top + scroll.height * kReaderHeadMaxFraction),
|
||||
);
|
||||
// 닿은 첨부를 받아 연다
|
||||
h.mail.attachmentFiles['a$fileCount'] = () =>
|
||||
FakeResponse.bytes(<int>[1, 2], contentType: 'application/pdf');
|
||||
await tapTooltip(tester, MailReaderTexts.download('file-$fileCount.pdf'));
|
||||
expect(h.opener.opened.single.path, endsWith('/file-$fileCount.pdf'));
|
||||
});
|
||||
|
||||
testWidgets('본문이 있으면 지금처럼 — 머리는 남은 자리의 절반까지(그 안에서 스크롤), 나머지는 본문 칸', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
detail: manyFiles(<String, Object?>{'textBody': '본문'}),
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
final Rect scroll = tester.getRect(head());
|
||||
final Rect body = tester.getRect(find.byType(MailBodyView));
|
||||
final Rect bar = actionBar(tester);
|
||||
expect(body.top, moreOrLessEquals(scroll.bottom));
|
||||
expect(body.bottom, moreOrLessEquals(bar.top));
|
||||
expect(
|
||||
scroll.height,
|
||||
moreOrLessEquals((bar.top - scroll.top) * kReaderHeadMaxFraction),
|
||||
);
|
||||
|
||||
// 마지막 첨부도 머리 안에서 스크롤해 닿는다 — 위 절반 안이다
|
||||
await tester.drag(head(), const Offset(0, -2000));
|
||||
await tester.pumpAndSettle();
|
||||
final Rect row = tester.getRect(find.text('file-$fileCount.pdf'));
|
||||
expect(row.bottom, lessThanOrEqualTo(scroll.bottom));
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('본문을 만드는 동안은 지금처럼 본문 자리에 진행 줄 — 다 만들어 비었으면 본문 칸이 없어진다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put('m1', isRead: true, detail: manyFiles());
|
||||
final Completer<SanitizedMailBody> building =
|
||||
Completer<SanitizedMailBody>();
|
||||
h.overrides.add(
|
||||
mailBodyBuilderProvider.overrideWithValue(
|
||||
({
|
||||
required String? htmlBody,
|
||||
required String? textBody,
|
||||
required bool imagesShown,
|
||||
required List<SearchRange> textHighlights,
|
||||
}) => building.future,
|
||||
),
|
||||
);
|
||||
await h.pump(tester);
|
||||
unawaited(h.router.push<void>(MailRoutes.message('m1')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(
|
||||
find.bySemanticsLabel(MailReaderTexts.bodyLoading),
|
||||
findsOneWidget,
|
||||
);
|
||||
final Rect loading = tester.getRect(head());
|
||||
expect(
|
||||
loading.height,
|
||||
moreOrLessEquals(
|
||||
(actionBar(tester).top - loading.top) * kReaderHeadMaxFraction,
|
||||
),
|
||||
);
|
||||
|
||||
building.complete(
|
||||
mailBodyFragment(htmlBody: null, textBody: '', imagesShown: false),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.bySemanticsLabel(MailReaderTexts.bodyLoading), findsNothing);
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(
|
||||
tester.getRect(head()).bottom,
|
||||
moreOrLessEquals(actionBar(tester).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('빈 본문에서 [이미지 보기] — 그림이 생기면 본문 칸이 생기고 펼친 받는 사람은 그대로 남는다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// 서버가 막은 바깥 그림을 지워 공백만 남은 서식 본문 — 글자 본문(빈 글)을 보인다
|
||||
put(
|
||||
'm1',
|
||||
isRead: true,
|
||||
to: <Map<String, Object?>>[addressJson('김아라', me)],
|
||||
detail: <String, Object?>{
|
||||
'htmlBody': '\n',
|
||||
'textBody': '',
|
||||
'hasBlockedImages': true,
|
||||
},
|
||||
);
|
||||
h.mail.shownImageDetails['m1'] = <String, Object?>{
|
||||
'htmlBody': '<img src="/api/mail-images/t1">',
|
||||
'hasBlockedImages': false,
|
||||
'imagesShown': true,
|
||||
};
|
||||
h.mail.mailImages['t1'] = FakeResponse.bytes(
|
||||
kTinyPng,
|
||||
contentType: 'image/png',
|
||||
);
|
||||
await open(tester, 'm1');
|
||||
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
expect(findCopy(MailReaderTexts.imagesBlocked), findsOneWidget);
|
||||
await tester.tap(
|
||||
find.text(MailReaderTexts.recipients(MailReaderTexts.me, 0)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text(MailReaderTexts.showImages));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(MailReaderTexts.showImages));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MailBodyView), findsOneWidget);
|
||||
expect(lastDocument(), contains('data:image/png;base64,'));
|
||||
expect(findCopy(MailReaderTexts.imagesBlocked), findsNothing);
|
||||
// 머리는 같은 자리의 같은 위젯이라 펼친 칸이 남는다
|
||||
expect(find.text(MailReaderTexts.fromLabel), findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(head()).bottom,
|
||||
moreOrLessEquals(tester.getRect(find.byType(MailBodyView)).top),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('아래 막대가 없는 예약 메일 — 머리 끝에 시스템 내비게이션 막대 몫을 더해 마지막 첨부가 그 위에 온다', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
put(
|
||||
's1',
|
||||
mailboxId: kSentId,
|
||||
isRead: true,
|
||||
pendingSend: pendingJson(
|
||||
kTestNow.add(const Duration(days: 1)),
|
||||
kind: 'scheduled',
|
||||
),
|
||||
detail: manyFiles(),
|
||||
);
|
||||
// 3버튼 내비게이션 막대(48) — 물리 픽셀(시험대는 3배)
|
||||
tester.view.padding = const FakeViewPadding(bottom: 48 * 3);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
await open(tester, 's1', location: MailRoutes.mailbox(kSentId));
|
||||
|
||||
expect(find.byType(ReaderActionBar), findsNothing);
|
||||
expect(find.byType(MailBodyView), findsNothing);
|
||||
// 머리가 화면 아래 끝까지 닿는다 — 그 밑의 48 은 내비게이션 막대 자리
|
||||
expect(tester.getRect(head()).bottom, kPhoneSize.height);
|
||||
await tester.drag(head(), const Offset(0, -3000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 마지막 첨부 줄 전체가 내비게이션 막대 위에 온다
|
||||
final Rect row = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('attachment-a$fileCount')),
|
||||
);
|
||||
expect(row.bottom, lessThanOrEqualTo(kPhoneSize.height - 48));
|
||||
});
|
||||
});
|
||||
|
||||
group('읽음 처리', () {
|
||||
testWidgets('열면 안 읽은 메일을 읽음으로 — 메일함 수를 먼저 줄이고 목록 줄은 바로 읽음', (
|
||||
WidgetTester tester,
|
||||
|
||||
Reference in New Issue
Block a user