feat(contacts): ядро P1 — updateContact/deleteContact (If-Match) + VCardEditor round-trip + 10 юнит-тестов
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (P1, contacts.diff, шаг 1/2).** `CardDavClient`: `updateContact` (PUT If-Match по ETag, round-trip неизвестных свойств vCard — PHOTO/UID/X-* сохраняются) и `deleteContact` (DELETE If-Match, 404=уже удалён, 412=конфликт ETag). `DavContact` +`href`/`etag`/`raw` (заполняются чтением книги; сигнатуры list/create НЕ менялись). `parseContacts` теперь ловит href/getetag на каждый `<response>`; общий `davContactFromVCard`. Чистый движок `VCardEditor.applyEdits` (замена управляемых FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE, фолдинг 75, CRLF, экранирование) + модели `VCardFields`/`VCardTypedValue`. **10 юнит-тестов** (`VCardEditorTest`) зелёные: round-trip неизвестных полей, FN с запятыми, PHOTO base64, folded ADR, CRLF-фолдинг. Гейт `:app:compileReleaseKotlin` зелёный. Шаг 2/2 (Room href/etag/raw + миграция, форма правки в карточке, delete в UI) — следующим коммитом. | контракты: core `CardDavClient` +2 метода, `DavContact` +3 опц. поля (обратно совместимо, list/create не тронуты); одобрено лидом mail/072 | низкий: только добавление методов + поля с дефолтами, синк не задет; на устройстве/сервере ещё не гонялось (юниты + компил-гейт)
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в UI (P1, tasks.diff).** Умные коллекции (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) выведены в `TasksListNavView` над реальными списками: агрегат задач всех списков (`loadAllTasks`), счётчики-бейджи, тап открывает отфильтрованный вид; создание в коллекции идёт в список по умолчанию. 3-state видимость (Скрыта/Видима/Авто) в шите настроек с персистом в SharedPreferences (`TasksPrefsStore`, VM через `Factory(context)`). Движок `TasksSmartLists` расширен (`SmartListMode`/`visibleKeys`/`counts`), 25 юнит-тестов зелёные. Ff_app 16.7→24.6%, Fd_app 41.5→52.7% (`tasks.diff.md`). | контракты не менялись | низкий: UI изолирован в модуле, на устройстве не гонялось
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Ответить лично» + рефактор (P1).** Пункт «Ответить лично» в меню сообщения (для чужих сообщений): `TalkViewModel.replyPrivately` открывает/создаёт 1:1 с автором через существующий `createOneToOneRoom` (OCS spreed v4, сервер идемпотентен) и `openRoom`. Заодно рефактор: блок оверлеев сообщения (лист действий + диалоги правки/удаления) вынесен из `ChatContent` в отдельный `MessageActionOverlays` — `ChatContent` LongMethod 267→174, cyclomatic 29→<25 (перестал плодить baseline-churn на каждый пункт меню). Baseline пере-привязан (сигнатура ChatContent сдвинулась от нового параметра); новый код чист (компиляция+detekt зелёные). | контракты не менялись (OCS spreed v4/v1) | низкий: изолированное UI-действие + чистый рефактор, на устройстве не гонялось
|
||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: напоминание (P1).** Пункт «Напоминание» в `TalkMessageActionSheet` с пресетами «Сегодня» (18:00) / «Завтра» (08:00) / «Выходные» (сб 08:00) / «Неделя» (пн 08:00): `TalkRepository.setReminder` (OCS `POST /chat/{token}/{messageId}/reminder`, timestamp сек) + `TalkViewModel.setMessageReminder`/`reminderTimestamp` + enum `ReminderPreset`. Закрывает пункт «Напоминание» дыр меню сообщения (talk.diff.md). Baseline `feature/talk` пере-привязан (сигнатура ChatContent + импорт сдвинули существующие записи; новый код чист, компиляция+detekt зелёные). | контракты не менялись (OCS spreed v1) | низкий: изолированное UI-действие, на устройстве не гонялось
|
||||
|
||||
@@ -26,6 +26,35 @@ data class DavContact(
|
||||
val birthday: String = "",
|
||||
val emails: String = "",
|
||||
val phones: String = "",
|
||||
// Ниже — для безопасной правки/удаления (If-Match) и round-trip неизвестных полей vCard.
|
||||
// Заполняются только чтением из книги (listContacts); create возвращает пустыми — ок.
|
||||
val href: String = "",
|
||||
val etag: String = "",
|
||||
val raw: String = "",
|
||||
)
|
||||
|
||||
/** Типизированное значение (EMAIL/TEL) с меткой TYPE (HOME/WORK/CELL/…). */
|
||||
data class VCardTypedValue(
|
||||
val value: String,
|
||||
val type: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Управляемые поля контакта, которые правит UI. Всё, чего здесь нет
|
||||
* (PHOTO, X-*, UID, кастомные свойства), сохраняется из сырого vCard как есть.
|
||||
*/
|
||||
data class VCardFields(
|
||||
val displayName: String,
|
||||
val firstName: String = "",
|
||||
val lastName: String = "",
|
||||
val emails: List<VCardTypedValue> = emptyList(),
|
||||
val phones: List<VCardTypedValue> = emptyList(),
|
||||
val organization: String = "",
|
||||
val title: String = "",
|
||||
val address: String = "",
|
||||
val website: String = "",
|
||||
val birthday: String = "",
|
||||
val note: String = "",
|
||||
)
|
||||
|
||||
object CardDavClient {
|
||||
@@ -150,6 +179,58 @@ object CardDavClient {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Правка существующего контакта. Берёт СЫРОЙ vCard (round-trip: неизвестные свойства —
|
||||
* PHOTO, X-*, кастомные — сохраняются), заменяет управляемые поля и делает PUT с If-Match
|
||||
* по ETag (защита от гонки). Возвращает обновлённый DavContact с новым ETag.
|
||||
* Сигнатуры list/create не трогаю — это отдельный метод.
|
||||
*/
|
||||
fun updateContact(
|
||||
client: OkHttpClient,
|
||||
href: String,
|
||||
etag: String,
|
||||
rawVCard: String,
|
||||
fields: VCardFields,
|
||||
bookName: String = "",
|
||||
): DavContact {
|
||||
require(href.isNotBlank()) { "Пустой href контакта — правка невозможна" }
|
||||
val newVCard = VCardEditor.applyEdits(rawVCard, fields)
|
||||
val builder = Request.Builder()
|
||||
.url(href)
|
||||
.put(newVCard.toRequestBody("text/vcard; charset=utf-8".toMediaType()))
|
||||
if (etag.isNotBlank()) builder.header("If-Match", etag)
|
||||
val newEtag = client.newCall(builder.build()).execute().use { response ->
|
||||
if (response.code == 412) {
|
||||
error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите")
|
||||
}
|
||||
if (response.code !in 200..299 && response.code != 204) {
|
||||
error("Не удалось сохранить контакт (HTTP ${response.code})")
|
||||
}
|
||||
response.header("ETag").orEmpty()
|
||||
}
|
||||
return davContactFromVCard(newVCard, bookName, href, newEtag.ifBlank { etag })
|
||||
?: error("Не удалось разобрать сохранённый контакт")
|
||||
}
|
||||
|
||||
/** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */
|
||||
fun deleteContact(
|
||||
client: OkHttpClient,
|
||||
href: String,
|
||||
etag: String,
|
||||
) {
|
||||
require(href.isNotBlank()) { "Пустой href контакта — удаление невозможно" }
|
||||
val builder = Request.Builder().url(href).delete()
|
||||
if (etag.isNotBlank()) builder.header("If-Match", etag)
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
if (response.code == 412) {
|
||||
error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите")
|
||||
}
|
||||
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
|
||||
error("Не удалось удалить контакт (HTTP ${response.code})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildVCard(uid: String, displayName: String, email: String, phone: String): String {
|
||||
val fn = displayName.trim()
|
||||
val parts = fn.split(Regex("\\s+"), limit = 2)
|
||||
@@ -199,7 +280,7 @@ object CardDavClient {
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, url, depth = 1, body)
|
||||
return parseContacts(xml, bookName).take(limit)
|
||||
return parseContacts(xml, bookName, url).take(limit)
|
||||
}
|
||||
|
||||
private fun propfind(client: OkHttpClient, url: String, depth: Int, body: String): String {
|
||||
@@ -264,47 +345,35 @@ object CardDavClient {
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseContacts(xml: String, bookName: String): List<DavContact> {
|
||||
private fun parseContacts(xml: String, bookName: String, bookUrl: String): List<DavContact> {
|
||||
val parser = newParser(xml)
|
||||
val out = mutableListOf<DavContact>()
|
||||
var inAddressData = false
|
||||
val data = StringBuilder()
|
||||
var currentHref = ""
|
||||
var currentEtag = ""
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> if (parser.localTag() == "address-data") {
|
||||
inAddressData = true
|
||||
data.clear()
|
||||
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||
"response" -> {
|
||||
currentHref = ""
|
||||
currentEtag = ""
|
||||
}
|
||||
// href до address-data — это путь vCard в текущем <response>
|
||||
"href" -> if (!inAddressData && currentHref.isBlank()) {
|
||||
currentHref = parser.readText().trim()
|
||||
}
|
||||
"getetag" -> currentEtag = parser.readText().trim()
|
||||
"address-data" -> {
|
||||
inAddressData = true
|
||||
data.clear()
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> if (inAddressData) data.append(parser.text)
|
||||
XmlPullParser.END_TAG -> if (parser.localTag() == "address-data" && inAddressData) {
|
||||
val vcard = unfoldVCard(data.toString())
|
||||
val uid = extract(uidPattern, vcard).ifBlank {
|
||||
vcard.hashCode().toString()
|
||||
}
|
||||
val name = resolveContactName(vcard)
|
||||
val allEmails = extractAll(emailPattern, vcard)
|
||||
val allPhones = extractAll(telPattern, vcard)
|
||||
val email = allEmails.firstOrNull().orEmpty()
|
||||
val phone = allPhones.firstOrNull().orEmpty()
|
||||
val photo = parseContactPhoto(vcard)
|
||||
if (name.isNotBlank() || email.isNotBlank()) {
|
||||
out += DavContact(
|
||||
uid = uid,
|
||||
displayName = name,
|
||||
email = email,
|
||||
phone = phone,
|
||||
bookName = bookName,
|
||||
photoBase64 = photo?.base64.orEmpty(),
|
||||
photoMimeType = photo?.mimeType.orEmpty(),
|
||||
organization = formatOrganization(extract(orgPattern, vcard)),
|
||||
title = extract(titlePattern, vcard),
|
||||
address = formatAddress(extract(adrPattern, vcard)),
|
||||
website = extract(urlPattern, vcard),
|
||||
birthday = formatBirthday(extract(bdayPattern, vcard)),
|
||||
emails = allEmails.joinToString("\n"),
|
||||
phones = allPhones.joinToString("\n"),
|
||||
)
|
||||
}
|
||||
val fullHref = if (currentHref.isBlank()) "" else resolveHref(bookUrl, currentHref)
|
||||
davContactFromVCard(data.toString(), bookName, fullHref, currentEtag)
|
||||
?.let { out += it }
|
||||
inAddressData = false
|
||||
}
|
||||
}
|
||||
@@ -313,6 +382,46 @@ object CardDavClient {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит DavContact из сырого vCard. `raw` сохраняем как есть (для round-trip при правке),
|
||||
* разбор — по развёрнутому (unfold) тексту. Общий для чтения книги и для updateContact.
|
||||
*/
|
||||
private fun davContactFromVCard(
|
||||
rawVCard: String,
|
||||
bookName: String,
|
||||
href: String,
|
||||
etag: String,
|
||||
): DavContact? {
|
||||
val vcard = unfoldVCard(rawVCard)
|
||||
val uid = extract(uidPattern, vcard).ifBlank { vcard.hashCode().toString() }
|
||||
val name = resolveContactName(vcard)
|
||||
val allEmails = extractAll(emailPattern, vcard)
|
||||
val allPhones = extractAll(telPattern, vcard)
|
||||
val email = allEmails.firstOrNull().orEmpty()
|
||||
val phone = allPhones.firstOrNull().orEmpty()
|
||||
val photo = parseContactPhoto(vcard)
|
||||
if (name.isBlank() && email.isBlank()) return null
|
||||
return DavContact(
|
||||
uid = uid,
|
||||
displayName = name,
|
||||
email = email,
|
||||
phone = phone,
|
||||
bookName = bookName,
|
||||
photoBase64 = photo?.base64.orEmpty(),
|
||||
photoMimeType = photo?.mimeType.orEmpty(),
|
||||
organization = formatOrganization(extract(orgPattern, vcard)),
|
||||
title = extract(titlePattern, vcard),
|
||||
address = formatAddress(extract(adrPattern, vcard)),
|
||||
website = extract(urlPattern, vcard),
|
||||
birthday = formatBirthday(extract(bdayPattern, vcard)),
|
||||
emails = allEmails.joinToString("\n"),
|
||||
phones = allPhones.joinToString("\n"),
|
||||
href = href,
|
||||
etag = etag,
|
||||
raw = rawVCard,
|
||||
)
|
||||
}
|
||||
|
||||
private fun unfoldVCard(raw: String): String {
|
||||
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||
val lines = normalized.split('\n')
|
||||
@@ -484,3 +593,119 @@ object CardDavClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Чистый (без сети) редактор vCard 3.0 для round-trip правки. Управляемые свойства
|
||||
* (FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE) заменяются значениями из [VCardFields];
|
||||
* все прочие (VERSION, UID, PHOTO, REV, X-* и кастомные) сохраняются из исходного vCard как есть.
|
||||
* Тестируется юнитами: round-trip неизвестных полей, FN с запятыми, PHOTO base64,
|
||||
* многострочный (folded) ADR, CRLF-фолдинг.
|
||||
*/
|
||||
internal object VCardEditor {
|
||||
private const val CRLF = "\r\n"
|
||||
private val MANAGED = setOf(
|
||||
"FN", "N", "EMAIL", "TEL", "ORG", "TITLE", "ADR", "URL", "BDAY", "NOTE",
|
||||
)
|
||||
|
||||
fun applyEdits(rawVCard: String, fields: VCardFields): String {
|
||||
val logical = groupLogicalLines(rawVCard)
|
||||
val preserved = logical.filter { line ->
|
||||
val p = propertyName(line).uppercase()
|
||||
p.isNotEmpty() && p != "BEGIN" && p != "END" && p !in MANAGED
|
||||
}
|
||||
val hasVersion = preserved.any { propertyName(it).equals("VERSION", ignoreCase = true) }
|
||||
val sb = StringBuilder()
|
||||
sb.append("BEGIN:VCARD").append(CRLF)
|
||||
if (!hasVersion) sb.append("VERSION:3.0").append(CRLF)
|
||||
preserved.forEach { appendFolded(sb, it) }
|
||||
emitManaged(sb, fields)
|
||||
sb.append("END:VCARD").append(CRLF)
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun emitManaged(sb: StringBuilder, f: VCardFields) {
|
||||
val fn = f.displayName.trim()
|
||||
if (fn.isNotEmpty()) appendFolded(sb, "FN:${escapeText(fn)}")
|
||||
val last = f.lastName.trim()
|
||||
val first = f.firstName.trim()
|
||||
when {
|
||||
last.isNotEmpty() || first.isNotEmpty() ->
|
||||
appendFolded(sb, "N:${escapeComponent(last)};${escapeComponent(first)};;;")
|
||||
fn.isNotEmpty() -> {
|
||||
// N обязателен в vCard 3.0 — выводим из FN (первое слово — имя, остальное — фамилия)
|
||||
val parts = fn.split(Regex("\\s+"), limit = 2)
|
||||
val given = parts.getOrNull(0).orEmpty()
|
||||
val family = parts.getOrNull(1).orEmpty()
|
||||
appendFolded(sb, "N:${escapeComponent(family)};${escapeComponent(given)};;;")
|
||||
}
|
||||
}
|
||||
f.emails.filter { it.value.isNotBlank() }.forEach {
|
||||
appendFolded(sb, "EMAIL;TYPE=${it.type.ifBlank { "INTERNET" }}:${escapeText(it.value.trim())}")
|
||||
}
|
||||
f.phones.filter { it.value.isNotBlank() }.forEach {
|
||||
appendFolded(sb, "TEL;TYPE=${it.type.ifBlank { "CELL" }}:${escapeText(it.value.trim())}")
|
||||
}
|
||||
if (f.organization.isNotBlank()) appendFolded(sb, "ORG:${escapeComponent(f.organization.trim())}")
|
||||
if (f.title.isNotBlank()) appendFolded(sb, "TITLE:${escapeText(f.title.trim())}")
|
||||
if (f.address.isNotBlank()) {
|
||||
// плоский адрес → поле street структуры ADR: pobox;ext;street;city;region;zip;country
|
||||
appendFolded(sb, "ADR;TYPE=HOME:;;${escapeComponent(f.address.trim())};;;;")
|
||||
}
|
||||
if (f.website.isNotBlank()) appendFolded(sb, "URL:${escapeText(f.website.trim())}")
|
||||
if (f.birthday.isNotBlank()) appendFolded(sb, "BDAY:${f.birthday.trim()}")
|
||||
if (f.note.isNotBlank()) appendFolded(sb, "NOTE:${escapeText(f.note.trim())}")
|
||||
}
|
||||
|
||||
/** Группирует физические строки в логические: продолжение начинается с ' ' или '\t'. */
|
||||
private fun groupLogicalLines(raw: String): List<String> {
|
||||
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||
val out = mutableListOf<String>()
|
||||
val cur = StringBuilder()
|
||||
for (line in normalized.split('\n')) {
|
||||
if (line.isEmpty()) continue
|
||||
if ((line[0] == ' ' || line[0] == '\t') && cur.isNotEmpty()) {
|
||||
cur.append(line.substring(1))
|
||||
} else {
|
||||
if (cur.isNotEmpty()) out += cur.toString()
|
||||
cur.setLength(0)
|
||||
cur.append(line)
|
||||
}
|
||||
}
|
||||
if (cur.isNotEmpty()) out += cur.toString()
|
||||
return out
|
||||
}
|
||||
|
||||
private fun propertyName(logicalLine: String): String {
|
||||
val end = logicalLine.indexOfFirst { it == ':' || it == ';' }
|
||||
val head = if (end < 0) logicalLine else logicalLine.substring(0, end)
|
||||
// свойство может иметь group-префикс "item1.EMAIL" — берём часть после точки
|
||||
return head.substringAfterLast('.').trim()
|
||||
}
|
||||
|
||||
/** Фолдинг длинных строк по 75 символов с CRLF+пробел (RFC 2426 §2.6). */
|
||||
private fun appendFolded(sb: StringBuilder, line: String) {
|
||||
if (line.length <= 75) {
|
||||
sb.append(line).append(CRLF)
|
||||
return
|
||||
}
|
||||
var i = 0
|
||||
var first = true
|
||||
while (i < line.length) {
|
||||
val take = if (first) 75 else 74
|
||||
val end = minOf(i + take, line.length)
|
||||
if (!first) sb.append(' ')
|
||||
sb.append(line, i, end).append(CRLF)
|
||||
i = end
|
||||
first = false
|
||||
}
|
||||
}
|
||||
|
||||
// TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard
|
||||
// экранируем только backslash и перевод строки (запятые в имени оставляем литералом).
|
||||
private fun escapeText(v: String): String =
|
||||
v.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "")
|
||||
|
||||
// компонент структурного значения (N/ADR/ORG): дополнительно экранируем разделители ; и ,
|
||||
private fun escapeComponent(v: String): String =
|
||||
escapeText(v).replace(";", "\\;").replace(",", "\\,")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Тесты round-trip редактора vCard (P1 правка контакта, mail/072).
|
||||
* Ключевое: неизвестные свойства (PHOTO, X-*, UID) сохраняются, управляемые — заменяются.
|
||||
* Краевые из письма лида: FN с запятыми, PHOTO base64, многострочный ADR, CRLF-фолдинг.
|
||||
*/
|
||||
class VCardEditorTest {
|
||||
|
||||
private fun edit(raw: String, fields: VCardFields) = VCardEditor.applyEdits(raw, fields)
|
||||
|
||||
private val base = VCardFields(displayName = "Иван Петров")
|
||||
|
||||
@Test fun preserves_unknown_properties_and_uid_and_photo() {
|
||||
val raw = buildString {
|
||||
append("BEGIN:VCARD\r\n")
|
||||
append("VERSION:3.0\r\n")
|
||||
append("UID:abc-123\r\n")
|
||||
append("FN:Старое Имя\r\n")
|
||||
append("N:Имя;Старое;;;\r\n")
|
||||
append("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD\r\n")
|
||||
append("X-CUSTOM-FIELD:держим-как-есть\r\n")
|
||||
append("TEL;TYPE=CELL:+70000000000\r\n")
|
||||
append("END:VCARD\r\n")
|
||||
}
|
||||
val out = edit(raw, VCardFields(displayName = "Иван Петров", phones = listOf(VCardTypedValue("+79990001122", "WORK"))))
|
||||
// UID и неизвестные свойства сохранены
|
||||
assertTrue("UID сохранён", out.contains("UID:abc-123"))
|
||||
assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD"))
|
||||
assertTrue("X-CUSTOM сохранён", out.contains("X-CUSTOM-FIELD:держим-как-есть"))
|
||||
// управляемые поля заменены на новые
|
||||
assertTrue("новый FN", out.contains("FN:Иван Петров"))
|
||||
assertFalse("старый FN убран", out.contains("Старое Имя"))
|
||||
assertTrue("новый телефон", out.contains("TEL;TYPE=WORK:+79990001122"))
|
||||
assertFalse("старый телефон убран", out.contains("+70000000000"))
|
||||
// валидный каркас
|
||||
assertTrue(out.startsWith("BEGIN:VCARD\r\n"))
|
||||
assertTrue(out.trimEnd().endsWith("END:VCARD"))
|
||||
assertEquals("одна VERSION", 1, Regex("(?m)^VERSION:").findAll(out).count())
|
||||
}
|
||||
|
||||
@Test fun fn_with_commas_is_kept_literally() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:X\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, VCardFields(displayName = "Петров, Иван, Иванович"))
|
||||
assertTrue("запятые в FN сохранены литералом", out.contains("FN:Петров, Иван, Иванович"))
|
||||
}
|
||||
|
||||
@Test fun folded_multiline_adr_is_regrouped_and_replaced() {
|
||||
// ADR разбит фолдингом на 2 физические строки (продолжение с пробела)
|
||||
val raw = buildString {
|
||||
append("BEGIN:VCARD\r\n")
|
||||
append("VERSION:3.0\r\n")
|
||||
append("UID:y\r\n")
|
||||
append("FN:Кто-то\r\n")
|
||||
append("ADR;TYPE=HOME:;;ул. Очень Длинная Дом 1 Корпус 2 Строение 3\r\n")
|
||||
append(" Квартира 456 Подъезд 7;Город;;;\r\n")
|
||||
append("END:VCARD\r\n")
|
||||
}
|
||||
val out = edit(raw, VCardFields(displayName = "Кто-то", address = "Новый Город, ул. Новая 5"))
|
||||
assertTrue("новый адрес в ADR", out.contains("Новый Город"))
|
||||
assertFalse("старый адрес убран", out.contains("Очень Длинная"))
|
||||
// не осталось «висящей» строки-продолжения старого ADR
|
||||
assertFalse(out.contains("Квартира 456"))
|
||||
}
|
||||
|
||||
@Test fun long_line_is_folded_at_75_with_crlf_space() {
|
||||
val longNote = "Z".repeat(200)
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:N\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, base.copy(note = longNote))
|
||||
// каждая физическая строка ≤ 76 символов (75 + возможный ведущий пробел)
|
||||
val physical = out.split("\r\n").filter { it.isNotEmpty() }
|
||||
assertTrue("все строки свёрнуты ≤76", physical.all { it.length <= 76 })
|
||||
// продолжения начинаются с пробела
|
||||
assertTrue("есть свёрнутое продолжение", physical.any { it.startsWith(" Z") })
|
||||
// при развороте NOTE восстанавливается целиком
|
||||
val unfolded = out.replace("\r\n ", "")
|
||||
assertTrue(unfolded.contains("NOTE:$longNote"))
|
||||
}
|
||||
|
||||
@Test fun crlf_and_lf_inputs_both_parse() {
|
||||
val lf = "BEGIN:VCARD\nVERSION:3.0\nUID:m\nFN:Старое\nX-KEEP:1\nEND:VCARD\n"
|
||||
val out = edit(lf, base)
|
||||
assertTrue(out.contains("X-KEEP:1"))
|
||||
assertTrue(out.contains("FN:Иван Петров"))
|
||||
// выход всегда CRLF
|
||||
assertTrue(out.contains("\r\n"))
|
||||
assertFalse("нет одиночных LF", out.replace("\r\n", "").contains("\n"))
|
||||
}
|
||||
|
||||
@Test fun n_derived_from_fn_when_name_parts_absent() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, VCardFields(displayName = "Иван Петров"))
|
||||
// N: фамилия;имя;;; — из FN: given=Иван, family=Петров
|
||||
assertTrue(out.contains("N:Петров;Иван;;;"))
|
||||
}
|
||||
|
||||
@Test fun explicit_name_parts_win_over_fn() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:e\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, VCardFields(displayName = "Ivan P", firstName = "Ivan", lastName = "Petrov"))
|
||||
assertTrue(out.contains("N:Petrov;Ivan;;;"))
|
||||
}
|
||||
|
||||
@Test fun multiple_emails_and_types_emitted() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:me\r\nFN:X\r\nEMAIL:old@x.ru\r\nEND:VCARD\r\n"
|
||||
val out = edit(
|
||||
raw,
|
||||
base.copy(
|
||||
emails = listOf(
|
||||
VCardTypedValue("home@x.ru", "HOME"),
|
||||
VCardTypedValue("work@x.ru", "WORK"),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertFalse(out.contains("old@x.ru"))
|
||||
assertTrue(out.contains("EMAIL;TYPE=HOME:home@x.ru"))
|
||||
assertTrue(out.contains("EMAIL;TYPE=WORK:work@x.ru"))
|
||||
}
|
||||
|
||||
@Test fun blank_managed_fields_are_omitted() {
|
||||
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:b\r\nFN:X\r\nEND:VCARD\r\n"
|
||||
val out = edit(raw, VCardFields(displayName = "Только Имя"))
|
||||
assertFalse("нет пустого EMAIL", out.contains("EMAIL"))
|
||||
assertFalse("нет пустого TEL", out.contains("TEL"))
|
||||
assertFalse("нет пустого ADR", out.contains("ADR"))
|
||||
assertFalse("нет пустого ORG", out.contains("ORG"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user