From 695a3438781f6a8808a7fa197727474fdc8bfc31 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 7 Nov 2026 02:11:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=82=AC=EB=9E=8C=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0(core/people)=20=E2=80=94=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=B9=A9=20=C2=B7=20=EB=B0=9B=EB=8A=94=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=20=EC=B6=94=EC=B2=9C=20=EB=A7=9E=EC=B6=94=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=8D=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 쓰기 화면의 받는 사람 칸이 쓰고, 나중의 캘린더 · 파일 모듈도 함께 쓰게 core 에 둔다. 주소 검사는 웹 mail-address.ts, 추천 맞추기(초성 · 겹자음 · 조합 중 글자)는 웹 recipient-match.ts 와 짝이고, 표본 표는 웹 · 백엔드와 같은 70행을 같은 차례로 옮겼다(세 쪽 짝 — 한쪽을 고치면 모두). Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/people/address_input.dart | 193 +++++++++ flutter/lib/core/people/people_texts.dart | 66 ++++ flutter/lib/core/people/person_address.dart | 27 ++ flutter/lib/core/people/recipient_match.dart | 341 ++++++++++++++++ .../lib/core/people/recipient_suggestion.dart | 181 +++++++++ .../test/core/people/address_input_test.dart | 188 +++++++++ .../core/people/recipient_match_samples.dart | 120 ++++++ .../core/people/recipient_match_test.dart | 369 ++++++++++++++++++ 8 files changed, 1485 insertions(+) create mode 100644 flutter/lib/core/people/address_input.dart create mode 100644 flutter/lib/core/people/people_texts.dart create mode 100644 flutter/lib/core/people/person_address.dart create mode 100644 flutter/lib/core/people/recipient_match.dart create mode 100644 flutter/lib/core/people/recipient_suggestion.dart create mode 100644 flutter/test/core/people/address_input_test.dart create mode 100644 flutter/test/core/people/recipient_match_samples.dart create mode 100644 flutter/test/core/people/recipient_match_test.dart diff --git a/flutter/lib/core/people/address_input.dart b/flutter/lib/core/people/address_input.dart new file mode 100644 index 0000000..054bd34 --- /dev/null +++ b/flutter/lib/core/people/address_input.dart @@ -0,0 +1,193 @@ +/// 받는 사람 칸의 주소 — 나누기 · 읽기 · 검사 · 겹침 (순수 함수). +/// +/// **짝: 웹 `frontend/src/shared/utils/mail-address.ts`** — 같은 정규식 · 같은 차례로 옮겼다(한쪽을 고치면 둘 다). +/// 옮긴 것: `isValidEmail` · `normalizeEmail` · `sameAddress` · `uniqueAddresses` · `formatAddress` · `formatAddressList` · +/// `parseRecipient` · `tokenFromAddress` · `hasOpenRecipientGroup` · `splitRecipientInput` · `recipientTokenKey` · `mergeRecipientTokens`. +/// 저장용 엄격 검사(`isStrictEmail` — 주소록 · 필터)는 쓰는 화면이 없어 옮기지 않았다. +/// 앱에만 있는 것: [splitPendingRecipients] — 웹은 키(쉼표 · Enter)를 받아 그 자리에서 나누지만, 휴대폰 자판은 키를 알려 주지 않아 +/// 칸의 글이 바뀔 때마다 구분자까지를 떼어 낸다. +library; + +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:flutter/foundation.dart'; + +/// 형식 검사 — 화면은 이 기준으로 바로 보이고 마지막 판단은 서버가 한다(`MAIL_002`) +final RegExp _emailPattern = RegExp(r'^[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+$'); + +/// "이름 <주소>" 꼴 +final RegExp _namedAddressPattern = RegExp(r'^(.*?)\s*<([^<>]*)>$'); + +/// 여러 주소를 한 번에 넣을 때의 구분자 +const Set _separators = {0x2C, 0x3B, 0x0A, 0x0D}; // , ; \n \r + +const int _quote = 0x22; // " +const int _angleOpen = 0x3C; // < +const int _angleClose = 0x3E; // > + +bool isValidEmail(String email) => _emailPattern.hasMatch(email.trim()); + +String normalizeEmail(String email) => email.trim().toLowerCase(); + +bool sameAddress(PersonAddress a, PersonAddress b) => + normalizeEmail(a.email) == normalizeEmail(b.email); + +/// 같은 주소(대소문자 무시)는 처음 것만 남긴다 +List uniqueAddresses(Iterable list) { + final Set seen = {}; + return [ + for (final PersonAddress address in list) + if (seen.add(normalizeEmail(address.email))) address, + ]; +} + +/// "이름 <주소>" — 이름이 없으면 주소만 +String formatAddress(PersonAddress address) { + final String? name = address.name?.trim(); + return name != null && name.isNotEmpty + ? '$name <${address.email}>' + : address.email; +} + +String formatAddressList(Iterable list) => + list.map(formatAddress).join(', '); + +/// 받는 사람 칸의 칩 하나 +@immutable +class RecipientToken { + const RecipientToken({ + required this.label, + required this.address, + required this.valid, + }); + + /// 칩에 보이는 글 — 넣은 그대로 + final String label; + final PersonAddress address; + + /// 형식이 맞는가 — 틀리면 칩을 빨갛게 보인다 + final bool valid; + + @override + bool operator ==(Object other) => + other is RecipientToken && + other.label == label && + other.address == address && + other.valid == valid; + + @override + int get hashCode => Object.hash(label, address, valid); +} + +String _unquote(String value) { + final String trimmed = value.trim(); + final bool quoted = + trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"'); + return quoted ? trimmed.substring(1, trimmed.length - 1).trim() : trimmed; +} + +/// 넣은 글 한 덩어리 → 칩. 빈 글이면 null — "이름 <주소>" · "\"이름\" <주소>" · "<주소>" · "주소" 를 받는다 +RecipientToken? parseRecipient(String raw) { + final String label = raw.trim(); + if (label.isEmpty) return null; + final RegExpMatch? named = _namedAddressPattern.firstMatch(label); + final String email = (named?.group(2) ?? label).trim(); + final String name = named == null ? '' : _unquote(named.group(1) ?? ''); + return RecipientToken( + label: label, + address: PersonAddress(email: email, name: name.isEmpty ? null : name), + valid: isValidEmail(email), + ); +} + +/// 미리 채울 때(답장 · 저장본 · 추천) — 서버가 준 주소를 칩으로 +RecipientToken tokenFromAddress(PersonAddress address) => RecipientToken( + label: formatAddress(address), + address: address, + valid: isValidEmail(address.email), +); + +/// 따옴표나 꺾쇠가 열린 채인가 — 그 안의 쉼표는 구분자가 아니다(`"Kim, Ara" `) +bool hasOpenRecipientGroup(String text) { + bool inQuotes = false; + bool inAngle = false; + for (final int rune in text.runes) { + if (rune == _quote && !inAngle) { + inQuotes = !inQuotes; + } else if (rune == _angleOpen && !inQuotes) { + inAngle = true; + } else if (rune == _angleClose && !inQuotes) { + inAngle = false; + } + } + return inQuotes || inAngle; +} + +/// 붙여 넣은 글을 주소 단위로 나눈다(따옴표 · 꺾쇠 안의 구분자는 무시) — 앞뒤 공백을 빼고 빈 조각은 버린다 +List splitRecipientInput(String text) { + final List parts = []; + final StringBuffer current = StringBuffer(); + bool inQuotes = false; + bool inAngle = false; + for (final int rune in text.runes) { + if (rune == _quote && !inAngle) { + inQuotes = !inQuotes; + } else if (rune == _angleOpen && !inQuotes) { + inAngle = true; + } else if (rune == _angleClose && !inQuotes) { + inAngle = false; + } + if (_separators.contains(rune) && !inQuotes && !inAngle) { + parts.add(current.toString()); + current.clear(); + continue; + } + current.writeCharCode(rune); + } + parts.add(current.toString()); + return [ + for (final String part in parts) + if (part.trim().isNotEmpty) part.trim(), + ]; +} + +/// 칸의 글에서 구분자(따옴표 · 꺾쇠 밖의 `,` `;` 줄바꿈)까지를 떼어 낸다 — 다 쓴 주소들과 이어 쓰는 글. +/// 구분자가 없으면 다 쓴 주소가 없다(글 그대로 이어 쓴다). 이어 쓰는 글의 앞 공백은 지운다("a@b.kr, " → 다 쓴 주소 하나 · 빈 글) +({List complete, String rest}) splitPendingRecipients(String text) { + bool inQuotes = false; + bool inAngle = false; + int last = -1; + for (int index = 0; index < text.length; index++) { + final int unit = text.codeUnitAt(index); + if (unit == _quote && !inAngle) { + inQuotes = !inQuotes; + } else if (unit == _angleOpen && !inQuotes) { + inAngle = true; + } else if (unit == _angleClose && !inQuotes) { + inAngle = false; + } else if (_separators.contains(unit) && !inQuotes && !inAngle) { + last = index; + } + } + if (last < 0) return (complete: const [], rest: text); + return ( + complete: splitRecipientInput(text.substring(0, last)), + rest: text.substring(last + 1).trimLeft(), + ); +} + +/// 겹침을 가르는 키 — 형식이 맞으면 주소(대소문자 무시), 틀리면 넣은 글 +String recipientTokenKey(RecipientToken token) => + token.valid ? normalizeEmail(token.address.email) : token.label; + +/// 이미 있는 칩과 겹치는 것은 빼고 뒤에 붙인다 +List mergeRecipientTokens( + List existing, + Iterable incoming, +) { + final Set seen = existing.map(recipientTokenKey).toSet(); + final List merged = [...existing]; + for (final RecipientToken token in incoming) { + if (seen.add(recipientTokenKey(token))) merged.add(token); + } + return merged; +} diff --git a/flutter/lib/core/people/people_texts.dart b/flutter/lib/core/people/people_texts.dart new file mode 100644 index 0000000..0c65c1e --- /dev/null +++ b/flutter/lib/core/people/people_texts.dart @@ -0,0 +1,66 @@ +/// 사람 고르기(받는 사람 추천)의 화면 글 — 웹 상수(`frontend/src/shared/constants/contacts.ts` 의 `RECIPIENT_CONTACT_TEXT` · +/// `CONTACT_EMAIL_LABEL_TEXT`, `RecipientSuggestionList.vue` 의 묶음 이름)를 그대로 옮겼다 — 웹과 앱을 함께 쓰는 직원에게 같은 말. +/// 말투는 디자인 README 「화면 문구 — 사용자에게 말하듯」. +library; + +import 'package:arasystem_app/core/people/recipient_match.dart'; +import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:arasystem_app/core/utils/korean_format.dart'; + +abstract final class PeopleTexts { + /// 추천 목록의 묶음 이름 — 사내와 최근 주고받은 주소를 나눠 보인다(계약 2절 — 바깥 사람이 동료 이름을 쓰는 경우) + static String groupLabel(RecipientOptionGroup group) => switch (group) { + RecipientOptionGroup.contact => '주소록', + RecipientOptionGroup.group => '그룹', + RecipientOptionGroup.directory => '사내', + RecipientOptionGroup.recent => '최근 주고받은 주소', + }; + + /// 주소록 줄의 주소 종류 + static String emailLabel(ContactEmailLabel label) => switch (label) { + ContactEmailLabel.work => '회사', + ContactEmailLabel.personal => '개인', + ContactEmailLabel.other => '기타', + }; + + /// 그룹 줄의 설명 — "고르면 4명을 한꺼번에 넣습니다" + static String groupDetail(int count) => + '고르면 ${formatCount(count)}명을 한꺼번에 넣습니다'; + + /// 구성원을 받는 동안 칸 아래 글 + static String groupLoading(String group) => '‘$group’ 그룹의 구성원을 불러오는 중…'; + + /// 그룹을 넣은 뒤 칸 아래 글 — "‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다. 주소가 없는 1명은 뺐습니다." + static String groupInserted( + String group, + String field, { + required int added, + required int withoutEmail, + required int already, + }) { + final String head = added > 0 + ? '‘$group’ 그룹의 ${formatCount(added)}명을 $field에 넣었습니다.' + : '‘$group’ 그룹에서 $field에 더 넣을 주소가 없습니다.'; + final List skipped = [ + if (withoutEmail > 0) '주소가 없는 ${formatCount(withoutEmail)}명', + if (already > 0) '이미 넣은 ${formatCount(already)}명', + ]; + return skipped.isEmpty ? head : '$head ${skipped.join(' · ')}은 뺐습니다.'; + } +} + +/// 주소록 줄의 주소 뒤 설명 — 회사 주소는 회사 이름, 그 밖은 종류("개인"). 주소록 줄이 아니면 null +/// (짝: 웹 `shared/utils/contacts.ts` 의 `contactSuggestionDetail`) +String? contactSuggestionDetail(RecipientSuggestion suggestion) { + if (suggestion.source != RecipientSource.contact) return null; + final String? company = suggestion.company?.trim(); + final String? shownCompany = company == null || company.isEmpty + ? null + : company; + final ContactEmailLabel? label = suggestion.emailLabel; + if (label == null) return shownCompany; + if (label == ContactEmailLabel.work) { + return shownCompany ?? PeopleTexts.emailLabel(label); + } + return PeopleTexts.emailLabel(label); +} diff --git a/flutter/lib/core/people/person_address.dart b/flutter/lib/core/people/person_address.dart new file mode 100644 index 0000000..194997e --- /dev/null +++ b/flutter/lib/core/people/person_address.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; + +/// 사람 한 명의 이름 · 메일 주소 — 받는 사람 칸 · 받는 사람 추천 · 그룹 구성원이 함께 쓴다(모듈 공통 — 메일 · 나중의 주소록). +/// +/// 메일 모듈의 `MailAddress`(계약 2절 `Address`)와 같은 모양이다. 모듈끼리 import 하지 않으려고 core 에 따로 둔다 +/// (flutter 스킬 "모듈끼리 서로 import 하지 않는다" — 사람 고르기는 core). +@immutable +class PersonAddress { + const PersonAddress({required this.email, this.name}); + + /// 보이는 이름 — 없으면 null + final String? name; + final String email; + + /// 계약의 `Address` 꼴 `{ name, email }` + Map toJson() => { + 'name': name, + 'email': email, + }; + + @override + bool operator ==(Object other) => + other is PersonAddress && other.email == email && other.name == name; + + @override + int get hashCode => Object.hash(email, name); +} diff --git a/flutter/lib/core/people/recipient_match.dart b/flutter/lib/core/people/recipient_match.dart new file mode 100644 index 0000000..9351752 --- /dev/null +++ b/flutter/lib/core/people/recipient_match.dart @@ -0,0 +1,341 @@ +/// 받는 사람 추천의 거르기 — 순수 함수. +/// +/// **짝 셋: 웹 `frontend/src/shared/utils/recipient-match.ts` · 백엔드 `backend/src/modules/mail/recipient-match.ts`(서버의 `q` 맞추기).** +/// 같은 규칙을 그대로 옮겼다 — 표본 표도 같다(웹 `frontend/src/test-utils/recipient-match-samples.ts` · 백엔드 spec 의 표 · +/// 앱 `test/core/people/recipient_match_samples.dart` — 행과 차례까지 같다. 한쪽을 고치면 셋 다). +/// +/// - 이름 · 주소에 친 글자가 들어 있으면 맞는다(대소문자 무시). 맞은 부분은 굵게 보이도록 범위를 준다. +/// - 이름은 초성으로도 찾는다: ㄷㅇ → 이"도윤". 초성과 글자를 섞어도 된다: 도ㅇ → 이"도윤". +/// - 마지막 글자는 아직 조합 중일 수 있다 — "이도유"(윤을 치는 중) · "이동"(윤의 ㅇ 이 받침으로 붙은 순간)도 "이도윤"에 맞는다. +/// Flutter 는 칸의 글(`TextEditingController.text`)에 조합 중인 글자가 들어 있어 그대로 맞춘다. +/// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다. +/// - 이미 넣은 주소는 뺀다. 묶음은 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 묶음 안은 받은 차례 그대로, 모두 합쳐 최대 8개. +/// - 대소문자는 JS 의 `toLowerCase` 와 같게 낮춘다([jsLowerCase] — Dart 는 'İ' 를 한 글자로 낮춘다). 그리스 문자의 낱말 끝 시그마(ς)는 가르지 않는다(알려진 한계). +library; + +import 'package:arasystem_app/core/people/address_input.dart'; +import 'package:arasystem_app/core/people/recipient_suggestion.dart'; +import 'package:flutter/foundation.dart'; + +/// 추천 목록에 보이는 최대 개수 +const int kRecipientSuggestionLimit = 8; + +/// 글자 범위 — UTF-16 위치, [end] 는 들지 않는다 +@immutable +class MatchRange { + const MatchRange(this.start, this.end); + + final int start; + final int end; + + @override + bool operator ==(Object other) => + other is MatchRange && other.start == start && other.end == end; + + @override + int get hashCode => Object.hash(start, end); + + @override + String toString() => 'MatchRange($start, $end)'; +} + +/// 친 글자에 맞은 추천 하나 +@immutable +class RecipientMatch { + const RecipientMatch({required this.suggestion, this.name, this.email}); + + final RecipientSuggestion suggestion; + + /// 이름에서 맞은 범위 — 없으면 null + final MatchRange? name; + + /// 주소에서 맞은 범위 — 없으면 null + final MatchRange? email; +} + +/// 그룹 이름에 맞은 그룹 +@immutable +class RecipientGroupMatch { + const RecipientGroupMatch({required this.group, required this.name}); + + final ContactGroupSummary group; + final MatchRange name; +} + +/// 추천 목록의 한 줄 — 주소 하나 또는 그룹 하나 +sealed class RecipientOption { + const RecipientOption(); +} + +final class AddressOption extends RecipientOption { + const AddressOption(this.match); + + final RecipientMatch match; +} + +final class GroupOption extends RecipientOption { + const GroupOption(this.match); + + final RecipientGroupMatch match; +} + +/// 목록의 묶음 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소 +enum RecipientOptionGroup { contact, group, directory, recent } + +RecipientOptionGroup recipientOptionGroupOf(RecipientOption option) => + switch (option) { + GroupOption() => RecipientOptionGroup.group, + AddressOption(:final RecipientMatch match) => + switch (match.suggestion.source) { + RecipientSource.contact => RecipientOptionGroup.contact, + RecipientSource.directory => RecipientOptionGroup.directory, + RecipientSource.recent => RecipientOptionGroup.recent, + }, + }; + +/// 굵게 보일 조각 — 맞은 부분만 [strong] +typedef HighlightPart = ({String text, bool strong}); + +// ── 한글 ───────────────────────────────────────────────── + +const int _syllableStart = 0xAC00; +const int _syllableEnd = 0xD7A3; +const int _medialCount = 21; +const int _finalCount = 28; + +/// 초성 — 호환 자모 +const List _initials = [ + 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', // + 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ', +]; + +/// 받침 — 0 은 받침 없음 +const List _finals = [ + '', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', // + 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', // + 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ', +]; + +/// 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다(닭 → 달 + ㄱ) +const Map _splitFinals = { + 'ㄳ': ('ㄱ', 'ㅅ'), + 'ㄵ': ('ㄴ', 'ㅈ'), + 'ㄶ': ('ㄴ', 'ㅎ'), + 'ㄺ': ('ㄹ', 'ㄱ'), + 'ㄻ': ('ㄹ', 'ㅁ'), + 'ㄼ': ('ㄹ', 'ㅂ'), + 'ㄽ': ('ㄹ', 'ㅅ'), + 'ㄾ': ('ㄹ', 'ㅌ'), + 'ㄿ': ('ㄹ', 'ㅍ'), + 'ㅀ': ('ㄹ', 'ㅎ'), + 'ㅄ': ('ㅂ', 'ㅅ'), +}; + +typedef _Syllable = ({int initial, int medial, int finalIndex}); + +_Syllable? _syllableOf(String char) { + if (char.length != 1) return null; + final int code = char.codeUnitAt(0); + if (code < _syllableStart || code > _syllableEnd) return null; + final int offset = code - _syllableStart; + return ( + initial: offset ~/ (_medialCount * _finalCount), + medial: (offset % (_medialCount * _finalCount)) ~/ _finalCount, + finalIndex: offset % _finalCount, + ); +} + +String _composeSyllable(int initial, int medial, int finalIndex) => + String.fromCharCode( + _syllableStart + + (initial * _medialCount + medial) * _finalCount + + finalIndex, + ); + +bool _isInitialJamo(String char) => _initials.contains(char); + +String? _initialOf(String char) { + final _Syllable? syllable = _syllableOf(char); + return syllable == null ? null : _initials[syllable.initial]; +} + +/// JS 의 `String.prototype.toLowerCase` 와 같은 결과 — 'İ'(U+0130) 는 두 글자(i + 윗점 U+0307)로 낮춘다. +/// Dart 의 `toLowerCase` 는 'i' 한 글자로 낮춰, 주소 길이가 달라지는 글자를 웹 · 서버와 다르게 맞추게 된다 +String jsLowerCase(String value) => value.replaceAll('İ', 'i̇').toLowerCase(); + +/// 글자(코드 포인트) 단위로 나눈다 — 이모지 같은 짝 글자를 가르지 않는다(JS `Array.from`) +List _codePoints(String value) => [ + for (final int rune in value.runes) String.fromCharCode(rune), +]; + +// ── 맞추기 ─────────────────────────────────────────────── + +/// 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성 +bool _matchesChar(String typed, String char) { + if (jsLowerCase(typed) == jsLowerCase(char)) return true; + return _isInitialJamo(typed) && _initialOf(char) == typed; +} + +/// 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0 +int _matchesLastChar(String typed, String char, String? next) { + if (_matchesChar(typed, char)) return 1; + final _Syllable? typedSyllable = _syllableOf(typed); + final _Syllable? syllable = _syllableOf(char); + if (typedSyllable == null || syllable == null) return 0; + if (typedSyllable.initial != syllable.initial || + typedSyllable.medial != syllable.medial) { + return 0; + } + if (typedSyllable.finalIndex == 0) return 1; + if (next == null) return 0; + final String finalJamo = _finals[typedSyllable.finalIndex]; + final (String rest, String moved) = + _splitFinals[finalJamo] ?? ('', finalJamo); + final String restSyllable = _composeSyllable( + typedSyllable.initial, + typedSyllable.medial, + _finals.indexOf(rest), + ); + return restSyllable == char && _initialOf(next) == moved ? 2 : 0; +} + +/// chars[start] 부터 친 글자가 맞으면 맞은 끝(들지 않는 위치), 아니면 null +int? _matchFrom(List typed, List chars, int start) { + int index = start; + for (int at = 0; at < typed.length; at++) { + if (index >= chars.length) return null; + final String char = chars[index]; + final String typedChar = typed[at]; + if (at == typed.length - 1) { + final String? next = index + 1 < chars.length ? chars[index + 1] : null; + final int used = _matchesLastChar(typedChar, char, next); + return used == 0 ? null : index + used; + } + if (!_matchesChar(typedChar, char)) return null; + index++; + } + return index; +} + +/// 글자 단위 위치 → UTF-16 위치 +int _utf16Offset(List chars, int index) { + int offset = 0; + for (int at = 0; at < index; at++) { + offset += chars[at].length; + } + return offset; +} + +/// 이름에서 친 글자(초성 · 조합 중 포함)가 처음 맞는 범위 +MatchRange? matchName(String query, String name) { + final List typed = _codePoints(query.trim()); + final List chars = _codePoints(name); + if (typed.isEmpty) return null; + for (int start = 0; start < chars.length; start++) { + final int? end = _matchFrom(typed, chars, start); + if (end != null) { + return MatchRange(_utf16Offset(chars, start), _utf16Offset(chars, end)); + } + } + return null; +} + +/// 주소에서 친 글자가 처음 들어 있는 범위(대소문자 무시). 완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다 +MatchRange? matchEmail(String query, String email) { + final String typed = jsLowerCase(query.trim()); + final String target = jsLowerCase(email); + // 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 범위를 믿을 수 없다 — 맞추지 않는다 + if (typed.isEmpty || target.length != email.length) return null; + final int at = target.indexOf(typed); + if (at >= 0) return MatchRange(at, at + typed.length); + final int localEnd = typed.indexOf('@'); + if (localEnd <= 0) return null; + final String local = typed.substring(0, localEnd + 1); + final int localAt = target.indexOf(local); + return localAt >= 0 ? MatchRange(localAt, localAt + local.length) : null; +} + +/// 추천 하나가 친 글자에 맞는가 — 이름 · 주소 가운데 하나라도 맞으면 +RecipientMatch? matchRecipient(String query, RecipientSuggestion suggestion) { + final String? suggestionName = suggestion.name; + final MatchRange? name = suggestionName == null || suggestionName.isEmpty + ? null + : matchName(query, suggestionName); + final MatchRange? email = matchEmail(query, suggestion.email); + if (name == null && email == null) return null; + return RecipientMatch(suggestion: suggestion, name: name, email: email); +} + +/// 친 글자에 맞는 추천 — 이미 넣은 주소([excluded] — 정규화한 주소)는 빼고, 같은 주소는 처음 것만, +/// 주소록 → 사내 → 최근 주고받은 주소 차례로(묶음 안은 받은 차례 그대로), 최대 [limit] 개. 빈 글자면 아무것도 보이지 않는다 +List filterRecipientSuggestions( + String query, + List suggestions, + Set excluded, { + int limit = kRecipientSuggestionLimit, +}) { + if (query.trim().isEmpty) return const []; + final Set seen = {...excluded}; + final List matches = []; + for (final RecipientSuggestion suggestion in suggestions) { + final String key = normalizeEmail(suggestion.email); + if (seen.contains(key)) continue; + final RecipientMatch? match = matchRecipient(query, suggestion); + if (match == null) continue; + seen.add(key); + matches.add(match); + } + // 묶음 차례로 — 같은 묶음 안은 받은 차례를 지킨다(Dart 의 sort 는 안정 정렬이 아니라 묶음마다 모은다) + return [ + for (final RecipientSource source in RecipientSource.values) + ...matches.where( + (RecipientMatch match) => match.suggestion.source == source, + ), + ].take(limit).toList(); +} + +/// 친 글자가 이름에 맞는 그룹 — 구성원이 없는 그룹은 뺀다(넣을 주소가 없다). 받은 차례(이름 차례) 그대로 +List matchContactGroups( + String query, + List groups, +) { + if (query.trim().isEmpty) return const []; + return [ + for (final ContactGroupSummary group in groups) + if (group.memberCount > 0) + if (matchName(query, group.name) case final MatchRange name) + RecipientGroupMatch(group: group, name: name), + ]; +} + +/// 추천 목록의 줄 — 주소록 → 그룹 → 사내 → 최근 주고받은 주소, 모두 합쳐 최대 [limit] 개 +List arrangeRecipientOptions( + List addresses, + List groups, { + int limit = kRecipientSuggestionLimit, +}) { + return [ + for (final RecipientMatch match in addresses) + if (match.suggestion.source == RecipientSource.contact) + AddressOption(match), + for (final RecipientGroupMatch match in groups) GroupOption(match), + for (final RecipientMatch match in addresses) + if (match.suggestion.source != RecipientSource.contact) + AddressOption(match), + ].take(limit).toList(); +} + +/// 굵게 보일 조각으로 나눈다 +List highlightParts(String text, MatchRange? range) { + if (range == null || + range.start >= range.end || + range.start < 0 || + range.end > text.length) { + return [(text: text, strong: false)]; + } + return [ + (text: text.substring(0, range.start), strong: false), + (text: text.substring(range.start, range.end), strong: true), + (text: text.substring(range.end), strong: false), + ].where((HighlightPart part) => part.text.isNotEmpty).toList(); +} diff --git a/flutter/lib/core/people/recipient_suggestion.dart b/flutter/lib/core/people/recipient_suggestion.dart new file mode 100644 index 0000000..0a12cd0 --- /dev/null +++ b/flutter/lib/core/people/recipient_suggestion.dart @@ -0,0 +1,181 @@ +/// 받는 사람 추천 · 주소록 그룹의 모양 — 계약 2절 `RecipientSuggestion` · 3절 `ContactGroup` · `Contact`(구성원의 대표 주소). +/// +/// 모델은 손으로 쓴다(flutter 스킬 "모델 생성기"). 모양이 틀린 항목은 [tryParse] 가 null — 부르는 쪽이 그 항목만 버린다 +/// (추천은 보조 정보라 한 줄이 틀려도 나머지는 보인다). +library; + +import 'package:arasystem_app/core/people/address_input.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:flutter/foundation.dart'; + +/// 추천의 묶음 — 주소록 · 사내 · 최근 주고받은 주소 (계약 2절) +enum RecipientSource { + contact, + directory, + recent; + + static RecipientSource? parse(Object? value) { + for (final RecipientSource source in RecipientSource.values) { + if (source.name == value) return source; + } + return null; + } +} + +/// 주소록 연락처의 주소 종류 — 회사 · 개인 · 기타 (계약 3절 `ContactEmailLabel`) +enum ContactEmailLabel { + work, + personal, + other; + + static ContactEmailLabel? parse(Object? value) { + for (final ContactEmailLabel label in ContactEmailLabel.values) { + if (label.name == value) return label; + } + return null; + } +} + +/// 받는 사람 추천 한 줄 — `GET /api/recipient-suggestions` +@immutable +class RecipientSuggestion { + const RecipientSuggestion({ + required this.email, + required this.source, + this.name, + this.company, + this.emailLabel, + }); + + final String? name; + final String email; + final RecipientSource source; + + /// 주소록 줄만 — 그 연락처의 회사 + final String? company; + + /// 주소록 줄만 — 그 주소의 종류 + final ContactEmailLabel? emailLabel; + + PersonAddress get address => PersonAddress(email: email, name: name); + + /// 모양이 틀리면 null — 모르는 묶음은 최근 주고받은 주소로 본다(웹과 같다). 8단계 앞 서버의 빠진 칸은 null + static RecipientSuggestion? tryParse(Object? json) { + if (json is! Map) return null; + final Object? email = json['email']; + final Object? name = json['name']; + final Object? company = json['company']; + if (email is! String || email.isEmpty) return null; + if (name != null && name is! String) return null; + return RecipientSuggestion( + email: email, + name: name as String?, + source: RecipientSource.parse(json['source']) ?? RecipientSource.recent, + company: company is String ? company : null, + emailLabel: ContactEmailLabel.parse(json['emailLabel']), + ); + } + + @override + bool operator ==(Object other) => + other is RecipientSuggestion && + other.name == name && + other.email == email && + other.source == source && + other.company == company && + other.emailLabel == emailLabel; + + @override + int get hashCode => Object.hash(name, email, source, company, emailLabel); +} + +/// 주소록 그룹 — `GET /api/contact-groups` (계약 3절 `ContactGroup`) +@immutable +class ContactGroupSummary { + const ContactGroupSummary({ + required this.id, + required this.name, + required this.memberCount, + }); + + final String id; + final String name; + + /// 이 주소록에 있는 구성원 수 + final int memberCount; + + static ContactGroupSummary? tryParse(Object? json) { + if (json is! Map) return null; + final Object? id = json['id']; + final Object? name = json['name']; + final Object? count = json['memberCount']; + if (id is! String || id.isEmpty || name is! String || count is! num) { + return null; + } + return ContactGroupSummary(id: id, name: name, memberCount: count.toInt()); + } + + @override + bool operator ==(Object other) => + other is ContactGroupSummary && + other.id == id && + other.name == name && + other.memberCount == memberCount; + + @override + int get hashCode => Object.hash(id, name, memberCount); +} + +/// 그룹 구성원 한 사람 — 이름과 대표 주소(`Contact.emails` 의 첫 주소 — 없으면 null) +typedef GroupMember = ({String? name, String? email}); + +/// 구성원(`Contact`) 한 사람을 읽는다 — 모양이 틀리면 null +GroupMember? groupMemberOf(Object? json) { + if (json is! Map) return null; + final Object? name = json['name']; + final Object? emails = json['emails']; + if ((name != null && name is! String) || emails is! List) { + return null; + } + String? email; + if (emails.isNotEmpty) { + final Object? first = emails.first; + final Object? address = first is Map + ? first['address'] + : null; + if (address is String && address.trim().isNotEmpty) email = address.trim(); + } + return (name: name as String?, email: email); +} + +/// 그룹 → 넣을 대표 주소(겹치면 한 번)와 이메일이 없는 구성원 수 (짝: 웹 `shared/utils/contacts.ts` 의 `groupRecipientsOf`) +@immutable +class GroupRecipients { + const GroupRecipients({required this.addresses, required this.withoutEmail}); + + final List addresses; + final int withoutEmail; +} + +GroupRecipients groupRecipientsOf(Iterable members) { + final List addresses = []; + int withoutEmail = 0; + for (final GroupMember member in members) { + final String? email = member.email; + if (email == null) { + withoutEmail++; + continue; + } + final String? name = member.name?.trim(); + addresses.add( + PersonAddress( + email: email, + name: name == null || name.isEmpty ? null : name, + ), + ); + } + return GroupRecipients( + addresses: uniqueAddresses(addresses), + withoutEmail: withoutEmail, + ); +} diff --git a/flutter/test/core/people/address_input_test.dart b/flutter/test/core/people/address_input_test.dart new file mode 100644 index 0000000..cb91df1 --- /dev/null +++ b/flutter/test/core/people/address_input_test.dart @@ -0,0 +1,188 @@ +import 'package:arasystem_app/core/people/address_input.dart'; +import 'package:arasystem_app/core/people/person_address.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// 짝: 웹 `frontend/src/shared/utils/mail-address.spec.ts` — 같은 표본을 옮겼다 +void main() { + group('isValidEmail', () { + for (final String email in [ + 'doyun.lee@arasystem.it.kr', + 'ara.kim@arasystem.test', + 'a+tag@b.co', + ]) { + test('$email 는 올바른 주소', () => expect(isValidEmail(email), isTrue)); + } + for (final String email in [ + 'kim@arasystem', + '이도윤', + 'a@b', + 'a b@c.kr', + '', + ]) { + test('$email 는 형식 오류', () => expect(isValidEmail(email), isFalse)); + } + }); + + group('parseRecipient', () { + test('"이름 <주소>" 를 이름과 주소로 나눈다', () { + expect( + parseRecipient('이도윤 '), + const RecipientToken( + label: '이도윤 ', + address: PersonAddress( + email: 'doyun.lee@arasystem.it.kr', + name: '이도윤', + ), + valid: true, + ), + ); + }); + + test('따옴표로 감싼 이름도 받는다', () { + expect( + parseRecipient('"Kim, Ara" ')?.address, + const PersonAddress(email: 'ara.kim@arasystem.it.kr', name: 'Kim, Ara'), + ); + }); + + test('주소만 쓰면 이름은 null', () { + expect( + parseRecipient(' junho.park@arasystem.it.kr ')?.address, + const PersonAddress(email: 'junho.park@arasystem.it.kr'), + ); + }); + + test('형식이 틀리면 칩은 만들되 valid=false (빨간 칩)', () { + final RecipientToken? token = parseRecipient('kim@arasystem'); + expect(token?.label, 'kim@arasystem'); + expect(token?.valid, isFalse); + }); + + test('빈 입력은 null', () => expect(parseRecipient(' '), isNull)); + }); + + group('splitRecipientInput', () { + test('쉼표 · 세미콜론 · 줄바꿈으로 나눈다', () { + expect(splitRecipientInput('a@b.kr, c@d.kr;e@f.kr\ng@h.kr'), [ + 'a@b.kr', + 'c@d.kr', + 'e@f.kr', + 'g@h.kr', + ]); + }); + + test('따옴표 · 꺾쇠 안의 쉼표는 나누지 않는다', () { + expect(splitRecipientInput('"Kim, Ara" , '), [ + '"Kim, Ara" ', + '', + ]); + }); + }); + + group('splitPendingRecipients — 칸의 글이 바뀔 때(앱에만 있다)', () { + test('구분자가 없으면 글 그대로 이어 쓴다', () { + final ({List complete, String rest}) split = + splitPendingRecipients('doyun@ara'); + expect(split.complete, isEmpty); + expect(split.rest, 'doyun@ara'); + }); + + test('마지막 구분자까지는 다 쓴 주소 · 뒤는 이어 쓰는 글(앞 공백은 지운다)', () { + final ({List complete, String rest}) split = + splitPendingRecipients('a@b.kr, c@d.kr; e@f'); + expect(split.complete, ['a@b.kr', 'c@d.kr']); + expect(split.rest, 'e@f'); + }); + + test('쉼표로 끝나면 이어 쓰는 글은 비었다', () { + final ({List complete, String rest}) split = + splitPendingRecipients('a@b.kr, '); + expect(split.complete, ['a@b.kr']); + expect(split.rest, isEmpty); + }); + + test('따옴표 · 꺾쇠가 열린 동안의 쉼표는 구분자가 아니다', () { + final ({List complete, String rest}) split = + splitPendingRecipients('"Kim, Ara'); + expect(split.complete, isEmpty); + expect(split.rest, '"Kim, Ara'); + expect( + splitPendingRecipients('"Kim, Ara" ,').complete, + ['"Kim, Ara" '], + ); + }); + }); + + group('hasOpenRecipientGroup', () { + test('따옴표나 꺾쇠가 열린 채면 참', () { + expect(hasOpenRecipientGroup('"Kim'), isTrue); + expect(hasOpenRecipientGroup('Kim '), isFalse); + }); + }); + + group('mergeRecipientTokens', () { + test('이미 있는 주소(대소문자 무시)는 다시 넣지 않는다', () { + final List existing = [ + tokenFromAddress( + const PersonAddress(email: 'doyun.lee@arasystem.it.kr', name: '이도윤'), + ), + ]; + final List incoming = [ + ?parseRecipient('DOYUN.LEE@arasystem.it.kr'), + ?parseRecipient('junho.park@arasystem.it.kr'), + ]; + expect( + mergeRecipientTokens( + existing, + incoming, + ).map((RecipientToken token) => token.address.email), + ['doyun.lee@arasystem.it.kr', 'junho.park@arasystem.it.kr'], + ); + }); + + test('형식이 틀린 칩은 넣은 글로 겹침을 가른다', () { + final List tokens = mergeRecipientTokens( + [?parseRecipient('kim@ara')], + [ + ?parseRecipient('kim@ara'), + ?parseRecipient('KIM@ara'), + ], + ); + expect(tokens.map((RecipientToken t) => t.label), [ + 'kim@ara', + 'KIM@ara', + ]); + }); + }); + + group('표시', () { + test('formatAddress — 이름이 있으면 "이름 <주소>"', () { + expect( + formatAddress(const PersonAddress(email: 'a@b.kr', name: '김아라')), + '김아라 ', + ); + expect( + formatAddress(const PersonAddress(email: 'a@b.kr', name: ' ')), + 'a@b.kr', + ); + expect( + formatAddressList(const [ + PersonAddress(email: 'a@b.kr', name: '김아라'), + PersonAddress(email: 'c@d.kr'), + ]), + '김아라 , c@d.kr', + ); + }); + + test('uniqueAddresses — 같은 주소(대소문자 무시)는 처음 것만', () { + expect( + uniqueAddresses(const [ + PersonAddress(email: 'a@b.kr', name: '처음'), + PersonAddress(email: 'A@B.KR', name: '나중'), + ]), + const [PersonAddress(email: 'a@b.kr', name: '처음')], + ); + }); + }); +} diff --git a/flutter/test/core/people/recipient_match_samples.dart b/flutter/test/core/people/recipient_match_samples.dart new file mode 100644 index 0000000..6bb832d --- /dev/null +++ b/flutter/test/core/people/recipient_match_samples.dart @@ -0,0 +1,120 @@ +/// 받는 사람 맞추기 표본 — 시험 전용(앱 코드에서 import 하지 않는다). +/// +/// ⚠️ **같은 표 셋**: 웹 `frontend/src/test-utils/recipient-match-samples.ts` · 백엔드 `src/modules/mail/recipient-match.spec.ts` 의 +/// `RECIPIENT_MATCH_SAMPLES` 와 행 · 차례까지 같다. 한쪽을 고치면 셋 다 고친다(맞추기 규칙을 바꾸면 세 `recipient-match` 와 세 표를 함께). +/// 웹의 `normalize('NFD')` 는 Dart 에 없어 한글 음절을 같은 규칙(유니코드의 한글 분해)으로 [_nfd] 가 푼다. +library; + +/// [찾는 글(q), 이름(없으면 null), 주소, 맞는가] +typedef RecipientMatchSample = ( + String query, + String? name, + String email, + bool matches, +); + +final List kRecipientMatchSamples = + [ + ('도윤', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('DOYUN', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('Lee', 'Doyun Lee', 'd.lee@example.com', true), + ('lEE', 'Doyun Lee', 'd.lee@example.com', true), + ('윤도', '이도윤', 'doyun.lee@arasystem.it.kr', false), + ('박', '이도윤', 'doyun.lee@arasystem.it.kr', false), + ('ㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('ㅇㄷㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('ㅂㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true), + ('ㅈㅎ', '박지훈', 'jihoon.park@hanbit.co.kr', true), + ('ㅎㅈ', '박지훈', 'jihoon.park@hanbit.co.kr', false), + ('ㄲ', '꽃님', 'flower@example.com', true), + ('ㄱ', '꽃님', 'flower@example.com', false), + ('ㅃ', '빵집', 'bread@example.com', true), + ('도ㅇ', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('ㄷ윤', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('이ㄷ', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('도유', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('이도유', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('동', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('이동', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('돈', '이도윤', 'doyun.lee@arasystem.it.kr', false), + ('도요', '이도윤', 'doyun.lee@arasystem.it.kr', false), + ('유', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('윤', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('도윤이', '이도윤', 'doyun.lee@arasystem.it.kr', false), + ('지후', '박지훈', 'jihoon.park@hanbit.co.kr', true), + ('박지후', '박지훈', 'jihoon.park@hanbit.co.kr', true), + ('박짛', '박지훈', 'jihoon.park@hanbit.co.kr', true), + ('박지', '박지훈', 'jihoon.park@hanbit.co.kr', true), + ('닭', '달걀', 'egg@example.com', true), + ('넋', '너서', 'neo@example.com', false), + ('값', '가비', 'gabi@example.com', false), + ('값', '갑시다', 'gapsi@example.com', true), + ('갑', '갑시다', 'gapsi@example.com', true), + ('ㄳ', '넋', 'neok@example.com', false), + ('ㄱ', '넋', 'neok@example.com', false), + ('김 아', '김 아라', 'ara.kim@arasystem.it.kr', true), + ('김아', '김 아라', 'ara.kim@arasystem.it.kr', false), + ('ㄱ ㅇ', '김 아라', 'ara.kim@arasystem.it.kr', true), + ('ㄱㅇ', '김 아라', 'ara.kim@arasystem.it.kr', false), + (' 도윤 ', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('arasystem', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('DOYUN.LEE@', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('doyun.lee@arasystem.it.kr', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('doyun.lee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', true), + ('doyun.leee@other.example', '이도윤', 'doyun.lee@arasystem.it.kr', false), + ('@arasystem', '', 'doyun.lee@arasystem.it.kr', true), + ('lee@', 'Doyun', 'doyun.lee@arasystem.it.kr', true), + ('a@b@c', '', 'a@x.example', true), + ('Doyun.Lee@AraSystem.it.kr', null, 'doyun.lee@arasystem.it.kr', true), + ('kr', null, 'doyun.lee@arasystem.it.kr', true), + ('istanbul', null, 'İstanbul@example.com', false), + ('example', null, 'İstanbul@example.com', false), + ('도윤', null, 'doyun.lee@arasystem.it.kr', false), + ('도윤', '', 'x@example.com', false), + ('😀', '팀😀', 'team@example.com', true), + ('팀😀', '팀😀', 'team@example.com', true), + ('😀팀', '팀😀', 'team@example.com', false), + ('𠀋', '𠀋田', 'kanji@example.com', true), + ('ㅏ', '아라', 'ara@example.com', false), + ('ㅇㅏ', '아라', 'ara@example.com', false), + ('2팀', '영업2팀', 'sales2@example.com', true), + ('(주)', '(주)한빛', 'hanbit@example.com', true), + (_nfd('도윤'), '이도윤', 'doyun.lee@arasystem.it.kr', false), + ('도윤', _nfd('이도윤'), 'x@example.com', false), + ( + 'ㄱ' * 33, + 'ㄱㄱㄱㄱㄱ가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가가', + 'long@example.com', + true, + ), + ('${'가' * 32}나', '${'가' * 40}나', 'long@example.com', true), + ('${'가' * 31}나다', '${'가' * 40}나다', 'long@example.com', true), + ('${'가' * 31}나라', '${'가' * 40}나다', 'long@example.com', false), + ]; + +/// 한글 음절을 첫소리 · 가운뎃소리 · 끝소리 낱자로 푼다 — 웹 `String.prototype.normalize('NFD')` 의 한글 부분과 같다 +String _nfd(String value) { + const int base = 0xAC00; + const int last = 0xD7A3; + const int leadBase = 0x1100; + const int vowelBase = 0x1161; + const int tailBase = 0x11A7; + const int vowelCount = 21; + const int tailCount = 28; + final StringBuffer out = StringBuffer(); + for (final int rune in value.runes) { + if (rune < base || rune > last) { + out.writeCharCode(rune); + continue; + } + final int index = rune - base; + out + ..writeCharCode(leadBase + index ~/ (vowelCount * tailCount)) + ..writeCharCode( + vowelBase + (index % (vowelCount * tailCount)) ~/ tailCount, + ); + final int tail = index % tailCount; + if (tail != 0) out.writeCharCode(tailBase + tail); + } + return out.toString(); +} diff --git a/flutter/test/core/people/recipient_match_test.dart b/flutter/test/core/people/recipient_match_test.dart new file mode 100644 index 0000000..a9bcaed --- /dev/null +++ b/flutter/test/core/people/recipient_match_test.dart @@ -0,0 +1,369 @@ +import 'package:arasystem_app/core/people/address_input.dart'; +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:flutter_test/flutter_test.dart'; + +import 'recipient_match_samples.dart'; + +const RecipientSuggestion _doyun = RecipientSuggestion( + name: '이도윤', + email: 'doyun.lee@arasystem.it.kr', + source: RecipientSource.directory, +); +const RecipientSuggestion _doyunKim = RecipientSuggestion( + name: '김도윤', + email: 'doyun.kim@arasystem.it.kr', + source: RecipientSource.directory, +); +const RecipientSuggestion _partner = RecipientSuggestion( + name: '최도윤', + email: 'choi@partner.example', + source: RecipientSource.recent, +); +const RecipientSuggestion _noName = RecipientSuggestion( + email: 'ops@vendor.example', + source: RecipientSource.recent, +); + +/// 맞은 부분 글자 +String? _matched(String text, MatchRange? range) => + range == null ? null : text.substring(range.start, range.end); + +void main() { + // 웹 · 서버의 q 맞추기와 같은 표 — 행 · 차례까지 같다(recipient_match_samples.dart) + group('표본 표 — 웹 · 서버(GET /recipient-suggestions?q=)와 같은 맞추기', () { + for (final (String query, String? name, String email, bool matches) + in kRecipientMatchSamples) { + test('q=$query 이름=$name 주소=$email → $matches', () { + final bool byName = matchName(query, name ?? '') != null; + final bool byEmail = matchEmail(query, email) != null; + expect(byName || byEmail, matches); + }); + } + }); + + group('matchName — 이름(대소문자 무시 · 초성 · 조합 중인 마지막 글자)', () { + for (final (String query, String name, String expected) + in <(String, String, String)>[ + ('도윤', '이도윤', '도윤'), + ('이도', '이도윤', '이도'), + ('ㄷㅇ', '이도윤', '도윤'), + ('ㅇㄷㅇ', '이도윤', '이도윤'), + ('도ㅇ', '이도윤', '도윤'), + ('ㅇ도', '이도윤', '이도'), + // 마지막 글자가 조합 중 — "윤"을 치는 중이면 "유", 다음 글자의 ㅇ 이 받침으로 붙으면 "동" + ('이도유', '이도윤', '이도윤'), + ('이동', '이도윤', '이도윤'), + ('읻', '이도윤', '이도'), + ('닭', '달기봉', '달기'), + ('kim', 'Ara KIM', 'KIM'), + (' 도윤 ', '이도윤', '도윤'), + ]) { + test('$query → $name 에서 "$expected"', () { + expect(_matched(name, matchName(query, name)), expected); + }); + } + + for (final (String query, String name) in <(String, String)>[ + ('도윤', '김아라'), + ('ㄷㅈ', '이도윤'), + ('동윤', '이도윤'), + ('이동', '이도'), + ('윤도', '이도윤'), + ('', '이도윤'), + ]) { + test('$query 는 $name 에 맞지 않는다', () { + expect(matchName(query, name), isNull); + }); + } + + test('조합 중인 글자는 마지막에만 — 가운데 글자는 그대로 맞아야 한다', () { + expect(matchName('유ㄴ', '윤나'), isNull); + expect(matchName('윤ㄴ', '윤나'), const MatchRange(0, 2)); + }); + + test('이모지 같은 두 칸 글자가 있어도 범위는 UTF-16 위치다', () { + const String name = '😀이도윤'; + expect(_matched(name, matchName('도윤', name)), '도윤'); + }); + }); + + group('matchEmail — 주소(대소문자 무시)', () { + test('친 글자가 들어 있는 곳', () { + expect( + _matched(_doyun.email, matchEmail('doyun', _doyun.email)), + 'doyun', + ); + expect( + _matched(_doyun.email, matchEmail('ARASYSTEM', _doyun.email)), + 'arasystem', + ); + expect(matchEmail('xyz', _doyun.email), isNull); + }); + + test('완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다', () { + expect( + _matched( + _doyun.email, + matchEmail('doyun.lee@partner.example', _doyun.email), + ), + 'doyun.lee@', + ); + expect(matchEmail('@partner.example', _doyun.email), isNull); + }); + + test('İ 는 JS 처럼 두 글자로 낮춘다 — 길이가 바뀌는 주소는 맞추지 않는다', () { + expect(jsLowerCase('İ').length, 2); + expect(matchEmail('stanbul', 'İstanbul@example.com'), isNull); + }); + }); + + group('filterRecipientSuggestions', () { + const List pool = [ + _partner, + _doyun, + _noName, + _doyunKim, + ]; + + List emails(List matches) => [ + for (final RecipientMatch match in matches) match.suggestion.email, + ]; + + test('사내를 먼저, 그 안에서는 받은 차례대로', () { + expect( + emails(filterRecipientSuggestions('도윤', pool, {})), + [_doyun.email, _doyunKim.email, _partner.email], + ); + }); + + test('주소록이 사내보다 먼저 — 주소록 → 사내 → 최근, 묶음 안은 받은 차례대로', () { + const RecipientSuggestion contact = RecipientSuggestion( + name: '도윤 거래처', + email: 'doyun@vendor.example', + source: RecipientSource.contact, + ); + expect( + emails( + filterRecipientSuggestions('도윤', const [ + _partner, + _doyun, + contact, + _doyunKim, + ], {}), + ), + [contact.email, _doyun.email, _doyunKim.email, _partner.email], + ); + }); + + test('이미 넣은 주소(대소문자 무시)는 빼고, 같은 주소는 한 번만', () { + const RecipientSuggestion again = RecipientSuggestion( + name: '이도윤', + email: 'Doyun.Lee@arasystem.it.kr', + source: RecipientSource.recent, + ); + expect( + emails( + filterRecipientSuggestions( + '도윤', + [...pool, again], + {normalizeEmail(_doyunKim.email)}, + ), + ), + [_doyun.email, _partner.email], + ); + }); + + test('이름이 없으면 주소로만 맞춘다', () { + expect( + filterRecipientSuggestions( + 'vendor', + pool, + {}, + ).map((RecipientMatch match) => match.suggestion).toList(), + [_noName], + ); + }); + + test('빈 글자는 아무것도 보이지 않고, 최대 8개까지', () { + expect(filterRecipientSuggestions(' ', pool, {}), isEmpty); + final List many = [ + for (int index = 0; index < 12; index++) + RecipientSuggestion( + name: '도윤$index', + email: 'doyun$index@arasystem.it.kr', + source: RecipientSource.directory, + ), + ]; + expect( + filterRecipientSuggestions('도윤', many, {}), + hasLength(kRecipientSuggestionLimit), + ); + }); + }); + + group('그룹 — matchContactGroups · arrangeRecipientOptions', () { + const ContactGroupSummary vendors = ContactGroupSummary( + id: 'g1', + name: '거래처', + memberCount: 4, + ); + const ContactGroupSummary team = ContactGroupSummary( + id: 'g2', + name: '도윤 팀', + memberCount: 2, + ); + const ContactGroupSummary empty = ContactGroupSummary( + id: 'g3', + name: '거래 준비', + memberCount: 0, + ); + const RecipientSuggestion contact = RecipientSuggestion( + name: '이도윤', + email: 'doyun@vendor.example', + source: RecipientSource.contact, + ); + + test('그룹 이름으로 찾는다 — 초성 · 조합 중 글자도, 구성원이 없는 그룹은 뺀다', () { + expect( + matchContactGroups('거래', const [ + vendors, + team, + empty, + ]).map((RecipientGroupMatch match) => match.group.id), + ['g1'], + ); + final List byInitials = matchContactGroups( + 'ㄱㄹ', + const [vendors, empty], + ); + expect(byInitials.single.group, vendors); + expect(byInitials.single.name, const MatchRange(0, 2)); + expect( + matchContactGroups(' ', const [vendors]), + isEmpty, + ); + }); + + test('줄의 차례는 주소록 → 그룹 → 사내 → 최근, 모두 합쳐 8개까지', () { + final List options = arrangeRecipientOptions( + filterRecipientSuggestions('도윤', const [ + _partner, + _doyun, + contact, + ], {}), + matchContactGroups('도윤', const [team]), + ); + expect(options.map(recipientOptionGroupOf), [ + RecipientOptionGroup.contact, + RecipientOptionGroup.group, + RecipientOptionGroup.directory, + RecipientOptionGroup.recent, + ]); + + final List many = [ + for (int index = 0; index < 10; index++) + RecipientSuggestion( + name: '도윤$index', + email: 'doyun$index@vendor.example', + source: RecipientSource.contact, + ), + ]; + final List full = arrangeRecipientOptions( + filterRecipientSuggestions('도윤', many, {}), + matchContactGroups('도윤', const [team]), + ); + // 주소록 줄이 8개를 채우면 그룹 줄은 밀려난다(차례가 앞선다) + expect(full, hasLength(kRecipientSuggestionLimit)); + expect( + full.every((RecipientOption option) => option is AddressOption), + isTrue, + ); + }); + }); + + group('보조 함수', () { + test('highlightParts — 맞은 부분만 굵게', () { + expect(highlightParts('이도윤', const MatchRange(1, 3)), [ + (text: '이', strong: false), + (text: '도윤', strong: true), + ]); + expect(highlightParts('abc', null), [ + (text: 'abc', strong: false), + ]); + // 범위가 글 밖이면 굵게 하지 않는다 + expect(highlightParts('abc', const MatchRange(2, 9)), [ + (text: 'abc', strong: false), + ]); + }); + + test('주소록 줄의 설명 — 회사 주소는 회사 이름, 그 밖은 종류(웹 contactSuggestionDetail)', () { + RecipientSuggestion contact({ + String? company, + ContactEmailLabel? label, + }) => RecipientSuggestion( + email: 'a@b.example', + source: RecipientSource.contact, + company: company, + emailLabel: label, + ); + expect( + contactSuggestionDetail( + contact(company: '한빛', label: ContactEmailLabel.work), + ), + '한빛', + ); + expect( + contactSuggestionDetail(contact(label: ContactEmailLabel.work)), + '회사', + ); + expect( + contactSuggestionDetail( + contact(company: '한빛', label: ContactEmailLabel.personal), + ), + '개인', + ); + expect(contactSuggestionDetail(contact(company: '한빛')), '한빛'); + expect(contactSuggestionDetail(_doyun), isNull); + }); + + test('그룹 구성원 — 대표 주소만, 겹치면 한 번, 주소가 없는 사람은 센다', () { + final GroupRecipients recipients = groupRecipientsOf([ + (name: ' 이도윤 ', email: 'doyun@x.example'), + (name: null, email: 'DOYUN@x.example'), + (name: '박지훈', email: null), + (name: '', email: 'jihoon@x.example'), + ]); + expect(recipients.addresses, const [ + PersonAddress(email: 'doyun@x.example', name: '이도윤'), + PersonAddress(email: 'jihoon@x.example'), + ]); + expect(recipients.withoutEmail, 1); + }); + + test('그룹을 넣은 뒤 칸 아래 글 — 넣은 수 · 뺀 까닭(웹과 같은 글)', () { + expect( + PeopleTexts.groupInserted( + '거래처', + '받는 사람', + added: 4, + withoutEmail: 1, + already: 0, + ), + '‘거래처’ 그룹의 4명을 받는 사람에 넣었습니다. 주소가 없는 1명은 뺐습니다.', + ); + expect( + PeopleTexts.groupInserted( + '거래처', + '참조', + added: 0, + withoutEmail: 0, + already: 2, + ), + '‘거래처’ 그룹에서 참조에 더 넣을 주소가 없습니다. 이미 넣은 2명은 뺐습니다.', + ); + }); + }); +}