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