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

@@ -69,6 +69,9 @@ kotlin {
// DataStore for widget data persistence
implementation("androidx.datastore:datastore-preferences:1.1.1")
// WorkManager for scheduled widget refresh (iOS parity — Stream L)
implementation("androidx.work:work-runtime-ktx:2.9.1")
// Encrypted SharedPreferences for secure token storage
implementation(libs.androidx.security.crypto)
@@ -126,6 +129,7 @@ kotlin {
implementation(libs.androidx.test.core)
implementation(libs.androidx.test.core.ktx)
implementation(libs.androidx.testExt.junit)
implementation("androidx.work:work-testing:2.9.1")
}
}
val androidInstrumentedTest by getting {

View File

@@ -0,0 +1,62 @@
package com.tt.honeyDue.widget
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
/**
* Pure-logic schedule for widget refresh cadence. Mirrors the iOS-parity
* split from the P3 parity plan:
*
* - 06:00 (inclusive) .. 23:00 (exclusive) local → refresh every 30 minutes
* - 23:00 (inclusive) .. 06:00 (exclusive) local → refresh every 120 minutes
*
* iOS ([BackgroundTaskManager.swift]) uses a random 12am4am overnight
* BGAppRefreshTask window rather than a fixed cadence, because iOS
* `BGTaskScheduler` is coalesced by the system. Android's WorkManager runs
* user-defined intervals, so this file encodes the ios-parity cadence the
* plan specifies. The split 30/120 preserves the core intent: frequent
* while awake, sparse while the user is asleep.
*/
object WidgetRefreshSchedule {
private const val DAY_START_HOUR_INCLUSIVE = 6 // 06:00 local
private const val DAY_END_HOUR_EXCLUSIVE = 23 // 23:00 local
const val DAY_INTERVAL_MINUTES: Long = 30L
const val NIGHT_INTERVAL_MINUTES: Long = 120L
/**
* Returns the refresh interval (in minutes) for a wall-clock time.
*
* Hour bands:
* - [06:00, 23:00) → [DAY_INTERVAL_MINUTES] (30)
* - [23:00, 06:00) → [NIGHT_INTERVAL_MINUTES] (120)
*/
fun intervalMinutes(at: LocalDateTime): Long {
val hour = at.hour
return if (hour in DAY_START_HOUR_INCLUSIVE until DAY_END_HOUR_EXCLUSIVE) {
DAY_INTERVAL_MINUTES
} else {
NIGHT_INTERVAL_MINUTES
}
}
/**
* Returns `now + intervalMinutes(now)` as a [LocalDateTime].
*
* Arithmetic is performed through [TimeZone.UTC] to avoid ambiguity
* around DST transitions in the local zone — the absolute minute offset
* is what WorkManager's `setInitialDelay` consumes, so the returned
* wall-clock value is for display/testing only.
*/
fun nextRefreshTime(now: LocalDateTime): LocalDateTime {
val interval = intervalMinutes(now)
val instant = now.toInstant(TimeZone.UTC)
val next = instant.plus(interval, DateTimeUnit.MINUTE)
return next.toLocalDateTime(TimeZone.UTC)
}
}

View File

@@ -0,0 +1,172 @@
package com.tt.honeyDue.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.models.TaskColumnsResponse
/**
* Abstraction over the data sources the worker consumes. Keeps
* [WidgetRefreshWorker] unit-testable without having to mock the
* [APILayer] singleton.
*/
interface WidgetRefreshDataSource {
/** Fetch the task list that should be displayed on the widget. */
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
/** Fetch the current user's subscription tier ("free" | "premium"). */
suspend fun fetchTier(): String
}
/**
* Default production data source — delegates to [APILayer] and maps the
* backend task kanban into the flat list the widget caches.
*/
internal object DefaultWidgetRefreshDataSource : WidgetRefreshDataSource {
private const val COMPLETED_COLUMN = "completed"
private const val CANCELLED_COLUMN = "cancelled"
private const val OVERDUE_COLUMN = "overdue"
override suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>> {
val result = APILayer.getTasks(forceRefresh = true)
return when (result) {
is ApiResult.Success -> ApiResult.Success(mapToWidgetTasks(result.data))
is ApiResult.Error -> result
ApiResult.Loading -> ApiResult.Error("Loading", null)
ApiResult.Idle -> ApiResult.Error("Idle", null)
}
}
override suspend fun fetchTier(): String {
val result = APILayer.getSubscriptionStatus(forceRefresh = true)
return when (result) {
is ApiResult.Success -> result.data.tier
else -> "free"
}
}
private fun mapToWidgetTasks(response: TaskColumnsResponse): List<WidgetTaskDto> {
val out = mutableListOf<WidgetTaskDto>()
for (column in response.columns) {
if (column.name == COMPLETED_COLUMN || column.name == CANCELLED_COLUMN) continue
val isOverdue = column.name == OVERDUE_COLUMN
for (task in column.tasks) {
out.add(
WidgetTaskDto(
id = task.id.toLong(),
title = task.title,
priority = task.priorityId?.toLong() ?: 0L,
dueDate = task.effectiveDueDate,
isOverdue = isOverdue,
// Server computes overdue/column bucketing — we don't
// recompute daysUntilDue here; it's a best-effort hint
// the widget displays when present. Zero for overdue
// matches iOS behaviour (daysUntilDue is not surfaced
// on the iOS WidgetTask model).
daysUntilDue = 0,
residenceId = task.residenceId.toLong(),
residenceName = "",
categoryIcon = task.categoryName ?: "",
completed = false
)
)
}
}
return out
}
}
/**
* Background worker that refreshes the on-disk widget cache and asks each
* Glance widget to redraw.
*
* **Error contract:**
* - [ApiResult.Success] → [Result.success]
* - transient [ApiResult.Error] (5xx / network) → [Result.retry]
* - auth [ApiResult.Error] (401/403) → [Result.failure]
*
* **Test hook:** set [dataSourceOverride] to swap the data source in tests.
*/
class WidgetRefreshWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val ctx = applicationContext
val dataSource = dataSourceOverride ?: DefaultWidgetRefreshDataSource
// Always attempt tier refresh — tier persistence is cheap and useful
// even if the task fetch later fails.
val tier = runCatching { dataSource.fetchTier() }.getOrDefault("free")
val tasksResult = runCatching { dataSource.fetchTasks() }.getOrElse { t ->
return Result.retry() // Unexpected throw → transient.
}
when (tasksResult) {
is ApiResult.Success -> {
val repo = WidgetDataRepository.get(ctx)
repo.saveTasks(tasksResult.data)
repo.saveTierState(tier)
refreshGlanceWidgets(ctx)
// Chain the next scheduled refresh so cadence keeps ticking
// even if the OS evicts our periodic request. Wrapped in
// runCatching — an un-initialized WorkManager (e.g. in
// unit tests) must not cause an otherwise-green refresh
// to report failure.
runCatching { WidgetUpdateManager.schedulePeriodic(ctx) }
return Result.success()
}
is ApiResult.Error -> {
// Still persist tier if we have it — subscription state is
// independent of task fetch.
runCatching { WidgetDataRepository.get(ctx).saveTierState(tier) }
return if (isPermanentError(tasksResult.code)) Result.failure() else Result.retry()
}
ApiResult.Loading, ApiResult.Idle -> return Result.retry()
}
}
private suspend fun refreshGlanceWidgets(ctx: Context) {
val glanceManager = GlanceAppWidgetManager(ctx)
runCatching {
val smallIds = glanceManager.getGlanceIds(HoneyDueSmallWidget::class.java)
val smallWidget = HoneyDueSmallWidget()
smallIds.forEach { id -> smallWidget.update(ctx, id) }
}
runCatching {
val mediumIds = glanceManager.getGlanceIds(HoneyDueMediumWidget::class.java)
val mediumWidget = HoneyDueMediumWidget()
mediumIds.forEach { id -> mediumWidget.update(ctx, id) }
}
runCatching {
val largeIds = glanceManager.getGlanceIds(HoneyDueLargeWidget::class.java)
val largeWidget = HoneyDueLargeWidget()
largeIds.forEach { id -> largeWidget.update(ctx, id) }
}
}
private fun isPermanentError(code: Int?): Boolean {
// 401/403 — credentials invalid; no amount of retry helps.
// 404 — endpoint removed; treat as permanent.
// Everything else (including null / 5xx / network) is transient.
return code == 401 || code == 403 || code == 404
}
companion object {
/**
* Test-only hook. Set to a fake data source before invoking
* [TestListenableWorkerBuilder<WidgetRefreshWorker>]. Always nulled
* in teardown.
*/
@Volatile
var dataSourceOverride: WidgetRefreshDataSource? = null
}
}

