package solutions.tretter.mindmachine.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) } }