diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ab7760..cbbf407 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
+- 2026-07-12 | (без бампа — версия/релиз за лидом) | **Задачи — умные списки, этап 1 (чистая логика + юниты).** Новый движок `TasksSmartLists` (`feature/tasks`): семантика 6 виртуальных списков строго по веб-Tasks Nextcloud — Важное (PRIORITY 1–4), Сегодня (start|due ≤ сегодня), На неделе (≤ сегодня+6), Все (все незавершённые), Текущие (start не в будущем/без start), Завершенные; агрегация по всем реальным спискам; модель видимости `SmartListVisibility` (под будущий SharedPreferences). Добавлено feature-поле `TaskItem.startRaw` + хелпер `startLocalDate()` (пока пусто — DTSTART в `DavTask` ещё нет, запрошен у лида отдельным письмом). Заведена тест-инфра модуля (`testImplementation junit`) + 22 юнит-теста семантики/видимости — зелёные. Detekt-долг чужого пакета v0.5.133–137 (TasksScreen/ViewModel/Components) унесён в `detekt-baseline.xml`, код лида не тронут. UI-подключение — следующий шаг. | контракты не менялись | низкий: изолированная логика, в UI ещё не выведено
- 2026-07-10 | v0.5.144 (152) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.133–143). **Whiteboard**: .whiteboard наконец открывается — реальная веб-сессия Nextcloud в WebView (login-flow app-паролем → cookie+CSRF), для web-файлов отключено OkHttp-проксирование (теряло тело POST/PROPFIND). **Календарь — большой пакет**: русские месяцы в шапке; тап по дате не крашит (нативный DatePickerDialog вместо material3); фикс 403 при создании события (учёт supported-calendar-component-set — событие не уходит в VTODO-коллекцию; в ошибках теперь sabre-precondition); окно события 1:1 с живой темой (pill-поля 40dp #FBFBFB/#E6E6E6, шапка с крестиком, зелёная галочка, высота 94% экрана); дата/время нативными пикерами; беседа Talk к событию (выбор существующей или создание публичной/приватной); вложения (ATTACH: «из файлов» с навигацией по WebDAV или загрузка с устройства в /Calendar; round-trip при редактировании); кастомное повторение (модалка: интервал+частота, кружки дней недели, окончание никогда/до даты/N раз ↔ честный RRULE); многодневные события (отдельная дата окончания); несколько напоминаний; управление календарём из сайдбара (✎: переименование, цвет, шаринг пользователям/группам через DAV oc:invite + OCS sharees, удаление с подтверждением; подписки read-only); discard-диалог при закрытии с несохранёнными изменениями; занятость участников (iTIP VFREEBUSY через DAV outbox, конфликты подсвечены). **Задачи** (из 133–137): подзадачи (RELATED-TO), меню «⋯» (вложенная задача/удаление), стрелка панели открывает списки, кэш ускоряет загрузку. **Файлы** (из 133–137): .md в нативном редакторе (WebDAV load/save + предпросмотр), прогрев WebView для docx/xlsx. **Меню**: иконки шторки в белых круглых рамках с тенью, единый размер. Push/звонки: 30-с таймаут → «Пропущенный звонок» (обезличенно на локскрине) | +NextcloudWebSession (POST /login); CalDAV: PROPPATCH/DELETE/share на календарях, POST outbox (VFREEBUSY), ATTACH в VEVENT; OCS: sharees, cloud/user(email), spreed room list | средний: пишущие операции календаря (шаринг/удаление/free-busy/вложения) проверены компиляцией, на устройстве не гонялись
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
diff --git a/feature/tasks/build.gradle b/feature/tasks/build.gradle
index 986571b..b1e886d 100644
--- a/feature/tasks/build.gradle
+++ b/feature/tasks/build.gradle
@@ -36,4 +36,5 @@ dependencies {
implementation libs.lifecycle.viewmodel.compose
implementation libs.coil.compose
implementation libs.coil.svg
+ testImplementation libs.junit
}
diff --git a/feature/tasks/detekt-baseline.xml b/feature/tasks/detekt-baseline.xml
index a764bbd..538b33a 100644
--- a/feature/tasks/detekt-baseline.xml
+++ b/feature/tasks/detekt-baseline.xml
@@ -7,15 +7,20 @@
ArgumentListWrapping:TasksComponents.kt$(model = tasksAsset(serverUrl, iconPath), contentDescription = null, modifier = Modifier.size(16.dp))
ArgumentListWrapping:TasksComponents.kt$(model = tasksAsset(serverUrl, iconPath), contentDescription = null, modifier = Modifier.size(18.dp))
ArgumentListWrapping:TasksComponents.kt$(serverUrl, iconPath)
- ArgumentListWrapping:TasksGrouping.kt$TasksGrouping$(compareByDescending<TaskItem> { it.dueLocalDate() ?: LocalDate.MIN } .thenBy { it.summary.lowercase() })
+ ArgumentListWrapping:TasksViewModel.kt$TasksViewModel$(saving = false, error = t.message ?: "Не удалось удалить", unauthorized = t is UnauthorizedException)
+ ImportOrdering:TasksScreen.kt$import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
Indentation:TasksComponents.kt$
+ Indentation:TasksScreen.kt$
LongMethod:TasksComponents.kt$@Composable fun TaskDetailSheet( visible: Boolean, serverUrl: String, task: TaskItem?, saving: Boolean, onDismiss: () -> Unit, onSwipeBack: () -> Unit = onDismiss, onToggleComplete: () -> Unit, onSave: (summary: String, due: String, priority: Int, description: String) -> Unit, onDelete: () -> Unit, )
+ LongMethod:TasksComponents.kt$@Composable fun TasksRow( task: TaskItem, serverUrl: String, showMeta: Boolean = true, onToggleComplete: () -> Unit, onOpen: () -> Unit, modifier: Modifier = Modifier, onAddSubtask: (() -> Unit)? = null, onDelete: (() -> Unit)? = null, )
+ LongMethod:TasksScreen.kt$@Composable fun TasksScreen( session: AuthSession, modifier: Modifier = Modifier, createRequest: Int = 0, openListSlug: String? = null, showListsRequest: Int = 0, onOpenListConsumed: () -> Unit = {}, onUnauthorized: () -> Unit = {}, )
MaximumLineLength:TasksComponents.kt$
+ MaximumLineLength:TasksViewModel.kt$TasksViewModel$
NoUnusedImports:TasksComponents.kt$ru.forbion.f7cloud.feature.tasks.TasksComponents.kt
UnusedParameter:TasksComponents.kt$onCreateCollapse: () -> Unit
UnusedPrivateProperty:TasksComponents.kt$private val TasksTagBg = Color(0xFFDAEDD6)
UnusedPrivateProperty:TasksComponents.kt$private val TasksTagText = Color(0xFF28A413)
- Wrapping:TasksGrouping.kt$TasksGrouping$(
+ Wrapping:TasksComponents.kt$;
Wrapping:TasksViewModel.kt$TasksViewModel$(
diff --git a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksGrouping.kt b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksGrouping.kt
index 396e807..5c80979 100644
--- a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksGrouping.kt
+++ b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksGrouping.kt
@@ -24,8 +24,10 @@ object TasksGrouping {
val sorted = if (sortAscending) {
tasks.sortedWith(compareBy({ it.dueLocalDate() ?: LocalDate.MAX }, { it.summary.lowercase() }))
} else {
- tasks.sortedWith(compareByDescending { it.dueLocalDate() ?: LocalDate.MIN }
- .thenBy { it.summary.lowercase() })
+ tasks.sortedWith(
+ compareByDescending { it.dueLocalDate() ?: LocalDate.MIN }
+ .thenBy { it.summary.lowercase() }
+ )
}
val grouped = linkedMapOf>()
val colors = linkedMapOf()
@@ -65,6 +67,13 @@ fun TaskItem.dueLocalDate(): LocalDate? {
}.getOrNull()
}
+fun TaskItem.startLocalDate(): LocalDate? {
+ if (startRaw.isBlank()) return null
+ return runCatching {
+ LocalDate.parse(startRaw.take(8), DateTimeFormatter.BASIC_ISO_DATE)
+ }.getOrNull()
+}
+
fun TaskItem.isOverdue(): Boolean {
val date = dueLocalDate() ?: return false
return !isCompleted && date.isBefore(LocalDate.now())
diff --git a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksRepository.kt b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksRepository.kt
index 8ca706a..6e1e827 100644
--- a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksRepository.kt
+++ b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksRepository.kt
@@ -30,6 +30,12 @@ data class TaskItem(
val listHref: String,
val listName: String,
val parentUid: String = "",
+ /**
+ * Сырой DTSTART в формате YYYYMMDD[THHMMSS[Z]] (как dueRaw). Нужен умным спискам
+ * «Текущие»/«Сегодня»/«На неделе» (веб-Tasks учитывает и start, и due). Пусто, пока
+ * core (`DavTask`) не отдаёт DTSTART — см. письмо лиду об этой правке core.
+ */
+ val startRaw: String = "",
) {
val isCompleted: Boolean
get() = status.equals("COMPLETED", ignoreCase = true) || percentComplete >= 100
diff --git a/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksSmartLists.kt b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksSmartLists.kt
new file mode 100644
index 0000000..56f092d
--- /dev/null
+++ b/feature/tasks/src/main/java/ru/forbion/f7cloud/feature/tasks/TasksSmartLists.kt
@@ -0,0 +1,94 @@
+package ru.forbion.f7cloud.feature.tasks
+
+import java.time.LocalDate
+
+/**
+ * Умные (виртуальные) списки Задач — агрегируют задачи ПО ВСЕМ реальным спискам.
+ * Семантика строго по веб-Tasks Nextcloud (согласовано с лидом, mail/070 п.2):
+ *
+ * - **Важное** (starred): незавершённые с высоким приоритетом (VTODO PRIORITY 1–4).
+ * - **Сегодня**: незавершённые, у которых start ИЛИ due не в будущем (today или раньше,
+ * т.е. включая просроченные). Задачи вовсе без дат сюда НЕ попадают.
+ * - **На неделе**: то же, но горизонт — конец текущей недели (today .. today+6).
+ * - **Все**: все незавершённые (включая будущий start).
+ * - **Текущие** (current): незавершённые, у которых start НЕ в будущем (уже стартовали
+ * или без start). Отличие от «Все» — исключает задачи с будущим start.
+ * - **Завершенные**: завершённые.
+ *
+ * Чистая логика без Android/сети — покрыта юнит-тестами (`TasksSmartListsTest`).
+ *
+ * NB: критерии «Сегодня»/«На неделе» учитывают start; в бою start появится, когда core
+ * (`DavTask`) начнёт отдавать DTSTART (см. письмо лиду) — до этого start пуст и в расчёте
+ * участвует только due. Границу окна «неделя» и участие start сверить на веб-версии glb.
+ */
+enum class SmartListKey(val title: String, val icon: String) {
+ IMPORTANT("Важное", "important-gray.svg"),
+ TODAY("Сегодня", "calendar-today-gray.svg"),
+ WEEK("На неделе", "calendar-nextweek-gray.svg"),
+ ALL("Все", "calendar-icon-gray.svg"),
+ CURRENT("Текущие", "current-gray.svg"),
+ COMPLETED("Завершенные", "check-done-gray.svg"),
+}
+
+/** Какие умные списки показывать в панели (шит «Параметры задач»). По умолчанию — все. */
+data class SmartListVisibility(
+ private val hidden: Set = emptySet(),
+) {
+ fun isVisible(key: SmartListKey): Boolean = key !in hidden
+
+ fun withVisible(key: SmartListKey, visible: Boolean): SmartListVisibility =
+ SmartListVisibility(if (visible) hidden - key else hidden + key)
+
+ /** Ключи скрытых списков — для сериализации в SharedPreferences. */
+ fun hiddenKeys(): Set = hidden
+
+ companion object {
+ val ALL_VISIBLE = SmartListVisibility()
+
+ fun of(hidden: Collection): SmartListVisibility =
+ SmartListVisibility(hidden.toSet())
+ }
+}
+
+object TasksSmartLists {
+ /** Высокий приоритет VTODO (1 — наивысший, 4 — ещё «важный»); 0 = не задан. */
+ private val HIGH_PRIORITY = 1..4
+
+ /** Горизонт списка «На неделе»: сегодня плюс 6 дней (окно текущей недели). */
+ private const val WEEK_HORIZON_DAYS = 6L
+
+ /** Попадает ли [task] в умный список [key] при опорной дате [today]. */
+ fun matches(key: SmartListKey, task: TaskItem, today: LocalDate): Boolean = when (key) {
+ SmartListKey.IMPORTANT -> !task.isCompleted && task.priority in HIGH_PRIORITY
+ SmartListKey.TODAY -> !task.isCompleted && task.dueOrStartOnOrBefore(today)
+ SmartListKey.WEEK -> !task.isCompleted && task.dueOrStartOnOrBefore(today.plusDays(WEEK_HORIZON_DAYS))
+ SmartListKey.ALL -> !task.isCompleted
+ SmartListKey.CURRENT -> !task.isCompleted && task.hasStarted(today)
+ SmartListKey.COMPLETED -> task.isCompleted
+ }
+
+ /** Задачи умного списка [key], сохраняя исходный порядок [tasks]. */
+ fun filter(key: SmartListKey, tasks: List, today: LocalDate): List =
+ tasks.filter { matches(key, it, today) }
+
+ /** Число задач в умном списке (для бейджа в панели). */
+ fun count(key: SmartListKey, tasks: List, today: LocalDate): Int =
+ tasks.count { matches(key, it, today) }
+
+ /** Уже стартовала: нет start, либо start сегодня/в прошлом. */
+ private fun TaskItem.hasStarted(today: LocalDate): Boolean {
+ val start = startLocalDate() ?: return true
+ return !start.isAfter(today)
+ }
+
+ /**
+ * Есть start ИЛИ due, и хотя бы один из них не позже [limit].
+ * Задача без обеих дат не попадает в «Сегодня»/«На неделе».
+ */
+ private fun TaskItem.dueOrStartOnOrBefore(limit: LocalDate): Boolean {
+ val start = startLocalDate()
+ val due = dueLocalDate()
+ if (start == null && due == null) return false
+ return (start != null && !start.isAfter(limit)) || (due != null && !due.isAfter(limit))
+ }
+}
diff --git a/feature/tasks/src/test/java/ru/forbion/f7cloud/feature/tasks/TasksSmartListsTest.kt b/feature/tasks/src/test/java/ru/forbion/f7cloud/feature/tasks/TasksSmartListsTest.kt
new file mode 100644
index 0000000..fa7ffdd
--- /dev/null
+++ b/feature/tasks/src/test/java/ru/forbion/f7cloud/feature/tasks/TasksSmartListsTest.kt
@@ -0,0 +1,204 @@
+package ru.forbion.f7cloud.feature.tasks
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+
+/**
+ * Семантика умных списков Задач по веб-Tasks Nextcloud (mail/070 п.2).
+ * Опорная дата фиксирована — тесты детерминированы, от сети/Android не зависят.
+ */
+class TasksSmartListsTest {
+
+ private val today: LocalDate = LocalDate.of(2026, 7, 12)
+
+ private fun stamp(date: LocalDate?): String =
+ date?.format(DateTimeFormatter.BASIC_ISO_DATE).orEmpty()
+
+ private fun task(
+ uid: String = "u",
+ summary: String = "t",
+ priority: Int = 0,
+ status: String = "NEEDS-ACTION",
+ percent: Int = 0,
+ due: LocalDate? = null,
+ start: LocalDate? = null,
+ ) = TaskItem(
+ uid = uid,
+ href = "/$uid.ics",
+ etag = "",
+ summary = summary,
+ status = status,
+ due = stamp(due),
+ dueRaw = stamp(due),
+ priority = priority,
+ percentComplete = percent,
+ description = "",
+ listHref = "/list/",
+ listName = "Список",
+ startRaw = stamp(start),
+ )
+
+ private fun matches(key: SmartListKey, task: TaskItem) =
+ TasksSmartLists.matches(key, task, today)
+
+ // --- Важное (priority 1..4, незавершённые) ---
+
+ @Test
+ fun important_highPriorityIncomplete_matches() {
+ assertTrue(matches(SmartListKey.IMPORTANT, task(priority = 1)))
+ assertTrue(matches(SmartListKey.IMPORTANT, task(priority = 4)))
+ }
+
+ @Test
+ fun important_mediumLowOrNone_excluded() {
+ assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 0)))
+ assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 5)))
+ assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 9)))
+ }
+
+ @Test
+ fun important_completedHighPriority_excluded() {
+ assertFalse(matches(SmartListKey.IMPORTANT, task(priority = 1, status = "COMPLETED")))
+ }
+
+ // --- Сегодня (start|due <= today, есть дата, незавершённые) ---
+
+ @Test
+ fun today_dueTodayOrOverdue_matches() {
+ assertTrue(matches(SmartListKey.TODAY, task(due = today)))
+ assertTrue(matches(SmartListKey.TODAY, task(due = today.minusDays(1))))
+ }
+
+ @Test
+ fun today_dueFuture_excluded() {
+ assertFalse(matches(SmartListKey.TODAY, task(due = today.plusDays(1))))
+ }
+
+ @Test
+ fun today_startTodayNoDue_matches() {
+ assertTrue(matches(SmartListKey.TODAY, task(start = today)))
+ }
+
+ @Test
+ fun today_noDates_excluded() {
+ assertFalse(matches(SmartListKey.TODAY, task()))
+ }
+
+ @Test
+ fun today_completedDueToday_excluded() {
+ assertFalse(matches(SmartListKey.TODAY, task(due = today, status = "COMPLETED")))
+ }
+
+ // --- На неделе (start|due <= today+6) ---
+
+ @Test
+ fun week_withinHorizon_matches() {
+ assertTrue(matches(SmartListKey.WEEK, task(due = today.plusDays(6))))
+ assertTrue(matches(SmartListKey.WEEK, task(due = today.minusDays(3))))
+ }
+
+ @Test
+ fun week_beyondHorizon_excluded() {
+ assertFalse(matches(SmartListKey.WEEK, task(due = today.plusDays(7))))
+ }
+
+ // --- Все (все незавершённые, включая будущий start/due) ---
+
+ @Test
+ fun all_incompleteRegardlessOfDates_matches() {
+ assertTrue(matches(SmartListKey.ALL, task()))
+ assertTrue(matches(SmartListKey.ALL, task(start = today.plusDays(30))))
+ assertTrue(matches(SmartListKey.ALL, task(due = today.plusDays(30))))
+ }
+
+ @Test
+ fun all_completed_excluded() {
+ assertFalse(matches(SmartListKey.ALL, task(status = "COMPLETED")))
+ }
+
+ // --- Текущие (start не в будущем или без start; незавершённые) ---
+
+ @Test
+ fun current_noStart_matches() {
+ assertTrue(matches(SmartListKey.CURRENT, task()))
+ }
+
+ @Test
+ fun current_startPastOrToday_matches() {
+ assertTrue(matches(SmartListKey.CURRENT, task(start = today)))
+ assertTrue(matches(SmartListKey.CURRENT, task(start = today.minusDays(5))))
+ }
+
+ @Test
+ fun current_futureStart_excluded() {
+ assertFalse(matches(SmartListKey.CURRENT, task(start = today.plusDays(1))))
+ }
+
+ @Test
+ fun current_vs_all_futureStartTask() {
+ // Ключевое отличие: задача с будущим start есть в «Все», но нет в «Текущие».
+ val future = task(start = today.plusDays(3))
+ assertTrue(matches(SmartListKey.ALL, future))
+ assertFalse(matches(SmartListKey.CURRENT, future))
+ }
+
+ // --- Завершенные ---
+
+ @Test
+ fun completed_byStatus_matches() {
+ assertTrue(matches(SmartListKey.COMPLETED, task(status = "COMPLETED")))
+ }
+
+ @Test
+ fun completed_byPercent100_matches() {
+ assertTrue(matches(SmartListKey.COMPLETED, task(percent = 100)))
+ }
+
+ @Test
+ fun completed_incomplete_excluded() {
+ assertFalse(matches(SmartListKey.COMPLETED, task()))
+ }
+
+ // --- filter/count по набору ---
+
+ @Test
+ fun filterAndCount_overMixedSet() {
+ // a: Важное+Сегодня+Неделя+Все+Текущие. b: Все+Текущие (будущий due, старт не в будущем).
+ // c: только Все (будущий start исключает из Текущие). d: Завершенные.
+ val tasks = listOf(
+ task(uid = "a", priority = 2, due = today),
+ task(uid = "b", due = today.plusDays(10)),
+ task(uid = "c", start = today.plusDays(2)),
+ task(uid = "d", status = "COMPLETED", priority = 1),
+ )
+ assertEquals(listOf("a"), TasksSmartLists.filter(SmartListKey.IMPORTANT, tasks, today).map { it.uid })
+ assertEquals(listOf("a"), TasksSmartLists.filter(SmartListKey.TODAY, tasks, today).map { it.uid })
+ assertEquals(3, TasksSmartLists.count(SmartListKey.ALL, tasks, today))
+ assertEquals(listOf("a", "b"), TasksSmartLists.filter(SmartListKey.CURRENT, tasks, today).map { it.uid })
+ assertEquals(1, TasksSmartLists.count(SmartListKey.COMPLETED, tasks, today))
+ }
+
+ // --- Видимость ---
+
+ @Test
+ fun visibility_defaultAllVisible() {
+ val v = SmartListVisibility.ALL_VISIBLE
+ SmartListKey.entries.forEach { assertTrue(v.isVisible(it)) }
+ }
+
+ @Test
+ fun visibility_hideAndRestore() {
+ val hidden = SmartListVisibility.ALL_VISIBLE.withVisible(SmartListKey.COMPLETED, false)
+ assertFalse(hidden.isVisible(SmartListKey.COMPLETED))
+ assertTrue(hidden.isVisible(SmartListKey.ALL))
+ assertEquals(setOf(SmartListKey.COMPLETED), hidden.hiddenKeys())
+
+ val restored = hidden.withVisible(SmartListKey.COMPLETED, true)
+ assertTrue(restored.isVisible(SmartListKey.COMPLETED))
+ assertTrue(restored.hiddenKeys().isEmpty())
+ }
+}