feat(flutter): 메일 검색 화면 1차 — 찾기 · 결과 강조 · 이어 찾기 · 안내를 더한다

목록 위 막대의 [찾기]가 탭 안 검색 화면(/mail/search)을 연다. 웹 14단계 검색과
같은 한도(UTF-16 200자 · 낱말 8개)를 먼저 보고, 찾을 곳은 시작한 메일함 | 전체
메일함이다. 결과 줄은 목록 줄 그대로 맞은 말을 노랑으로 칠하고(회색 미리 보기
안은 글자를 진하게), 결과에서 연 읽기 화면도 제목 · 첨부 이름 · 본문을 칠한다.
이어 찾기는 한 차례 5번 · 20통이고, 모자라면 [더 찾기]로 멈춘다. 결과는 새 메일
확인으로 다시 찾지 않고, 읽기 화면에서 한 일과 붙잡힌 메일의 풀림만 반영한다.
낱말 나누기 · 한 글자 판정은 웹 · 서버와 짝이고, 시험이 백엔드 표본 표를 그대로
읽는다. 시험 750 → 870.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 12:19:45 +09:00
parent 88ec6433b9
commit 37f078e65c
42 changed files with 6697 additions and 142 deletions
@@ -3,6 +3,7 @@ import 'package:arasystem_app/core/people/people_texts.dart';
import 'package:arasystem_app/core/people/person_address.dart';
import 'package:arasystem_app/core/people/recipient_match.dart';
import 'package:arasystem_app/core/people/recipient_suggestion.dart';
import 'package:arasystem_app/core/utils/js_case.dart';
import 'package:flutter_test/flutter_test.dart';
import 'recipient_match_samples.dart';
@@ -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/mail_search_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';
@@ -467,6 +468,7 @@ void main() {
testWidgets('알림은 10초 뒤(단추가 없으면 5초 뒤) 저절로 닫힌다', (WidgetTester tester) async {
h.mail.seed(kInboxId, 2);
h.mail.messages[kInboxId]!.add(_heldMessage(h.clock.now));
await h.pump(tester);
await longPress(tester, '제목 m1');
await tester.tap(find.byTooltip(MailBulkTexts.delete));
@@ -481,11 +483,12 @@ void main() {
await elapse(tester, const Duration(seconds: 1));
expect(find.byType(MailToastCard), findsNothing);
await tester.tap(find.byTooltip(MailListTexts.search));
// 단추 없는 알림 — 고를 수 없는 메일의 까닭
await tester.tap(avatarOf(_heldSubject));
await tester.pumpAndSettle();
expect(noticeText(MailListTexts.searchNotReady), findsOneWidget);
expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget);
await elapse(tester, kMailToastDuration - const Duration(seconds: 1));
expect(noticeText(MailListTexts.searchNotReady), findsOneWidget);
expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget);
await elapse(tester, const Duration(seconds: 1));
expect(find.byType(SnackBar), findsNothing);
});
@@ -494,16 +497,17 @@ void main() {
WidgetTester tester,
) async {
h.mail.seed(kInboxId, 2);
h.mail.messages[kInboxId]!.add(_heldMessage(h.clock.now));
await h.pump(tester);
await longPress(tester, '제목 m1');
await tester.tap(find.byTooltip(MailBulkTexts.delete));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(MailListTexts.search));
await tester.tap(avatarOf(_heldSubject));
await tester.pumpAndSettle();
expect(toastText('메일 1통을 휴지통으로 옮겼습니다.'), findsOneWidget);
expect(noticeText(MailListTexts.searchNotReady), findsOneWidget);
expect(noticeText(MailListTexts.heldNotSelectable), findsOneWidget);
final Rect snack = tester.getRect(find.byType(SnackBar));
final Rect compose = tester.getRect(
find.widgetWithText(FloatingActionButton, MailListTexts.compose),
@@ -841,7 +845,7 @@ void main() {
expect(find.text('거래처'), findsOneWidget);
});
testWidgets('[찾기]는 아직 준비 중이라 알리고, [메일 쓰기]는 쓰기 화면을 연다', (
testWidgets('[찾기]는 검색 화면을 위에 쌓고(탭 막대는 그대로), [메일 쓰기]는 쓰기 화면을 연다', (
WidgetTester tester,
) async {
h.mail.seed(kInboxId, 1);
@@ -849,7 +853,11 @@ void main() {
await tester.tap(find.byTooltip(MailListTexts.search));
await tester.pumpAndSettle();
expect(noticeText(MailListTexts.searchNotReady), findsOneWidget);
expect(find.byType(MailSearchPage), findsOneWidget);
expect(find.byType(NavigationBar), findsOneWidget);
await tester.tap(find.byTooltip(MailSearchTexts.close));
await tester.pumpAndSettle();
expect(find.byType(MailSearchPage), findsNothing);
await tester.tap(find.text(MailListTexts.compose));
await tester.pumpAndSettle();
@@ -1098,3 +1106,15 @@ void main() {
expect(h.container.exists(mailStateWatcherProvider), isFalse);
});
}
/// 고를 수 없는 메일(내일 보낼 예약) — 사진을 누르면 단추 없는 알림이 뜬다
const String _heldSubject = '예약한 메일';
Map<String, Object?> _heldMessage(DateTime now) => messageJson(
'held',
mailboxId: kInboxId,
subject: _heldSubject,
isRead: true,
receivedAt: now.subtract(const Duration(days: 1)),
pendingSend: pendingJson(now.add(const Duration(days: 1)), kind: 'scheduled'),
);
@@ -2,9 +2,11 @@ import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/files/downloaded_files.dart';
import 'package:arasystem_app/features/mail/data/models/mail_bulk_results.dart';
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
@@ -498,4 +500,147 @@ void main() {
expect(apiErrorOf(error).code, 'FILE_007');
});
});
group('검색 (계약 2절 "검색 — 14단계")', () {
test(
'검색어 · 메일함 · 쪽 크기 · 커서를 그대로 보낸다 — 전체 메일함이면 mailboxId 를 뺀다, silent 면 알리지 않는다',
() async {
h.server.on(
'GET',
MailApiPaths.messages,
(_) => FakeResponse.ok(<String, Object?>{
'items': <Object?>[
<String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'searchHighlight': null,
},
],
'pageInfo': <String, Object?>{
'hasNext': true,
'nextCursor': 'opaque==',
'total': null,
},
}),
);
final DateTime serverTime = DateTime.utc(2026, 11, 7, 2);
h.server.serverTime = serverTime;
final MailPage<MailSearchItem> page = await repository.searchMessages(
q: '계약',
mailboxId: kInboxId,
cursor: 'abc',
silent: true,
);
expect(page.items.single.highlightSupported, isTrue);
expect(page.pageInfo.total, isNull);
// 결과의 붙잡힌 메일이 풀릴 때를 서버 시계로 잰다
expect(page.serverTime, serverTime);
expect(h.server.requests.last.queryParameters, <String, Object?>{
'q': '계약',
'mailboxId': kInboxId,
'limit': kMailPageSize,
'cursor': 'abc',
});
expect(h.server.requests.last.extra[kSilentExtraKey], isTrue);
await repository.searchMessages(q: '계약');
expect(h.server.requests.last.queryParameters, <String, Object?>{
'q': '계약',
'limit': kMailPageSize,
});
expect(h.server.requests.last.extra[kSilentExtraKey], isNot(isTrue));
},
);
test('다음 쪽의 실패는 알린다 · 끊은 요청은 알리지 않는다', () async {
final Completer<void> gate = Completer<void>();
h.server.on('GET', MailApiPaths.messages, (_) async {
await gate.future;
return FakeResponse.fail(503, 'MAIL_001');
});
final CancelToken cancel = CancelToken();
final Future<Object> cancelled = errorOf(
repository.searchMessages(q: '계약', cursor: 'c1', cancelToken: cancel),
);
await pumpEventsUntil(() => h.server.requests.isNotEmpty);
cancel.cancel();
final Object error = await cancelled;
expect((error as DioException).type, DioExceptionType.cancel);
gate.complete();
expect(
apiErrorOf(
await errorOf(repository.searchMessages(q: '계약', cursor: 'c1')),
).code,
'MAIL_001',
);
expect(h.notifier.shownCodes, <String>['MAIL_001']);
});
test(
'준비 상태 — building · ready, 모르는 값은 null, 받지 못하면(404) 알리지 않고 던진다',
() async {
Object? status = 'building';
h.server.on(
'GET',
MailApiPaths.searchIndex,
(_) => status == null
? FakeResponse.fail(404, 'RES_001')
: FakeResponse.ok(<String, Object?>{
'status': status,
'done': 3,
'total': 10,
}),
);
expect(
await repository.searchIndexStatus(),
SearchIndexStatus.building,
);
status = 'ready';
expect(await repository.searchIndexStatus(), SearchIndexStatus.ready);
status = 'paused';
expect(await repository.searchIndexStatus(), isNull);
status = null;
expect(
apiErrorOf(await errorOf(repository.searchIndexStatus())).code,
'RES_001',
);
expect(h.notifier.shownCodes, isEmpty);
},
);
test('메일 한 통 — 강조 쿼리는 빈 칸을 빼고 싣는다(외부 이미지와 함께도)', () async {
h.server.onPattern(
'GET',
RegExp(r'^/messages/m1$'),
(_) => FakeResponse.ok(<String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'searchHighlight': <String, Object?>{
'subject': <Object?>[
<Object?>[0, 2],
],
'attachments': <String, Object?>{},
'textBody': <Object?>[],
},
}),
);
final ({MessageDetail detail, DateTime? serverTime}) result =
await repository.getMessage(
'm1',
showImages: true,
highlight: const MessageHighlightQuery(q: '계약'),
);
expect(h.server.requests.last.queryParameters, <String, Object?>{
kMailImagesQuery: kMailImagesShow,
'q': '계약',
});
expect(result.detail.searchHighlight?.subject, <({int start, int end})>[
(start: 0, end: 2),
]);
await repository.getMessage('m1');
expect(h.server.requests.last.queryParameters, isEmpty);
});
});
}
@@ -0,0 +1,729 @@
import 'dart:async';
import 'package:arasystem_app/core/api/request_flags.dart';
import 'package:arasystem_app/core/utils/accessible_navigation.dart';
import 'package:arasystem_app/core/utils/app_foreground.dart';
import 'package:arasystem_app/core/utils/provider_retry.dart';
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
import 'package:arasystem_app/features/mail/data/models/message_summary.dart';
import 'package:arasystem_app/features/mail/data/repositories/mail_repository.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_providers.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_search_controller.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_state_watcher.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_toast_provider.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/mail_fakes.dart';
import '../../support/mail_harness.dart';
import '../../support/provider_harness.dart';
class _Foreground extends AppForeground {
@override
bool build() => true;
}
class _NoScreenReader extends AccessibleNavigation {
@override
bool build() => false;
}
/// 메일을 보는 직원 — 시험이 바꾼다(세션이 바뀜)
class _Account extends Notifier<String?> {
@override
String? build() => kMailTestAccount;
void set(String? value) => state = value;
}
final NotifierProvider<_Account, String?> _accountProvider =
NotifierProvider<_Account, String?>(_Account.new);
/// 검색 응답 한 쪽
FakeResponse _page(
List<Map<String, Object?>> items, {
String? next,
int? total,
}) => FakeResponse.ok(<String, Object?>{
'items': items,
'pageInfo': <String, Object?>{
'hasNext': next != null,
'nextCursor': next,
'total': total,
},
});
/// 받은편지함의 메일 [n] 통 — id 는 `<prefix>1` 부터(앞이 최신)
List<Map<String, Object?>> _mails(
int n, {
String prefix = 'r',
int from = 1,
String mailboxId = kInboxId,
}) => <Map<String, Object?>>[
for (int i = from; i < from + n; i++)
messageJson(
'$prefix$i',
mailboxId: mailboxId,
subject: '계약 $prefix$i',
receivedAt: kTestNow.subtract(Duration(minutes: i)),
isRead: true,
),
];
/// 메일 검색 컨트롤러 — 계약 2절 "검색" · "검색 — 14단계"(이어 찾기 · 준비 상태 · 결과 반영)
void main() {
late ProviderHarness h;
late FakeMailBackend mail;
late ProviderContainer container;
int instance = 0;
setUp(() async {
h = ProviderHarness();
mail = FakeMailBackend(h.server)..install();
await h.prepare();
await h.store.save(tokensFor(1, now: h.clock.now));
container = ProviderContainer(
overrides: <Override>[
...h.overrides,
mailAccountProvider.overrideWith(
(Ref ref) => ref.watch(_accountProvider),
),
appForegroundProvider.overrideWith(_Foreground.new),
accessibleNavigationProvider.overrideWith(_NoScreenReader.new),
mailNoticeSinkProvider.overrideWithValue((String _) {}),
],
retry: noAutomaticRetry,
);
addTearDown(container.dispose);
});
/// 검색 화면 하나를 연다(화면이 보는 것처럼 구독한다) — 메일함 목록을 받을 때까지 기다린다
Future<MailSearchArgs> open({String? origin}) async {
final MailSearchArgs args = MailSearchArgs(
instance: ++instance,
originMailboxId: origin,
);
container
..listen(mailboxesProvider, (MailboxesState? _, MailboxesState _) {})
..listen(
mailSearchProvider(args),
(MailSearchState? _, MailSearchState _) {},
);
await pumpEventsUntil(
() => container.read(mailboxesProvider).status == MailboxesStatus.ready,
reason: '메일함 목록',
);
return args;
}
MailSearchState stateOf(MailSearchArgs args) =>
container.read(mailSearchProvider(args));
MailSearchController controllerOf(MailSearchArgs args) =>
container.read(mailSearchProvider(args).notifier);
/// 검색이 멈출 때까지(찾는 중 · 이어 찾는 중이 끝날 때까지)
Future<void> settle(MailSearchArgs args) => pumpEventsUntil(() {
final MailSearchState s = stateOf(args);
return s.phase != MailSearchPhase.searching &&
s.fill == MailSearchFill.none &&
!s.refreshing;
}, reason: '검색');
List<String> ids(MailSearchArgs args) => <String>[
for (final MailSearchItem item in stateOf(args).items) item.id,
];
List<RequestOptions> searchRequests() => <RequestOptions>[
for (final RequestOptions r in mail.listRequests)
if (r.queryParameters.containsKey('q')) r,
];
MessageSummary summaryOf(Map<String, Object?> json) =>
MessageSummary.tryParse(json)!;
group('찾기', () {
test('빈 칸 · 공백뿐이면 묻지 않고 거짓 — 찾기 전 그대로', () async {
final MailSearchArgs args = await open();
expect(controllerOf(args).submit(''), isFalse);
expect(controllerOf(args).submit(' \u3000\n'), isFalse);
await pumpEventsUntil(() => true);
expect(searchRequests(), isEmpty);
expect(stateOf(args).phase, MailSearchPhase.idle);
});
test(
'받은편지함 주소에서 열면 기본 메일함에서 찾는다 — q · mailboxId · limit, 첫 쪽은 알리지 않는다',
() async {
mail.messages[kInboxId] = _mails(3);
final MailSearchArgs args = await open();
expect(controllerOf(args).submit(' 계약 '), isTrue);
expect(stateOf(args).phase, MailSearchPhase.searching);
expect(stateOf(args).query, '계약');
await settle(args);
final RequestOptions request = searchRequests().single;
expect(request.queryParameters, <String, Object?>{
'q': '계약',
'mailboxId': kInboxId,
'limit': kMailPageSize,
});
expect(request.extra[kSilentExtraKey], isTrue);
expect(stateOf(args).phase, MailSearchPhase.ready);
expect(ids(args), <String>['r1', 'r2', 'r3']);
expect(stateOf(args).total, 3);
expect(stateOf(args).searchedMailboxId, kInboxId);
},
);
test('전체 메일함은 mailboxId 를 빼고 묻는다 — 시작한 메일함은 그 메일함', () async {
final MailSearchArgs args = await open(origin: kClientsId);
controllerOf(args).submit('계약');
await settle(args);
expect(searchRequests().last.queryParameters['mailboxId'], kClientsId);
controllerOf(args).setScope(all: true, fieldText: '계약');
await settle(args);
expect(
searchRequests().last.queryParameters.containsKey('mailboxId'),
isFalse,
);
expect(stateOf(args).searchedAll, isTrue);
});
test('낱말이 8개를 넘으면 묻지 않는다 — 찾을 말이 너무 많음', () async {
final MailSearchArgs args = await open();
expect(controllerOf(args).submit('11월 정기 점검 일정 안내 메일 다시 보내 주세요'), isTrue);
await pumpEventsUntil(() => true);
expect(stateOf(args).phase, MailSearchPhase.tooManyWords);
expect(stateOf(args).query, isNull);
expect(searchRequests(), isEmpty);
});
test('첫 쪽을 받지 못하면 받지 못함(알리지 않는다 — 화면이 그린다) · [다시 시도]는 처음부터', () async {
mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001');
final MailSearchArgs args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(stateOf(args).phase, MailSearchPhase.failed);
expect(stateOf(args).error?.code, 'MAIL_001');
expect(h.notifier.shownCodes, isEmpty);
mail.searchResponder = null;
mail.messages[kInboxId] = _mails(1);
await controllerOf(args).retry();
await settle(args);
expect(stateOf(args).phase, MailSearchPhase.ready);
expect(ids(args), <String>['r1']);
});
test('찾던 메일함이 지워졌으면 RES_001 — [전체 메일함에서 찾기]는 보이는 검색어를 전체에서', () async {
final MailSearchArgs args = await open(origin: 'mb-gone');
controllerOf(args).submit('계약');
await settle(args);
expect(stateOf(args).error?.code, 'RES_001');
controllerOf(args).searchAll();
await settle(args);
expect(stateOf(args).allMailboxes, isTrue);
expect(searchRequests().last.queryParameters['q'], '계약');
expect(
searchRequests().last.queryParameters.containsKey('mailboxId'),
isFalse,
);
});
test('같은 검색어 · 같은 곳이면 보이는 채로 다시 찾는다(결과를 두고) — 실패하면 그대로 두고 알린다', () async {
mail.messages[kInboxId] = _mails(2);
final MailSearchArgs args = await open();
controllerOf(args).submit('계약');
await settle(args);
mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001');
controllerOf(args).submit('계약');
expect(stateOf(args).refreshing, isTrue);
expect(ids(args), <String>['r1', 'r2']);
await settle(args);
expect(stateOf(args).phase, MailSearchPhase.ready);
expect(ids(args), <String>['r1', 'r2']);
expect(h.notifier.shownCodes, <String>['MAIL_001']);
expect(searchRequests().last.extra[kSilentExtraKey], isNot(isTrue));
});
test('[✕]는 결과를 비우고 찾기 전으로 — 찾을 곳은 그대로', () async {
mail.messages[kInboxId] = _mails(1);
final MailSearchArgs args = await open();
controllerOf(args).setScope(all: true, fieldText: '');
controllerOf(args).submit('계약');
await settle(args);
controllerOf(args).clear();
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).items, isEmpty);
expect(stateOf(args).allMailboxes, isTrue);
});
test('찾을 곳 바꾸기 — 칸의 글로, 칸이 비었으면 보이는 결과의 검색어로, 둘 다 없으면 곳만', () async {
final MailSearchArgs args = await open();
controllerOf(args).setScope(all: true, fieldText: '');
await pumpEventsUntil(() => true);
expect(searchRequests(), isEmpty);
expect(stateOf(args).allMailboxes, isTrue);
controllerOf(args).setScope(all: false, fieldText: '견적');
await settle(args);
expect(searchRequests().last.queryParameters['q'], '견적');
controllerOf(args).setScope(all: true, fieldText: ' ');
await settle(args);
expect(searchRequests().last.queryParameters['q'], '견적');
expect(stateOf(args).searchedAll, isTrue);
});
});
group('이어 찾기 (한 차례 5번 · 한 쪽 20통)', () {
test(
'하나도 못 찾고 hasNext 면 커서로 5번까지 — 그래도 없으면 [더 찾기](아직 못 찾음), 누르면 5번 더',
() async {
int calls = 0;
mail.searchResponder = (Map<String, Object?> query) {
calls++;
if (calls <= 7) {
return _page(const <Map<String, Object?>>[], next: 'c$calls');
}
return _page(_mails(2), total: null);
};
final MailSearchArgs args = await open();
controllerOf(args).submit('정산서');
await settle(args);
expect(searchRequests(), hasLength(kMailSearchFillMaxRequests));
expect(searchRequests()[1].queryParameters['cursor'], 'c1');
expect(
<Object?>[
for (final RequestOptions r in searchRequests().skip(1))
r.extra[kSilentExtraKey],
],
everyElement(isTrue),
reason: '하나도 찾기 전의 이어 찾기는 알리지 않는다',
);
expect(stateOf(args).phase, MailSearchPhase.ready);
expect(stateOf(args).items, isEmpty);
expect(stateOf(args).partial, isTrue);
final Future<void> more = controllerOf(args).searchMore();
expect(stateOf(args).fill, MailSearchFill.more);
await more;
expect(searchRequests(), hasLength(8));
expect(ids(args), <String>['r1', 'r2']);
expect(stateOf(args).partial, isFalse);
expect(stateOf(args).hasMore, isFalse);
},
);
test(
'한 쪽이 차면 멈추고, 끝에 닿으면 다음 차례 — 다음 쪽의 total 이 null 이면 첫 쪽 수를 남긴다',
() async {
mail.searchResponder = (Map<String, Object?> query) {
final Object? cursor = query['cursor'];
if (cursor == null) return _page(_mails(12), next: 'c1', total: 30);
if (cursor == 'c1') {
return _page(_mails(10, from: 13), next: 'c2');
}
return _page(_mails(8, from: 23));
};
final MailSearchArgs args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(searchRequests(), hasLength(2));
expect(stateOf(args).items, hasLength(22));
expect(stateOf(args).total, 30);
expect(stateOf(args).partial, isFalse);
expect(stateOf(args).hasMore, isTrue);
expect(searchRequests()[1].extra[kSilentExtraKey], isNot(isTrue));
await controllerOf(args).loadMore();
expect(searchRequests(), hasLength(3));
expect(stateOf(args).items, hasLength(30));
expect(stateOf(args).hasMore, isFalse);
expect(stateOf(args).total, 30);
},
);
test('한 차례가 한 쪽을 못 채우고 끝났는데 더 있으면 저절로 잇지 않는다 — 목록 끝의 [더 찾기]', () async {
int calls = 0;
mail.searchResponder = (Map<String, Object?> query) {
calls++;
return _page(
calls == 1 ? _mails(3) : const <Map<String, Object?>>[],
next: 'c$calls',
);
};
final MailSearchArgs args = await open();
controllerOf(args).submit('견적');
await settle(args);
expect(searchRequests(), hasLength(kMailSearchFillMaxRequests));
expect(stateOf(args).items, hasLength(3));
expect(stateOf(args).partial, isTrue);
// 끝 표시가 불러도 잇지 않는다 — [더 찾기]로만
await controllerOf(args).loadMore();
expect(searchRequests(), hasLength(kMailSearchFillMaxRequests));
});
test('같은 커서가 새 메일 없이 돌아오면 [다시 시도]로 멈춘다', () async {
mail.searchResponder = (Map<String, Object?> query) =>
query['cursor'] == null
? _page(_mails(3), next: 'same', total: 10)
: _page(const <Map<String, Object?>>[], next: 'same');
final MailSearchArgs args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(searchRequests(), hasLength(2));
expect(stateOf(args).loadMoreFailed, isTrue);
expect(stateOf(args).partial, isFalse);
});
test('이어 찾다 실패하면 받은 데까지 보이고 끝에 [다시 시도] — 인터셉터가 알린다', () async {
int calls = 0;
mail.searchResponder = (Map<String, Object?> query) {
calls++;
if (calls == 1) return _page(_mails(3), next: 'c1', total: 9);
if (calls == 2) return FakeResponse.fail(503, 'MAIL_001');
return _page(_mails(6, from: 4));
};
final MailSearchArgs args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(ids(args), hasLength(3));
expect(stateOf(args).loadMoreFailed, isTrue);
expect(h.notifier.shownCodes, <String>['MAIL_001']);
await controllerOf(args).loadMore();
expect(ids(args), hasLength(9));
expect(stateOf(args).loadMoreFailed, isFalse);
expect(stateOf(args).hasMore, isFalse);
});
test('하나도 찾기 전의 이어 찾기가 실패하면 받지 못함(알리지 않는다)', () async {
int calls = 0;
mail.searchResponder = (_) {
calls++;
return calls == 1
? _page(const <Map<String, Object?>>[], next: 'c1')
: FakeResponse.fail(503, 'MAIL_001');
};
final MailSearchArgs args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(stateOf(args).phase, MailSearchPhase.failed);
expect(h.notifier.shownCodes, isEmpty);
});
});
group('늦은 응답 · 세션', () {
test('새 검색을 하면 앞 검색의 요청을 끊고 그 응답은 버린다 — 끊은 요청은 알리지 않는다', () async {
mail.messages[kInboxId] = <Map<String, Object?>>[
..._mails(2),
..._mails(1, prefix: 'q'),
];
final MailSearchArgs args = await open();
final Completer<void> slow = Completer<void>();
mail.searchGate = slow;
controllerOf(args).submit('r1');
await pumpEventsUntil(() => searchRequests().length == 1);
controllerOf(args).submit('q1');
await settle(args);
expect(stateOf(args).query, 'q1');
expect(ids(args), <String>['q1']);
slow.complete();
await pumpEventsUntil(() => true);
expect(stateOf(args).query, 'q1');
expect(ids(args), <String>['q1']);
expect(h.notifier.shownCodes, isEmpty);
});
test('화면이 닫히면(구독이 끊기면) 도는 요청을 끊는다 — 늦은 응답이 와도 아무 일 없다', () async {
final MailSearchArgs args = MailSearchArgs(instance: ++instance);
final ProviderSubscription<MailSearchState> subscription = container
.listen(
mailSearchProvider(args),
(MailSearchState? _, MailSearchState _) {},
);
container.listen(
mailboxesProvider,
(MailboxesState? _, MailboxesState _) {},
);
await pumpEventsUntil(
() => container.read(mailboxesProvider).status == MailboxesStatus.ready,
);
final Completer<void> slow = Completer<void>();
mail.searchGate = slow;
controllerOf(args).submit('계약');
await pumpEventsUntil(() => searchRequests().length == 1);
subscription.close();
await pumpEventsUntil(() => !container.exists(mailSearchProvider(args)));
slow.complete();
await pumpEventsUntil(() => true);
expect(h.notifier.shownCodes, isEmpty);
});
test('세션이 바뀌면 처음(찾기 전)으로', () async {
mail.messages[kInboxId] = _mails(1);
final MailSearchArgs args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(stateOf(args).phase, MailSearchPhase.ready);
container.read(_accountProvider.notifier).set('other@arasystem.it.kr');
await pumpEventsUntil(() => stateOf(args).phase == MailSearchPhase.idle);
expect(stateOf(args).items, isEmpty);
});
});
group('준비 상태 (GET /search-index)', () {
test(
'찾을 때마다 조용히 묻는다 — building · ready 를 담고, 받지 못하면(404 · 실패) 모름',
() async {
mail.messages[kInboxId] = _mails(1);
mail.searchIndex = FakeResponse.ok(<String, Object?>{
'status': 'building',
'done': 3,
'total': 10,
});
final MailSearchArgs args = await open();
controllerOf(args).submit('계약');
await settle(args);
await pumpEventsUntil(
() => stateOf(args).indexStatus == SearchIndexStatus.building,
);
expect(mail.searchIndexRequests, 1);
expect(
h.server
.received('GET', MailApiPaths.searchIndex)
.single
.extra[kSilentExtraKey],
isTrue,
);
mail.searchIndex = FakeResponse.fail(404, 'RES_001');
controllerOf(args).submit('견적');
await settle(args);
await pumpEventsUntil(() => stateOf(args).indexStatus == null);
expect(h.notifier.shownCodes, isEmpty);
mail.searchIndex = FakeResponse.ok(<String, Object?>{
'status': 'paused',
});
controllerOf(args).submit('보고');
await settle(args);
await pumpEventsUntil(() => mail.searchIndexRequests == 3);
await pumpEventsUntil(() => true);
expect(stateOf(args).indexStatus, isNull);
},
);
});
group('읽기 화면이 바꾼 것 반영 (새 메일 확인으로는 다시 찾지 않는다)', () {
late MailSearchArgs args;
Future<void> searchInbox() async {
mail.messages[kInboxId] = _mails(3);
args = await open();
controllerOf(args).submit('계약');
await settle(args);
}
MailStateWatcher watcher() =>
container.read(mailStateWatcherProvider.notifier);
test('읽음 · 중요는 표시만 덮는다 — 제목 · 미리 보기는 그 응답의 것 그대로', () async {
await searchInbox();
final int before = searchRequests().length;
watcher().markChanged(
updated: <MessageSummary>[
summaryOf(<String, Object?>{
...messageJson(
'r2',
mailboxId: kInboxId,
subject: '바뀐 제목(NFC 아님)',
isFlagged: true,
),
}),
],
);
final MailSearchItem item = stateOf(args).items[1];
expect(item.summary.isFlagged, isTrue);
expect(item.summary.isRead, isFalse);
expect(item.summary.subject, '계약 r2');
expect(searchRequests(), hasLength(before));
});
test(
'한 메일함에서 찾았으면 그 메일함을 떠난 메일을 빼고 · [되돌리기]로 돌아오면 받은 시각 차례의 그 자리에',
() async {
await searchInbox();
final Map<String, Object?> moved = Map<String, Object?>.of(
mail.messages[kInboxId]![1],
)..['mailboxIds'] = <String>[kTrashId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(moved)]);
expect(ids(args), <String>['r1', 'r3']);
expect(stateOf(args).total, 2);
final Map<String, Object?> back = Map<String, Object?>.of(moved)
..['mailboxIds'] = <String>[kInboxId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(back)]);
expect(ids(args), <String>['r1', 'r2', 'r3']);
expect(stateOf(args).total, 3);
},
);
test(
'전체 메일함에서 찾았으면 휴지통 · 스팸메일함만 남은 메일을 빼고, 다른 메일함으로 옮긴 메일은 메일함만 바꾼다',
() async {
mail.messages[kInboxId] = _mails(2);
args = await open();
controllerOf(args).setScope(all: true, fieldText: '계약');
await settle(args);
final Map<String, Object?> toClients = Map<String, Object?>.of(
mail.messages[kInboxId]![0],
)..['mailboxIds'] = <String>[kClientsId];
final Map<String, Object?> toJunk = Map<String, Object?>.of(
mail.messages[kInboxId]![1],
)..['mailboxIds'] = <String>[kJunkId];
watcher().markChanged(
updated: <MessageSummary>[summaryOf(toClients), summaryOf(toJunk)],
);
expect(ids(args), <String>['r1']);
expect(stateOf(args).items.single.summary.mailboxIds, <String>[
kClientsId,
]);
},
);
test('영구 삭제 · 발송 취소(removedIds)는 빼고 기억하지 않는다 · 바뀐 것 없는 소식은 무시한다', () async {
await searchInbox();
watcher().markChanged(removedIds: <String>{'r1'});
expect(ids(args), <String>['r2', 'r3']);
expect(stateOf(args).total, 2);
watcher().markChanged();
expect(ids(args), <String>['r2', 'r3']);
expect(searchRequests(), hasLength(1));
});
test(
'불러온 끝 줄을 뺐다 [되돌리기]해도 그 자리(끝)에 다시 넣고 수도 되돌린다 — 다음 쪽은 그 줄 다음부터라 빠지지도 겹치지도 않는다',
() async {
mail.messages[kInboxId] = _mails(21);
args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(ids(args), hasLength(kMailPageSize));
expect(stateOf(args).hasMore, isTrue);
expect(stateOf(args).total, 21);
watcher().markChanged(
updated: <MessageSummary>[summaryOf(mail.move('r20', kTrashId))],
);
expect(ids(args), hasLength(19));
expect(stateOf(args).total, 20);
watcher().markChanged(
updated: <MessageSummary>[summaryOf(mail.move('r20', kInboxId))],
);
expect(ids(args), hasLength(kMailPageSize));
expect(ids(args).last, 'r20');
expect(stateOf(args).total, 21);
await controllerOf(args).loadMore();
expect(ids(args), <String>[for (int i = 1; i <= 21; i++) 'r$i']);
expect(stateOf(args).hasMore, isFalse);
},
);
test('1통만 찾고 [더 찾기]로 멈춘 결과에서 그 1통을 뺐다 되돌리면 다시 보인다 — [더 찾기]는 그대로', () async {
int calls = 0;
mail.searchResponder = (Map<String, Object?> query) {
calls++;
return _page(
calls == 1 ? _mails(1) : const <Map<String, Object?>>[],
next: 'c$calls',
total: calls == 1 ? 3 : null,
);
};
args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(ids(args), <String>['r1']);
expect(stateOf(args).partial, isTrue);
final Map<String, Object?> moved = Map<String, Object?>.of(
_mails(1).single,
)..['mailboxIds'] = <String>[kTrashId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(moved)]);
expect(ids(args), isEmpty);
expect(stateOf(args).waitsForMore, isTrue);
expect(stateOf(args).total, 2);
final Map<String, Object?> back = Map<String, Object?>.of(moved)
..['mailboxIds'] = <String>[kInboxId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(back)]);
expect(ids(args), <String>['r1']);
expect(stateOf(args).partial, isTrue);
expect(stateOf(args).total, 3);
expect(searchRequests(), hasLength(kMailSearchFillMaxRequests));
});
test('불러온 결과를 모두 뺐는데 서버에 더 있으면 [더 찾기]로 잇는다 — 목록 끝이 없다', () async {
mail.messages[kInboxId] = _mails(21);
args = await open();
controllerOf(args).submit('계약');
await settle(args);
expect(stateOf(args).partial, isFalse);
watcher().markChanged(
removedIds: <String>{for (int i = 1; i <= kMailPageSize; i++) 'r$i'},
);
expect(ids(args), isEmpty);
expect(stateOf(args).waitsForMore, isTrue);
final Future<void> more = controllerOf(args).searchMore();
expect(stateOf(args).fill, MailSearchFill.more);
await more;
expect(ids(args), <String>['r21']);
expect(stateOf(args).hasMore, isFalse);
expect(stateOf(args).waitsForMore, isFalse);
});
test('이 검색에서 뺀 메일이라도 서버가 다음 쪽에 주면 넣는다 — 겹침은 지금 목록의 id 로만 막는다', () async {
mail.searchResponder = (Map<String, Object?> query) =>
query['cursor'] == null
? _page(_mails(20), next: 'c1', total: 21)
// 다른 곳에서 되돌린 r5 · 새 r21 — 이미 있는 r1 은 한 번만
: _page(<Map<String, Object?>>[
..._mails(1),
..._mails(1, from: 5),
..._mails(1, from: 21),
]);
args = await open();
controllerOf(args).submit('계약');
await settle(args);
final Map<String, Object?> moved = Map<String, Object?>.of(_mails(5).last)
..['mailboxIds'] = <String>[kTrashId];
watcher().markChanged(updated: <MessageSummary>[summaryOf(moved)]);
expect(ids(args), hasLength(19));
expect(stateOf(args).total, 20);
await controllerOf(args).loadMore();
expect(ids(args).sublist(19), <String>['r5', 'r21']);
expect(ids(args).where((String id) => id == 'r1'), hasLength(1));
expect(stateOf(args).total, 21);
});
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,306 @@
import 'package:arasystem_app/features/mail/data/models/mail_page.dart';
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
import 'package:arasystem_app/features/mail/data/models/mailbox.dart';
import 'package:arasystem_app/features/mail/domain/search_highlight.dart';
import 'package:arasystem_app/features/mail/presentation/mail_row_view.dart';
import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mail_search_state.dart';
import 'package:arasystem_app/features/mail/presentation/providers/mailboxes_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/fakes.dart';
import '../../support/mail_fakes.dart';
final MailboxesState _mailboxes = MailboxesState(
status: MailboxesStatus.ready,
items: <Mailbox>[
for (final Map<String, Object?> json in standardMailboxes())
Mailbox.tryParse(json)!,
],
);
MailSearchItem _item(
String id, {
required String mailboxId,
String subject = '유지보수 계약 갱신 건',
String preview = '본문 첫 부분',
List<Map<String, Object?>> to = const <Map<String, Object?>>[],
bool isRead = true,
bool hasAttachment = false,
Map<String, Object?>? highlight,
bool supported = true,
}) => MailSearchItem.tryParse(<String, Object?>{
...messageJson(
id,
mailboxId: mailboxId,
subject: subject,
preview: preview,
to: to,
isRead: isRead,
hasAttachment: hasAttachment,
),
if (supported) 'searchHighlight': highlight,
})!;
MailSearchState _ready({
String query = '계약',
bool all = false,
String? mailboxId = kInboxId,
int? total = 4,
SearchIndexStatus? index,
bool refreshing = false,
}) => MailSearchState(
phase: MailSearchPhase.ready,
allMailboxes: all,
query: query,
searchedAll: all,
searchedMailboxId: all ? null : mailboxId,
pageInfo: MailPageInfo(hasNext: false, total: total),
indexStatus: index,
refreshing: refreshing,
);
/// 검색 화면의 표시 — 결과 머리 · 안내 줄 · 결과 줄 · 연 메일에 넘기는 값 (보드 `App1Search`)
void main() {
group('결과 머리(개수 줄)', () {
test('찾은 뒤 "‘계약’ · 4통", 전체 수를 모르면 검색어만, 찾는 동안 · 다시 찾는 동안은 "찾는 중"', () {
expect(searchCountLineOf(_ready()), MailSearchTexts.count('계약', 4));
expect(
searchCountLineOf(_ready(total: null)),
MailSearchTexts.quoted('계약'),
);
expect(
searchCountLineOf(_ready(refreshing: true)),
MailSearchTexts.searching('계약'),
);
expect(
searchCountLineOf(
const MailSearchState(phase: MailSearchPhase.searching, query: '계약'),
),
MailSearchTexts.searching('계약'),
);
});
test('찾기 전 · 낱말이 너무 많음 · 받지 못함에는 머리가 없다', () {
expect(searchCountLineOf(const MailSearchState()), isNull);
expect(
searchCountLineOf(
const MailSearchState(phase: MailSearchPhase.tooManyWords),
),
isNull,
);
expect(
searchCountLineOf(
const MailSearchState(phase: MailSearchPhase.failed, query: '계약'),
),
isNull,
);
});
});
group('안내 줄', () {
test('준비 중이면 준비 중 줄, 준비됐고 한 글자 낱말뿐이면 한 글자 안내 — 함께 뜨지 않는다', () {
expect(
searchNoticeOf(_ready(index: SearchIndexStatus.building)),
MailSearchNotice.indexing,
);
expect(
searchNoticeOf(_ready(query: '김', index: SearchIndexStatus.building)),
MailSearchNotice.indexing,
);
expect(
searchNoticeOf(_ready(query: '김', index: SearchIndexStatus.ready)),
MailSearchNotice.singleChar,
);
expect(searchNoticeOf(_ready(index: SearchIndexStatus.ready)), isNull);
});
test('준비 상태를 모르면(받지 못함 · 14단계 앞 서버) 아무 줄도 없다 · 찾는 동안에도 없다', () {
expect(searchNoticeOf(_ready(query: '김')), isNull);
expect(
searchNoticeOf(
const MailSearchState(
phase: MailSearchPhase.searching,
query: '김',
indexStatus: SearchIndexStatus.ready,
),
),
isNull,
);
});
});
group('찾을 곳의 시작한 메일함 이름', () {
test('받은편지함 주소는 기본 메일함 이름(받기 전이면 "받은편지함"), 그 밖은 그 메일함(모르면 "메일함")', () {
expect(searchOriginNameOf(null, _mailboxes), '받은편지함');
expect(
searchOriginNameOf(null, const MailboxesState()),
MailListTexts.inbox,
);
expect(searchOriginNameOf(kClientsId, _mailboxes), '거래처');
expect(
searchOriginNameOf('mb-gone', _mailboxes),
MailListTexts.mailboxFallback,
);
});
});
group('결과 줄', () {
final DateTime now = kTestNow;
test('한 메일함에서 찾으면 목록 줄 그대로 — 메일함 이름이 없다', () {
final MailRowView view = searchRowViewOf(
_item('m1', mailboxId: kInboxId),
state: _ready(),
mailboxes: _mailboxes,
now: now,
);
expect(view.mailboxName, isNull);
expect(view.who, '이도윤');
});
test('전체 메일함이면 줄마다 그 메일의 메일함 — 보낸편지함 · 임시보관함은 "받는 사람: …"', () {
final MailSearchState all = _ready(all: true);
final MailRowView sent = searchRowViewOf(
_item(
's1',
mailboxId: kSentId,
to: <Map<String, Object?>>[addressJson('이도윤', 'doyun@example.com')],
),
state: all,
mailboxes: _mailboxes,
now: now,
);
expect(sent.mailboxName, '보낸편지함');
expect(sent.who, '${MailListTexts.recipientPrefix}이도윤');
final MailRowView draft = searchRowViewOf(
_item(
'd1',
mailboxId: kDraftsId,
to: <Map<String, Object?>>[addressJson('박지훈', 'jihoon@example.com')],
),
state: all,
mailboxes: _mailboxes,
now: now,
);
expect(draft.mailboxName, '임시보관함');
expect(draft.who, '${MailListTexts.recipientPrefix}박지훈');
// 한 메일함(임시보관함)에서 찾으면 이름만
final MailRowView draftOnly = searchRowViewOf(
_item(
'd1',
mailboxId: kDraftsId,
to: <Map<String, Object?>>[addressJson('박지훈', 'jihoon@example.com')],
),
state: _ready(mailboxId: kDraftsId),
mailboxes: _mailboxes,
now: now,
);
expect(draftOnly.who, '박지훈');
});
test('강조가 있으면 미리 보기를 서버의 맞은 말 둘레 한 줄로 바꾸고 제목 · 미리 보기의 맞은 곳을 싣는다', () {
final MailRowView view = searchRowViewOf(
_item(
'm1',
mailboxId: kInboxId,
preview: '엔진 미리 보기',
highlight: <String, Object?>{
'subject': <Object?>[
<Object?>[5, 7],
],
'preview': <String, Object?>{
'text': '…지난달 계약 기간에',
'ranges': <Object?>[
<Object?>[5, 7],
],
},
},
),
state: _ready(),
mailboxes: _mailboxes,
now: now,
);
expect(view.preview, '…지난달 계약 기간에');
expect(view.subjectHighlights, <SearchRange>[(start: 5, end: 7)]);
expect(view.previewHighlights, <SearchRange>[(start: 5, end: 7)]);
// 강조가 없으면 지금 미리 보기
final MailRowView plain = searchRowViewOf(
_item('m2', mailboxId: kInboxId, preview: '엔진 미리 보기'),
state: _ready(),
mailboxes: _mailboxes,
now: now,
);
expect(plain.preview, '엔진 미리 보기');
expect(plain.subjectHighlights, isEmpty);
});
test('제목이 없으면 "(제목 없음)"에 강조를 싣지 않는다', () {
final MailRowView view = searchRowViewOf(
_item(
'm1',
mailboxId: kInboxId,
subject: '',
highlight: <String, Object?>{
'subject': <Object?>[
<Object?>[0, 2],
],
'preview': <String, Object?>{'text': '계약', 'ranges': <Object?>[]},
},
),
state: _ready(),
mailboxes: _mailboxes,
now: now,
);
expect(view.subject, MailListTexts.noSubject);
expect(view.subjectHighlights, isEmpty);
});
test('낭독 글은 안 읽음 · 보낸 사람 · 메일함 · 시각 · 제목 · 첨부 · 미리 보기 차례 — 강조는 읽지 않는다', () {
final MailRowView view = searchRowViewOf(
_item(
'm1',
mailboxId: kInboxId,
subject: '유지보수 계약',
preview: '계약서 초안',
isRead: false,
hasAttachment: true,
),
state: _ready(all: true),
mailboxes: _mailboxes,
now: now,
);
expect(
view.semanticsLabel,
<String>[
MailListTexts.unreadPrefix,
'이도윤',
'받은편지함',
formatMailListTime(kTestNow, now),
'유지보수 계약',
MailListTexts.attachment,
'계약서 초안',
].join(', '),
);
});
});
group('결과에서 연 메일에 넘기는 값', () {
test('14단계 서버의 결과만 강조 쿼리를 싣는다 — 한 메일함이면 그 메일함도', () {
final MailSearchOpen open = searchOpenOf(
_item('m1', mailboxId: kInboxId, highlight: null),
_ready(),
);
expect(open.highlight, const MessageHighlightQuery(q: '계약'));
expect(open.scopeMailboxId, kInboxId);
final MailSearchOpen old = searchOpenOf(
_item('m1', mailboxId: kInboxId, supported: false),
_ready(all: true),
);
expect(old.highlight, isNull);
expect(old.scopeMailboxId, isNull);
});
});
}
@@ -10,6 +10,7 @@ import 'package:arasystem_app/features/mail/data/repositories/mail_repository.da
import 'package:arasystem_app/features/mail/domain/compose_request.dart';
import 'package:arasystem_app/features/mail/domain/mail_body_notices.dart';
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/search_highlight.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';
@@ -172,6 +173,7 @@ void main() {
required String? htmlBody,
required String? textBody,
required bool imagesShown,
required List<SearchRange> textHighlights,
}) => building.future,
),
);
@@ -1099,7 +1101,12 @@ void main() {
),
findsNothing,
);
expect(h.container.exists(messageReaderProvider('m1')), isFalse);
expect(
h.container.exists(
messageReaderProvider(const MessageReaderArgs('m1')),
),
isFalse,
);
});
testWidgets('로그아웃하면 읽기 화면이 사라지고 늦은 읽음 응답은 알리지 않는다', (
@@ -0,0 +1,376 @@
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
import 'package:arasystem_app/features/mail/data/models/message_detail.dart';
import 'package:arasystem_app/features/mail/domain/mail_html.dart';
import 'package:arasystem_app/features/mail/domain/search_highlight.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/mail_fakes.dart';
/// 조각을 "[맞은 말]" 꼴의 글로 — 시험에서 보기 쉽게
String _marked(String text, List<SearchRange>? ranges) => <String>[
for (final HighlightSegment segment in highlightSegments(text, ranges))
segment.mark ? '[${segment.text}]' : segment.text,
].join();
List<SearchRange> _ranges(List<(int, int)> pairs) => <SearchRange>[
for (final (int start, int end) in pairs) (start: start, end: end),
];
/// 검색 강조 — 조각 나누기(짝: 웹 `search-highlight.ts`) · 응답 읽기 · 글 본문의 `<mark>`
void main() {
group('highlightSegments (강조 조각)', () {
test('맞은 곳이 없으면 글 한 조각 — 빈 글이면 조각이 없다', () {
expect(highlightSegments('계약서 초안', null), <HighlightSegment>[
(text: '계약서 초안', mark: false),
]);
expect(
highlightSegments('계약서 초안', const <SearchRange>[]),
<HighlightSegment>[(text: '계약서 초안', mark: false)],
);
expect(highlightSegments('', null), isEmpty);
});
test('여러 범위를 차례로 자른다 — 앞 · 끝 · 맞닿은 곳도', () {
expect(
_marked('Re: 유지보수 계약 갱신 건', _ranges(<(int, int)>[(12, 14), (4, 8)])),
'Re: [유지보수] 계약 [갱신] 건',
);
expect(_marked('계약서', _ranges(<(int, int)>[(0, 2)])), '[계약]서');
expect(_marked('견적서와 계약', _ranges(<(int, int)>[(5, 7)])), '견적서와 [계약]');
expect(_marked('계약서', _ranges(<(int, int)>[(0, 3)])), '[계약서]');
});
test('겹치거나 맞닿은 범위는 합친다', () {
expect(_marked('aaaa', _ranges(<(int, int)>[(0, 2), (1, 3)])), '[aaa]a');
expect(_marked('계약서', _ranges(<(int, int)>[(0, 2), (2, 3)])), '[계약서]');
});
test('글 밖 · 거꾸로 된 위치는 버리고, 끝이 글을 넘으면 글 끝에서 자른다', () {
expect(_marked('계약', _ranges(<(int, int)>[(5, 7)])), '계약');
expect(
_marked('계약', _ranges(<(int, int)>[(1, 1), (2, 1), (-1, 1), (2, 5)])),
'계약',
);
expect(_marked('계약', _ranges(<(int, int)>[(1, 10)])), '계[약]');
});
test('BMP 밖 글자 뒤의 위치도 UTF-16 그대로 쓴다', () {
expect(
_marked('\u{1F600}계약', _ranges(<(int, int)>[(2, 4)])),
'\u{1F600}[계약]',
);
});
test('위치가 짝 글자(이모지)의 가운데를 가르면 그 글자를 통째로 넣는다 — 반쪽 글자를 만들지 않는다', () {
expect(
_marked('\u{1F600}계약', _ranges(<(int, int)>[(1, 4)])),
'[\u{1F600}계약]',
);
expect(
_marked('\u{1F600}계약', _ranges(<(int, int)>[(0, 1)])),
'[\u{1F600}]계약',
);
expect(
_marked('계\u{1F600}약', _ranges(<(int, int)>[(2, 3)])),
'계[\u{1F600}]약',
);
expect(
_marked('계\u{1F600}약', _ranges(<(int, int)>[(0, 2)])),
'[계\u{1F600}]약',
);
// 가른 자리를 넓힌 뒤 맞닿으면 합친다
expect(
_marked('\u{1F600}\u{1F600}', _ranges(<(int, int)>[(0, 1), (3, 4)])),
'[\u{1F600}\u{1F600}]',
);
final List<HighlightSegment> segments = highlightSegments(
'a\u{1F600}b\u{1F600}c',
_ranges(<(int, int)>[(2, 5)]),
);
expect(
<String>[for (final HighlightSegment s in segments) s.text],
<String>['a', '\u{1F600}b\u{1F600}', 'c'],
);
});
test(
'NFC 로 다시 맞추지 않는다 — 위치는 그 위치를 준 응답의 글에만 쓴다(서버가 강조가 있으면 NFC 로 싣는다)',
() {
// 같은 응답의 글(모은 한글)이면 맞고, 풀어 쓴 다른 글에 쓰면 글 그대로 자를 뿐이다(어긋남 — 그래서 쓰지 않는다)
expect(_marked('계약서', _ranges(<(int, int)>[(0, 2)])), '[계약]서');
const String nfd = '\u1100\u1168\u110B\u1163\u11A8';
expect(highlightSegments(nfd, null).single.text, nfd);
},
);
});
group('parseSearchRanges (응답의 맞은 곳 읽기)', () {
test('[[시작, 끝], …] 을 읽는다 — 소수 부분 없는 수(2.0)는 정수다', () {
expect(
parseSearchRanges(<Object?>[
<Object?>[0, 2],
<Object?>[4.0, 6],
]),
_ranges(<(int, int)>[(0, 2), (4, 6)]),
);
expect(parseSearchRanges(<Object?>[]), isEmpty);
});
test('정수가 아닌 위치는 그 칸만 버린다(웹 Number.isInteger) — 무한 · 아주 큰 수도', () {
expect(
parseSearchRanges(<Object?>[
<Object?>[0.5, 1],
<Object?>[1, 2],
<Object?>[double.infinity, 3],
<Object?>[1e300, 1e300],
]),
_ranges(<(int, int)>[(1, 2)]),
);
});
test('모양이 틀리면 null — 목록이 아님 · 칸이 두 수가 아님', () {
expect(parseSearchRanges(null), isNull);
expect(parseSearchRanges('0,2'), isNull);
expect(
parseSearchRanges(<Object?>[
<Object?>[0],
]),
isNull,
);
expect(
parseSearchRanges(<Object?>[
<Object?>[0, '2'],
]),
isNull,
);
expect(
parseSearchRanges(<Object?>[
<Object?>[0, 1, 2],
]),
isNull,
);
expect(parseSearchRanges(<Object?>[0, 2]), isNull);
});
});
group('검색 결과 항목 (MailSearchItem)', () {
Map<String, Object?> item({Object? highlight = _absent}) =>
<String, Object?>{
...messageJson('m1', mailboxId: kInboxId, subject: '유지보수 계약 갱신 건'),
if (!identical(highlight, _absent)) 'searchHighlight': highlight,
};
test('searchHighlight 칸이 없으면(14단계 앞 서버) 강조 없음 · 강조 쿼리도 싣지 않는다', () {
final MailSearchItem parsed = MailSearchItem.tryParse(item())!;
expect(parsed.highlight, isNull);
expect(parsed.highlightSupported, isFalse);
});
test('칸이 null 이면(엔진 방식) 강조 없음 — 그래도 14단계 서버다', () {
final MailSearchItem parsed = MailSearchItem.tryParse(
item(highlight: null),
)!;
expect(parsed.highlight, isNull);
expect(parsed.highlightSupported, isTrue);
});
test('값이 있으면 제목 · 미리 보기 한 줄 · 그 맞은 곳을 읽는다', () {
final MailSearchItem parsed = MailSearchItem.tryParse(
item(
highlight: <String, Object?>{
'subject': <Object?>[
<Object?>[5, 7],
],
'preview': <String, Object?>{
'text': '…지난달 계약 기간에 맞춰',
'ranges': <Object?>[
<Object?>[5, 7],
],
},
},
),
)!;
expect(
parsed.highlight,
MessageListHighlight(
subject: _ranges(<(int, int)>[(5, 7)]),
previewText: '…지난달 계약 기간에 맞춰',
previewRanges: _ranges(<(int, int)>[(5, 7)]),
),
);
});
test('강조의 모양이 틀리면 강조만 버리고 항목은 살린다 — 목록 칸이 틀리면 항목을 버린다', () {
for (final Object? broken in <Object?>[
'x',
<String, Object?>{'subject': <Object?>[], 'preview': 'x'},
<String, Object?>{
'subject': <Object?>['x'],
'preview': <String, Object?>{'text': 'a', 'ranges': <Object?>[]},
},
<String, Object?>{
'subject': <Object?>[],
'preview': <String, Object?>{'text': 1, 'ranges': <Object?>[]},
},
]) {
final MailSearchItem? parsed = MailSearchItem.tryParse(
item(highlight: broken),
);
expect(parsed, isNotNull);
expect(parsed!.highlight, isNull);
expect(parsed.highlightSupported, isTrue);
}
expect(
MailSearchItem.tryParse(<String, Object?>{...item(), 'isRead': 'x'}),
isNull,
);
});
});
group('읽기 화면의 강조 (MessageDetail.searchHighlight)', () {
Map<String, Object?> detailJson(Object? highlight) => <String, Object?>{
...messageJson('m1', mailboxId: kInboxId),
'textBody': '본문',
'attachments': <Object?>[attachmentJson('a1', name: '계약서.pdf')],
'searchHighlight': highlight,
};
test('제목 · 첨부(맞은 첨부만) · 글 본문의 맞은 곳을 읽는다', () {
final MessageDetail detail = MessageDetail.tryParse(
detailJson(<String, Object?>{
'subject': <Object?>[
<Object?>[0, 2],
],
'attachments': <String, Object?>{
'a1': <Object?>[
<Object?>[0, 2],
],
},
'textBody': <Object?>[],
}),
)!;
expect(
detail.searchHighlight,
MessageDetailHighlight(
subject: _ranges(<(int, int)>[(0, 2)]),
attachments: <String, List<SearchRange>>{
'a1': _ranges(<(int, int)>[(0, 2)]),
},
),
);
});
test('없거나 모양이 틀리면 강조 없이 메일을 연다', () {
expect(MessageDetail.tryParse(detailJson(null))!.searchHighlight, isNull);
for (final Object? broken in <Object?>[
<String, Object?>{'subject': <Object?>[], 'textBody': <Object?>[]},
<String, Object?>{
'subject': <Object?>[],
'attachments': <String, Object?>{'a1': 'x'},
'textBody': <Object?>[],
},
]) {
final MessageDetail? detail = MessageDetail.tryParse(
detailJson(broken),
);
expect(detail, isNotNull);
expect(detail!.searchHighlight, isNull);
}
});
test('[이미지 보기]의 본문 바꾸기는 강조도 그 응답의 것으로, 요약 바꾸기는 강조를 그대로 둔다', () {
final MessageDetail first = MessageDetail.tryParse(
detailJson(<String, Object?>{
'subject': <Object?>[
<Object?>[0, 2],
],
'attachments': <String, Object?>{},
'textBody': <Object?>[],
}),
)!;
final MessageDetail plain = MessageDetail.tryParse(detailJson(null))!;
expect(first.withBodyOf(plain).searchHighlight, isNull);
expect(
first.withSummary(plain.summary).searchHighlight,
first.searchHighlight,
);
});
});
group('강조 쿼리 · 준비 상태', () {
test('읽기 화면에 넘기는 쿼리는 빈 칸을 뺀 다섯 이름뿐이다', () {
expect(
const MessageHighlightQuery(q: '계약').toQueryParameters(),
<String, String>{'q': '계약'},
);
expect(
const MessageHighlightQuery(q: '계약', subject: '견적').toQueryParameters(),
<String, String>{'q': '계약', 'subject': '견적'},
);
expect(const MessageHighlightQuery().isEmpty, isTrue);
});
test('준비 상태는 building · ready 만 — 모르는 값은 null(안내 줄을 보이지 않는다)', () {
expect(SearchIndexStatus.parse('building'), SearchIndexStatus.building);
expect(SearchIndexStatus.parse('ready'), SearchIndexStatus.ready);
expect(SearchIndexStatus.parse('paused'), isNull);
expect(SearchIndexStatus.parse(null), isNull);
});
});
group('글 본문의 <mark> · 본문 문서의 mark 모양', () {
test('맞은 곳은 속성 없는 <mark> — 글은 조각마다 이스케이프한다', () {
expect(
highlightedTextHtml(
'<계약> & 계약서',
_ranges(<(int, int)>[(1, 3), (7, 9)]),
),
'&lt;<mark>계약</mark>&gt; &amp; <mark>계약</mark>서',
);
expect(highlightedTextHtml('<b>', const <SearchRange>[]), '&lt;b&gt;');
});
test('글 본문이면 강조를 넣고, 서식 본문이면 넣지 않는다(서버가 이미 넣었다 — 앱의 정화가 mark 를 남긴다)', () {
final SanitizedMailBody text = mailBodyFragment(
htmlBody: null,
textBody: '계약서를 보냈습니다',
imagesShown: false,
textHighlights: _ranges(<(int, int)>[(0, 2)]),
);
expect(text.fragment, contains('<mark>계약</mark>서를'));
final SanitizedMailBody html = mailBodyFragment(
htmlBody:
'<p><mark>계약</mark>서를 <mark style="color:red">보냈</mark>습니다</p>',
textBody: '계약서를 보냈습니다',
imagesShown: false,
textHighlights: _ranges(<(int, int)>[(0, 2)]),
);
expect(
html.fragment,
'<p><mark>계약</mark>서를 <mark style="color:red">보냈</mark>습니다</p>',
);
});
test('서식 본문이 한도를 넘어 글 본문으로 보일 때도 글 본문의 위치로 강조한다', () {
final SanitizedMailBody omitted = mailBodyFragment(
htmlBody: '<div>${'<div>' * 4000}x${'</div>' * 4000}</div>',
textBody: '계약 안내',
imagesShown: false,
textHighlights: _ranges(<(int, int)>[(0, 2)]),
);
expect(omitted.htmlOmitted, isTrue);
expect(omitted.fragment, contains('<mark>계약</mark> 안내'));
});
test('문서의 mark 는 웹과 같은 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1px', () {
expect(
buildMailDocument(''),
contains(
'mark{background:#FCE7A4;color:inherit;border-radius:2px;padding:0 1px}',
),
);
});
});
}
const Object _absent = Object();
@@ -0,0 +1,256 @@
import 'package:arasystem_app/features/mail/domain/search_text.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../support/ts_table.dart';
/// 서버 · 웹과 같은 표본 표 — 백엔드 파일을 그대로 읽는다(표는 하나 — 세 쪽 시험이 같은 표를 본다)
const String _samplesPath = '../backend/test/search-text-samples.ts';
/// 표의 한 행 — [넣은 글, 답]
List<(String, Object?)> _rows(String name) => <(String, Object?)>[
for (final Object? row in readTsArrayFile(_samplesPath, name))
if (row case <Object?>[final String input, final Object? answer])
(input, answer)
else
throw FormatException('표 $name 의 행 모양이 다르다: $row'),
];
/// 메일 검색의 낱말 나누기 · 접기 · 묶음 · 한 글자 판정 · 한도 (짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`)
void main() {
group('표 읽기 (test/support/ts_table.dart)', () {
test('작은따옴표 · 큰따옴표 글 · 이스케이프 · 주석 · 끝 쉼표 · 겹친 배열을 읽는다', () {
const String source = r'''
export const SAMPLE: readonly (readonly [a: string, b: readonly string[]])[] = [
// 주석은 건너뛴다
['가\u3000나', ['\n', '\t', '\r', '\\', '\'', "\"", '\u{1F600}']],
/* 막힌 주석 */ ["끝", []],
];
''';
expect(readTsArray(source, 'SAMPLE'), <Object?>[
<Object?>[
'가\u3000나',
<Object?>['\n', '\t', '\r', r'\', "'", '"', '\u{1F600}'],
],
<Object?>['끝', <Object?>[]],
]);
});
test('모르는 꼴(템플릿 글 · 수)이나 없는 표는 실패한다 — 표의 모양이 바뀐 것을 알아챈다', () {
expect(
() => readTsArray('export const X = [`a`];', 'X'),
throwsFormatException,
);
expect(
() => readTsArray('export const X = [1];', 'X'),
throwsFormatException,
);
expect(() => readTsArray('', 'X'), throwsFormatException);
});
test('백엔드 표를 읽는다 — 특별한 행(풀어 쓴 한글 · \u0130 · BMP 밖 글자)이 들어 있다', () {
final List<(String, Object?)> fold = _rows('FOLD_SAMPLES');
final List<(String, Object?)> grams = _rows('GRAM_SAMPLES');
expect(fold.length, greaterThanOrEqualTo(10));
expect(grams.length, greaterThanOrEqualTo(8));
final List<String> inputs = <String>[
for (final (String input, Object? _) in fold) input,
];
expect(inputs, contains('\u1112\u1161\u11AB\u1100\u1173\u11AF'));
expect(inputs, contains('\u0130stanbul'));
expect(inputs, contains('\u{10400}\u{1F600}'));
});
});
group('foldText (접기 — 표 FOLD_SAMPLES)', () {
test('표의 모든 행 — 넣은 글을 접으면 답과 같다', () {
for (final (String input, Object? folded) in _rows('FOLD_SAMPLES')) {
expect(foldText(input), folded, reason: '행 ${input.runes.toList()}');
}
});
test('접은 글은 (한글을 모은) 원문과 UTF-16 길이가 늘 같다 — BMP 의 모든 글자', () {
for (final (String input, Object? _) in _rows('FOLD_SAMPLES')) {
expect(foldText(input).length, composeHangul(input).length);
}
final List<int> changedLength = <int>[];
for (int code = 0; code <= 0xFFFF; code++) {
if (code >= 0xD800 && code <= 0xDFFF) continue;
final String char = String.fromCharCode(code);
if (foldText(char).length != char.length) changedLength.add(code);
}
expect(changedLength, isEmpty);
});
test('두 번 접어도 같다 · 짝이 없는 서로게이트는 그대로 둔다', () {
for (final (String input, Object? folded) in _rows('FOLD_SAMPLES')) {
expect(foldText(folded! as String), folded);
expect(foldText(foldText(input)), folded);
}
expect(foldText('a\uD800B'), 'a\uD800b');
expect(foldText('\uDC00A'), '\uDC00a');
});
});
group('gramsOf (글자 두 개 묶음 — 표 GRAM_SAMPLES)', () {
test('표의 모든 행 — 나온 차례대로 겹치지 않게', () {
for (final (String fold, Object? grams) in _rows('GRAM_SAMPLES')) {
expect(gramsOf(fold).toList(), grams, reason: '행 $fold');
}
});
test(
'공백은 정규식 \\s 와 같은 글자다(낱말 나누기와 같다) — Dart 의 trim 이 지우는 U+0085 는 아니다',
() {
final RegExp space = RegExp(r'\s', unicode: true);
final List<int> differ = <int>[];
for (int code = 0; code <= 0xFFFF; code++) {
if (code >= 0xD800 && code <= 0xDFFF) continue;
if (isSearchSpaceCode(code) !=
space.hasMatch(String.fromCharCode(code))) {
differ.add(code);
}
}
expect(differ, isEmpty);
expect(isSearchSpaceCode(0x85), isFalse);
},
);
});
group('parseSearchTerms (검색어 나누기 — 짝: 백엔드 search-query.ts)', () {
List<String> texts(String query) => <String>[
for (final SearchTerm term in parseSearchTerms(query)) term.text,
];
test('띄어쓰기로 낱말을 나눈다 — 여러 칸 · 전각 공백 · 줄바꿈도 한 번의 띄어쓰기', () {
expect(parseSearchTerms(' 서버 이전\u3000일정\n공유 '), <SearchTerm>[
(kind: SearchTermKind.word, text: '서버'),
(kind: SearchTermKind.word, text: '이전'),
(kind: SearchTermKind.word, text: '일정'),
(kind: SearchTermKind.word, text: '공유'),
]);
});
test('큰따옴표 안은 구절 하나다 — 짝이 없는 마지막 따옴표는 없는 것으로 본다', () {
expect(parseSearchTerms('회의 "일정 공유" 서버'), <SearchTerm>[
(kind: SearchTermKind.word, text: '회의'),
(kind: SearchTermKind.phrase, text: '일정 공유'),
(kind: SearchTermKind.word, text: '서버'),
]);
expect(texts('회의 "일정 공유'), <String>['회의', '일정', '공유']);
expect(parseSearchTerms('"a b" "c'), <SearchTerm>[
(kind: SearchTermKind.phrase, text: 'a b'),
(kind: SearchTermKind.word, text: 'c'),
]);
});
test('문자 · 숫자가 없는 낱말 · 구절은 버리고, 낱말 앞뒤의 문장부호는 뗀다 — 같은 낱말은 한 번', () {
for (final String query in <String>[
'-',
'…',
'- … !!',
'"- …"',
'\u{1F600}',
'" "',
'""',
]) {
expect(parseSearchTerms(query), isEmpty, reason: query);
}
expect(texts('회의, (회의) 회의. “회의” [공지] 2026. 회의!'), <String>[
'회의',
'공지',
'2026',
]);
expect(parseSearchTerms('"회의, 일정."'), <SearchTerm>[
(kind: SearchTermKind.phrase, text: '회의, 일정.'),
]);
expect(parseSearchTerms('회의 회의 "일정" "일정" 일정'), <SearchTerm>[
(kind: SearchTermKind.word, text: '회의'),
(kind: SearchTermKind.phrase, text: '일정'),
(kind: SearchTermKind.word, text: '일정'),
]);
});
test('글자에 붙은 결합 문자는 남기고, 그림 문자와 그 이체 선택자는 함께 뗀다', () {
expect(texts('cafe\u0301!'), <String>['cafe\u0301']);
expect(texts('회의\u{1F600}\uFE0F'), <String>['회의']);
});
test('풀어 쓴 한글은 음절로 모으고 NUL 은 U+FFFD 로 바꾼다(서버의 색인과 같다)', () {
expect(
parseSearchTerms('\u1112\u1161\u11AB\u1100\u1173\u11AF'),
<SearchTerm>[(kind: SearchTermKind.word, text: '한글')],
);
expect(parseSearchTerms('계\u0000약'), <SearchTerm>[
(kind: SearchTermKind.word, text: '계\uFFFD약'),
]);
});
});
group('isSingleCharSearch (한 글자 낱말뿐인 검색 — 서버가 본문을 찾지 않는다)', () {
test('낱말이 모두 한 글자면 그렇다 — 여러 칸을 합쳐 본다', () {
expect(isSingleCharSearch(<String>['김']), isTrue);
expect(isSingleCharSearch(<String>['김 a 3']), isTrue);
expect(isSingleCharSearch(<String>['김', '', '', '', '박']), isTrue);
// 한 글자 낱말로만 된 구절도 묶음이 없다
expect(isSingleCharSearch(<String>['"가 나"']), isTrue);
});
test('두 글자 이상 낱말이 하나라도 있으면 아니다 — 다른 칸에 있어도', () {
expect(isSingleCharSearch(<String>['계약']), isFalse);
expect(isSingleCharSearch(<String>['김 계약']), isFalse);
expect(isSingleCharSearch(<String>['김', '', '', '견적', '']), isFalse);
expect(isSingleCharSearch(<String>['"가나"']), isFalse);
// 풀어 쓴 한 글자도 모은 뒤 센다
expect(isSingleCharSearch(<String>['\u1100\u1161']), isTrue);
});
test('찾을 낱말이 없으면(빈 글 · 문장부호뿐) 아니다 — 앞뒤 문장부호는 뗀 뒤 센다', () {
expect(isSingleCharSearch(<String>[]), isFalse);
expect(isSingleCharSearch(<String>['', ' ']), isFalse);
expect(isSingleCharSearch(<String>['- …']), isFalse);
expect(isSingleCharSearch(<String>['(김)']), isTrue);
});
});
group('검색어 다듬기 · 한도', () {
test(
'앞뒤 공백(전각 공백 · 줄바꿈 포함)을 빼고 200자(UTF-16)에서 자른다 — U+0085 는 지우지 않는다(JS trim 과 같다)',
() {
expect(normalizeSearchText(' 계약 \u3000\n'), '계약');
expect(normalizeSearchText('\u0085계약'), '\u0085계약');
expect(normalizeSearchText('가' * 250).length, kMailSearchMaxLength);
expect(normalizeSearchText(''), '');
expect(normalizeSearchText(' \u00A0 '), '');
},
);
test('이모지를 가운데서 자르게 되면 그 글자를 뺀다 · 자른 뒤 끝 공백도 뺀다', () {
final String cut = normalizeSearchText('${'가' * 199}\u{1F600}');
expect(cut, '가' * 199);
expect(normalizeSearchText('${'가' * 199} 나').length, 199);
});
test('낱말 수는 따옴표와 상관없이 공백으로 나눈 조각이다(서버와 같은 기준)', () {
expect(countSearchWords('계약'), 1);
expect(countSearchWords(' "유지보수 계약" 갱신\u3000건 '), 4);
expect(countSearchWords(''), 0);
expect(countSearchWords('11월 정기 점검 일정 안내 메일 다시 보내 주세요'), 9);
});
});
group('composeHangul (한글 자모 모으기 — NFC 의 한글 부분)', () {
test('첫소리 + 가운뎃소리(+ 끝소리)를 음절로, 받침 없는 음절 + 끝소리도 모은다', () {
expect(composeHangul('\u1112\u1161\u11AB'), '한');
expect(composeHangul('가\u11AB'), '간');
expect(composeHangul('a\u1100\u1161b'), 'a가b');
});
test('모을 것이 없으면 같은 글 · 짝이 없는 자모는 그대로 · 받침 있는 음절에는 끝소리를 더 붙이지 않는다', () {
const String plain = '계약서 \u{1F600}';
expect(identical(composeHangul(plain), plain), isTrue);
expect(composeHangul('\u1100'), '\u1100');
expect(composeHangul('\u1161'), '\u1161');
expect(composeHangul('간\u11AB'), '간\u11AB');
});
});
}
+149 -2
View File
@@ -166,6 +166,46 @@ class FakeMailBackend {
final List<({String id, Object? images})> detailRequests =
<({String id, Object? images})>[];
/// 받은 GET /messages/{id} 의 쿼리 전체 (메일 id · 쿼리 — 검색 강조 쿼리를 본다)
final List<({String id, Map<String, Object?> query})> detailQueries =
<({String id, Map<String, Object?> query})>[];
/// 검색 강조 쿼리(`q`)로 물었을 때 덮는 칸 — 메일 id 마다(`searchHighlight` · NFC 로 실은 제목 등)
final Map<String, Map<String, Object?>> highlightDetails =
<String, Map<String, Object?>>{};
// ── 검색 (GET /messages?q=… · GET /search-index) ──
/// 검색 응답 — 있으면 이것으로 답한다(쿼리를 받는다). 없으면 메일 제목 · 미리 보기에 q 가 든 메일을 한 쪽씩([defaultSearch])
FakeResponse Function(Map<String, Object?> query)? searchResponder;
/// 기본 검색이 항목에 `searchHighlight` 칸을 싣는다(14단계 서버) — 값은 [searchHighlights] 에서(없으면 null)
bool searchHighlightField = false;
/// 기본 검색 항목의 `searchHighlight` — 메일 id 마다
final Map<String, Map<String, Object?>> searchHighlights =
<String, Map<String, Object?>>{};
/// 있으면 다음 검색 응답이 이것이 끝날 때까지 기다린다(한 번 쓰면 비운다)
Completer<void>? searchGate;
/// `GET /search-index` 의 응답 — 기본은 준비됨
FakeResponse searchIndex = FakeResponse.ok(<String, Object?>{
'status': 'ready',
'done': 10,
'total': 10,
});
/// 받은 검색 요청의 쿼리 (차례대로)
List<Map<String, Object?>> get searchQueries => <Map<String, Object?>>[
for (final RequestOptions request in listRequests)
if (request.queryParameters.containsKey('q')) request.queryParameters,
];
/// 받은 `GET /search-index` 의 수
int get searchIndexRequests =>
server.received('GET', MailApiPaths.searchIndex).length;
/// 받은 PATCH /messages/{id} (메일 id · 본문)
final List<({String id, Map<String, Object?> body})> singlePatches =
<({String id, Map<String, Object?> body})>[];
@@ -216,6 +256,18 @@ class FakeMailBackend {
});
});
server.on('GET', MailApiPaths.messages, (RequestOptions options) async {
if (options.queryParameters.containsKey('q')) {
final Completer<void>? gate = searchGate;
if (gate != null) {
searchGate = null;
await gate.future;
}
final FakeResponse Function(Map<String, Object?> query)? responder =
searchResponder;
return responder == null
? defaultSearch(options.queryParameters)
: responder(options.queryParameters);
}
final Completer<void>? gate = listGate;
if (gate != null) {
if (listGateOnce) listGate = null;
@@ -301,9 +353,87 @@ class FakeMailBackend {
if (failure != null) return failure;
return FakeResponse.ok(<String, Object?>{'state': state});
});
server.on('GET', MailApiPaths.searchIndex, (_) => searchIndex);
_installReader();
}
/// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을
/// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) —
/// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다.
/// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다
FakeResponse defaultSearch(Map<String, Object?> query) {
final String q = query['q']! as String;
final Object? mailboxId = query['mailboxId'];
final List<Map<String, Object?>> pool = <Map<String, Object?>>[
for (final MapEntry<String, List<Map<String, Object?>>> entry
in messages.entries)
if (mailboxId == null
? entry.key != kTrashId && entry.key != kJunkId
: entry.key == mailboxId)
...entry.value,
];
if (mailboxId != null &&
!mailboxes.any((Map<String, Object?> m) => m['id'] == mailboxId)) {
return FakeResponse.fail(404, 'RES_001');
}
final List<Map<String, Object?>> matched =
pool
.where(
(Map<String, Object?> m) =>
(m['subject']! as String).contains(q) ||
(m['preview']! as String).contains(q),
)
.toList()
..sort((Map<String, Object?> a, Map<String, Object?> b) {
final int byTime = (b['receivedAt']! as String).compareTo(
a['receivedAt']! as String,
);
return byTime != 0
? byTime
: (b['id']! as String).compareTo(a['id']! as String);
});
final int limit = (query['limit'] as int?) ?? kMailPageSize;
final Object? cursor = query['cursor'];
final List<Map<String, Object?>> rest = cursor is String
? matched.where(_afterSearchCursor(cursor)).toList()
: matched;
final List<Map<String, Object?>> page = rest.take(limit).toList();
final bool hasNext = rest.length > page.length;
return FakeResponse.ok(<String, Object?>{
'items': <Map<String, Object?>>[
for (final Map<String, Object?> message in page)
<String, Object?>{
...message,
if (searchHighlightField)
'searchHighlight': searchHighlights[message['id']],
},
],
'pageInfo': <String, Object?>{
'hasNext': hasNext,
'nextCursor': hasNext ? searchCursorAfter(page.last) : null,
'total': cursor == null ? matched.length : null,
},
});
}
/// 기본 검색의 커서 — [message] 줄 다음부터(`s:<받은 시각>|<id>` — 서버 색인 커서처럼 받은 시각 · id 가 더 작은 쪽)
static String searchCursorAfter(Map<String, Object?> message) =>
's:${message['receivedAt']}|${message['id']}';
/// 커서 자리 다음의 메일인가
static bool Function(Map<String, Object?> message) _afterSearchCursor(
String cursor,
) {
final List<String> parts = cursor.substring(2).split('|');
final String at = parts[0];
final String id = parts[1];
return (Map<String, Object?> message) {
final int byTime = (message['receivedAt']! as String).compareTo(at);
return byTime < 0 ||
(byTime == 0 && (message['id']! as String).compareTo(id) < 0);
};
}
static final RegExp _messagePath = RegExp(r'^/messages/([^/]+)$');
static final RegExp _attachmentPath = RegExp(
r'^/messages/([^/]+)/attachments/([^/]+)$',
@@ -319,6 +449,10 @@ class FakeMailBackend {
final String id = _idOf(_messagePath, options.path);
final Object? images = options.queryParameters[kMailImagesQuery];
detailRequests.add((id: id, images: images));
detailQueries.add((
id: id,
query: Map<String, Object?>.of(options.queryParameters),
));
final Completer<void>? gate = detailGate;
if (gate != null) {
detailGate = null;
@@ -329,6 +463,7 @@ class FakeMailBackend {
final Map<String, Object?>? detail = detailJsonOf(
id,
showImages: images == kMailImagesShow,
highlighted: options.queryParameters.containsKey('q'),
);
return detail == null
? FakeResponse.fail(404, 'RES_001')
@@ -380,8 +515,13 @@ class FakeMailBackend {
});
}
/// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails])를 덮는다. 없는 메일은 null
Map<String, Object?>? detailJsonOf(String id, {bool showImages = false}) {
/// 메일 한 통의 읽기 칸 — 목록 칸 위에 [details] (와 외부 이미지를 보일 때 [shownImageDetails] · 강조 쿼리로 물었을 때 [highlightDetails])를 덮는다.
/// 없는 메일은 null
Map<String, Object?>? detailJsonOf(
String id, {
bool showImages = false,
bool highlighted = false,
}) {
final ({String mailboxId, Map<String, Object?> message})? found = _find(id);
if (found == null) return null;
return <String, Object?>{
@@ -402,6 +542,7 @@ class FakeMailBackend {
'largeFiles': <Object?>[],
...?details[id],
if (showImages) ...?shownImageDetails[id],
if (highlighted) ...?highlightDetails[id],
};
}
@@ -418,6 +559,12 @@ class FakeMailBackend {
/// 메일이 지금 든 메일함 (없으면 null)
String? mailboxOf(String id) => _find(id)?.mailboxId;
/// 메일을 [mailboxId] 로 옮긴다(다른 화면 · 다른 기기에서 옮긴 것처럼) — 옮긴 뒤의 칸
Map<String, Object?> move(String id, String mailboxId) {
_apply(id, <String, Object?>{'mailboxId': mailboxId});
return _find(id)!.message;
}
/// 메일의 지금 칸 (없으면 null)
Map<String, Object?>? messageOf(String id) => _find(id)?.message;
+146
View File
@@ -0,0 +1,146 @@
import 'dart:io';
/// 모노레포의 TypeScript 표본 표를 읽는다 — 시험 전용(앱 코드에서 쓰지 않는다).
///
/// `export const NAME … = [` 부터 짝이 맞는 `]` 까지를 풀어 글(String) · 목록(List)의 나무로 돌려준다.
/// 읽는 것: 작은따옴표 · 큰따옴표 글, `\u` · `\u{…}` · `\n` · `\t` · `\r` · `\0` · `\\` · `\'` · `\"` 이스케이프, 대괄호 · 쉼표(끝 쉼표 포함) · `//` 주석 · `/* */` 주석.
/// 줄바꿈 모양(prettier)이 바뀌어도 읽는다. 그 밖의 꼴(템플릿 글 · 수 · 변수)이 나오면 [FormatException] — 표의 모양이 바뀐 것을 시험이 알아챈다.
List<Object?> readTsArray(String source, String name) {
final int at = source.indexOf('export const $name');
if (at < 0) throw FormatException('표 $name 이 없다');
final int open = source.indexOf('= [', at);
if (open < 0) throw FormatException('표 $name 의 배열이 없다');
final _TsReader reader = _TsReader(source, open + 2);
final Object? value = reader.readValue();
if (value is! List<Object?>) throw FormatException('표 $name 이 배열이 아니다');
return value;
}
/// [path](flutter/ 에서 본 상대 경로)의 TS 파일에서 [name] 표를 읽는다 — `flutter test` 는 flutter/ 에서 돈다
List<Object?> readTsArrayFile(String path, String name) =>
readTsArray(File(path).readAsStringSync(), name);
class _TsReader {
_TsReader(this._source, this._index);
final String _source;
int _index;
Object? readValue() {
_skip();
_expectMore();
final String char = _source[_index];
if (char == '[') return _readArray();
if (char == "'" || char == '"') return _readString();
throw FormatException('모르는 값', _source, _index);
}
List<Object?> _readArray() {
_index++;
final List<Object?> items = <Object?>[];
while (true) {
_skip();
_expectMore();
if (_source[_index] == ']') {
_index++;
return items;
}
items.add(readValue());
_skip();
_expectMore();
final String next = _source[_index];
if (next == ',') {
_index++;
} else if (next != ']') {
throw FormatException('쉼표나 ] 가 와야 한다', _source, _index);
}
}
}
String _readString() {
final String quote = _source[_index];
_index++;
final StringBuffer out = StringBuffer();
while (true) {
_expectMore();
final String char = _source[_index];
if (char == quote) {
_index++;
return out.toString();
}
if (char == '\n' || char == '\r') {
throw FormatException('글 안에 줄바꿈', _source, _index);
}
if (char != r'\') {
out.write(char);
_index++;
continue;
}
_index++;
_expectMore();
final String escape = _source[_index];
_index++;
switch (escape) {
case 'n':
out.write('\n');
case 't':
out.write('\t');
case 'r':
out.write('\r');
case '0':
out.write('\u0000');
case r'\':
case "'":
case '"':
out.write(escape);
case 'u':
out.writeCharCode(_readUnicodeEscape());
default:
throw FormatException('모르는 이스케이프 \\$escape', _source, _index);
}
}
}
/// `\u` 뒤 — `{1F600}` 또는 네 자리 16진수
int _readUnicodeEscape() {
_expectMore();
if (_source[_index] == '{') {
final int close = _source.indexOf('}', _index);
if (close < 0) throw FormatException('닫히지 않은 \\u{', _source, _index);
final String hex = _source.substring(_index + 1, close);
_index = close + 1;
return int.parse(hex, radix: 16);
}
if (_index + 4 > _source.length) {
throw FormatException('짧은 \\u', _source, _index);
}
final String hex = _source.substring(_index, _index + 4);
_index += 4;
return int.parse(hex, radix: 16);
}
/// 공백 · 주석을 건너뛴다
void _skip() {
while (_index < _source.length) {
final String char = _source[_index];
if (char == ' ' || char == '\n' || char == '\r' || char == '\t') {
_index++;
} else if (_source.startsWith('//', _index)) {
final int end = _source.indexOf('\n', _index);
_index = end < 0 ? _source.length : end + 1;
} else if (_source.startsWith('/*', _index)) {
final int end = _source.indexOf('*/', _index + 2);
if (end < 0) throw FormatException('닫히지 않은 주석', _source, _index);
_index = end + 2;
} else {
return;
}
}
}
void _expectMore() {
if (_index >= _source.length) {
throw FormatException('표가 중간에 끝났다', _source, _index);
}
}
}