View File

@@ -1,113 +1,83 @@
package com.tt.honeyDue.widget
import android.content.Context
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.concurrent.TimeUnit
/**
* Manager for updating all widgets with new data
* Scheduler for the widget-refresh background work. Thin wrapper over
* [WorkManager] that enqueues a [WidgetRefreshWorker] with the cadence
* defined by [WidgetRefreshSchedule].
*
* We use a chained one-time-work pattern rather than `PeriodicWorkRequest`
* because:
* - `PeriodicWorkRequest` has a 15-minute floor which is fine, but more
* importantly can't *vary* its cadence between runs.
* - The iOS-parity spec needs 30-min during the day and 120-min overnight
* — so each run computes the next interval based on the local clock
* and enqueues the next one-time request.
*
* On [schedulePeriodic], the worker is enqueued with an initial delay of
* `intervalMinutes(now)`. On successful completion [WidgetRefreshWorker]
* calls [schedulePeriodic] again to chain the next wake.
*/
object WidgetUpdateManager {
private val json = Json { ignoreUnknownKeys = true }
/** Unique name for the periodic (chained) refresh queue. */
const val UNIQUE_WORK_NAME: String = "widget_refresh_periodic"
/** Unique name for user- / app-triggered forced refreshes. */
const val FORCE_REFRESH_WORK_NAME: String = "widget_refresh_force"
/**
* Update all honeyDue widgets with new data
* Schedule the next periodic refresh. Delay = [WidgetRefreshSchedule.intervalMinutes]
* evaluated against the current local-zone clock. Existing work under
* [UNIQUE_WORK_NAME] is replaced — the new interval always wins.
*/
fun updateAllWidgets(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
try {
val repository = WidgetDataRepository.getInstance(context)
val summary = repository.widgetSummary.first()
val isProUser = repository.isProUser.first()
fun schedulePeriodic(context: Context) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val delayMinutes = WidgetRefreshSchedule.intervalMinutes(now)
updateWidgetsWithData(context, summary, isProUser)
} catch (e: Exception) {
e.printStackTrace()
}
}
val request = OneTimeWorkRequestBuilder<WidgetRefreshWorker>()
.setInitialDelay(delayMinutes, TimeUnit.MINUTES)
.addTag(TAG)
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
/**
* Update widgets with the provided summary data
* Force an immediate refresh. Runs as an expedited worker so the OS
* treats it as a foreground-ish job (best-effort — may be denied
* quota, in which case it falls back to a regular one-time enqueue).
*/
suspend fun updateWidgetsWithData(
context: Context,
summary: WidgetSummary,
isProUser: Boolean
) {
val glanceManager = GlanceAppWidgetManager(context)
fun forceRefresh(context: Context) {
val request = OneTimeWorkRequestBuilder<WidgetRefreshWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag(TAG)
.build()
// Update small widgets
val smallWidgetIds = glanceManager.getGlanceIds(HoneyDueSmallWidget::class.java)
smallWidgetIds.forEach { id ->
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
prefs.toMutablePreferences().apply {
this[intPreferencesKey("overdue_count")] = summary.overdueCount
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
}
}
HoneyDueSmallWidget().update(context, id)
}
// Update medium widgets
val mediumWidgetIds = glanceManager.getGlanceIds(HoneyDueMediumWidget::class.java)
mediumWidgetIds.forEach { id ->
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
prefs.toMutablePreferences().apply {
this[intPreferencesKey("overdue_count")] = summary.overdueCount
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
}
}
HoneyDueMediumWidget().update(context, id)
}
// Update large widgets
val largeWidgetIds = glanceManager.getGlanceIds(HoneyDueLargeWidget::class.java)
largeWidgetIds.forEach { id ->
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
prefs.toMutablePreferences().apply {
this[intPreferencesKey("overdue_count")] = summary.overdueCount
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
this[intPreferencesKey("total_tasks_count")] = summary.totalTasksCount
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
this[stringPreferencesKey("is_pro_user")] = if (isProUser) "true" else "false"
this[longPreferencesKey("last_updated")] = summary.lastUpdated
}
}
HoneyDueLargeWidget().update(context, id)
}
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(FORCE_REFRESH_WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
/**
* Clear all widget data (called on logout)
* Cancel any pending/chained periodic refresh. Does not affect
* in-flight forced refreshes — call [cancel] from a logout flow to
* stop the scheduler wholesale, or clear both queues explicitly.
*/
fun clearAllWidgets(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
try {
val emptyData = WidgetSummary()
updateWidgetsWithData(context, emptyData, false)
// Also clear the repository
val repository = WidgetDataRepository.getInstance(context)
repository.clearData()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun cancel(context: Context) {
val wm = WorkManager.getInstance(context.applicationContext)
wm.cancelUniqueWork(UNIQUE_WORK_NAME)
wm.cancelUniqueWork(FORCE_REFRESH_WORK_NAME)
}
private const val TAG = "widget_refresh"
}

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())
}
}