From e84ab89856abc039e0fb63cb00aeafb2d25cf52d Mon Sep 17 00:00:00 2001 From: Tretzi Date: Fri, 20 Mar 2026 23:01:02 -0500 Subject: [PATCH] Auto commit Fri Mar 20 11:01:01 PM CDT 2026 --- .../mvp/session/TimelineEditorGestureTest.kt | 167 +++++++++++++ .../mvp/session/TimelineEditorScreen.kt | 2 + .../com/mindmachine/mvp/DragGestureTest.kt | 182 +++++++++++++++ .../mindmachine/mvp/PinchZoomGestureTest.kt | 188 +++++++++++++++ .../mvp/TimelineCurveGestureTest.kt | 219 ++++++++++++++++++ .../mvp/TimelineViewportGestureTest.kt | 201 ++++++++++++++++ 6 files changed, 959 insertions(+) create mode 100644 app/src/androidTest/java/com/mindmachine/mvp/session/TimelineEditorGestureTest.kt create mode 100644 app/src/test/java/com/mindmachine/mvp/DragGestureTest.kt create mode 100644 app/src/test/java/com/mindmachine/mvp/PinchZoomGestureTest.kt create mode 100644 app/src/test/java/com/mindmachine/mvp/TimelineCurveGestureTest.kt create mode 100644 app/src/test/java/com/mindmachine/mvp/TimelineViewportGestureTest.kt diff --git a/app/src/androidTest/java/com/mindmachine/mvp/session/TimelineEditorGestureTest.kt b/app/src/androidTest/java/com/mindmachine/mvp/session/TimelineEditorGestureTest.kt new file mode 100644 index 0000000..1f5b3e2 --- /dev/null +++ b/app/src/androidTest/java/com/mindmachine/mvp/session/TimelineEditorGestureTest.kt @@ -0,0 +1,167 @@ +package com.mindmachine.mvp.session + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeUp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Compose UI tests for TimelineEditorScreen gesture handling. + * These tests run without a physical device using Compose testing framework. + */ +@OptIn(ExperimentalTestApi::class) +@RunWith(AndroidJUnit4::class) +class TimelineEditorGestureTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun timelineGraph_renders_without_crashing() { + composeTestRule.setContent { + // Create a minimal test state + var state by remember { mutableStateOf(TimelineEditorState.default()) } + + TimelineGraph( + title = "Visual", + leftLabel = "Flash Interval", + rightLabel = "Blank Interval", + curveLeft = state.visualLeft, + curveRight = state.visualRight, + activeCurve = state.activeCurve, + viewport = state.viewport, + selection = state.selection, + playheadSec = state.playheadSec, + onViewportChanged = { state = state.copy(viewport = it) }, + onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, + onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) }, + onTapSideSelect = { side -> + state = state.copy(activeCurve = if (side == Side.LEFT) TimelineEditorState.ActiveCurve.VISUAL_LEFT else TimelineEditorState.ActiveCurve.VISUAL_RIGHT) + } + ) + } + + // Basic sanity check that the graph renders + composeTestRule.waitForIdle() + } + + @Test + fun single_finger_vertical_swipe_does_not_crash() { + composeTestRule.setContent { + var state by remember { mutableStateOf(TimelineEditorState.default()) } + + TimelineGraph( + title = "Visual", + leftLabel = "Flash Interval", + rightLabel = "Blank Interval", + curveLeft = state.visualLeft, + curveRight = state.visualRight, + activeCurve = state.activeCurve, + viewport = state.viewport, + selection = state.selection, + playheadSec = state.playheadSec, + onViewportChanged = { state = state.copy(viewport = it) }, + onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, + onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) }, + onTapSideSelect = { side -> + state = state.copy(activeCurve = if (side == Side.LEFT) TimelineEditorState.ActiveCurve.VISUAL_LEFT else TimelineEditorState.ActiveCurve.VISUAL_RIGHT) + } + ) + } + + composeTestRule.waitForIdle() + + // Perform vertical swipe (used for dragging points) + composeTestRule.onNodeWithTag("timeline_graph") + .performTouchInput { + swipeUp(startY = 150f, endY = 50f) + } + + composeTestRule.waitForIdle() + } + + @Test + fun single_finger_horizontal_swipe_does_not_crash() { + composeTestRule.setContent { + var state by remember { mutableStateOf(TimelineEditorState.default()) } + + TimelineGraph( + title = "Visual", + leftLabel = "Flash Interval", + rightLabel = "Blank Interval", + curveLeft = state.visualLeft, + curveRight = state.visualRight, + activeCurve = state.activeCurve, + viewport = state.viewport, + selection = state.selection, + playheadSec = state.playheadSec, + onViewportChanged = { state = state.copy(viewport = it) }, + onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, + onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) }, + onTapSideSelect = { side -> + state = state.copy(activeCurve = if (side == Side.LEFT) TimelineEditorState.ActiveCurve.VISUAL_LEFT else TimelineEditorState.ActiveCurve.VISUAL_RIGHT) + } + ) + } + + composeTestRule.waitForIdle() + + // Perform horizontal swipe (panning) + composeTestRule.onNodeWithTag("timeline_graph") + .performTouchInput { + swipeLeft(startX = 250f, endX = 150f) + } + + composeTestRule.waitForIdle() + } + + @Test + fun multi_touch_gesture_does_not_crash() { + composeTestRule.setContent { + var state by remember { mutableStateOf(TimelineEditorState.default()) } + + TimelineGraph( + title = "Visual", + leftLabel = "Flash Interval", + rightLabel = "Blank Interval", + curveLeft = state.visualLeft, + curveRight = state.visualRight, + activeCurve = state.activeCurve, + viewport = state.viewport, + selection = state.selection, + playheadSec = state.playheadSec, + onViewportChanged = { state = state.copy(viewport = it) }, + onSelectionChanged = { sel -> state = state.copy(selection = sel).clampSelection() }, + onUpdateCurve = { curveId, newCurve -> state = state.withCurve(curveId, newCurve) }, + onTapSideSelect = { side -> + state = state.copy(activeCurve = if (side == Side.LEFT) TimelineEditorState.ActiveCurve.VISUAL_LEFT else TimelineEditorState.ActiveCurve.VISUAL_RIGHT) + } + ) + } + + composeTestRule.waitForIdle() + + // Simulate multi-touch gesture (two fingers) + composeTestRule.onNodeWithTag("timeline_graph") + .performTouchInput { + down(0, androidx.compose.ui.geometry.Offset(100f, 100f)) + down(1, androidx.compose.ui.geometry.Offset(200f, 100f)) + moveTo(0, androidx.compose.ui.geometry.Offset(150f, 100f)) + moveTo(1, androidx.compose.ui.geometry.Offset(250f, 100f)) + up(0) + up(1) + } + + composeTestRule.waitForIdle() + } +} diff --git a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt index 2752997..0559e10 100644 --- a/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt +++ b/app/src/main/java/com/mindmachine/mvp/session/TimelineEditorScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path @@ -258,6 +259,7 @@ internal fun TimelineGraph( .fillMaxWidth() .height(190.dp) .background(Color(0xFF0A0E18), RoundedCornerShape(10.dp)) + .testTag("timeline_graph") .pointerInput(viewport, selection, curveLeft, curveRight, activeCurve) { awaitPointerEventScope { while (true) { diff --git a/app/src/test/java/com/mindmachine/mvp/DragGestureTest.kt b/app/src/test/java/com/mindmachine/mvp/DragGestureTest.kt new file mode 100644 index 0000000..d310dea --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/DragGestureTest.kt @@ -0,0 +1,182 @@ +package com.mindmachine.mvp.session + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.* +import org.junit.Test + +/** + * Unit tests for single-finger drag gesture handling. + * Tests both point dragging (up/down) and handle dragging (start/end selection). + */ +class DragGestureTest { + + @Test + fun `single-finger point dragging updates curve values`() { + val curve = TimelineCurve.constant(0.5f, 600) + val heightPx = 200f + + // Simulate dragging point at index 0 upward (Y decreases, value increases) + val newY = 20f // Near top of screen + val result = curve.movePointVertical(0, newY, heightPx) + + // Value should increase (since Y decreased from center) + assertTrue("Dragging up should increase value", result.points[0].value01 > 0.5f) + + // Simulate dragging point downward (Y increases, value decreases) + val newY2 = 180f // Near bottom of screen + val result2 = curve.movePointVertical(0, newY2, heightPx) + + // Value should decrease (since Y increased from center) + assertTrue("Dragging down should decrease value", result2.points[0].value01 < 0.5f) + } + + @Test + fun `single-finger handle dragging constrains selection range`() { + val state = TimelineEditorState.default(durationSec = 600) + val originalSelection = state.selection + + // Simulate dragging start handle forward (increase startSec) + // But ensure it doesn't exceed endSec - 60 (minimum 60 second duration) + val newStartSec = originalSelection.startSec + 120 + + // Clamp the selection to respect minimum duration + val clampedStart = newStartSec.coerceAtMost(originalSelection.endSec - 60) + + val newSelection = TimelineSelection(startSec = clampedStart, endSec = originalSelection.endSec) + + // Verify minimum duration is enforced + assertTrue("Selection should maintain minimum duration", newSelection.durationSec >= 60) + } + + @Test + fun `single-finger handle dragging clamps to timeline bounds`() { + val state = TimelineEditorState.default(durationSec = 600) + + // Try to drag end handle beyond timeline duration + val tooFarEnd = state.durationSec + 100 + + // Should be clamped to duration + val clampedEnd = tooFarEnd.coerceAtMost(state.durationSec) + + assertEquals(state.durationSec, clampedEnd) + } + + @Test + fun `single-finger panning updates viewport start position`() { + val viewport = TimelineViewport(startSec = 100f, secondsPerScreen = 600f) + val widthPx = 400f + + // Simulate panning right (drag left) - moves viewport later in time + val deltaXPan = -50f // Negative means moving right in time + val newViewport = viewport.applyZoomPan( + zoomChange = 1f, + panPx = deltaXPan, + widthPx = widthPx, + centroidPx = widthPx / 2f + ) + + // Start position should have increased (moved later in time) + assertTrue("Panning right should increase startSec", newViewport.startSec > viewport.startSec) + } + + @Test + fun `hitTestPoint finds nearest curve point for dragging`() { + val points = listOf( + TimelinePoint(0, 0.5f), + TimelinePoint(60, 0.8f), + TimelinePoint(120, 0.3f), + ) + val curve = TimelineCurve(points) + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 200f) + val widthPx = 400f + val heightPx = 200f + + // Click near the middle point (time=60, value=0.8, Y ~ 40) + // timeToX(60) = (60 / 200) * 400 = 120 + // valueToY(0.8) = (1 - 0.8) * 200 = 40 + val offset = Offset(x = 125f, y = 45f) + + val hitIndex = curve.hitTestPoint(offset, viewport, widthPx, heightPx) + + // Should hit index 1 (the middle point) + assertEquals(1, hitIndex) + } + + @Test + fun `moving multiple points creates consistent curve`() { + val curve = TimelineCurve.constant(0.5f, 600) + val heightPx = 200f + + // Drag point at index 0 to top + val result1 = curve.movePointVertical(0, 0f, heightPx) + + // Drag point at index 1 to bottom + val result2 = result1.movePointVertical(1, heightPx, heightPx) + + // Verify both changes persist + assertTrue("First point should be at top", result2.points[0].value01 > 0.9f) + assertTrue("Second point should be at bottom", result2.points[1].value01 < 0.1f) + } + + @Test + fun `selection dragging respects minimum 60 second duration constraint`() { + val startSec = 0 + val endSec = 120 + + // Try to drag end handle to 140 (would make duration 140, still valid) + val newEndSec = 140 + val selection = TimelineSelection(startSec, newEndSec) + assertTrue("140 second duration should be valid", selection.durationSec >= 60) + + // Try to drag start handle to 90 (would make duration 30, invalid) + val invalidStartSec = 90 + val clampedStart = invalidStartSec.coerceAtMost(endSec - 60) // Should clamp to 60 + val clampedSelection = TimelineSelection(clampedStart, endSec) + assertTrue("Minimum duration should be enforced", clampedSelection.durationSec >= 60) + } + + @Test + fun `viewport panning prevents scrolling past timeline start`() { + val viewport = TimelineViewport(startSec = 50f, secondsPerScreen = 600f) + val widthPx = 400f + + // Try to pan left past start (should be clamped to 0) + val result = viewport.applyZoomPan( + zoomChange = 1f, + panPx = 100f, // Pan left + widthPx = widthPx, + centroidPx = widthPx / 2f + ) + + // Start should be clamped to 0 or above + assertTrue("Viewport start should not be negative", result.startSec >= 0f) + } + + @Test + fun `screen coordinate to time conversion is accurate for dragging`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + + // At x=400 (right edge), time should be 600 seconds + val rightEdgeTime = viewport.xToTimeSec(400f, widthPx) + assertEquals(600f, rightEdgeTime, 0.5f) + + // At x=200 (center), time should be 300 seconds + val centerTime = viewport.xToTimeSec(200f, widthPx) + assertEquals(300f, centerTime, 0.5f) + } + + @Test + fun `time to screen coordinate conversion is accurate for dragging`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + + // Time 0 should be at x=0 + val xAtStart = viewport.timeToX(0, widthPx) + assertEquals(0f, xAtStart, 0.5f) + + // Time 300 should be at x=200 (halfway) + val xAtMiddle = viewport.timeToX(300, widthPx) + assertEquals(200f, xAtMiddle, 0.5f) + } +} diff --git a/app/src/test/java/com/mindmachine/mvp/PinchZoomGestureTest.kt b/app/src/test/java/com/mindmachine/mvp/PinchZoomGestureTest.kt new file mode 100644 index 0000000..4e21c61 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/PinchZoomGestureTest.kt @@ -0,0 +1,188 @@ +package com.mindmachine.mvp.session + +import org.junit.Assert.* +import org.junit.Test + +/** + * Unit tests for two-finger pinch-to-zoom gesture handling logic. + * These tests verify that pinch-to-zoom is detected and applied correctly without requiring a device or emulator. + */ +class PinchZoomGestureTest { + + @Test + fun `detects pinch-in gesture from zoom factor greater than 1`() { + // Simulating two fingers moving closer together + // In real Compose, calculateZoom() returns the zoom factor based on pointer distance change + // A factor > 1 means zooming in (fingers closer) + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val zoomFactor = 2.0f // Pinch in 2x + + val result = viewport.applyZoomPan( + zoomChange = zoomFactor, // > 1 means pinch in (zoom in) + panPx = 0f, + widthPx = 400f, + centroidPx = 200f + ) + + // Zooming in should reduce seconds per screen + assertTrue("Zooming in should reduce seconds per screen", result.secondsPerScreen < viewport.secondsPerScreen) + assertEquals(300f, result.secondsPerScreen, 1f) + } + + @Test + fun `detects pinch-out gesture from zoom factor less than 1`() { + // Simulating two fingers moving apart + // A factor < 1 means zooming out (fingers farther) + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 300f) + val zoomFactor = 0.5f // Pinch out 2x (zoom out) + + val result = viewport.applyZoomPan( + zoomChange = zoomFactor, // < 1 means pinch out (zoom out) + panPx = 0f, + widthPx = 400f, + centroidPx = 200f + ) + + // Zooming out should increase seconds per screen + assertTrue("Zooming out should increase seconds per screen", result.secondsPerScreen > viewport.secondsPerScreen) + assertEquals(600f, result.secondsPerScreen, 1f) + } + + @Test + fun `pinch-zoom maintains focus on centroid point`() { + val viewport = TimelineViewport(startSec = 100f, secondsPerScreen = 600f) + val widthPx = 400f + val centroidPx = 250f // Some point on screen + + // Calculate time at centroid before zoom + val timeAtCentroidBefore = viewport.xToTimeSec(centroidPx, widthPx) + + // Apply pinch zoom + val result = viewport.applyZoomPan( + zoomChange = 1.5f, + panPx = 0f, + widthPx = widthPx, + centroidPx = centroidPx + ) + + // Calculate time at centroid after zoom + val timeAtCentroidAfter = result.xToTimeSec(centroidPx, widthPx) + + // The time at the centroid should remain approximately the same + assertEquals("Centroid time should stay same during pinch-zoom", timeAtCentroidBefore, timeAtCentroidAfter, 1f) + } + + @Test + fun `pinch-zoom with simultaneous pan handles both transformations`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + + // Apply both zoom and pan simultaneously + val result = viewport.applyZoomPan( + zoomChange = 2.0f, // Zoom in 2x + panPx = 50f, // Pan right by 50px + widthPx = widthPx, + centroidPx = 200f + ) + + // Should have applied both transformations + assertEquals(300f, result.secondsPerScreen, 1f) // Zoomed in + assertTrue("Viewport should have shifted due to pan", result.startSec > 0f) + } + + @Test + fun `zoom factor of 1 (no pinch) results in no zoom change`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + + val result = viewport.applyZoomPan( + zoomChange = 1.0f, // No zoom + panPx = 0f, + widthPx = widthPx, + centroidPx = 200f + ) + + // Seconds per screen should remain unchanged + assertEquals(viewport.secondsPerScreen, result.secondsPerScreen, 0.1f) + } + + @Test + fun `extreme pinch-zoom values are clamped to valid range`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + + // Extreme pinch in (zoom in 1000x) + val resultIn = viewport.applyZoomPan( + zoomChange = 1000f, + panPx = 0f, + widthPx = widthPx, + centroidPx = 200f + ) + // Should be clamped to minimum + assertEquals(30f, resultIn.secondsPerScreen, 1f) + + // Extreme pinch out (zoom out 0.001x) + val resultOut = viewport.applyZoomPan( + zoomChange = 0.001f, + panPx = 0f, + widthPx = widthPx, + centroidPx = 200f + ) + // Should be clamped to maximum + assertEquals(86400f, resultOut.secondsPerScreen, 1f) + } + + @Test + fun `pinch-zoom from edge maintains view on that edge`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + + // Pinch-zoom from left edge (centroid at 0px) + val result = viewport.applyZoomPan( + zoomChange = 2.0f, + panPx = 0f, + widthPx = widthPx, + centroidPx = 0f + ) + + // After zoom, time at left edge should still be 0 (or close to it) + val leftEdgeTime = result.xToTimeSec(0f, widthPx) + assertTrue("Left edge time should stay near 0 when pinching from edge", leftEdgeTime < 10f) + } + + @Test + fun `viewport maintains minimum visible duration constraint`() { + // Minimum seconds per screen should be 30 + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 30f) + val widthPx = 400f + + // Try to zoom in further + val result = viewport.applyZoomPan( + zoomChange = 2.0f, + panPx = 0f, + widthPx = widthPx, + centroidPx = 200f + ) + + // Should be clamped to minimum + assertEquals(30f, result.secondsPerScreen, 0.1f) + } + + @Test + fun `viewport maintains maximum visible duration constraint`() { + // Maximum seconds per screen should be 86400 (24 hours) + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 86400f) + val widthPx = 400f + + // Try to zoom out further + val result = viewport.applyZoomPan( + zoomChange = 0.5f, + panPx = 0f, + widthPx = widthPx, + centroidPx = 200f + ) + + // Should be clamped to maximum + assertEquals(86400f, result.secondsPerScreen, 0.1f) + } +} diff --git a/app/src/test/java/com/mindmachine/mvp/TimelineCurveGestureTest.kt b/app/src/test/java/com/mindmachine/mvp/TimelineCurveGestureTest.kt new file mode 100644 index 0000000..cf13ec2 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/TimelineCurveGestureTest.kt @@ -0,0 +1,219 @@ +package com.mindmachine.mvp.session + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.* +import org.junit.Test + +/** + * Unit tests for TimelineCurve gesture handling logic. + * These tests verify single-finger point dragging and hit testing without requiring a device or emulator. + */ +class TimelineCurveGestureTest { + + @Test + fun `movePointVertical updates value correctly`() { + val curve = TimelineCurve.constant(0.5f, 600) + val heightPx = 200f + + // Move point at index 0 (time=0) to Y=0 (should be value=1.0) + val result = curve.movePointVertical(0, 0f, heightPx) + + assertEquals(1.0f, result.points[0].value01, 0.01f) + + // Move to bottom of screen (Y=heightPx) should be value=0.0 + val result2 = curve.movePointVertical(0, heightPx, heightPx) + assertEquals(0.0f, result2.points[0].value01, 0.01f) + + // Move to middle should be value=0.5 + val result3 = curve.movePointVertical(0, heightPx / 2, heightPx) + assertEquals(0.5f, result3.points[0].value01, 0.01f) + } + + @Test + fun `movePointVertical respects value bounds 0 to 1`() { + val curve = TimelineCurve.constant(0.5f, 600) + val heightPx = 200f + + // Try to move above top (negative Y) + val result = curve.movePointVertical(0, -50f, heightPx) + assertTrue(result.points[0].value01 >= 0f) + assertTrue(result.points[0].value01 <= 1f) + + // Try to move below bottom (Y > height) + val result2 = curve.movePointVertical(0, 250f, heightPx) + assertTrue(result2.points[0].value01 >= 0f) + assertTrue(result2.points[0].value01 <= 1f) + } + + @Test + fun `movePointVertical returns same curve for invalid index`() { + val curve = TimelineCurve.constant(0.5f, 600) + val heightPx = 200f + + // Try to move point at invalid index + val result = curve.movePointVertical(999, 0f, heightPx) + + // Should return the original curve unchanged + assertEquals(curve.points.size, result.points.size) + assertEquals(curve.points[0].value01, result.points[0].value01, 0.01f) + } + + @Test + fun `hitTestPoint finds nearest point within radius`() { + val curve = TimelineCurve.constant(0.5f, 600) + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + val heightPx = 200f + + // Point at index 0 is at time=0, value=0.5 + // timeToX(0) = 0, valueToY(0.5) = 100 (halfway up) + val offset = Offset(x = 10f, y = 100f) // Close to point at index 0 + + val hitIndex = curve.hitTestPoint(offset, viewport, widthPx, heightPx) + + // Should hit the first point (index 0) + assertNotNull(hitIndex) + assertEquals(0, hitIndex) + } + + @Test + fun `hitTestPoint returns null when outside radius`() { + val curve = TimelineCurve.constant(0.5f, 600) + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + val heightPx = 200f + + // Point far away from any curve point + val offset = Offset(x = 500f, y = 0f) + + val hitIndex = curve.hitTestPoint(offset, viewport, widthPx, heightPx) + + assertNull(hitIndex) + } + + @Test + fun `hitTestPoint finds closest point when multiple within radius`() { + // Create a curve with points close together + val curve = TimelineCurve.constant(0.5f, 120) // Points every minute (60 sec) + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 120f) + val widthPx = 400f + val heightPx = 200f + + // Point near middle of screen (around index 1-2) + // x=200 should be at time=60 seconds (index 1) + val offset = Offset(x = 200f, y = 100f) + + val hitIndex = curve.hitTestPoint(offset, viewport, widthPx, heightPx) + + assertNotNull(hitIndex) + // Should hit a point near the middle (not too far away) + assertTrue(hitIndex!! < 10) + } + + @Test + fun `hitTestPoint filters out points outside viewport bounds`() { + val curve = TimelineCurve.constant(0.5f, 600) + // Viewport only shows first 60 seconds + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 60f) + val widthPx = 400f + val heightPx = 200f + + // Try to hit a point at time=300 seconds (far outside viewport) + // This should be clipped and not hit + val offset = Offset(x = 3000f, y = 100f) // Way off screen + + val hitIndex = curve.hitTestPoint(offset, viewport, widthPx, heightPx) + + assertNull(hitIndex) + } + + @Test + fun `valueAt returns interpolated values between points`() { + val points = listOf( + TimelinePoint(0, 0.0f), // At time 0, value is 0 + TimelinePoint(100, 1.0f), // At time 100, value is 1 + ) + val curve = TimelineCurve(points) + + // Midpoint should interpolate to 0.5 + val midValue = curve.valueAt(50f) + assertEquals(0.5f, midValue, 0.01f) + + // Quarter point should interpolate to 0.25 + val quarterValue = curve.valueAt(25f) + assertEquals(0.25f, quarterValue, 0.01f) + } + + @Test + fun `valueAt clamps to first point before start`() { + val points = listOf( + TimelinePoint(100, 0.5f), + TimelinePoint(200, 0.8f), + ) + val curve = TimelineCurve(points) + + // Before first point should return first point's value + val beforeValue = curve.valueAt(50f) + assertEquals(0.5f, beforeValue, 0.01f) + } + + @Test + fun `valueAt clamps to last point after end`() { + val points = listOf( + TimelinePoint(100, 0.5f), + TimelinePoint(200, 0.8f), + ) + val curve = TimelineCurve(points) + + // After last point should return last point's value + val afterValue = curve.valueAt(250f) + assertEquals(0.8f, afterValue, 0.01f) + } + + @Test + fun `ensureMinutePoints snaps points to minute boundaries`() { + val points = listOf( + TimelinePoint(0, 0.0f), + TimelinePoint(30, 0.5f), // 30 seconds - not a minute boundary + TimelinePoint(120, 0.8f), + ) + val curve = TimelineCurve(points) + + val result = curve.ensureMinutePoints(180) + + // Check that all points are on minute boundaries (multiples of 60) + result.points.forEach { point -> + assertTrue("Point at ${point.tSec} should be on minute boundary", point.tSec % 60 == 0) + } + + // Should have points at 0, 60, 120, 180 seconds + assertEquals(4, result.points.size) + } + + @Test + fun `ensureMinutePoints preserves interpolated values`() { + val points = listOf( + TimelinePoint(0, 0.0f), + TimelinePoint(120, 1.0f), + ) + val curve = TimelineCurve(points) + + val result = curve.ensureMinutePoints(180) + + // Value at 60 seconds should interpolate to 0.5 + val valueAt60 = result.valueAt(60f) + assertEquals(0.5f, valueAt60, 0.01f) + } + + @Test + fun `extendTo adds points for extended duration`() { + val curve = TimelineCurve.constant(0.5f, 60) + + val extended = curve.extendTo(120) + + // Should have points covering up to 120 seconds + assertTrue(extended.points.any { it.tSec == 120 }) + // Points should be at minute intervals + assertTrue(extended.points.size > 2) + } +} diff --git a/app/src/test/java/com/mindmachine/mvp/TimelineViewportGestureTest.kt b/app/src/test/java/com/mindmachine/mvp/TimelineViewportGestureTest.kt new file mode 100644 index 0000000..0161724 --- /dev/null +++ b/app/src/test/java/com/mindmachine/mvp/TimelineViewportGestureTest.kt @@ -0,0 +1,201 @@ +package com.mindmachine.mvp.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs + +/** + * Unit tests for TimelineViewport gesture handling logic. + * These tests verify zoom/pan transformations without requiring a device or emulator. + */ +class TimelineViewportGestureTest { + + @Test + fun `applyZoomPan zooms in around centroid correctly`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + val centroidPx = widthPx / 2f // Center of screen + + // Zoom in 2x around center + val result = viewport.applyZoomPan( + zoomChange = 2.0f, + panPx = 0f, + widthPx = widthPx, + centroidPx = centroidPx + ) + + // Seconds per screen should halve (zoom in) + assertEquals(300f, result.secondsPerScreen, 1f) + // Center time should stay the same (300 seconds into timeline) + // Time at x position: startSec + (x / widthPx) * secondsPerScreen + val centerTime = viewport.startSec + (widthPx / 2f) / widthPx * viewport.secondsPerScreen + val resultCenterTime = result.startSec + (widthPx / 2f) / widthPx * result.secondsPerScreen + assertEquals(centerTime, resultCenterTime, 1f) + } + + @Test + fun `applyZoomPan zooms out around centroid correctly`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 300f) + val widthPx = 400f + val centroidPx = widthPx / 2f // Center of screen + + // Zoom out 2x around center + val result = viewport.applyZoomPan( + zoomChange = 0.5f, + panPx = 0f, + widthPx = widthPx, + centroidPx = centroidPx + ) + + // Seconds per screen should double (zoom out) + assertEquals(600f, result.secondsPerScreen, 1f) + } + + @Test + fun `applyZoomPan respects minimum zoom level`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 10f) + val widthPx = 400f + + // Try to zoom in beyond minimum (30 seconds per screen) + val result = viewport.applyZoomPan( + zoomChange = 1000f, // Extreme zoom + panPx = 0f, + widthPx = widthPx, + centroidPx = widthPx / 2f + ) + + // Should clamp to minimum + assertEquals(30f, result.secondsPerScreen, 1f) + } + + @Test + fun `applyZoomPan respects maximum zoom level`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 3600f) // 1 hour + val widthPx = 400f + + // Try to zoom out beyond maximum (24 hours) + val result = viewport.applyZoomPan( + zoomChange = 0.001f, // Extreme zoom out + panPx = 0f, + widthPx = widthPx, + centroidPx = widthPx / 2f + ) + + // Should clamp to maximum (86400 seconds = 24 hours) + assertEquals(86400f, result.secondsPerScreen, 1f) + } + + @Test + fun `applyZoomPan pans horizontally correctly`() { + val viewport = TimelineViewport(startSec = 100f, secondsPerScreen = 600f) + val widthPx = 400f + + // Pan right by 100 pixels + val result = viewport.applyZoomPan( + zoomChange = 1f, + panPx = -100f, // Negative pan moves viewport right + widthPx = widthPx, + centroidPx = widthPx / 2f + ) + + // Calculate expected start time after pan + // Pan amount in seconds: -100px * (600s / 400px) = -150s + // Should move 150 seconds earlier + assertEquals(250f, result.startSec, 1f) + } + + @Test + fun `applyZoomPan prevents negative start time`() { + val viewport = TimelineViewport(startSec = 10f, secondsPerScreen = 600f) + val widthPx = 400f + + // Pan left (past start of timeline) + val result = viewport.applyZoomPan( + zoomChange = 1f, + panPx = 200f, // Pan left + widthPx = widthPx, + centroidPx = widthPx / 2f + ) + + // Should clamp to 0 + assertEquals(0f, result.startSec, 0.1f) + } + + @Test + fun `timeToX converts time to X coordinate correctly`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + + // 300 seconds should be at 50% of width + val x = viewport.timeToX(300, widthPx) + assertEquals(200f, x, 0.5f) + } + + @Test + fun `xToTimeSec converts X coordinate to time correctly`() { + val viewport = TimelineViewport(startSec = 100f, secondsPerScreen = 600f) + val widthPx = 400f + + // 200px (center) should map to start + 300 seconds + val time = viewport.xToTimeSec(200f, widthPx) + assertEquals(400f, time, 0.5f) + } + + @Test + fun `xToTimeSec and timeToX are inverses`() { + val viewport = TimelineViewport(startSec = 50f, secondsPerScreen = 300f) + val widthPx = 400f + + val originalTime = 200f + val x = viewport.timeToX(originalTime.toInt(), widthPx) + val resultTime = viewport.xToTimeSec(x, widthPx) + + assertEquals(originalTime, resultTime, 0.5f) + } + + @Test + fun `applyZoomPan maintains view on centroid when zooming without panning`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + val centroidPx = 200f // Center of screen + + // Get the time at centroid before zoom + val timeAtCentroidBefore = viewport.xToTimeSec(centroidPx, widthPx) + + // Zoom without panning + val result = viewport.applyZoomPan( + zoomChange = 2f, + panPx = 0f, // No panning + widthPx = widthPx, + centroidPx = centroidPx + ) + + // The time at centroid should remain the same when only zooming + val timeAtCentroidAfter = result.xToTimeSec(centroidPx, widthPx) + assertEquals(timeAtCentroidBefore, timeAtCentroidAfter, 1f) + } + + @Test + fun `applyZoomPan with panning changes time at centroid appropriately`() { + val viewport = TimelineViewport(startSec = 0f, secondsPerScreen = 600f) + val widthPx = 400f + val centroidPx = 200f // Center of screen + + // Get the time at centroid before panning + val timeAtCentroidBefore = viewport.xToTimeSec(centroidPx, widthPx) + + // Pan right (negative pan moves view to later times) + val result = viewport.applyZoomPan( + zoomChange = 1f, // No zoom + panPx = -50f, // Pan right (negative = moves to later times) + widthPx = widthPx, + centroidPx = centroidPx + ) + + // The time at centroid should change when panning + val timeAtCentroidAfter = result.xToTimeSec(centroidPx, widthPx) + // Pan right should move to later times, so time should increase + assertTrue("Panning right should increase time at centroid", timeAtCentroidAfter > timeAtCentroidBefore) + } +}