From dbff329384a8982178cb4eabe43e0910127899f7 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 12:54:35 -0500 Subject: [PATCH] P3 Stream L: widget refresh scheduler (WorkManager, iOS cadence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- composeApp/build.gradle.kts | 4 + .../honeyDue/widget/WidgetRefreshSchedule.kt | 62 ++++++ .../tt/honeyDue/widget/WidgetRefreshWorker.kt | 172 +++++++++++++++++ .../tt/honeyDue/widget/WidgetUpdateManager.kt | 150 ++++++--------- .../widget/WidgetRefreshScheduleTest.kt | 96 ++++++++++ .../widget/WidgetRefreshWorkerTest.kt | 177 ++++++++++++++++++ .../widget/WidgetUpdateManagerTest.kt | 111 +++++++++++ 7 files changed, 682 insertions(+), 90 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshSchedule.kt create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorker.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetRefreshScheduleTest.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorkerTest.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetUpdateManagerTest.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index abe9072..18f6f2c 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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 { diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshSchedule.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshSchedule.kt new file mode 100644 index 0000000..03d549f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshSchedule.kt @@ -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 12am–4am 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) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorker.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorker.kt new file mode 100644 index 0000000..844ea17 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorker.kt @@ -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> + /** 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> { + 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 { + val out = mutableListOf() + 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]. Always nulled + * in teardown. + */ + @Volatile + var dataSourceOverride: WidgetRefreshDataSource? = null + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetUpdateManager.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetUpdateManager.kt index 81b6162..f3fa4a6 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetUpdateManager.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetUpdateManager.kt @@ -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() + .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() + .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" } diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetRefreshScheduleTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetRefreshScheduleTest.kt new file mode 100644 index 0000000..e2a574f --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetRefreshScheduleTest.kt @@ -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) + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorkerTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorkerTest.kt new file mode 100644 index 0000000..b7e3577 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetRefreshWorkerTest.kt @@ -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(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(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(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(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(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(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(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>, + private val tier: String + ) : WidgetRefreshDataSource { + override suspend fun fetchTasks(): ApiResult> = tasksResult + override suspend fun fetchTier(): String = tier + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetUpdateManagerTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetUpdateManagerTest.kt new file mode 100644 index 0000000..18c201c --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetUpdateManagerTest.kt @@ -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()) + } +}