P2 Stream H: standalone TaskSuggestionsScreen

Port iOS TaskSuggestionsView as a standalone route reachable outside
onboarding. Uses shared suggestions API + accept/skip analytics in
non-onboarding variant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:10:47 -05:00
parent 7d71408bcc
commit 19471d780d
19 changed files with 2161 additions and 3 deletions

View File

@@ -0,0 +1,363 @@
package com.tt.honeyDue.notifications
import android.app.AlarmManager
import android.app.Application
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import com.tt.honeyDue.MainActivity
import com.tt.honeyDue.models.TaskCompletionCreateRequest
import com.tt.honeyDue.models.TaskCompletionResponse
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.widget.WidgetUpdateManager
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockkObject
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
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.Shadows.shadowOf
import org.robolectric.annotation.Config
/**
* Unit tests for the iOS-parity [NotificationActionReceiver] (P4 Stream O).
*
* Covers the action dispatch table: Complete, Snooze, Open, Accept, Decline,
* plus defensive handling of missing extras.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class NotificationActionReceiverTest {
private lateinit var context: Context
private lateinit var app: Application
private lateinit var notificationManager: NotificationManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
app = context.applicationContext as Application
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancelAll()
mockkObject(APILayer)
mockkObject(WidgetUpdateManager)
every { WidgetUpdateManager.forceRefresh(any()) } just runs
}
@After
fun tearDown() {
unmockkAll()
notificationManager.cancelAll()
}
// Build a receiver whose async work runs synchronously on the test scheduler.
private fun receiverFor(scope: CoroutineScope): NotificationActionReceiver =
NotificationActionReceiver().apply { coroutineScopeOverride = scope }
private fun successCompletion(taskId: Int) = TaskCompletionResponse(
id = 1,
taskId = taskId,
completedBy = null,
completedAt = "2026-04-16T00:00:00Z",
notes = "Completed from notification",
actualCost = null,
rating = null,
images = emptyList(),
createdAt = "2026-04-16T00:00:00Z",
updatedTask = null
)
private fun postDummyNotification(id: Int) {
// Create channels so the notify() call below actually posts on O+.
NotificationChannels.ensureChannels(context)
val n = androidx.core.app.NotificationCompat.Builder(context, NotificationChannels.TASK_REMINDER)
.setSmallIcon(com.tt.honeyDue.R.mipmap.ic_launcher)
.setContentTitle("t")
.setContentText("b")
.build()
notificationManager.notify(id, n)
assertTrue(
"precondition: dummy notification should be posted",
notificationManager.activeNotifications.any { it.id == id }
)
}
// ---------- 1. COMPLETE dispatches to APILayer + cancels notification ----------
@Test
fun complete_callsCreateTaskCompletion_and_cancelsNotification() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
coEvery { APILayer.createTaskCompletion(any()) } returns ApiResult.Success(successCompletion(42))
val notifId = 9001
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_TASK_ID, 42L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 1) {
APILayer.createTaskCompletion(match<TaskCompletionCreateRequest> {
it.taskId == 42 && it.notes == "Completed from notification"
})
}
verify(exactly = 1) { WidgetUpdateManager.forceRefresh(any()) }
assertFalse(
"notification should be canceled on success",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 2. COMPLETE failure: notification survives for retry ----------
@Test
fun complete_apiFailure_keepsNotification_forRetry() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
coEvery { APILayer.createTaskCompletion(any()) } returns ApiResult.Error("nope", 500)
val notifId = 9002
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_TASK_ID, 7L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 1) { APILayer.createTaskCompletion(any()) }
verify(exactly = 0) { WidgetUpdateManager.forceRefresh(any()) }
assertTrue(
"notification should remain posted so the user can retry",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 3. SNOOZE: schedules AlarmManager +30 min ----------
@Test
fun snooze_schedulesAlarm_thirtyMinutesOut() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9003
postDummyNotification(notifId)
val beforeMs = System.currentTimeMillis()
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.SNOOZE
putExtra(NotificationActions.EXTRA_TASK_ID, 55L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
putExtra(NotificationActions.EXTRA_TITLE, "Title")
putExtra(NotificationActions.EXTRA_BODY, "Body")
putExtra(NotificationActions.EXTRA_TYPE, NotificationChannels.TASK_REMINDER)
}
receiverFor(scope).onReceive(context, intent)
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val scheduled = shadowOf(am).scheduledAlarms
assertEquals("exactly one snooze alarm scheduled", 1, scheduled.size)
val alarm = scheduled.first()
val delta = alarm.triggerAtTime - beforeMs
val expected = NotificationActions.SNOOZE_DELAY_MS
// Allow ±2s jitter around the expected 30 minutes.
assertTrue(
"snooze alarm should fire ~30 min out (delta=$delta)",
delta in (expected - 2_000)..(expected + 2_000)
)
assertFalse(
"original notification should be cleared after snooze",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 4. OPEN: launches MainActivity with deep-link ----------
@Test
fun open_launchesMainActivity_withDeepLinkAndExtras() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9004
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.OPEN
putExtra(NotificationActions.EXTRA_TASK_ID, 77L)
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 3L)
putExtra(NotificationActions.EXTRA_DEEP_LINK, "honeydue://task/77")
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
val started = shadowOf(app).nextStartedActivity
assertNotNull("MainActivity should be launched", started)
assertEquals(MainActivity::class.java.name, started.component?.className)
assertEquals("honeydue", started.data?.scheme)
assertEquals("77", started.data?.pathSegments?.last())
assertEquals(77L, started.getLongExtra(FcmService.EXTRA_TASK_ID, -1))
assertEquals(3L, started.getLongExtra(FcmService.EXTRA_RESIDENCE_ID, -1))
assertFalse(
"notification should be canceled after open",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 5. ACCEPT_INVITE: clears notification (TODO: API call) ----------
@Test
fun acceptInvite_withResidenceId_cancelsNotification() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9005
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.ACCEPT_INVITE
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 101L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
// API method does not yet exist — see TODO in receiver. Expectation is
// that the notification is still cleared (best-effort UX) and we did
// NOT crash. APILayer.createTaskCompletion should NOT have been called.
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
assertFalse(
"invite notification should be cleared on accept",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 6. Missing extras: no crash, no-op ----------
@Test
fun complete_withoutTaskId_isNoOp() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val notifId = 9006
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.COMPLETE
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
// no task_id
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
assertTrue(
"notification must survive a malformed COMPLETE",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
// ---------- 7. Unknown action: no-op ----------
@Test
fun unknownAction_isNoOp() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = "com.tt.honeyDue.action.NONSENSE"
putExtra(NotificationActions.EXTRA_TASK_ID, 1L)
}
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
verify(exactly = 0) { WidgetUpdateManager.forceRefresh(any()) }
// No started activity either.
assertNull(shadowOf(app).nextStartedActivity)
scope.cancel()
}
// ---------- 8. Null action: no crash ----------
@Test
fun nullAction_doesNotCrash() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val intent = Intent() // action is null
// Should not throw.
receiverFor(scope).onReceive(context, intent)
advanceUntilIdle()
coVerify(exactly = 0) { APILayer.createTaskCompletion(any()) }
scope.cancel()
}
// ---------- 9. Decline invite: clears notification ----------
@Test
fun declineInvite_withResidenceId_cancelsNotification() = runTest {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val notifId = 9009
postDummyNotification(notifId)
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationActions.DECLINE_INVITE
putExtra(NotificationActions.EXTRA_RESIDENCE_ID, 77L)
putExtra(NotificationActions.EXTRA_NOTIFICATION_ID, notifId)
}
receiverFor(scope).onReceive(context, intent)
assertFalse(
"invite notification should be cleared on decline",
notificationManager.activeNotifications.any { it.id == notifId }
)
scope.cancel()
}
}

View File

@@ -0,0 +1,91 @@
package com.tt.honeyDue.notifications
import android.app.AlarmManager
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
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.Shadows.shadowOf
import org.robolectric.annotation.Config
/**
* Tests for [SnoozeScheduler] — verifies the AlarmManager scheduling path
* used by the P4 Stream O notification Snooze action.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
class SnoozeSchedulerTest {
private lateinit var context: Context
private lateinit var am: AlarmManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// Robolectric's ShadowAlarmManager doesn't have an explicit clear, but
// scheduledAlarms is filtered by live pending intents so cancel() the
// world before each test.
shadowOf(am).scheduledAlarms.toList().forEach { alarm ->
alarm.operation?.let { am.cancel(it) }
}
}
// ---------- 7. schedule() sets alarm 30 minutes in future ----------
@Test
fun schedule_setsAlarmThirtyMinutesInFuture() {
val before = System.currentTimeMillis()
SnoozeScheduler.schedule(
context = context,
taskId = 123L,
title = "t",
body = "b",
type = NotificationChannels.TASK_REMINDER
)
val scheduled = shadowOf(am).scheduledAlarms
assertEquals(1, scheduled.size)
val delta = scheduled.first().triggerAtTime - before
val expected = NotificationActions.SNOOZE_DELAY_MS
assertTrue(
"expected ~30 min trigger, got delta=$delta",
delta in (expected - 2_000)..(expected + 2_000)
)
}
// ---------- 8. cancel() removes the pending alarm ----------
@Test
fun cancel_preventsLaterDelivery() {
SnoozeScheduler.schedule(context, taskId = 456L)
assertEquals(
"precondition: alarm scheduled",
1,
shadowOf(am).scheduledAlarms.size
)
SnoozeScheduler.cancel(context, taskId = 456L)
// After cancel(), the PendingIntent is consumed so scheduledAlarms
// shrinks back to zero (Robolectric matches by PI equality).
assertEquals(
"alarm should be gone after cancel()",
0,
shadowOf(am).scheduledAlarms.size
)
}
// Bonus coverage: different task ids get independent scheduling slots.
@Test
fun schedule_twoDifferentTasks_yieldsTwoAlarms() {
SnoozeScheduler.schedule(context, taskId = 1L)
SnoozeScheduler.schedule(context, taskId = 2L)
assertEquals(2, shadowOf(am).scheduledAlarms.size)
}
}

View File

@@ -0,0 +1,103 @@
package com.tt.honeyDue.ui.haptics
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
/**
* Unit tests for the cross-platform [Haptics] API on Android.
*
* Uses a pluggable [HapticBackend] to verify the contract without
* depending on real hardware (no-op in JVM unit tests otherwise).
*
* Mirrors iOS haptic taxonomy:
* UIImpactFeedbackGenerator(.light) -> light
* UIImpactFeedbackGenerator(.medium) -> medium
* UIImpactFeedbackGenerator(.heavy) -> heavy
* UINotificationFeedbackGenerator(.success|.warning|.error)
*/
@RunWith(RobolectricTestRunner::class)
class HapticsAndroidTest {
private lateinit var fake: RecordingHapticBackend
@Before
fun setUp() {
fake = RecordingHapticBackend()
Haptics.setBackend(fake)
}
@After
fun tearDown() {
Haptics.resetBackend()
}
@Test
fun light_delegatesToBackend_withLightEvent() {
Haptics.light()
assertEquals(listOf(HapticEvent.LIGHT), fake.events)
}
@Test
fun medium_delegatesToBackend_withMediumEvent() {
Haptics.medium()
assertEquals(listOf(HapticEvent.MEDIUM), fake.events)
}
@Test
fun heavy_delegatesToBackend_withHeavyEvent() {
Haptics.heavy()
assertEquals(listOf(HapticEvent.HEAVY), fake.events)
}
@Test
fun success_delegatesToBackend_withSuccessEvent() {
Haptics.success()
assertEquals(listOf(HapticEvent.SUCCESS), fake.events)
}
@Test
fun warning_delegatesToBackend_withWarningEvent() {
Haptics.warning()
assertEquals(listOf(HapticEvent.WARNING), fake.events)
}
@Test
fun error_delegatesToBackend_withErrorEvent() {
Haptics.error()
assertEquals(listOf(HapticEvent.ERROR), fake.events)
}
@Test
fun multipleCalls_areRecordedInOrder() {
Haptics.light()
Haptics.success()
Haptics.error()
assertEquals(
listOf(HapticEvent.LIGHT, HapticEvent.SUCCESS, HapticEvent.ERROR),
fake.events
)
}
@Test
fun androidDefaultBackend_isResilientWithoutInstalledContext() {
Haptics.resetBackend()
// Default backend must not crash even when no context/view is installed.
Haptics.light()
Haptics.success()
Haptics.error()
assertTrue("platform default backend should be resilient", true)
}
}
/** Test-only backend that records events for assertion. */
private class RecordingHapticBackend : HapticBackend {
val events = mutableListOf<HapticEvent>()
override fun perform(event: HapticEvent) {
events += event
}
}