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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user