P3 Stream J: widget data repository (DataStore-backed)

Ports iOS WidgetDataManager.swift semantics. DTO + JSON serialization +
pending-completion tracking + stats (overdueCount / dueWithin7 / dueWithin8To30).
Same-process DataStore is sufficient for Glance widgets.

Unblocks Streams K (widgets) / L (scheduler) / M (actions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:40:48 -05:00
parent 7aab8b0f29
commit 6d7b5ee990
4 changed files with 566 additions and 41 deletions

View File

@@ -0,0 +1,273 @@
package com.tt.honeyDue.widget
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* Unit tests for [WidgetDataRepository].
*
* Mirrors iOS WidgetDataManager.swift semantics:
* - Save/load tasks as JSON
* - Pending-completion tracking (optimistic UI while server sync in flight)
* - Stats computation: overdueCount / dueWithin7 / dueWithin8To30
* - Tier state persistence ("free" vs "premium")
*/
@RunWith(RobolectricTestRunner::class)
class WidgetDataRepositoryTest {
private lateinit var context: Context
private lateinit var repo: WidgetDataRepository
@Before
fun setUp() = runTest {
context = ApplicationProvider.getApplicationContext()
repo = WidgetDataRepository.get(context)
// Ensure a clean slate between tests — DataStore singletons persist per-process.
repo.clearAll()
}
@After
fun tearDown() = runTest {
repo.clearAll()
}
// ---------- Helpers ----------
/** Produce an ISO-8601 date string N days from now (at midnight local). */
private fun isoDateDaysFromNow(days: Int): String {
val now = Clock.System.now()
val future = now.plus(days, DateTimeUnit.DAY, TimeZone.currentSystemDefault())
val local = future.toLocalDateTime(TimeZone.currentSystemDefault())
// YYYY-MM-DD
val month = local.monthNumber.toString().padStart(2, '0')
val day = local.dayOfMonth.toString().padStart(2, '0')
return "${local.year}-$month-$day"
}
private fun task(
id: Long,
title: String = "Task $id",
dueInDays: Int? = null,
isOverdue: Boolean = false,
residenceId: Long = 1L,
residenceName: String = "Home",
categoryIcon: String = "house.fill",
priority: Long = 2L,
completed: Boolean = false
): WidgetTaskDto {
val dueDate = dueInDays?.let { isoDateDaysFromNow(it) }
val daysUntilDue = dueInDays ?: 0
return WidgetTaskDto(
id = id,
title = title,
priority = priority,
dueDate = dueDate,
isOverdue = isOverdue,
daysUntilDue = daysUntilDue,
residenceId = residenceId,
residenceName = residenceName,
categoryIcon = categoryIcon,
completed = completed
)
}
// ---------- Tests ----------
@Test
fun saveTasks_then_loadTasks_roundTrip() = runTest {
val input = listOf(
task(id = 1L, title = "Change air filter", dueInDays = 3),
task(id = 2L, title = "Clean gutters", dueInDays = 14),
task(id = 3L, title = "Check smoke detector", isOverdue = true, dueInDays = -2)
)
repo.saveTasks(input)
val output = repo.loadTasks()
assertEquals(3, output.size)
val byId = output.associateBy { it.id }
assertEquals("Change air filter", byId[1L]?.title)
assertEquals("Clean gutters", byId[2L]?.title)
assertEquals("Check smoke detector", byId[3L]?.title)
assertEquals(true, byId[3L]?.isOverdue)
assertEquals(1L, byId[1L]?.residenceId)
assertEquals("Home", byId[1L]?.residenceName)
assertEquals("house.fill", byId[1L]?.categoryIcon)
assertNotNull(byId[1L]?.dueDate)
}
@Test
fun empty_initial_state_returns_empty_list() = runTest {
val loaded = repo.loadTasks()
assertTrue("Expected empty list from fresh store", loaded.isEmpty())
}
@Test
fun markPendingCompletion_excludes_task_from_loadTasks() = runTest {
val input = listOf(
task(id = 10L, dueInDays = 2),
task(id = 20L, dueInDays = 5),
task(id = 30L, dueInDays = 9)
)
repo.saveTasks(input)
repo.markPendingCompletion(20L)
val loaded = repo.loadTasks()
assertEquals(2, loaded.size)
assertFalse(loaded.any { it.id == 20L })
assertTrue(loaded.any { it.id == 10L })
assertTrue(loaded.any { it.id == 30L })
}
@Test
fun clearPendingCompletion_restores_task_to_loadTasks() = runTest {
val input = listOf(
task(id = 100L, dueInDays = 1),
task(id = 200L, dueInDays = 4)
)
repo.saveTasks(input)
repo.markPendingCompletion(200L)
assertEquals(1, repo.loadTasks().size)
repo.clearPendingCompletion(200L)
val restored = repo.loadTasks()
assertEquals(2, restored.size)
assertTrue(restored.any { it.id == 200L })
}
@Test
fun computeStats_overdueCount() = runTest {
val input = listOf(
task(id = 1L, isOverdue = true, dueInDays = -1),
task(id = 2L, isOverdue = true, dueInDays = -5),
task(id = 3L, dueInDays = 3)
)
repo.saveTasks(input)
val stats = repo.computeStats()
assertEquals(2, stats.overdueCount)
}
@Test
fun computeStats_dueWithin7() = runTest {
val input = listOf(
task(id = 1L, dueInDays = 1),
task(id = 2L, dueInDays = 3),
task(id = 3L, dueInDays = 6)
)
repo.saveTasks(input)
val stats = repo.computeStats()
assertEquals(3, stats.dueWithin7)
}
@Test
fun computeStats_dueWithin8To30() = runTest {
val input = listOf(
task(id = 1L, dueInDays = 10),
task(id = 2L, dueInDays = 25)
)
repo.saveTasks(input)
val stats = repo.computeStats()
assertEquals(2, stats.dueWithin8To30)
}
@Test
fun computeStats_boundaries() = runTest {
val input = listOf(
task(id = 1L, dueInDays = 7), // inclusive upper bound of 7-day window
task(id = 2L, dueInDays = 8), // inclusive lower bound of 8-30 window
task(id = 3L, dueInDays = 30), // inclusive upper bound of 8-30 window
task(id = 4L, dueInDays = 31) // outside both windows
)
repo.saveTasks(input)
val stats = repo.computeStats()
assertEquals("7-day task should count in dueWithin7", 1, stats.dueWithin7)
assertEquals("8-day and 30-day tasks should count in dueWithin8To30", 2, stats.dueWithin8To30)
assertEquals("No overdue tasks in this set", 0, stats.overdueCount)
}
@Test
fun saveTierState_loadTierState_roundTrip() = runTest {
// Default should be "free".
assertEquals("free", repo.loadTierState())
repo.saveTierState("premium")
assertEquals("premium", repo.loadTierState())
repo.saveTierState("free")
assertEquals("free", repo.loadTierState())
}
@Test
fun overwrite_tasks_replaces_not_appends() = runTest {
val first = listOf(
task(id = 1L, dueInDays = 1),
task(id = 2L, dueInDays = 2),
task(id = 3L, dueInDays = 3)
)
repo.saveTasks(first)
assertEquals(3, repo.loadTasks().size)
val second = listOf(
task(id = 4L, dueInDays = 4),
task(id = 5L, dueInDays = 5)
)
repo.saveTasks(second)
val loaded = repo.loadTasks()
assertEquals(2, loaded.size)
assertFalse("Previous tasks should be gone after overwrite", loaded.any { it.id == 1L })
assertTrue(loaded.any { it.id == 4L })
assertTrue(loaded.any { it.id == 5L })
}
@Test
fun saveTasks_empty_list_clears_previous() = runTest {
repo.saveTasks(listOf(task(id = 1L, dueInDays = 1), task(id = 2L, dueInDays = 2)))
assertEquals(2, repo.loadTasks().size)
repo.saveTasks(emptyList())
assertTrue(repo.loadTasks().isEmpty())
}
@Test
fun multiple_pending_completions_all_excluded() = runTest {
val input = listOf(
task(id = 1L, dueInDays = 1),
task(id = 2L, dueInDays = 2),
task(id = 3L, dueInDays = 3),
task(id = 4L, dueInDays = 4)
)
repo.saveTasks(input)
repo.markPendingCompletion(2L)
repo.markPendingCompletion(4L)
val loaded = repo.loadTasks()
assertEquals(2, loaded.size)
assertTrue(loaded.any { it.id == 1L })
assertTrue(loaded.any { it.id == 3L })
assertFalse(loaded.any { it.id == 2L })
assertFalse(loaded.any { it.id == 4L })
}
}