generated from ttipo/project-base
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:
@@ -28,6 +28,21 @@ void main() {
|
||||
expect(AppTypography.bodyMedium, 15);
|
||||
});
|
||||
|
||||
test('아래 시트의 가림막은 대화상자 가림막 색 · 날짜 고르는 창의 머리 글은 22 · 600', () {
|
||||
expect(theme.bottomSheetTheme.modalBarrierColor, AppColors.backdrop);
|
||||
expect(theme.colorScheme.scrim, AppColors.backdrop);
|
||||
expect(
|
||||
theme.datePickerTheme.headerHeadlineStyle?.fontSize,
|
||||
AppTypography.headline,
|
||||
);
|
||||
expect(AppTypography.headline, 22);
|
||||
expect(
|
||||
theme.datePickerTheme.headerHeadlineStyle?.fontWeight,
|
||||
AppTypography.semiBold,
|
||||
);
|
||||
expect(AppTypography.semiBold, FontWeight.w600);
|
||||
});
|
||||
|
||||
test('단추의 누르는 곳은 48dp 이상이다', () {
|
||||
expect(AppSize.touchMin, 48);
|
||||
expect(
|
||||
|
||||
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();
|
||||
@@ -196,10 +196,26 @@ class FakeMailBackend {
|
||||
'total': 10,
|
||||
});
|
||||
|
||||
/// 검색 요청의 쿼리 이름 — 이 가운데 하나라도 있으면 검색이다(검색어 또는 조건 — 계약 2절 "검색 — 14단계")
|
||||
static const Set<String> searchKeys = <String>{
|
||||
'q',
|
||||
'from',
|
||||
'to',
|
||||
'subject',
|
||||
'body',
|
||||
'after',
|
||||
'before',
|
||||
'hasAttachment',
|
||||
};
|
||||
|
||||
/// 검색 요청인가 — 메일함 목록 요청과 가른다
|
||||
static bool isSearchQuery(Map<String, Object?> query) =>
|
||||
query.keys.any(searchKeys.contains);
|
||||
|
||||
/// 받은 검색 요청의 쿼리 (차례대로)
|
||||
List<Map<String, Object?>> get searchQueries => <Map<String, Object?>>[
|
||||
for (final RequestOptions request in listRequests)
|
||||
if (request.queryParameters.containsKey('q')) request.queryParameters,
|
||||
if (isSearchQuery(request.queryParameters)) request.queryParameters,
|
||||
];
|
||||
|
||||
/// 받은 `GET /search-index` 의 수
|
||||
@@ -256,7 +272,7 @@ class FakeMailBackend {
|
||||
});
|
||||
});
|
||||
server.on('GET', MailApiPaths.messages, (RequestOptions options) async {
|
||||
if (options.queryParameters.containsKey('q')) {
|
||||
if (isSearchQuery(options.queryParameters)) {
|
||||
final Completer<void>? gate = searchGate;
|
||||
if (gate != null) {
|
||||
searchGate = null;
|
||||
@@ -360,9 +376,10 @@ class FakeMailBackend {
|
||||
/// 기본 검색 — 찾을 곳(메일함 · 없으면 휴지통 · 스팸메일함 밖 모두)의 메일 가운데 제목 · 미리 보기에 q 가 든 것을
|
||||
/// 받은 시각 내림차순(같으면 id 내림차순 — 서버 색인과 같다)으로 한 쪽씩. 커서는 그 쪽 끝 줄의 자리([searchCursorAfter]) —
|
||||
/// 서버처럼 그 줄 **다음**부터 준다(그사이 앞쪽 메일이 빠지거나 되돌아와도 다음 쪽은 밀리지도 겹치지도 않는다). 전체 수는 첫 쪽에만 싣는다.
|
||||
/// 조건([_matchesConditions] — 보낸 사람 · 받는 사람 · 제목 · 내용(미리 보기로 대신) · 받은 시각 · 첨부)도 거른다. q 가 없으면 조건만으로.
|
||||
/// [searchResponder] 가 쪽마다 이것을 불러 섞어 쓸 수 있다
|
||||
FakeResponse defaultSearch(Map<String, Object?> query) {
|
||||
final String q = query['q']! as String;
|
||||
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
|
||||
@@ -380,8 +397,10 @@ class FakeMailBackend {
|
||||
pool
|
||||
.where(
|
||||
(Map<String, Object?> m) =>
|
||||
(m['subject']! as String).contains(q) ||
|
||||
(m['preview']! as String).contains(q),
|
||||
(q == null ||
|
||||
(m['subject']! as String).contains(q) ||
|
||||
(m['preview']! as String).contains(q)) &&
|
||||
_matchesConditions(m, query),
|
||||
)
|
||||
.toList()
|
||||
..sort((Map<String, Object?> a, Map<String, Object?> b) {
|
||||
@@ -416,6 +435,39 @@ class FakeMailBackend {
|
||||
});
|
||||
}
|
||||
|
||||
/// 기본 검색의 조건 — 글 조건은 그 칸에 그 글이 들었는지(보낸 사람 · 받는 사람은 이름이나 주소, 내용은 미리 보기로 대신),
|
||||
/// 기간은 받은 시각이 `after` 이상 · `before` 미만(둘 다 UTC ISO 라 글로 견준다), 첨부는 `hasAttachment=true` 일 때 있는 메일만
|
||||
static bool _matchesConditions(
|
||||
Map<String, Object?> message,
|
||||
Map<String, Object?> query,
|
||||
) {
|
||||
bool contains(Object? value, Object? text) =>
|
||||
text is! String || (value is String && value.contains(text));
|
||||
bool person(Object? address, Object? text) =>
|
||||
address is Map<String, Object?> &&
|
||||
(contains(address['name'], text) || contains(address['email'], text));
|
||||
final Object? from = query['from'];
|
||||
if (from != null && !person(message['from'], from)) return false;
|
||||
final Object? to = query['to'];
|
||||
if (to != null &&
|
||||
!(message['to']! as List<Object?>).any(
|
||||
(Object? address) => person(address, to),
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
if (!contains(message['subject'], query['subject'])) return false;
|
||||
if (!contains(message['preview'], query['body'])) return false;
|
||||
final String receivedAt = message['receivedAt']! as String;
|
||||
final Object? after = query['after'];
|
||||
if (after is String && receivedAt.compareTo(after) < 0) return false;
|
||||
final Object? before = query['before'];
|
||||
if (before is String && receivedAt.compareTo(before) >= 0) return false;
|
||||
if (query['hasAttachment'] == 'true' && message['hasAttachment'] != true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 기본 검색의 커서 — [message] 줄 다음부터(`s:<받은 시각>|<id>` — 서버 색인 커서처럼 받은 시각 · id 가 더 작은 쪽)
|
||||
static String searchCursorAfter(Map<String, Object?> message) =>
|
||||
's:${message['receivedAt']}|${message['id']}';
|
||||
@@ -463,7 +515,7 @@ class FakeMailBackend {
|
||||
final Map<String, Object?>? detail = detailJsonOf(
|
||||
id,
|
||||
showImages: images == kMailImagesShow,
|
||||
highlighted: options.queryParameters.containsKey('q'),
|
||||
highlighted: isSearchQuery(options.queryParameters),
|
||||
);
|
||||
return detail == null
|
||||
? FakeResponse.fail(404, 'RES_001')
|
||||
|
||||
Reference in New Issue
Block a user