diff --git a/papa/api/papa.api b/papa/api/papa.api index 43154bf..c646fe4 100644 --- a/papa/api/papa.api +++ b/papa/api/papa.api @@ -310,7 +310,6 @@ public final class papa/InteractionTrace$Companion { public abstract interface class papa/InteractionTrigger { public static final field Companion Lpapa/InteractionTrigger$Companion; - public abstract fun equals (Ljava/lang/Object;)Z public abstract fun getName ()Ljava/lang/String; public abstract fun getTriggerUptime-UwyO8pc ()J public abstract fun takeOverInteractionTrace ()Lpapa/InteractionTrace; @@ -322,7 +321,6 @@ public final class papa/InteractionTrigger$Companion { public final class papa/InteractionTriggerWithPayload : papa/InteractionTrigger { public synthetic fun (JLjava/lang/String;Lpapa/InteractionTrace;Ljava/lang/Object;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun equals (Ljava/lang/Object;)Z public fun getName ()Ljava/lang/String; public final fun getPayload ()Ljava/lang/Object; public fun getTriggerUptime-UwyO8pc ()J @@ -584,7 +582,6 @@ public final class papa/SentEvent { public final class papa/SimpleInteractionTrigger : papa/InteractionTrigger { public synthetic fun (JLjava/lang/String;Lpapa/InteractionTrace;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (JLjava/lang/String;Lpapa/InteractionTrace;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun equals (Ljava/lang/Object;)Z public fun getName ()Ljava/lang/String; public fun getTriggerUptime-UwyO8pc ()J public fun takeOverInteractionTrace ()Lpapa/InteractionTrace; diff --git a/papa/src/main/java/papa/InputEventTrigger.kt b/papa/src/main/java/papa/InputEventTrigger.kt index 5140d06..dbcaddd 100644 --- a/papa/src/main/java/papa/InputEventTrigger.kt +++ b/papa/src/main/java/papa/InputEventTrigger.kt @@ -49,6 +49,18 @@ class InputEventTrigger private constructor( }) return trigger } + + /** + * Test-only helper for callers that need an [InputEventTrigger] payload without also wiring + * frame-render tracking through a real [Window]. This keeps unit tests focused on stack and + * payload behavior instead of Kotlin/JVM constructor details or Android frame callbacks. + */ + internal fun createForTest( + inputEvent: InputEvent, + deliveryUptime: Duration + ): InputEventTrigger { + return InputEventTrigger(inputEvent, deliveryUptime) + } } } diff --git a/papa/src/main/java/papa/InteractionTrigger.kt b/papa/src/main/java/papa/InteractionTrigger.kt index d3eede3..70e3223 100644 --- a/papa/src/main/java/papa/InteractionTrigger.kt +++ b/papa/src/main/java/papa/InteractionTrigger.kt @@ -8,8 +8,6 @@ sealed interface InteractionTrigger { val name: String fun takeOverInteractionTrace(): InteractionTrace? - override fun equals(other: Any?): Boolean - companion object { fun triggerNow( name: String @@ -37,12 +35,6 @@ class SimpleInteractionTrigger( } } - override fun equals(other: Any?): Boolean { - if (other == null) return false - if (other !is SimpleInteractionTrigger) return false - return other.name == name && other.triggerUptime == triggerUptime - } - override fun toString(): String { return "InteractionTrigger(name='$name', triggerUptime=$triggerUptime)" } @@ -57,10 +49,4 @@ class InteractionTriggerWithPayload( override fun toString(): String { return "InteractionTrigger(name='$name', triggerUptime=$triggerUptime, payload=$payload)" } - - override fun equals(other: Any?): Boolean { - if (other == null) return false - if (other !is InteractionTriggerWithPayload<*>) return false - return other.name == name && other.triggerUptime == triggerUptime - } } diff --git a/papa/src/main/java/papa/MainThreadTriggerStack.kt b/papa/src/main/java/papa/MainThreadTriggerStack.kt index d8dfb06..e14a0a0 100644 --- a/papa/src/main/java/papa/MainThreadTriggerStack.kt +++ b/papa/src/main/java/papa/MainThreadTriggerStack.kt @@ -2,18 +2,39 @@ package papa object MainThreadTriggerStack { + /** + * Returns the trigger with the earliest (minimum) [InteractionTrigger.triggerUptime], or `null` + * if the stack is empty. + * + * When duplicate triggers with identical uptime coexist on the stack, the most recently pushed + * one is preferred. + * + * Uses [reduceOrNull] for a single O(n) pass with zero allocations. On an equal-uptime tie the + * later element wins. This is optimal - finding a minimum in an unsorted collection requires + * examining every element at least once. + */ val earliestInteractionTrigger: InteractionTrigger? get() { Handlers.checkOnMainThread() - return interactionTriggerStack.minByOrNull { it.triggerUptime } + return interactionTriggerStack.reduceOrNull { acc, trigger -> + if (trigger.triggerUptime <= acc.triggerUptime) { + trigger + } else { + acc + } + } } + /** + * Returns the input-event triggers currently visible on the stack in stack order. + * + * This is a filtered view of [interactionTriggerStack]. Duplicate triggers are returned in + * their current stack order. + */ val inputEventInteractionTriggers: List> get() { Handlers.checkOnMainThread() - return interactionTriggerStack.mapNotNull { - it.toInputEventTriggerOrNull() - } + return interactionTriggerStack.mapNotNull { it.toInputEventTriggerOrNull() } } private val interactionTriggerStack = mutableListOf() @@ -31,8 +52,9 @@ object MainThreadTriggerStack { /** * Must be called from the main thread. - * Adds [trigger] to the [interactionTriggerStack], it will replace any existing trigger with - * the same [InteractionTrigger.name] and [InteractionTrigger.triggerUptime]. + * Adds [trigger] to the [interactionTriggerStack] for the duration of [block]. Duplicate + * trigger instances intentionally coexist on the stack so nested scopes do not evict an earlier + * instance that is still responsible for later cleanup. * * @param endTraceAfterBlock Finish the interaction trace after [block] runs. * @param block The code to run, during whose call stack the trigger added will be available @@ -44,15 +66,11 @@ object MainThreadTriggerStack { block: () -> T ): T { Handlers.checkOnMainThread() - // First, remove based on object equality (which uses name/triggerUptime). This has the effect - // of replacing any existing same-named, same-timed triggers. - // After the block() we remove just this instance from the stack. - interactionTriggerStack.removeAll { it == trigger } - interactionTriggerStack.add(trigger) + pushTriggeredBy(trigger) try { return block() } finally { - interactionTriggerStack.removeAll { it === trigger } + popTriggeredBy(trigger) if (endTraceAfterBlock) { trigger.takeOverInteractionTrace()?.endTrace() } diff --git a/papa/src/test/java/papa/InteractionTriggerTest.kt b/papa/src/test/java/papa/InteractionTriggerTest.kt index a70370a..cadf28a 100644 --- a/papa/src/test/java/papa/InteractionTriggerTest.kt +++ b/papa/src/test/java/papa/InteractionTriggerTest.kt @@ -1,12 +1,6 @@ package papa -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -import org.junit.Assert.assertTrue +import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -34,10 +28,10 @@ class InteractionTriggerTest { val afterTime = System.nanoTime() - assertEquals(name, trigger.name) - assertTrue(trigger.triggerUptime.inWholeNanoseconds >= beforeTime) - assertTrue(trigger.triggerUptime.inWholeNanoseconds <= afterTime) - assertTrue(trigger is SimpleInteractionTrigger) + assertThat(trigger.name).isEqualTo(name) + assertThat(trigger.triggerUptime.inWholeNanoseconds).isAtLeast(beforeTime) + assertThat(trigger.triggerUptime.inWholeNanoseconds).isAtMost(afterTime) + assertThat(trigger).isInstanceOf(SimpleInteractionTrigger::class.java) } @Test @@ -47,8 +41,8 @@ class InteractionTriggerTest { val trigger = SimpleInteractionTrigger(triggerUptime, name) - assertEquals(triggerUptime, trigger.triggerUptime) - assertEquals(name, trigger.name) + assertThat(trigger.triggerUptime).isEqualTo(triggerUptime) + assertThat(trigger.name).isEqualTo(name) } @Test @@ -59,8 +53,8 @@ class InteractionTriggerTest { val trigger = SimpleInteractionTrigger(triggerUptime, name, trace) - assertEquals(triggerUptime, trigger.triggerUptime) - assertEquals(name, trigger.name) + assertThat(trigger.triggerUptime).isEqualTo(triggerUptime) + assertThat(trigger.name).isEqualTo(name) } @Test @@ -71,13 +65,11 @@ class InteractionTriggerTest { val trigger = SimpleInteractionTrigger(triggerUptime, name, trace) - // First call should return the trace val returnedTrace = trigger.takeOverInteractionTrace() - assertSame(trace, returnedTrace) + assertThat(returnedTrace).isSameInstanceAs(trace) - // Second call should return null since trace was taken over val secondTrace = trigger.takeOverInteractionTrace() - assertNull(secondTrace) + assertThat(secondTrace).isNull() } @Test @@ -88,11 +80,11 @@ class InteractionTriggerTest { val trigger = SimpleInteractionTrigger(triggerUptime, name, null) val returnedTrace = trigger.takeOverInteractionTrace() - assertNull(returnedTrace) + assertThat(returnedTrace).isNull() } @Test - fun `SimpleInteractionTrigger equals compares name and triggerUptime`() { + fun `SimpleInteractionTrigger uses reference equality for distinct instances`() { val triggerUptime = 1000.nanoseconds val name = "test-trigger" @@ -100,8 +92,8 @@ class InteractionTriggerTest { val trigger2 = SimpleInteractionTrigger(triggerUptime, name) val trigger3 = SimpleInteractionTrigger(triggerUptime, name, FakeInteractionTrace()) - assertEquals(trigger1, trigger2) - assertEquals(trigger2, trigger3) + assertThat(trigger1).isNotEqualTo(trigger2) + assertThat(trigger2).isNotEqualTo(trigger3) } @Test @@ -113,7 +105,7 @@ class InteractionTriggerTest { val trigger1 = SimpleInteractionTrigger(time1, name) val trigger2 = SimpleInteractionTrigger(time2, name) - assertNotEquals(trigger1, trigger2) + assertThat(trigger1).isNotEqualTo(trigger2) } @Test @@ -125,7 +117,7 @@ class InteractionTriggerTest { val trigger1 = SimpleInteractionTrigger(time, name1) val trigger2 = SimpleInteractionTrigger(time, name2) - assertNotEquals(trigger1, trigger2) + assertThat(trigger1).isNotEqualTo(trigger2) } @Test @@ -136,9 +128,9 @@ class InteractionTriggerTest { val trigger = SimpleInteractionTrigger(triggerUptime, name) val toString = trigger.toString() - assertTrue(toString.contains(name)) - assertTrue(toString.contains(triggerUptime.toString())) - assertTrue(toString.contains("InteractionTrigger")) + assertThat(toString).contains(name) + assertThat(toString).contains(triggerUptime.toString()) + assertThat(toString).contains("InteractionTrigger") } @Test @@ -150,9 +142,9 @@ class InteractionTriggerTest { val trigger = InteractionTriggerWithPayload(triggerUptime, name, trace, payload) - assertEquals(triggerUptime, trigger.triggerUptime) - assertEquals(name, trigger.name) - assertEquals(payload, trigger.payload) + assertThat(trigger.triggerUptime).isEqualTo(triggerUptime) + assertThat(trigger.name).isEqualTo(name) + assertThat(trigger.payload).isEqualTo(payload) } @Test @@ -164,17 +156,15 @@ class InteractionTriggerTest { val trigger = InteractionTriggerWithPayload(triggerUptime, name, trace, payload) - // Should delegate to underlying SimpleInteractionTrigger val returnedTrace = trigger.takeOverInteractionTrace() - assertSame(trace, returnedTrace) + assertThat(returnedTrace).isSameInstanceAs(trace) - // Second call should return null val secondTrace = trigger.takeOverInteractionTrace() - assertNull(secondTrace) + assertThat(secondTrace).isNull() } @Test - fun `InteractionTriggerWithPayload equals works correctly`() { + fun `InteractionTriggerWithPayload uses reference equality for distinct instances`() { val triggerUptime = 1000.nanoseconds val name = "test-trigger" val payload = "test-payload" @@ -185,9 +175,9 @@ class InteractionTriggerTest { InteractionTriggerWithPayload(triggerUptime, name, FakeInteractionTrace(), payload) val trigger4 = InteractionTriggerWithPayload(triggerUptime, name, null, "other-payload") - assertEquals(trigger1, trigger2) - assertEquals(trigger1, trigger3) // Should equal based on name and triggerUptime - assertEquals(trigger1, trigger4) // Should equal based on name and triggerUptime + assertThat(trigger1).isNotEqualTo(trigger2) + assertThat(trigger1).isNotEqualTo(trigger3) + assertThat(trigger1).isNotEqualTo(trigger4) } @Test @@ -200,7 +190,7 @@ class InteractionTriggerTest { val trigger1 = InteractionTriggerWithPayload(time1, name, null, payload) val trigger2 = InteractionTriggerWithPayload(time2, name, null, payload) - assertNotEquals(trigger1, trigger2) + assertThat(trigger1).isNotEqualTo(trigger2) } @Test @@ -213,7 +203,7 @@ class InteractionTriggerTest { val trigger1 = InteractionTriggerWithPayload(time, name1, null, payload) val trigger2 = InteractionTriggerWithPayload(time, name2, null, payload) - assertNotEquals(trigger1, trigger2) + assertThat(trigger1).isNotEqualTo(trigger2) } @Test @@ -225,10 +215,10 @@ class InteractionTriggerTest { val trigger = InteractionTriggerWithPayload(triggerUptime, name, null, payload) val toString = trigger.toString() - assertTrue(toString.contains(name)) - assertTrue(toString.contains(triggerUptime.toString())) - assertTrue(toString.contains(payload)) - assertTrue(toString.contains("InteractionTrigger")) + assertThat(toString).contains(name) + assertThat(toString).contains(triggerUptime.toString()) + assertThat(toString).contains(payload) + assertThat(toString).contains("InteractionTrigger") } @Test @@ -245,9 +235,9 @@ class InteractionTriggerTest { val trigger = InteractionTriggerWithPayload(triggerUptime, name, null, payload) - assertEquals(payload, trigger.payload) - assertEquals(42, trigger.payload.id) - assertEquals("test description", trigger.payload.description) + assertThat(trigger.payload).isEqualTo(payload) + assertThat(trigger.payload.id).isEqualTo(42) + assertThat(trigger.payload.description).isEqualTo("test description") } @Test @@ -258,7 +248,7 @@ class InteractionTriggerTest { val simpleTrigger = SimpleInteractionTrigger(triggerUptime, name) val payloadTrigger = InteractionTriggerWithPayload(triggerUptime, name, null, "payload") - assertNotEquals(simpleTrigger, payloadTrigger) + assertThat(simpleTrigger).isNotEqualTo(payloadTrigger) } @Test @@ -268,9 +258,8 @@ class InteractionTriggerTest { val trigger = InteractionTrigger.triggerNow(name) as SimpleInteractionTrigger val trace = trigger.takeOverInteractionTrace() - assertNotNull(trace) - // Trace should be null after being taken over - assertNull(trigger.takeOverInteractionTrace()) + assertThat(trace).isNotNull() + assertThat(trigger.takeOverInteractionTrace()).isNull() } @Test @@ -281,16 +270,16 @@ class InteractionTriggerTest { val trigger = SimpleInteractionTrigger(millisDuration, name) - assertEquals(millisDuration, trigger.triggerUptime) - assertEquals(nanosDuration, trigger.triggerUptime) + assertThat(trigger.triggerUptime).isEqualTo(millisDuration) + assertThat(trigger.triggerUptime).isEqualTo(nanosDuration) } @Test fun `InteractionTrace endTrace can be called`() { val trace = FakeInteractionTrace() - assertFalse(trace.endTraceCalled) + assertThat(trace.endTraceCalled).isFalse() trace.endTrace() - assertTrue(trace.endTraceCalled) + assertThat(trace.endTraceCalled).isTrue() } } diff --git a/papa/src/test/java/papa/MainThreadTriggerStackTest.kt b/papa/src/test/java/papa/MainThreadTriggerStackTest.kt index 5fa068a..0d53a79 100644 --- a/papa/src/test/java/papa/MainThreadTriggerStackTest.kt +++ b/papa/src/test/java/papa/MainThreadTriggerStackTest.kt @@ -1,11 +1,8 @@ package papa -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotSame -import org.junit.Assert.assertNull +import android.view.MotionEvent +import com.google.common.truth.Truth.assertThat import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -23,22 +20,27 @@ class MainThreadTriggerStackTest { } } + private fun createInputEventPayload(): InputEventTrigger { + val motionEvent = MotionEvent.obtain(0L, 1L, MotionEvent.ACTION_UP, 0f, 0f, 0) + return InputEventTrigger.createForTest(motionEvent, 1000.nanoseconds) + } + @Test fun `currentTriggers returns empty list initially`() { val triggers = MainThreadTriggerStack.currentTriggers - assertTrue(triggers.isEmpty()) + assertThat(triggers).isEmpty() } @Test fun `earliestInteractionTrigger returns null when stack is empty`() { val earliest = MainThreadTriggerStack.earliestInteractionTrigger - assertNull(earliest) + assertThat(earliest).isNull() } @Test fun `inputEventInteractionTriggers returns empty list when stack is empty`() { val triggers = MainThreadTriggerStack.inputEventInteractionTriggers - assertTrue(triggers.isEmpty()) + assertThat(triggers).isEmpty() } @Test @@ -47,8 +49,7 @@ class MainThreadTriggerStackTest { MainThreadTriggerStack.triggeredBy(trigger, endTraceAfterBlock = false) { val currentTriggers = MainThreadTriggerStack.currentTriggers - assertEquals(1, currentTriggers.size) - assertEquals(trigger, currentTriggers[0]) + assertThat(currentTriggers).containsExactly(trigger) } } @@ -57,12 +58,10 @@ class MainThreadTriggerStackTest { val trigger = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") MainThreadTriggerStack.triggeredBy(trigger, endTraceAfterBlock = false) { - // Trigger should be present during execution - assertFalse(MainThreadTriggerStack.currentTriggers.isEmpty()) + assertThat(MainThreadTriggerStack.currentTriggers).isNotEmpty() } - // Trigger should be removed after execution - assertTrue(MainThreadTriggerStack.currentTriggers.isEmpty()) + assertThat(MainThreadTriggerStack.currentTriggers).isEmpty() } @Test @@ -74,7 +73,7 @@ class MainThreadTriggerStackTest { expectedResult } - assertEquals(expectedResult, result) + assertThat(result).isEqualTo(expectedResult) } @Test @@ -82,15 +81,13 @@ class MainThreadTriggerStackTest { val trace = FakeInteractionTrace() val trigger = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger", trace) - assertFalse(trace.endTraceCalled) + assertThat(trace.endTraceCalled).isFalse() MainThreadTriggerStack.triggeredBy(trigger, endTraceAfterBlock = true) { - // Trace should not be ended yet during block execution - assertFalse(trace.endTraceCalled) + assertThat(trace.endTraceCalled).isFalse() } - // Trace should be ended after block execution - assertTrue(trace.endTraceCalled) + assertThat(trace.endTraceCalled).isTrue() } @Test @@ -102,26 +99,57 @@ class MainThreadTriggerStackTest { // Do nothing } - // Trace should not be ended - assertFalse(trace.endTraceCalled) + assertThat(trace.endTraceCalled).isFalse() } @Test - fun `triggeredBy removes existing trigger with same properties`() { - val trigger1 = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") - val trigger2 = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") // Same properties - - MainThreadTriggerStack.triggeredBy(trigger1, endTraceAfterBlock = false) { - // First trigger should be in stack - assertEquals(1, MainThreadTriggerStack.currentTriggers.size) - assertEquals(trigger1, MainThreadTriggerStack.currentTriggers[0]) + fun `earliestInteractionTrigger prefers most recent duplicate trigger with same uptime`() { + val originalTrigger = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") + val duplicateTrigger = + SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") // Same uptime - MainThreadTriggerStack.triggeredBy(trigger2, endTraceAfterBlock = false) { - // Second trigger should replace first (only one trigger in stack) + MainThreadTriggerStack.pushTriggeredBy(originalTrigger) + try { + MainThreadTriggerStack.triggeredBy(duplicateTrigger, endTraceAfterBlock = false) { val triggers = MainThreadTriggerStack.currentTriggers - assertEquals(1, triggers.size) - assertEquals(trigger2, triggers[0]) + assertThat(triggers).containsExactly(originalTrigger, duplicateTrigger).inOrder() + assertThat(MainThreadTriggerStack.earliestInteractionTrigger).isSameInstanceAs( + duplicateTrigger + ) + } + + assertThat(MainThreadTriggerStack.earliestInteractionTrigger).isSameInstanceAs( + originalTrigger + ) + + val triggersAfterBlock = MainThreadTriggerStack.currentTriggers + assertThat(triggersAfterBlock).containsExactly(originalTrigger) + } finally { + MainThreadTriggerStack.popTriggeredBy(originalTrigger) + } + } + + @Test + fun `triggeredBy leaves original trigger on stack after duplicate exits`() { + val originalTrigger = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") + val duplicateTrigger = + SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") // Same uptime + + MainThreadTriggerStack.pushTriggeredBy(originalTrigger) + try { + MainThreadTriggerStack.triggeredBy(duplicateTrigger, endTraceAfterBlock = false) { + assertThat(MainThreadTriggerStack.earliestInteractionTrigger).isSameInstanceAs( + duplicateTrigger + ) } + + val triggersAfterBlock = MainThreadTriggerStack.currentTriggers + assertThat(triggersAfterBlock).containsExactly(originalTrigger) + assertThat(MainThreadTriggerStack.earliestInteractionTrigger).isSameInstanceAs( + originalTrigger + ) + } finally { + MainThreadTriggerStack.popTriggeredBy(originalTrigger) } } @@ -132,12 +160,9 @@ class MainThreadTriggerStackTest { SimpleInteractionTrigger(2000.nanoseconds, "test-trigger-2") // Different properties MainThreadTriggerStack.triggeredBy(trigger1, endTraceAfterBlock = false) { - // Should not throw since triggers are different MainThreadTriggerStack.triggeredBy(trigger2, endTraceAfterBlock = false) { val triggers = MainThreadTriggerStack.currentTriggers - assertEquals(2, triggers.size) - assertTrue(triggers.contains(trigger1)) - assertTrue(triggers.contains(trigger2)) + assertThat(triggers).containsExactly(trigger1, trigger2).inOrder() } } } @@ -146,18 +171,14 @@ class MainThreadTriggerStackTest { fun `triggeredBy removes trigger even when exception is thrown`() { val trigger = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") - try { + assertThrows(RuntimeException::class.java) { MainThreadTriggerStack.triggeredBy(trigger, endTraceAfterBlock = false) { - // Verify trigger is in stack - assertEquals(1, MainThreadTriggerStack.currentTriggers.size) + assertThat(MainThreadTriggerStack.currentTriggers).containsExactly(trigger) throw RuntimeException("Test exception") } - } catch (_: RuntimeException) { - // Expected exception } - // Trigger should still be removed from stack despite exception - assertTrue(MainThreadTriggerStack.currentTriggers.isEmpty()) + assertThat(MainThreadTriggerStack.currentTriggers).isEmpty() } @Test @@ -165,16 +186,13 @@ class MainThreadTriggerStackTest { val trace = FakeInteractionTrace() val trigger = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger", trace) - try { + assertThrows(RuntimeException::class.java) { MainThreadTriggerStack.triggeredBy(trigger, endTraceAfterBlock = true) { throw RuntimeException("Test exception") } - } catch (_: RuntimeException) { - // Expected exception } - // Trace should still be ended despite exception - assertTrue(trace.endTraceCalled) + assertThat(trace.endTraceCalled).isTrue() } @Test @@ -186,8 +204,7 @@ class MainThreadTriggerStackTest { MainThreadTriggerStack.triggeredBy(trigger1, endTraceAfterBlock = false) { MainThreadTriggerStack.triggeredBy(trigger2, endTraceAfterBlock = false) { MainThreadTriggerStack.triggeredBy(trigger3, endTraceAfterBlock = false) { - val earliest = MainThreadTriggerStack.earliestInteractionTrigger - assertEquals(trigger2, earliest) + assertThat(MainThreadTriggerStack.earliestInteractionTrigger).isSameInstanceAs(trigger2) } } } @@ -197,15 +214,13 @@ class MainThreadTriggerStackTest { fun `pushTriggeredBy adds trigger to stack`() { val trigger = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") - assertTrue(MainThreadTriggerStack.currentTriggers.isEmpty()) + assertThat(MainThreadTriggerStack.currentTriggers).isEmpty() MainThreadTriggerStack.pushTriggeredBy(trigger) val triggers = MainThreadTriggerStack.currentTriggers - assertEquals(1, triggers.size) - assertEquals(trigger, triggers[0]) + assertThat(triggers).containsExactly(trigger) - // Clean up MainThreadTriggerStack.popTriggeredBy(trigger) } @@ -219,27 +234,24 @@ class MainThreadTriggerStackTest { MainThreadTriggerStack.pushTriggeredBy(trigger) // Same instance } - // Clean up MainThreadTriggerStack.popTriggeredBy(trigger) } @Test - fun `pushTriggeredBy allows different trigger instances with same properties`() { + fun `pushTriggeredBy allows different trigger instances with same uptime`() { val trigger1 = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") val trigger2 = SimpleInteractionTrigger( 1000.nanoseconds, "test-trigger" - ) // Same properties, different instance + ) // Same uptime, different instance MainThreadTriggerStack.pushTriggeredBy(trigger1) - // Should not throw since it's a different instance MainThreadTriggerStack.pushTriggeredBy(trigger2) val triggers = MainThreadTriggerStack.currentTriggers - assertEquals(2, triggers.size) + assertThat(triggers).containsExactly(trigger1, trigger2).inOrder() - // Clean up MainThreadTriggerStack.popTriggeredBy(trigger1) MainThreadTriggerStack.popTriggeredBy(trigger2) } @@ -249,10 +261,10 @@ class MainThreadTriggerStackTest { val trigger = SimpleInteractionTrigger(1000.nanoseconds, "test-trigger") MainThreadTriggerStack.pushTriggeredBy(trigger) - assertEquals(1, MainThreadTriggerStack.currentTriggers.size) + assertThat(MainThreadTriggerStack.currentTriggers).containsExactly(trigger) MainThreadTriggerStack.popTriggeredBy(trigger) - assertTrue(MainThreadTriggerStack.currentTriggers.isEmpty()) + assertThat(MainThreadTriggerStack.currentTriggers).isEmpty() } @Test @@ -261,15 +273,13 @@ class MainThreadTriggerStackTest { val otherTrigger = SimpleInteractionTrigger(2000.nanoseconds, "other-trigger") MainThreadTriggerStack.pushTriggeredBy(trigger) - assertEquals(1, MainThreadTriggerStack.currentTriggers.size) + assertThat(MainThreadTriggerStack.currentTriggers).containsExactly(trigger) - // Popping a different trigger should not affect the stack MainThreadTriggerStack.popTriggeredBy(otherTrigger) - assertEquals(1, MainThreadTriggerStack.currentTriggers.size) + assertThat(MainThreadTriggerStack.currentTriggers).containsExactly(trigger) - // Clean up MainThreadTriggerStack.popTriggeredBy(trigger) - assertTrue(MainThreadTriggerStack.currentTriggers.isEmpty()) + assertThat(MainThreadTriggerStack.currentTriggers).isEmpty() } @Test @@ -290,10 +300,28 @@ class MainThreadTriggerStackTest { inputEventTrigger, endTraceAfterBlock = false ) { + assertThat(MainThreadTriggerStack.inputEventInteractionTriggers).isEmpty() + } + } + } + + @Test + fun `inputEventInteractionTriggers keeps duplicate triggers in stack order`() { + val payload = createInputEventPayload() + val originalTrigger = InteractionTriggerWithPayload(1000.nanoseconds, "tap", null, payload) + val duplicateTrigger = InteractionTriggerWithPayload(1000.nanoseconds, "tap", null, payload) + + MainThreadTriggerStack.pushTriggeredBy(originalTrigger) + try { + MainThreadTriggerStack.triggeredBy(duplicateTrigger, endTraceAfterBlock = false) { val inputTriggers = MainThreadTriggerStack.inputEventInteractionTriggers - // Both triggers should be filtered out since neither has InputEventTrigger payload - assertTrue(inputTriggers.isEmpty()) + assertThat(inputTriggers).containsExactly(originalTrigger, duplicateTrigger).inOrder() } + + val inputTriggersAfterBlock = MainThreadTriggerStack.inputEventInteractionTriggers + assertThat(inputTriggersAfterBlock).containsExactly(originalTrigger) + } finally { + MainThreadTriggerStack.popTriggeredBy(originalTrigger) } } @@ -305,11 +333,8 @@ class MainThreadTriggerStackTest { val triggers1 = MainThreadTriggerStack.currentTriggers val triggers2 = MainThreadTriggerStack.currentTriggers - // Should return different instances (copies) - assertNotSame(triggers1, triggers2) - - // But with same content - assertEquals(triggers1, triggers2) + assertThat(triggers1).isNotSameInstanceAs(triggers2) + assertThat(triggers1).containsExactlyElementsIn(triggers2).inOrder() } } @@ -319,30 +344,25 @@ class MainThreadTriggerStackTest { val innerTrigger = SimpleInteractionTrigger(2000.nanoseconds, "inner") MainThreadTriggerStack.triggeredBy(outerTrigger, endTraceAfterBlock = false) { - assertEquals(1, MainThreadTriggerStack.currentTriggers.size) - assertEquals(outerTrigger, MainThreadTriggerStack.earliestInteractionTrigger) + assertThat(MainThreadTriggerStack.currentTriggers).containsExactly(outerTrigger) + assertThat(MainThreadTriggerStack.earliestInteractionTrigger).isSameInstanceAs(outerTrigger) MainThreadTriggerStack.triggeredBy( innerTrigger, endTraceAfterBlock = false ) { - assertEquals(2, MainThreadTriggerStack.currentTriggers.size) - assertEquals( + assertThat(MainThreadTriggerStack.currentTriggers).containsExactly( outerTrigger, + innerTrigger + ).inOrder() + assertThat( MainThreadTriggerStack.earliestInteractionTrigger - ) // Still earliest - - val triggers = MainThreadTriggerStack.currentTriggers - assertTrue(triggers.contains(outerTrigger)) - assertTrue(triggers.contains(innerTrigger)) + ).isSameInstanceAs(outerTrigger) } - // Inner trigger should be removed - assertEquals(1, MainThreadTriggerStack.currentTriggers.size) - assertEquals(outerTrigger, MainThreadTriggerStack.currentTriggers[0]) + assertThat(MainThreadTriggerStack.currentTriggers).containsExactly(outerTrigger) } - // All triggers should be removed - assertTrue(MainThreadTriggerStack.currentTriggers.isEmpty()) + assertThat(MainThreadTriggerStack.currentTriggers).isEmpty() } }