Auto commit Fri Mar 20 11:01:01 PM CDT 2026
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
@@ -258,6 +259,7 @@ internal fun TimelineGraph(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(190.dp)
|
.height(190.dp)
|
||||||
.background(Color(0xFF0A0E18), RoundedCornerShape(10.dp))
|
.background(Color(0xFF0A0E18), RoundedCornerShape(10.dp))
|
||||||
|
.testTag("timeline_graph")
|
||||||
.pointerInput(viewport, selection, curveLeft, curveRight, activeCurve) {
|
.pointerInput(viewport, selection, curveLeft, curveRight, activeCurve) {
|
||||||
awaitPointerEventScope {
|
awaitPointerEventScope {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
182
app/src/test/java/com/mindmachine/mvp/DragGestureTest.kt
Normal file
182
app/src/test/java/com/mindmachine/mvp/DragGestureTest.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
188
app/src/test/java/com/mindmachine/mvp/PinchZoomGestureTest.kt
Normal file
188
app/src/test/java/com/mindmachine/mvp/PinchZoomGestureTest.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user