feat(flutter): 메일 검색 2차 — 조건으로 찾기 시트와 결과의 조건 줄

2026-11-07 확정한 보드 App1SearchConditions 대로 웹 14단계의 [조건] 패널을
바닥 시트로 옮겼다. 보낸 사람 · 받는 사람 · 제목 · 내용 · 기간 · 첨부로 찾고,
결과에는 "조건: …" 줄과 [조건 지우기]를 둔다. 조건 다듬기 · 합친 낱말 수 ·
기간 · 날짜의 UTC 시각 · 강조 쿼리 · "나"는 웹 mail-search.ts 와 짝이다.

[조건]은 위 막대 맨 오른쪽에 고정해 글을 써도 옆으로 밀리지 않게 했고,
시트에서 낱말 8개를 넘기면 시트를 연 채 알린다. 날짜는 오늘까지만 고른다.
바닥 시트 가림막을 앱 대화상자 색으로 맞춰 메일함 옮기기 시트도 같은 색이 된다.

리뷰에서 가로 화면에 자판이 오르면 시트 모양이 바뀌며 칸이 새로 만들어져
초점을 잃는 것을 찾았다. 시트를 한 트리로 두고 같은 레이아웃 안에서 머리 ·
단추 줄의 실제 높이로 모양 셋(위를 남긴 고정 · 화면 위까지 고정 · 한 스크롤)을
고르게 고쳤고, 모양이 바뀌거나 알림이 뜨면 초점 칸 · 알림을 보이게 한다.
시험 872 → 932.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 19:00:15 +09:00
parent bec78bce42
commit facf9cd936
20 changed files with 4170 additions and 82 deletions
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ 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/domain/search_conditions.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';
@@ -141,7 +142,7 @@ void main() {
List<RequestOptions> searchRequests() => <RequestOptions>[
for (final RequestOptions r in mail.listRequests)
if (r.queryParameters.containsKey('q')) r,
if (FakeMailBackend.isSearchQuery(r.queryParameters)) r,
];
MessageSummary summaryOf(Map<String, Object?> json) =>
@@ -726,4 +727,166 @@ void main() {
expect(stateOf(args).total, 21);
});
});
group('조건 (2차 — 화면 상태에만 둔다)', () {
const SearchConditions fromDoyun = SearchConditions(
from: '이도윤',
hasAttachment: true,
);
test(
'시트의 [찾기]는 조건을 걸고 찾는다 — 빈 검색어는 싣지 않고, 이어 찾기 · 다시 찾기 · [다시 시도]도 걸린 조건 그대로',
() async {
mail.searchResponder = (Map<String, Object?> query) =>
query['cursor'] == null
? _page(_mails(20), next: 'c1', total: 30)
: _page(_mails(10, from: 21));
final MailSearchArgs args = await open();
expect(controllerOf(args).submit('', conditions: fromDoyun), isTrue);
await settle(args);
expect(stateOf(args).query, '');
expect(stateOf(args).conditions, fromDoyun);
expect(searchRequests().single.queryParameters, <String, Object?>{
'from': '이도윤',
'hasAttachment': 'true',
'mailboxId': kInboxId,
'limit': kMailPageSize,
});
await controllerOf(args).loadMore();
expect(searchRequests().last.queryParameters, <String, Object?>{
'from': '이도윤',
'hasAttachment': 'true',
'mailboxId': kInboxId,
'limit': kMailPageSize,
'cursor': 'c1',
});
await controllerOf(args).refresh();
await settle(args);
expect(searchRequests().last.queryParameters['from'], '이도윤');
expect(searchRequests().last.queryParameters.containsKey('q'), isFalse);
mail.searchResponder = (_) => FakeResponse.fail(503, 'MAIL_001');
controllerOf(args).submit('계약');
await settle(args);
expect(stateOf(args).phase, MailSearchPhase.failed);
mail.searchResponder = null;
await controllerOf(args).retry();
await settle(args);
expect(searchRequests().last.queryParameters['q'], '계약');
expect(searchRequests().last.queryParameters['from'], '이도윤');
},
);
test(
'같은 검색어 · 같은 조건이면 보이는 채로 다시 찾고, 조건이 다르면 처음부터 찾는다 — 조건은 다듬어 건다',
() async {
mail.messages[kInboxId] = _mails(2);
final MailSearchArgs args = await open();
controllerOf(
args,
).submit('계약', conditions: const SearchConditions(from: ' 이도윤 '));
expect(stateOf(args).conditions, const SearchConditions(from: '이도윤'));
await settle(args);
controllerOf(args).submit('계약');
expect(stateOf(args).refreshing, isTrue);
await settle(args);
controllerOf(
args,
).submit('계약', conditions: const SearchConditions(from: '박지훈'));
expect(stateOf(args).refreshing, isFalse);
expect(stateOf(args).phase, MailSearchPhase.searching);
await settle(args);
expect(searchRequests().last.queryParameters['from'], '박지훈');
},
);
test(
'[✕]는 걸린 조건을 남기고 · 빈 칸의 [검색]은 조건만으로 · 검색어도 조건도 없는 [찾기]는 요청 없이 찾기 전으로',
() async {
mail.messages[kInboxId] = _mails(2);
final MailSearchArgs args = await open();
controllerOf(args).submit('계약', conditions: fromDoyun);
await settle(args);
controllerOf(args).clear();
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).conditions, fromDoyun);
// 찾기 전에 찾을 곳만 바꾸면 곳만 바꿔 둔다
expect(controllerOf(args).setScope(all: true, fieldText: ''), isFalse);
expect(controllerOf(args).submit(' '), isTrue);
await settle(args);
expect(searchRequests().last.queryParameters, <String, Object?>{
'from': '이도윤',
'hasAttachment': 'true',
'limit': kMailPageSize,
});
final int before = searchRequests().length;
expect(
controllerOf(args).submit('', conditions: SearchConditions.none),
isTrue,
);
await pumpEventsUntil(() => true);
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).conditions.isEmpty, isTrue);
expect(searchRequests(), hasLength(before));
// 그 뒤의 빈 칸 [검색]은 아무 일도 없다
expect(controllerOf(args).submit(''), isFalse);
},
);
test(
'조건 줄의 [조건 지우기] — 칸의 글(비었으면 보이는 검색어)로 조건 없이 다시, 검색어가 없으면 요청 없이 찾기 전, 찾기 전이면 조건만 뺀다',
() async {
mail.messages[kInboxId] = _mails(2);
final MailSearchArgs args = await open();
controllerOf(args).submit('계약', conditions: fromDoyun);
await settle(args);
expect(controllerOf(args).clearConditions(fieldText: ''), isTrue);
await settle(args);
expect(searchRequests().last.queryParameters, <String, Object?>{
'q': '계약',
'mailboxId': kInboxId,
'limit': kMailPageSize,
});
expect(stateOf(args).conditions.isEmpty, isTrue);
controllerOf(args).submit('', conditions: fromDoyun);
await settle(args);
final int before = searchRequests().length;
controllerOf(args).clearConditions(fieldText: '');
await pumpEventsUntil(() => true);
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).conditions.isEmpty, isTrue);
expect(searchRequests(), hasLength(before));
controllerOf(args).submit('계약', conditions: fromDoyun);
await settle(args);
controllerOf(args).clear();
expect(controllerOf(args).clearConditions(fieldText: '견적'), isFalse);
expect(stateOf(args).phase, MailSearchPhase.idle);
expect(stateOf(args).conditions.isEmpty, isTrue);
expect(searchRequests(), hasLength(before + 1));
},
);
test('검색어와 걸린 조건의 낱말이 합쳐 8개를 넘으면 묻지 않는다 — 조건은 걸린 채', () async {
final MailSearchArgs args = await open();
controllerOf(
args,
).submit('', conditions: const SearchConditions(subject: '계약 갱신 확인'));
await settle(args);
final int before = searchRequests().length;
expect(controllerOf(args).submit('견적 단가 납품 일정 변경 요청'), isTrue);
await pumpEventsUntil(() => true);
expect(stateOf(args).phase, MailSearchPhase.tooManyWords);
expect(stateOf(args).conditions.subject, '계약 갱신 확인');
expect(searchRequests(), hasLength(before));
});
});
}
@@ -472,7 +472,7 @@ void main() {
expect(find.text(MailSearchTexts.count('해지 통보', 0)), findsOneWidget);
expect(findCopy(MailSearchTexts.emptyTitle('해지 통보')), findsOneWidget);
expect(findCopy(MailSearchTexts.emptyHint), findsOneWidget);
expect(findCopy(MailSearchTexts.emptyWordsHint), findsOneWidget);
expect(findCopy(MailSearchTexts.wordLimitHint), findsNothing);
await tester.tap(find.text(MailSearchTexts.searchAll));
@@ -1,6 +1,7 @@
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_conditions.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';
@@ -303,4 +304,80 @@ void main() {
expect(old.scopeMailboxId, isNull);
});
});
group('조건 (2차)', () {
const SearchConditions fromDoyun = SearchConditions(
from: '이도윤',
subject: '갱신',
since: SearchDay(2026, 10, 6),
hasAttachment: true,
);
test('조건만으로 찾았으면 머리는 "2통" · "찾는 중", 전체 수를 모르면 머리가 없다', () {
MailSearchState conditionsOnly({int? total, bool refreshing = false}) =>
MailSearchState(
phase: MailSearchPhase.ready,
query: '',
pageInfo: MailPageInfo(hasNext: false, total: total),
conditions: fromDoyun,
refreshing: refreshing,
);
expect(searchCountLineOf(conditionsOnly(total: 2)), '2통');
expect(
searchCountLineOf(conditionsOnly(total: 2, refreshing: true)),
'찾는 중',
);
expect(searchCountLineOf(conditionsOnly()), isNull);
});
test('한 글자 안내는 본문을 찾을 말(검색어 · 내용 조건)이 한 글자뿐일 때만', () {
MailSearchState ready(String query, SearchConditions conditions) =>
MailSearchState(
phase: MailSearchPhase.ready,
query: query,
conditions: conditions,
indexStatus: SearchIndexStatus.ready,
);
expect(
searchNoticeOf(ready('', const SearchConditions(body: '가'))),
MailSearchNotice.singleChar,
);
expect(
searchNoticeOf(ready('', const SearchConditions(from: '김'))),
isNull,
);
expect(
searchNoticeOf(ready('김', const SearchConditions(subject: '견적'))),
isNull,
);
});
test('결과에서 연 메일에는 검색어와 글 조건만 — 실을 칸이 없으면 강조 쿼리 없이', () {
const MailSearchState state = MailSearchState(
phase: MailSearchPhase.ready,
query: '계약',
searchedMailboxId: kInboxId,
pageInfo: MailPageInfo(hasNext: false, total: 1),
conditions: fromDoyun,
);
expect(
searchOpenOf(_item('m1', mailboxId: kInboxId), state).highlight,
const MessageHighlightQuery(q: '계약', from: '이도윤', subject: '갱신'),
);
const MailSearchState datesOnly = MailSearchState(
phase: MailSearchPhase.ready,
query: '',
searchedMailboxId: kInboxId,
pageInfo: MailPageInfo(hasNext: false, total: 1),
conditions: SearchConditions(
since: SearchDay(2026, 10, 6),
hasAttachment: true,
),
);
expect(
searchOpenOf(_item('m1', mailboxId: kInboxId), datesOnly).highlight,
isNull,
);
});
});
}
@@ -0,0 +1,456 @@
import 'package:arasystem_app/features/mail/data/models/mail_search.dart';
import 'package:arasystem_app/features/mail/domain/search_conditions.dart';
import 'package:arasystem_app/features/mail/presentation/mail_search_view.dart';
import 'package:arasystem_app/features/mail/presentation/mail_texts.dart';
import 'package:flutter_test/flutter_test.dart';
/// 시험 날짜 — 'YYYY-MM-DD' 를 [SearchDay] 로
SearchDay _day(String text) {
final List<int> parts = text.split('-').map(int.parse).toList();
return SearchDay(parts[0], parts[1], parts[2]);
}
/// 걸린 조건 — 웹 spec 의 `conditions(partial)` 과 같은 모양
SearchConditions _conditions({
String from = '',
String to = '',
String subject = '',
String body = '',
String? since,
String? until,
bool hasAttachment = false,
}) => SearchConditions(
from: from,
to: to,
subject: subject,
body: body,
since: since == null ? null : _day(since),
until: until == null ? null : _day(until),
hasAttachment: hasAttachment,
);
/// KST(UTC+9)로 고정한 그날 0시 — 기기 시간대에 기대지 않고 after · before 를 본다
DateTime _kstMidnight(int year, int month, int day) =>
DateTime.utc(year, month, day).subtract(const Duration(hours: 9));
/// 조건 검색 (2차 — 계약 2절 "검색 — 14단계"). **짝: 웹 `frontend/src/shared/utils/mail-search.spec.ts`** 의 같은 경우를 옮겼다.
/// 오늘은 지역 시각(`DateTime(년, 월, 일)`)으로 넘긴다 — 시간대(UTC · KST)와 상관없이 같은 날이다
void main() {
group('조건 다듬기 · 같은 조건', () {
test('글 칸은 검색어처럼(앞뒤 공백 · 200자), 날짜는 있는 날짜만', () {
expect(
normalizeSearchConditions(
_conditions(
from: ' 이도윤 ',
subject: '가' * 250,
since: '2026-02-30',
until: '2026-11-05',
hasAttachment: true,
),
),
_conditions(
from: '이도윤',
subject: '가' * 200,
until: '2026-11-05',
hasAttachment: true,
),
);
expect(normalizeSearchConditions(null), SearchConditions.none);
});
test('날짜는 1970-01-01 ~ 9999-12-30 안의 있는 날짜만 — 밖의 날짜는 그 날짜만 버린다', () {
expect(isSearchDay(_day('1970-01-01')), isTrue);
expect(isSearchDay(_day('9999-12-30')), isTrue);
expect(isSearchDay(_day('1969-12-31')), isFalse);
// 9999-12-31 의 다음 날 0시는 10000년 — 서버가 받지 않는다
expect(isSearchDay(_day('9999-12-31')), isFalse);
expect(isSearchDay(_day('10000-01-01')), isFalse);
expect(isSearchDay(_day('2024-02-29')), isTrue);
expect(isSearchDay(_day('2026-02-29')), isFalse);
expect(
normalizeSearchConditions(
_conditions(since: '1969-12-31', until: '2026-11-01'),
),
_conditions(until: '2026-11-01'),
);
expect(
normalizeSearchConditions(
_conditions(since: '2026-11-01', until: '9999-12-31'),
),
_conditions(since: '2026-11-01'),
);
});
test('시작이 끝보다 늦은 기간은 통째로 버린다 — 같은 날은 그 하루', () {
expect(
normalizeSearchConditions(
_conditions(
since: '2026-11-06',
until: '2026-10-01',
hasAttachment: true,
),
),
_conditions(hasAttachment: true),
);
expect(
normalizeSearchConditions(
_conditions(since: '2026-11-06', until: '2026-11-06'),
),
_conditions(since: '2026-11-06', until: '2026-11-06'),
);
});
test('조건이 하나라도 있는가 · 같은 조건인가 — 빈 조건은 조건 없음과 같다', () {
expect(SearchConditions.none.isEmpty, isTrue);
expect(_conditions().isEmpty, isTrue);
expect(_conditions(hasAttachment: true).isNotEmpty, isTrue);
expect(_conditions(until: '2026-11-05').isNotEmpty, isTrue);
expect(_conditions(), SearchConditions.none);
expect(_conditions(from: '이도윤'), _conditions(from: '이도윤'));
expect(_conditions(from: '이도윤'), isNot(_conditions(to: '이도윤')));
expect(_day('2026-11-05').toString(), '2026-11-05');
});
});
group('API 쿼리 · 읽기 화면의 강조 쿼리', () {
test(
'기간은 시작 날짜 0시 · 끝 날짜 다음 날 0시를 기기 시간대에서 UTC 로 — 첨부는 hasAttachment=true',
() {
final SearchConditions value = _conditions(
to: 'ara.kim',
subject: '견적',
since: '2026-10-06',
until: '2026-11-06',
hasAttachment: true,
);
expect(
searchConditionsParams(value, midnight: _kstMidnight),
<String, String>{
'to': 'ara.kim',
'subject': '견적',
'after': '2026-10-05T15:00:00.000Z',
'before': '2026-11-06T15:00:00.000Z',
'hasAttachment': 'true',
},
);
// 기본은 기기 시간대 — 그날 0시의 UTC (웹 spec 의 localMidnightIso 와 같은 셈)
expect(searchConditionsParams(value)['after'], _localIso(2026, 10, 6));
expect(searchConditionsParams(value)['before'], _localIso(2026, 11, 7));
},
);
test('시작만 · 끝만도 된다 — 끝 날짜가 그 달 · 그 해의 마지막 날이면 다음 달 · 해로 넘긴다', () {
expect(
searchConditionsParams(
_conditions(until: '2026-12-31'),
midnight: _kstMidnight,
),
<String, String>{'before': '2026-12-31T15:00:00.000Z'},
);
expect(
searchConditionsParams(_conditions(until: '2026-12-31')),
<String, String>{'before': _localIso(2027, 1, 1)},
);
expect(
searchConditionsParams(
_conditions(since: '2026-03-01'),
midnight: _kstMidnight,
),
<String, String>{'after': '2026-02-28T15:00:00.000Z'},
);
expect(searchConditionsParams(SearchConditions.none), isEmpty);
});
test('읽기 화면에는 검색어와 글 칸 넷만 넘긴다 — 기간 · 첨부는 서버가 받지 않는다', () {
final SearchConditions value = _conditions(
from: '이도윤',
body: '계약',
since: '2026-10-06',
hasAttachment: true,
);
expect(
messageHighlightQueryOf('갱신', value),
const MessageHighlightQuery(q: '갱신', from: '이도윤', body: '계약'),
);
expect(
messageHighlightQueryOf('', _conditions(subject: '견적')),
const MessageHighlightQuery(subject: '견적'),
);
expect(
messageHighlightQueryOf(
'',
_conditions(since: '2026-10-06', hasAttachment: true),
),
isNull,
);
expect(
messageHighlightQueryOf(
'계약',
SearchConditions.none,
)!.toQueryParameters(),
<String, String>{'q': '계약'},
);
});
});
group('합친 낱말 수 — 검색어와 조건 넷을 합쳐 8개까지 (서버와 같은 기준)', () {
test('띄어쓰기로 나눈 조각을 칸마다 세어 더한다 — 날짜 · 첨부는 낱말이 아니다', () {
final SearchConditions value = _conditions(
from: '이도윤',
to: '김아라 박지훈',
subject: '"계약 갱신"',
body: '단가',
);
expect(combinedSearchWords('견적 회의', value), 8);
expect(exceedsCombinedSearchWords('견적 회의', value), isFalse);
expect(exceedsCombinedSearchWords('견적 회의 일정', value), isTrue);
expect(
combinedSearchWords(
'',
_conditions(since: '2026-10-06', hasAttachment: true),
),
0,
);
});
});
group('기간 고르기', () {
final DateTime today = DateTime(2026, 11, 6, 15, 30);
test('최근 1주 · 1달 · 3달 · 1년은 그만큼 앞의 같은 날부터 오늘까지', () {
expect(periodRange(SearchPeriod.week, today), (
since: _day('2026-10-30'),
until: _day('2026-11-06'),
));
expect(periodRange(SearchPeriod.month, today), (
since: _day('2026-10-06'),
until: _day('2026-11-06'),
));
expect(periodRange(SearchPeriod.quarter, today), (
since: _day('2026-08-06'),
until: _day('2026-11-06'),
));
expect(periodRange(SearchPeriod.year, today), (
since: _day('2025-11-06'),
until: _day('2026-11-06'),
));
expect(periodRange(SearchPeriod.all, today), (since: null, until: null));
expect(periodRange(SearchPeriod.custom, today), (
since: null,
until: null,
));
});
test('달은 그 달의 마지막 날을 넘지 않는다 — 3월 31일의 1달 앞은 2월 28일(윤년이면 29일)', () {
expect(
periodRange(SearchPeriod.month, DateTime(2026, 3, 31)).since,
_day('2026-02-28'),
);
expect(
periodRange(SearchPeriod.month, DateTime(2028, 3, 31)).since,
_day('2028-02-29'),
);
expect(
periodRange(SearchPeriod.quarter, DateTime(2026, 5, 31)).since,
_day('2026-02-28'),
);
expect(
periodRange(SearchPeriod.week, DateTime(2026, 1, 3)).since,
_day('2025-12-27'),
);
expect(
periodRange(SearchPeriod.year, DateTime(2028, 2, 29)).since,
_day('2027-02-28'),
);
});
test('두 날짜가 오늘 기준의 미리 정한 기간과 같으면 그 고르기, 아니면 날짜 고르기', () {
expect(periodOf(null, null, today), SearchPeriod.all);
expect(
periodOf(_day('2026-10-06'), _day('2026-11-06'), today),
SearchPeriod.month,
);
expect(
periodOf(_day('2026-10-30'), _day('2026-11-06'), today),
SearchPeriod.week,
);
// 다음 날 보면 같은 날짜도 날짜 고르기다
expect(
periodOf(_day('2026-10-06'), _day('2026-11-06'), DateTime(2026, 11, 7)),
SearchPeriod.custom,
);
expect(periodOf(_day('2026-10-01'), null, today), SearchPeriod.custom);
});
test('시작이 끝보다 늦으면 거꾸로다 — 같은 날 · 한쪽만 있으면 아니다', () {
expect(isReversedPeriod(_day('2026-11-05'), _day('2026-10-01')), isTrue);
expect(isReversedPeriod(_day('2026-10-01'), _day('2026-10-01')), isFalse);
expect(isReversedPeriod(_day('2026-11-05'), null), isFalse);
expect(isReversedPeriod(null, _day('2026-10-01')), isFalse);
});
});
group('조건 줄 글', () {
final DateTime today = DateTime(2026, 11, 6);
test('"조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음" — 쉼표로 잇는다', () {
expect(
searchConditionsSummary(
_conditions(
from: '이도윤',
since: '2026-10-06',
until: '2026-11-06',
hasAttachment: true,
),
today,
),
'조건: 이도윤 님이 보낸 메일, 최근 1달, 첨부 있음',
);
});
test('받는 사람 · 제목 · 내용은 조사를 붙인다 — 날짜 고르기는 날짜로(같은 해면 끝 날짜의 해를 뺀다)', () {
expect(
searchConditionsSummary(
_conditions(
to: '김아라',
subject: '견적',
body: '계약서',
since: '2026-10-01',
until: '2026-11-05',
),
today,
),
'조건: 김아라 님이 받은 메일, 제목에 ‘견적’이 든 메일, 내용에 ‘계약서’가 든 메일, 2026. 10. 1.부터 11. 5.까지',
);
expect(
searchConditionsSummary(
_conditions(since: '2025-12-01', until: '2026-01-05'),
today,
),
'조건: 2025. 12. 1.부터 2026. 1. 5.까지',
);
expect(
searchConditionsSummary(_conditions(since: '2026-10-01'), today),
'조건: 2026. 10. 1.부터',
);
expect(
searchConditionsSummary(_conditions(until: '2026-10-31'), today),
'조건: 2026. 10. 31.까지',
);
// 소리를 알 수 없는 끝 글자(라틴)는 "이(가)"
expect(
searchConditionsSummary(_conditions(subject: 'ERP'), today),
'조건: 제목에 ‘ERP’이(가) 든 메일',
);
expect(searchConditionsSummary(SearchConditions.none, today), '');
});
test('하루만 고른 기간은 "2026. 11. 6. 하루" — 같은 날을 부터 · 까지로 두 번 쓰지 않는다', () {
expect(
searchConditionsSummary(
_conditions(since: '2026-11-06', until: '2026-11-06'),
today,
),
'조건: 2026. 11. 6. 하루',
);
expect(
searchConditionsSummary(
_conditions(since: '2025-12-31', until: '2025-12-31'),
today,
),
'조건: 2025. 12. 31. 하루',
);
});
test(
'보낸 사람 · 받는 사람이 내 이름이나 주소면 "내가 보낸 메일" · "내가 받은 메일" — 대소문자 · 앞뒤 공백은 가리지 않고, 일부만 같으면 아니다',
() {
const SearchConditionsMe me = (
name: '김아라',
email: 'ara.kim@arasystem.test',
);
expect(
searchConditionsSummary(
_conditions(from: '김아라', to: ' ARA.KIM@arasystem.test '),
today,
me: me,
),
'조건: 내가 보낸 메일, 내가 받은 메일',
);
expect(
searchConditionsSummary(
_conditions(from: 'ara.kim@arasystem.test'),
today,
me: me,
),
'조건: 내가 보낸 메일',
);
expect(
searchConditionsSummary(
_conditions(from: '아라', to: 'ara.kim'),
today,
me: me,
),
'조건: 아라 님이 보낸 메일, ara.kim 님이 받은 메일',
);
// 누구인지 모르면 지금 글 그대로
expect(
searchConditionsSummary(_conditions(from: '김아라'), today),
'조건: 김아라 님이 보낸 메일',
);
},
);
test('"나"는 풀어 쓴 한글(맥에서 온 글)도 모아서 견준다 — NFC', () {
// 김아라 를 첫소리 · 가운뎃소리 · 끝소리 자모로 풀어 쓴 글
final String decomposed = String.fromCharCodes(<int>[
0x1100, 0x1175, 0x11B7, // 김
0x110B, 0x1161, // 아
0x1105, 0x1161, // 라
]);
expect(decomposed, isNot('김아라'));
expect(
isSearchConditionMe(decomposed, (name: '김아라', email: 'ara@x.test')),
isTrue,
);
expect(
isSearchConditionMe('김아', (name: '김아라', email: 'ara@x.test')),
isFalse,
);
expect(isSearchConditionMe('김아라', null), isFalse);
expect(
isSearchConditionMe(' ', (name: '', email: 'ara@x.test')),
isFalse,
);
});
test('날짜 칸 · 조건 줄의 날짜는 "2026. 11. 7." — 해를 뺄 수 있다', () {
expect(formatSearchDay(_day('2026-11-07')), '2026. 11. 7.');
expect(formatSearchDay(_day('2026-01-05'), withYear: false), '1. 5.');
expect(MailSearchTexts.period(SearchPeriod.quarter), '최근 3달');
});
});
group('한 글자 안내 — 본문을 찾을 말이 한 글자뿐일 때만', () {
test('검색어나 내용 조건이 모두 한 글자면 그렇다', () {
expect(skipsBodySearch('김', SearchConditions.none), isTrue);
expect(skipsBodySearch('', _conditions(body: '가')), isTrue);
expect(skipsBodySearch('김', _conditions(from: '이')), isTrue);
});
test('두 글자 이상 낱말이 어느 칸에든 있거나, 본문을 찾을 말이 없으면 아니다', () {
expect(skipsBodySearch('김 계약', SearchConditions.none), isFalse);
expect(skipsBodySearch('김', _conditions(subject: '견적')), isFalse);
// 보낸 사람 · 제목 조건만이면 본문은 원래 찾지 않는다
expect(skipsBodySearch('', _conditions(from: '김')), isFalse);
expect(
skipsBodySearch('', _conditions(subject: '가', since: '2026-10-01')),
isFalse,
);
});
});
}
/// 기기 시간대의 그날 0시를 UTC ISO 로 — 시험이 도는 곳의 시간대와 상관없이 앱과 같은 셈
String _localIso(int year, int month, int day) =>
DateTime(year, month, day).toUtc().toIso8601String();