P3 Stream L: widget refresh scheduler (WorkManager, iOS cadence)

WidgetRefreshSchedule: 30-min day / 120-min overnight (6am–11pm split).
WidgetRefreshWorker: CoroutineWorker fetches via APILayer -> repo -> widget.update.
WidgetUpdateManager: chained one-time enqueue pattern (WorkManager PeriodicWork
can't vary cadence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:54:35 -05:00
parent 58b9371d0d
commit dbff329384
7 changed files with 682 additions and 90 deletions

View File

@@ -0,0 +1,96 @@
package com.tt.honeyDue.widget
import kotlinx.datetime.LocalDateTime
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure-logic tests for [WidgetRefreshSchedule]. No Android/framework deps.
*
* Cadence matches iOS-parity spec:
* - 06:00 (inclusive) .. 23:00 (exclusive) local → 30-minute interval
* - 23:00 (inclusive) .. 06:00 (exclusive) local → 120-minute interval
*
* (iOS [BackgroundTaskManager.swift] uses a random-window overnight refresh;
* Android uses WorkManager and the plan specifies this fixed-cadence split
* since WorkManager can't simulate the iOS random-BGTask scheduling.)
*/
class WidgetRefreshScheduleTest {
private fun dt(hour: Int, minute: Int = 0): LocalDateTime =
LocalDateTime(year = 2026, monthNumber = 4, dayOfMonth = 16, hour = hour, minute = minute)
@Test
fun intervalMinutes_at_09am_returns_30() {
assertEquals(30L, WidgetRefreshSchedule.intervalMinutes(dt(9, 0)))
}
@Test
fun intervalMinutes_at_22_59_returns_30() {
assertEquals(30L, WidgetRefreshSchedule.intervalMinutes(dt(22, 59)))
}
@Test
fun intervalMinutes_at_23_00_returns_120() {
assertEquals(120L, WidgetRefreshSchedule.intervalMinutes(dt(23, 0)))
}
@Test
fun intervalMinutes_at_05_59_returns_120() {
assertEquals(120L, WidgetRefreshSchedule.intervalMinutes(dt(5, 59)))
}
@Test
fun intervalMinutes_at_06_00_returns_30() {
assertEquals(30L, WidgetRefreshSchedule.intervalMinutes(dt(6, 0)))
}
@Test
fun intervalMinutes_at_02_00_returns_120() {
assertEquals(120L, WidgetRefreshSchedule.intervalMinutes(dt(2, 0)))
}
@Test
fun intervalMinutes_at_midnight_returns_120() {
assertEquals(120L, WidgetRefreshSchedule.intervalMinutes(dt(0, 0)))
}
@Test
fun intervalMinutes_at_noon_returns_30() {
assertEquals(30L, WidgetRefreshSchedule.intervalMinutes(dt(12, 0)))
}
@Test
fun nextRefreshTime_at_09am_is_09_30() {
val next = WidgetRefreshSchedule.nextRefreshTime(dt(9, 0))
assertEquals(9, next.hour)
assertEquals(30, next.minute)
assertEquals(16, next.dayOfMonth)
}
@Test
fun nextRefreshTime_at_23_00_is_01_00_next_day() {
val next = WidgetRefreshSchedule.nextRefreshTime(dt(23, 0))
assertEquals(1, next.hour)
assertEquals(0, next.minute)
assertEquals(17, next.dayOfMonth)
}
@Test
fun nextRefreshTime_at_22_45_is_23_15_same_day() {
// 22:45 + 30min = 23:15 (still 30min because 22:45 < 23:00)
val next = WidgetRefreshSchedule.nextRefreshTime(dt(22, 45))
assertEquals(23, next.hour)
assertEquals(15, next.minute)
assertEquals(16, next.dayOfMonth)
}
@Test
fun nextRefreshTime_at_05_30_is_07_30_same_day() {
// 05:30 + 120min = 07:30
val next = WidgetRefreshSchedule.nextRefreshTime(dt(5, 30))
assertEquals(7, next.hour)
assertEquals(30, next.minute)
assertEquals(16, next.dayOfMonth)
}
}

View File

@@ -0,0 +1,177 @@
package com.tt.honeyDue.widget
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder
import com.tt.honeyDue.network.ApiResult
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Tests for [WidgetRefreshWorker] under Robolectric + WorkManager's
* TestListenableWorkerBuilder.
*
* We avoid mocking the [com.tt.honeyDue.network.APILayer] singleton directly.
* Instead the worker is parameterized by a [WidgetRefreshDataSource] that the
* test swaps in via [WidgetRefreshWorker.dataSourceOverride].
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class WidgetRefreshWorkerTest {
private lateinit var context: Context
private lateinit var repo: WidgetDataRepository
@Before
fun setUp() = runBlocking {
context = ApplicationProvider.getApplicationContext()
repo = WidgetDataRepository.get(context)
repo.clearAll()
}
@After
fun tearDown() = runBlocking {
WidgetRefreshWorker.dataSourceOverride = null
repo.clearAll()
}
private fun sampleTask(id: Long = 1L) = WidgetTaskDto(
id = id,
title = "Change air filter",
priority = 2L,
dueDate = "2026-04-20",
isOverdue = false,
daysUntilDue = 4,
residenceId = 10L,
residenceName = "Home",
categoryIcon = "house.fill",
completed = false
)
@Test
fun worker_success_when_dataSource_returns_success() = runTest {
val tasks = listOf(sampleTask(id = 1L), sampleTask(id = 2L))
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
tasksResult = ApiResult.Success(tasks),
tier = "free"
)
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
val result = worker.doWork()
assertEquals(ListenableWorker.Result.success(), result)
}
@Test
fun worker_persists_tasks_via_repository_on_success() = runTest {
val tasks = listOf(
sampleTask(id = 100L),
sampleTask(id = 200L).copy(title = "Clean gutters")
)
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
tasksResult = ApiResult.Success(tasks),
tier = "free"
)
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
worker.doWork()
val stored = repo.loadTasks()
assertEquals(2, stored.size)
val byId = stored.associateBy { it.id }
assertEquals("Change air filter", byId[100L]?.title)
assertEquals("Clean gutters", byId[200L]?.title)
}
@Test
fun worker_persists_tier_state_on_success() = runTest {
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
tasksResult = ApiResult.Success(emptyList()),
tier = "premium"
)
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
worker.doWork()
assertEquals("premium", repo.loadTierState())
}
@Test
fun worker_returns_retry_when_api_returns_transient_error() = runTest {
// 500/503/timeout-class errors are retryable.
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
tasksResult = ApiResult.Error("server unavailable", 503),
tier = "free"
)
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
val result = worker.doWork()
assertEquals(ListenableWorker.Result.retry(), result)
}
@Test
fun worker_returns_retry_when_api_returns_network_error() = runTest {
// Unknown code (e.g. network failure) → retryable.
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
tasksResult = ApiResult.Error("network timeout", null),
tier = "free"
)
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
val result = worker.doWork()
assertEquals(ListenableWorker.Result.retry(), result)
}
@Test
fun worker_returns_failure_when_api_returns_auth_error() = runTest {
// 401 is permanent — user logged out, widget should stop trying.
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
tasksResult = ApiResult.Error("not authenticated", 401),
tier = "free"
)
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
val result = worker.doWork()
assertEquals(ListenableWorker.Result.failure(), result)
}
@Test
fun worker_does_not_clobber_tasks_on_api_failure() = runTest {
// Pre-seed store with prior tasks.
repo.saveTasks(listOf(sampleTask(id = 999L)))
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
tasksResult = ApiResult.Error("server", 503),
tier = "free"
)
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
worker.doWork()
val stored = repo.loadTasks()
assertEquals(1, stored.size)
assertTrue(stored.any { it.id == 999L })
}
/** Minimal [WidgetRefreshDataSource] stub for tests. */
private class FakeDataSource(
private val tasksResult: ApiResult<List<WidgetTaskDto>>,
private val tier: String
) : WidgetRefreshDataSource {
override suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>> = tasksResult
override suspend fun fetchTier(): String = tier
}
}

View File

@@ -0,0 +1,111 @@
package com.tt.honeyDue.widget
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import androidx.work.Configuration
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Tests for [WidgetUpdateManager] using WorkManager's in-memory test
* infrastructure.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class WidgetUpdateManagerTest {
private lateinit var context: Context
private lateinit var workManager: WorkManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
val config = Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.DEBUG)
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
workManager = WorkManager.getInstance(context)
}
@Test
fun schedulePeriodic_enqueues_unique_work() = runBlocking {
WidgetUpdateManager.schedulePeriodic(context)
val infos = workManager
.getWorkInfosForUniqueWork(WidgetUpdateManager.UNIQUE_WORK_NAME)
.get()
assertEquals(1, infos.size)
val state = infos.first().state
assertTrue(
"Expected ENQUEUED or RUNNING, got $state",
state == WorkInfo.State.ENQUEUED || state == WorkInfo.State.RUNNING
)
}
@Test
fun schedulePeriodic_twice_replaces_work() = runBlocking {
WidgetUpdateManager.schedulePeriodic(context)
val firstInfos = workManager
.getWorkInfosForUniqueWork(WidgetUpdateManager.UNIQUE_WORK_NAME)
.get()
val firstId = firstInfos.first().id
WidgetUpdateManager.schedulePeriodic(context)
val secondInfos = workManager
.getWorkInfosForUniqueWork(WidgetUpdateManager.UNIQUE_WORK_NAME)
.get()
// With REPLACE policy, only one active entry remains; the old id may
// linger briefly in CANCELLED state but the active id is new.
val activeSecond = secondInfos.filter {
it.state != WorkInfo.State.CANCELLED && it.state != WorkInfo.State.SUCCEEDED
}
assertEquals(1, activeSecond.size)
assertTrue(
"REPLACE should have enqueued a new work id",
activeSecond.first().id != firstId
)
}
@Test
fun forceRefresh_enqueues_separate_unique_work() = runBlocking {
WidgetUpdateManager.forceRefresh(context)
val infos = workManager
.getWorkInfosForUniqueWork(WidgetUpdateManager.FORCE_REFRESH_WORK_NAME)
.get()
assertEquals(1, infos.size)
val state = infos.first().state
assertTrue(
"Expected ENQUEUED or RUNNING, got $state",
state == WorkInfo.State.ENQUEUED || state == WorkInfo.State.RUNNING
)
}
@Test
fun cancel_removes_scheduled_work() = runBlocking {
WidgetUpdateManager.schedulePeriodic(context)
WidgetUpdateManager.cancel(context)
val infos = workManager
.getWorkInfosForUniqueWork(WidgetUpdateManager.UNIQUE_WORK_NAME)
.get()
val active = infos.filter {
it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING
}
assertTrue("No active work should remain after cancel", active.isEmpty())
}
}