diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4505835..0f99fac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,104 +1,123 @@ -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) - id("com.google.devtools.ksp") version "2.0.0-1.0.22" // Update version as needed -} - -android { - namespace = "com.wavehitech.aptracker" - compileSdk = 35 - - defaultConfig { - applicationId = "com.wavehitech.aptracker" - minSdk = 26 - targetSdk = 35 - versionCode = 1 - versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } - buildFeatures { - compose = true - } - dependenciesInfo { - includeInApk = true - includeInBundle = true - } - buildToolsVersion = "36.0.0" - -} - -dependencies { - implementation(libs.androidx.media3.common.ktx) - val composeVersion = "1.8.0" // Update this version as needed - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.material3:material3:1.2.0") - implementation("androidx.compose.material:material-icons-extended") - // Navigation for Compose - implementation("androidx.navigation:navigation-compose:2.8.9") - // Paging 3 library - implementation("androidx.paging:paging-compose:3.3.6") - -// For HorizontalPager - implementation("androidx.compose.foundation:foundation:$composeVersion") - // Testing dependencies - androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - androidTestImplementation("androidx.compose.ui:ui-test-junit4:$composeVersion") - - implementation("com.google.android.material:material:1.12.0") - implementation("io.coil-kt:coil-compose:2.7.0") - implementation("androidx.compose.foundation:foundation:$composeVersion") - - // Persistent Data - implementation("androidx.room:room-runtime:2.7.0") - implementation("androidx.room:room-ktx:2.7.0") - ksp("androidx.room:room-compiler:2.7.0") - // Also add Gson for TypeConverters: - implementation("com.google.code.gson:gson:2.13.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") - // Jetpack Compose - implementation("androidx.compose.runtime:runtime-livedata:1.7.8") - // For collectAsState extension on Flow: - implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") - implementation("androidx.core:core-ktx:1.16.0") - - implementation("androidx.exifinterface:exifinterface:1.4.0") - -} - +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + id("com.google.devtools.ksp") version "2.0.0-1.0.22" +} + +android { + namespace = "com.wavehitech.aptracker" + compileSdk = 35 + + defaultConfig { + applicationId = "com.wavehitech.aptracker" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } + dependenciesInfo { + includeInApk = true + includeInBundle = true + } + buildToolsVersion = "36.0.0" +} + +dependencies { + // Define consistent versions + val composeVersion = "1.8.2" + val composeBomVersion = "2025.05.01" + + // Core Android dependencies + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // Compose BOM - this manages all Compose library versions + implementation(platform(libs.androidx.compose.bom)) + + // Compose UI dependencies (versions managed by BOM) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.foundation:foundation") + + // Material Design + implementation(libs.androidx.material3) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation(libs.material) + + // Navigation + implementation("androidx.navigation:navigation-compose:2.9.0") + + // Paging + implementation("androidx.paging:paging-compose:3.3.6") + + // Image loading + implementation("io.coil-kt:coil-compose:2.7.0") + + // Room database + implementation("androidx.room:room-runtime:2.7.1") + implementation("androidx.room:room-ktx:2.7.1") + ksp("androidx.room:room-compiler:2.7.1") + + // JSON parsing + implementation("com.google.code.gson:gson:2.13.1") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + + // Compose Runtime + implementation("androidx.compose.runtime:runtime-livedata") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0") + + // EXIF data + implementation("androidx.exifinterface:exifinterface:1.4.1") + + // Media3 (if needed) + implementation(libs.androidx.media3.common.ktx) + + // Testing dependencies + testImplementation(libs.junit) + testImplementation("org.mockito:mockito-core:5.13.0") + testImplementation("org.mockito:mockito-inline:5.2.0") + testImplementation("org.robolectric:robolectric:4.13") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + testImplementation("androidx.room:room-testing:2.7.1") + testImplementation("androidx.arch.core:core-testing:2.2.0") + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.androidx.compose.ui.ui.test.junit4) + + // Debug dependencies + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/src/test/java/com/wavehitech/aptracker/BitmapUtilsTest.kt b/app/src/test/java/com/wavehitech/aptracker/BitmapUtilsTest.kt new file mode 100644 index 0000000..f8d7814 --- /dev/null +++ b/app/src/test/java/com/wavehitech/aptracker/BitmapUtilsTest.kt @@ -0,0 +1,194 @@ +package com.wavehitech.aptracker + +import android.graphics.Bitmap +import org.junit.Test +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.mockito.Mockito.* +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class BitmapUtilsTest { + + @Test + fun `isLandscape returns true for landscape bitmap`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(1920) + `when`(mockBitmap.height).thenReturn(1080) + + val result = BitmapUtils.isLandscape(mockBitmap) + + assertTrue(result) + } + + @Test + fun `isLandscape returns false for portrait bitmap`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(1080) + `when`(mockBitmap.height).thenReturn(1920) + + val result = BitmapUtils.isLandscape(mockBitmap) + + assertFalse(result) + } + + @Test + fun `isLandscape returns false for square bitmap`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(1080) + `when`(mockBitmap.height).thenReturn(1080) + + val result = BitmapUtils.isLandscape(mockBitmap) + + assertFalse(result) + } + + @Test + fun `calculateResizedDimensions for landscape image uses width target`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(3840) + `when`(mockBitmap.height).thenReturn(2160) + + val ppi = 300 + val targetWidthInches = 4.0f + val targetHeightInches = 3.0f + + val (width, height) = BitmapUtils.calculateResizedDimensions( + mockBitmap, ppi, targetWidthInches, targetHeightInches + ) + + val expectedWidth = (targetWidthInches * ppi).toInt() // 1200 + val expectedHeight = (2160 * expectedWidth.toFloat() / 3840).toInt() // 675 + + assertEquals(expectedWidth, width) + assertEquals(expectedHeight, height) + } + + @Test + fun `calculateResizedDimensions for portrait image uses height target`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(2160) + `when`(mockBitmap.height).thenReturn(3840) + + val ppi = 300 + val targetWidthInches = 4.0f + val targetHeightInches = 6.0f + + val (width, height) = BitmapUtils.calculateResizedDimensions( + mockBitmap, ppi, targetWidthInches, targetHeightInches + ) + + val expectedHeight = (targetHeightInches * ppi).toInt() // 1800 + val expectedWidth = (2160 * expectedHeight.toFloat() / 3840).toInt() // 1012 + + assertEquals(expectedWidth, width) + assertEquals(expectedHeight, height) + } + + @Test + fun `calculateResizedDimensions with default values`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(1920) + `when`(mockBitmap.height).thenReturn(1080) + + val ppi = 200 + + val (width, height) = BitmapUtils.calculateResizedDimensions(mockBitmap, ppi) + + val expectedWidth = (3.44f * ppi).toInt() // 688 + val expectedHeight = (1080 * expectedWidth.toFloat() / 1920).toInt() // 387 + + assertEquals(expectedWidth, width) + assertEquals(expectedHeight, height) + } + + @Test + fun `calculateResizedDimensions maintains aspect ratio for landscape`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(1600) + `when`(mockBitmap.height).thenReturn(900) + + val ppi = 150 + val targetWidthInches = 5.0f + + val (width, height) = BitmapUtils.calculateResizedDimensions( + mockBitmap, ppi, targetWidthInches, 3.0f + ) + + val originalAspectRatio = 1600.0f / 900.0f + val newAspectRatio = width.toFloat() / height.toFloat() + + assertEquals(originalAspectRatio, newAspectRatio, 0.01f) + } + + @Test + fun `calculateResizedDimensions maintains aspect ratio for portrait`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(900) + `when`(mockBitmap.height).thenReturn(1600) + + val ppi = 150 + val targetHeightInches = 8.0f + + val (width, height) = BitmapUtils.calculateResizedDimensions( + mockBitmap, ppi, 5.0f, targetHeightInches + ) + + val originalAspectRatio = 900.0f / 1600.0f + val newAspectRatio = width.toFloat() / height.toFloat() + + assertEquals(originalAspectRatio, newAspectRatio, 0.01f) + } + + @Test + fun `createResizedBitmap creates bitmap with correct dimensions`() { + val mockBitmap = mock(Bitmap::class.java) + `when`(mockBitmap.width).thenReturn(2000) + `when`(mockBitmap.height).thenReturn(1500) + + val ppi = 100 + val targetWidthInches = 4.0f + val targetHeightInches = 3.0f + + mockStatic(Bitmap::class.java).use { mockedBitmap -> + val expectedWidth = (targetWidthInches * ppi).toInt() + val expectedHeight = (1500 * expectedWidth.toFloat() / 2000).toInt() + + val resultBitmap = mock(Bitmap::class.java) + mockedBitmap.`when` { + Bitmap.createScaledBitmap(mockBitmap, expectedWidth, expectedHeight, true) + }.thenReturn(resultBitmap) + + val result = BitmapUtils.createResizedBitmap(mockBitmap, ppi, targetWidthInches, targetHeightInches) + + assertEquals(resultBitmap, result) + mockedBitmap.verify { + Bitmap.createScaledBitmap(mockBitmap, expectedWidth, expectedHeight, true) + } + } + } + + @Test + fun `logResizeOperation does not throw exception`() { + // This test ensures the logging function executes without errors + assertDoesNotThrow { + BitmapUtils.logResizeOperation( + filename = "test.jpg", + originalWidth = 1920, + originalHeight = 1080, + newWidth = 960, + newHeight = 540, + ppi = 150, + isLandscape = true + ) + } + } + + private fun assertDoesNotThrow(block: () -> Unit) { + try { + block() + } catch (e: Exception) { + fail("Expected no exception, but got: ${e.message}") + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/wavehitech/aptracker/ConvertersTest.kt b/app/src/test/java/com/wavehitech/aptracker/ConvertersTest.kt new file mode 100644 index 0000000..9222238 --- /dev/null +++ b/app/src/test/java/com/wavehitech/aptracker/ConvertersTest.kt @@ -0,0 +1,200 @@ +package com.wavehitech.aptracker + +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before + +class ConvertersTest { + + private lateinit var converters: Converters + + @Before + fun setup() { + converters = Converters() + } + + @Test + fun `fromStringList converts list to string with separator`() { + val list = listOf("item1", "item2", "item3") + + val result = converters.fromStringList(list) + + assertEquals("item1;;item2;;item3", result) + } + + @Test + fun `fromStringList handles empty list`() { + val list = emptyList() + + val result = converters.fromStringList(list) + + assertEquals("", result) + } + + @Test + fun `fromStringList handles single item`() { + val list = listOf("singleItem") + + val result = converters.fromStringList(list) + + assertEquals("singleItem", result) + } + + @Test + fun `fromStringList handles items with special characters`() { + val list = listOf("item with spaces", "item/with/slashes", "item-with-dashes") + + val result = converters.fromStringList(list) + + assertEquals("item with spaces;;item/with/slashes;;item-with-dashes", result) + } + + @Test + fun `fromStringList handles items containing separator`() { + // This tests edge case where items contain the separator string + val list = listOf("item;;with;;separators", "normal item") + + val result = converters.fromStringList(list) + + assertEquals("item;;with;;separators;;normal item", result) + } + + @Test + fun `toStringList converts string to list with separator`() { + val data = "item1;;item2;;item3" + + val result = converters.toStringList(data) + + assertEquals(listOf("item1", "item2", "item3"), result) + } + + @Test + fun `toStringList handles empty string`() { + val data = "" + + val result = converters.toStringList(data) + + assertEquals(emptyList(), result) + } + + @Test + fun `toStringList handles single item`() { + val data = "singleItem" + + val result = converters.toStringList(data) + + assertEquals(listOf("singleItem"), result) + } + + @Test + fun `toStringList handles items with special characters`() { + val data = "item with spaces;;item/with/slashes;;item-with-dashes" + + val result = converters.toStringList(data) + + assertEquals(listOf("item with spaces", "item/with/slashes", "item-with-dashes"), result) + } + + @Test + fun `toStringList handles string with trailing separator`() { + val data = "item1;;item2;;" + + val result = converters.toStringList(data) + + assertEquals(listOf("item1", "item2", ""), result) + } + + @Test + fun `toStringList handles string with leading separator`() { + val data = ";;item1;;item2" + + val result = converters.toStringList(data) + + assertEquals(listOf("", "item1", "item2"), result) + } + + @Test + fun `toStringList handles consecutive separators`() { + val data = "item1;;;;item2" + + val result = converters.toStringList(data) + + assertEquals(listOf("item1", "", "item2"), result) + } + + @Test + fun `toStringList handles only separators`() { + val data = ";;;;" + + val result = converters.toStringList(data) + + assertEquals(listOf("", "", ""), result) + } + + @Test + fun `roundtrip conversion preserves original list`() { + val originalList = listOf("item1", "item2", "item3", "item with spaces") + + val stringData = converters.fromStringList(originalList) + val convertedBackList = converters.toStringList(stringData) + + assertEquals(originalList, convertedBackList) + } + + @Test + fun `roundtrip conversion preserves empty list`() { + val originalList = emptyList() + + val stringData = converters.fromStringList(originalList) + val convertedBackList = converters.toStringList(stringData) + + assertEquals(originalList, convertedBackList) + } + + @Test + fun `roundtrip conversion preserves single item list`() { + val originalList = listOf("single item") + + val stringData = converters.fromStringList(originalList) + val convertedBackList = converters.toStringList(stringData) + + assertEquals(originalList, convertedBackList) + } + + @Test + fun `roundtrip conversion with complex items`() { + val originalList = listOf( + "file_name.jpg", + "path/to/file.png", + "item with spaces and numbers 123", + "", // empty string item + "item-with-dashes_and_underscores" + ) + + val stringData = converters.fromStringList(originalList) + val convertedBackList = converters.toStringList(stringData) + + assertEquals(originalList, convertedBackList) + } + + @Test + fun `converter handles unicode characters`() { + val originalList = listOf("测试", "тест", "🎯", "café") + + val stringData = converters.fromStringList(originalList) + val convertedBackList = converters.toStringList(stringData) + + assertEquals(originalList, convertedBackList) + } + + @Test + fun `separator is consistently used`() { + val separator = ";;" + val list = listOf("a", "b", "c") + + val result = converters.fromStringList(list) + + assertTrue(result.contains(separator)) + assertEquals(2, result.split(separator).size - 1) // Should have 2 separators for 3 items + } +} \ No newline at end of file diff --git a/app/src/test/java/com/wavehitech/aptracker/EntityValidationTest.kt b/app/src/test/java/com/wavehitech/aptracker/EntityValidationTest.kt new file mode 100644 index 0000000..083940d --- /dev/null +++ b/app/src/test/java/com/wavehitech/aptracker/EntityValidationTest.kt @@ -0,0 +1,237 @@ +package com.wavehitech.aptracker + +import com.wavehitech.aptracker.models.AccessPoint +import com.wavehitech.aptracker.models.ProjectWithAccessPoints +import org.junit.Test +import org.junit.Assert.* +import java.util.UUID + +class EntityValidationTest { + + @Test + fun `ProjectEntity creates with required fields`() { + val id = "project-123" + val name = "Test Project" + + val project = ProjectEntity(id, name) + + assertEquals(id, project.id) + assertEquals(name, project.name) + } + + @Test + fun `ProjectEntity equality works correctly`() { + val project1 = ProjectEntity("123", "Test") + val project2 = ProjectEntity("123", "Test") + val project3 = ProjectEntity("456", "Test") + + assertEquals(project1, project2) + assertNotEquals(project1, project3) + } + + @Test + fun `AccessPointEntity creates with required fields`() { + val id = "ap-123" + val projectId = "project-456" + val name = "Access Point 1" + + val accessPoint = AccessPointEntity(id, projectId, name) + + assertEquals(id, accessPoint.id) + assertEquals(projectId, accessPoint.projectId) + assertEquals(name, accessPoint.name) + } + + @Test + fun `AccessPointEntity maintains foreign key relationship`() { + val projectId = "project-123" + val accessPoint = AccessPointEntity("ap-1", projectId, "AP1") + + assertEquals(projectId, accessPoint.projectId) + } + + @Test + fun `ImageEntity creates with all required fields`() { + val id = "img-123" + val accessPointId = "ap-456" + val filename = "test-image.jpg" + val isCircled = false + val originalImageId = null + val orderIndex = 0 + + val image = ImageEntity(id, accessPointId, filename, isCircled, originalImageId, orderIndex) + + assertEquals(id, image.id) + assertEquals(accessPointId, image.accessPointId) + assertEquals(filename, image.filename) + assertEquals(isCircled, image.isCircled) + assertEquals(originalImageId, image.originalImageId) + assertEquals(orderIndex, image.orderIndex) + } + + @Test + fun `ImageEntity creates circled version with original reference`() { + val originalId = "img-original" + val circledImage = ImageEntity( + id = "img-circled", + accessPointId = "ap-1", + filename = "image_circle.jpg", + isCircled = true, + originalImageId = originalId, + orderIndex = 1 + ) + + assertTrue(circledImage.isCircled) + assertEquals(originalId, circledImage.originalImageId) + assertTrue(circledImage.filename.contains("circle")) + } + + @Test + fun `ImageEntity generates UUID by default`() { + val image = ImageEntity( + accessPointId = "ap-1", + filename = "test.jpg", + isCircled = false, + orderIndex = 0 + ) + + assertNotNull(image.id) + assertTrue(image.id.isNotEmpty()) + // Verify it's a valid UUID format + assertDoesNotThrow { + UUID.fromString(image.id) + } + } + + @Test + fun `AccessPoint model creates with default values`() { + val name = "Test AP" + val accessPoint = AccessPoint(name = name) + + assertNotNull(accessPoint.id) + assertEquals(name, accessPoint.name) + assertTrue(accessPoint.pictures.isEmpty()) + + // Verify default ID is valid UUID + assertDoesNotThrow { + UUID.fromString(accessPoint.id) + } + } + + @Test + fun `AccessPoint model allows custom ID`() { + val customId = "custom-ap-id" + val name = "Test AP" + val accessPoint = AccessPoint(id = customId, name = name) + + assertEquals(customId, accessPoint.id) + assertEquals(name, accessPoint.name) + } + + @Test + fun `AccessPoint pictures list is mutable`() { + val accessPoint = AccessPoint(name = "Test AP") + + assertTrue(accessPoint.pictures.isEmpty()) + + accessPoint.pictures.add("image1.jpg") + accessPoint.pictures.add("image2.jpg") + + assertEquals(2, accessPoint.pictures.size) + assertEquals("image1.jpg", accessPoint.pictures[0]) + assertEquals("image2.jpg", accessPoint.pictures[1]) + } + + @Test + fun `ProjectWithAccessPoints creates relationship correctly`() { + val project = ProjectEntity("project-1", "Test Project") + val accessPoints = listOf( + AccessPointEntity("ap-1", "project-1", "AP1"), + AccessPointEntity("ap-2", "project-1", "AP2") + ) + + val projectWithAPs = ProjectWithAccessPoints(project, accessPoints) + + assertEquals(project, projectWithAPs.project) + assertEquals(2, projectWithAPs.accessPoints.size) + assertEquals("ap-1", projectWithAPs.accessPoints[0].id) + assertEquals("ap-2", projectWithAPs.accessPoints[1].id) + + // Verify all access points belong to the same project + projectWithAPs.accessPoints.forEach { ap -> + assertEquals(project.id, ap.projectId) + } + } + + @Test + fun `ImageEntity filename follows naming convention`() { + val projectId = "project-123" + val apName = "AP01" + val imageNumber = 1 + val expectedFilename = "${projectId}_${apName}-${imageNumber}.jpg" + + val image = ImageEntity( + accessPointId = "ap-1", + filename = expectedFilename, + isCircled = false, + orderIndex = 0 + ) + + assertEquals(expectedFilename, image.filename) + assertTrue(image.filename.contains(projectId)) + assertTrue(image.filename.contains(apName)) + assertTrue(image.filename.endsWith(".jpg")) + } + + @Test + fun `ImageEntity circled filename follows naming convention`() { + val baseFilename = "project-123_AP01-1.jpg" + val circledFilename = baseFilename.replace(".jpg", "_circle_.jpg") + + val circledImage = ImageEntity( + accessPointId = "ap-1", + filename = circledFilename, + isCircled = true, + originalImageId = "original-img-id", + orderIndex = 1 + ) + + assertTrue(circledImage.filename.contains("_circle_")) + assertTrue(circledImage.isCircled) + assertNotNull(circledImage.originalImageId) + } + + @Test + fun `Entity IDs are not empty strings`() { + val project = ProjectEntity("", "Test") + val accessPoint = AccessPointEntity("", "project-1", "AP") + val image = ImageEntity("", "ap-1", "test.jpg", false, null, 0) + + // While entities can be created with empty IDs, + // this tests that we can detect such cases + assertTrue(project.id.isEmpty()) + assertTrue(accessPoint.id.isEmpty()) + assertTrue(image.id.isEmpty()) + } + + @Test + fun `Entity names handle special characters`() { + val specialName = "Test Project (2024) - #1" + val project = ProjectEntity("project-1", specialName) + + assertEquals(specialName, project.name) + + val specialAPName = "AP-01/Floor-2" + val accessPoint = AccessPointEntity("ap-1", "project-1", specialAPName) + + assertEquals(specialAPName, accessPoint.name) + } + + private fun assertDoesNotThrow(block: () -> Unit) { + try { + block() + } catch (e: Exception) { + fail("Expected no exception, but got: ${e.message}") + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/wavehitech/aptracker/ExifHandlerTest.kt b/app/src/test/java/com/wavehitech/aptracker/ExifHandlerTest.kt new file mode 100644 index 0000000..d59fb9a --- /dev/null +++ b/app/src/test/java/com/wavehitech/aptracker/ExifHandlerTest.kt @@ -0,0 +1,279 @@ +package com.wavehitech.aptracker + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.io.FileOutputStream + +@RunWith(RobolectricTestRunner::class) +class ExifHandlerTest { + + @Mock + private lateinit var mockSourceFile: File + + @Mock + private lateinit var mockDestFile: File + + @Mock + private lateinit var mockExifInterface: ExifInterface + + @Mock + private lateinit var mockBitmap: Bitmap + + @Mock + private lateinit var mockFileOutputStream: FileOutputStream + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun `preserveExifData copies important EXIF attributes`() { + val sourcePath = "/source/image.jpg" + val destPath = "/dest/image.jpg" + + mockStatic(ExifInterface::class.java).use { mockedExif -> + val sourceExif = mock(ExifInterface::class.java) + val destExif = mock(ExifInterface::class.java) + + mockedExif.`when` { ExifInterface(sourcePath) }.thenReturn(sourceExif) + mockedExif.`when` { ExifInterface(destPath) }.thenReturn(destExif) + + // Mock some EXIF attributes + `when`(sourceExif.getAttribute(ExifInterface.TAG_DATETIME)).thenReturn("2024:01:15 10:30:00") + `when`(sourceExif.getAttribute(ExifInterface.TAG_MAKE)).thenReturn("Canon") + `when`(sourceExif.getAttribute(ExifInterface.TAG_MODEL)).thenReturn("EOS R5") + `when`(sourceExif.getAttribute(ExifInterface.TAG_GPS_LATITUDE)).thenReturn("40.7128") + + ExifHandler.preserveExifData(sourcePath, destPath) + + verify(destExif).setAttribute(ExifInterface.TAG_DATETIME, "2024:01:15 10:30:00") + verify(destExif).setAttribute(ExifInterface.TAG_MAKE, "Canon") + verify(destExif).setAttribute(ExifInterface.TAG_MODEL, "EOS R5") + verify(destExif).setAttribute(ExifInterface.TAG_GPS_LATITUDE, "40.7128") + verify(destExif).setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL.toString()) + verify(destExif).saveAttributes() + } + } + + @Test + fun `preserveExifData handles null attributes gracefully`() { + val sourcePath = "/source/image.jpg" + val destPath = "/dest/image.jpg" + + mockStatic(ExifInterface::class.java).use { mockedExif -> + val sourceExif = mock(ExifInterface::class.java) + val destExif = mock(ExifInterface::class.java) + + mockedExif.`when` { ExifInterface(sourcePath) }.thenReturn(sourceExif) + mockedExif.`when` { ExifInterface(destPath) }.thenReturn(destExif) + + // Return null for all attributes + `when`(sourceExif.getAttribute(any())).thenReturn(null) + + ExifHandler.preserveExifData(sourcePath, destPath) + + // Verify only orientation is set (to NORMAL) + verify(destExif).setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL.toString()) + verify(destExif).saveAttributes() + // Verify null attributes are not set + verify(destExif, never()).setAttribute(eq(ExifInterface.TAG_DATETIME), any()) + } + } + + @Test + fun `preserveExifData handles exceptions gracefully`() { + val sourcePath = "/source/image.jpg" + val destPath = "/dest/image.jpg" + + mockStatic(ExifInterface::class.java).use { mockedExif -> + mockedExif.`when` { ExifInterface(sourcePath) } + .thenThrow(RuntimeException("File not found")) + + // Should not throw exception + assertDoesNotThrow { + ExifHandler.preserveExifData(sourcePath, destPath) + } + } + } + + @Test + fun `processImageWithExif returns true for successful processing`() { + `when`(mockSourceFile.absolutePath).thenReturn("/source/image.jpg") + `when`(mockDestFile.absolutePath).thenReturn("/dest/image.jpg") + `when`(mockSourceFile.name).thenReturn("image.jpg") + + mockStatic(ExifInterface::class.java).use { mockedExif -> + mockStatic(BitmapFactory::class.java).use { mockedBitmapFactory -> + mockStatic(BitmapUtils::class.java).use { mockedBitmapUtils -> + mockStatic(FileOutputStream::class.java).use { mockedFileOutputStream -> + val sourceExif = mock(ExifInterface::class.java) + val destExif = mock(ExifInterface::class.java) + + mockedExif.`when` { ExifInterface("/source/image.jpg") }.thenReturn(sourceExif) + mockedExif.`when` { ExifInterface("/dest/image.jpg") }.thenReturn(destExif) + + `when`(sourceExif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) + .thenReturn(ExifInterface.ORIENTATION_NORMAL) + + mockedBitmapFactory.`when` { BitmapFactory.decodeFile("/source/image.jpg") } + .thenReturn(mockBitmap) + + `when`(mockBitmap.width).thenReturn(1920) + `when`(mockBitmap.height).thenReturn(1080) + + mockedBitmapUtils.`when` { BitmapUtils.isLandscape(mockBitmap) }.thenReturn(true) + mockedBitmapUtils.`when`> { + BitmapUtils.calculateResizedDimensions(mockBitmap, 150, 3.44f, 2.09f) + }.thenReturn(Pair(516, 290)) + + val resizedBitmap = mock(Bitmap::class.java) + mockStatic(Bitmap::class.java).use { mockedBitmap -> + mockedBitmap.`when` { Bitmap.createScaledBitmap(mockBitmap, 516, 290, true) } + .thenReturn(resizedBitmap) + + mockedFileOutputStream.`when` { FileOutputStream(mockDestFile) } + .thenReturn(mockFileOutputStream) + + `when`(resizedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, mockFileOutputStream)) + .thenReturn(true) + + val result = ExifHandler.processImageWithExif(mockSourceFile, mockDestFile, 150) + + assertTrue(result) + verify(resizedBitmap).recycle() + verify(mockBitmap).recycle() + } + } + } + } + } + } + + @Test + fun `processImageWithExif returns false when bitmap loading fails`() { + `when`(mockSourceFile.absolutePath).thenReturn("/source/image.jpg") + + mockStatic(ExifInterface::class.java).use { mockedExif -> + mockStatic(BitmapFactory::class.java).use { mockedBitmapFactory -> + val sourceExif = mock(ExifInterface::class.java) + mockedExif.`when` { ExifInterface("/source/image.jpg") }.thenReturn(sourceExif) + + `when`(sourceExif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) + .thenReturn(ExifInterface.ORIENTATION_NORMAL) + + // Return null bitmap to simulate loading failure + mockedBitmapFactory.`when` { BitmapFactory.decodeFile("/source/image.jpg") } + .thenReturn(null) + + val result = ExifHandler.processImageWithExif(mockSourceFile, mockDestFile, 150) + + assertFalse(result) + } + } + } + + @Test + fun `copyWithOrientationCorrection handles normal orientation by copying file`() { + `when`(mockSourceFile.absolutePath).thenReturn("/source/image.jpg") + `when`(mockSourceFile.copyTo(mockDestFile, true)).thenReturn(mockDestFile) + + mockStatic(ExifInterface::class.java).use { mockedExif -> + val sourceExif = mock(ExifInterface::class.java) + mockedExif.`when` { ExifInterface("/source/image.jpg") }.thenReturn(sourceExif) + + `when`(sourceExif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) + .thenReturn(ExifInterface.ORIENTATION_NORMAL) + + val result = ExifHandler.copyWithOrientationCorrection(mockSourceFile, mockDestFile) + + assertTrue(result) + verify(mockSourceFile).copyTo(mockDestFile, true) + } + } + + @Test + fun `copyWithOrientationCorrection processes rotated images`() { + `when`(mockSourceFile.absolutePath).thenReturn("/source/image.jpg") + `when`(mockDestFile.absolutePath).thenReturn("/dest/image.jpg") + + mockStatic(ExifInterface::class.java).use { mockedExif -> + mockStatic(BitmapFactory::class.java).use { mockedBitmapFactory -> + mockStatic(FileOutputStream::class.java).use { mockedFileOutputStream -> + val sourceExif = mock(ExifInterface::class.java) + val destExif = mock(ExifInterface::class.java) + + mockedExif.`when` { ExifInterface("/source/image.jpg") }.thenReturn(sourceExif) + mockedExif.`when` { ExifInterface("/dest/image.jpg") }.thenReturn(destExif) + + `when`(sourceExif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) + .thenReturn(ExifInterface.ORIENTATION_ROTATE_90) + + mockedBitmapFactory.`when` { BitmapFactory.decodeFile("/source/image.jpg") } + .thenReturn(mockBitmap) + + `when`(mockBitmap.width).thenReturn(1920) + `when`(mockBitmap.height).thenReturn(1080) + + val rotatedBitmap = mock(Bitmap::class.java) + mockStatic(Bitmap::class.java).use { mockedBitmap -> + val matrix = mock(Matrix::class.java) + mockStatic(Matrix::class.java).use { mockedMatrix -> + mockedMatrix.`when` { Matrix() }.thenReturn(matrix) + + mockedBitmap.`when` { + Bitmap.createBitmap(mockBitmap, 0, 0, 1920, 1080, matrix, true) + }.thenReturn(rotatedBitmap) + + mockedFileOutputStream.`when` { FileOutputStream(mockDestFile) } + .thenReturn(mockFileOutputStream) + + `when`(rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, 95, mockFileOutputStream)) + .thenReturn(true) + + val result = ExifHandler.copyWithOrientationCorrection(mockSourceFile, mockDestFile) + + assertTrue(result) + verify(matrix).postRotate(90f) + verify(mockBitmap).recycle() + verify(rotatedBitmap).recycle() + } + } + } + } + } + } + + @Test + fun `copyWithOrientationCorrection returns false on exception`() { + `when`(mockSourceFile.absolutePath).thenReturn("/source/image.jpg") + + mockStatic(ExifInterface::class.java).use { mockedExif -> + mockedExif.`when` { ExifInterface("/source/image.jpg") } + .thenThrow(RuntimeException("File error")) + + val result = ExifHandler.copyWithOrientationCorrection(mockSourceFile, mockDestFile) + + assertFalse(result) + } + } + + private fun assertDoesNotThrow(block: () -> Unit) { + try { + block() + } catch (e: Exception) { + fail("Expected no exception, but got: ${e.message}") + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/wavehitech/aptracker/ProjectRepositoryTest.kt b/app/src/test/java/com/wavehitech/aptracker/ProjectRepositoryTest.kt new file mode 100644 index 0000000..9ba7d6b --- /dev/null +++ b/app/src/test/java/com/wavehitech/aptracker/ProjectRepositoryTest.kt @@ -0,0 +1,287 @@ +package com.wavehitech.aptracker + +import android.content.Context +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class ProjectRepositoryTest { + + @Mock + private lateinit var projectDao: ProjectDao + + @Mock + private lateinit var accessPointDao: AccessPointDao + + @Mock + private lateinit var imageDao: ImageDao + + @Mock + private lateinit var context: Context + + @Mock + private lateinit var mockFile: File + + private lateinit var repository: ProjectRepository + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + + // Mock context.filesDir + `when`(context.filesDir).thenReturn(mockFile) + + repository = ProjectRepository(projectDao, accessPointDao, imageDao, context) + } + + @Test + fun `getAllProjectsFlow returns flow from dao`() = runTest { + val projects = listOf( + ProjectEntity("1", "Project 1", "Description 1"), + ProjectEntity("2", "Project 2", "Description 2") + ) + `when`(projectDao.getAllProjectsFlow()).thenReturn(flowOf(projects)) + + val result = repository.getAllProjectsFlow().first() + + assertEquals(projects, result) + verify(projectDao).getAllProjectsFlow() + } + + @Test + fun `addProjectWithAccessPoints inserts project and access points`() = runTest { + val project = ProjectEntity("1", "Test Project", "Description") + val accessPoints = listOf( + AccessPointEntity("ap1", "1", "AP 1", "Location 1"), + AccessPointEntity("ap2", "1", "AP 2", "Location 2") + ) + + repository.addProjectWithAccessPoints(project, accessPoints) + + verify(projectDao).insertProject(project) + verify(accessPointDao).insertAccessPoint(accessPoints[0]) + verify(accessPointDao).insertAccessPoint(accessPoints[1]) + } + + @Test + fun `getAccessPointsForProjectFlow returns flow from dao`() = runTest { + val projectId = "project1" + val accessPoints = listOf( + AccessPointEntity("ap1", projectId, "AP 1", "Location 1") + ) + `when`(accessPointDao.getAccessPointsForProjectFlow(projectId)).thenReturn(flowOf(accessPoints)) + + val result = repository.getAccessPointsForProjectFlow(projectId).first() + + assertEquals(accessPoints, result) + verify(accessPointDao).getAccessPointsForProjectFlow(projectId) + } + + @Test + fun `updateAccessPoint calls dao update`() = runTest { + val accessPoint = AccessPointEntity("ap1", "project1", "Updated AP", "New Location") + + repository.updateAccessPoint(accessPoint) + + verify(accessPointDao).updateAccessPoint(accessPoint) + } + + @Test + fun `addAccessPoint calls dao insert`() = runTest { + val accessPoint = AccessPointEntity("ap1", "project1", "New AP", "Location") + + repository.addAccessPoint(accessPoint) + + verify(accessPointDao).insertAccessPoint(accessPoint) + } + + @Test + fun `getAccessPointsForProject returns list from dao`() = runTest { + val projectId = "project1" + val accessPoints = listOf( + AccessPointEntity("ap1", projectId, "AP 1", "Location 1") + ) + `when`(accessPointDao.getAccessPointsForProject(projectId)).thenReturn(accessPoints) + + val result = repository.getAccessPointsForProject(projectId) + + assertEquals(accessPoints, result) + verify(accessPointDao).getAccessPointsForProject(projectId) + } + + @Test + fun `getAccessPointsForProject returns empty list on exception`() = runTest { + val projectId = "project1" + `when`(accessPointDao.getAccessPointsForProject(projectId)).thenThrow(RuntimeException("DB Error")) + + val result = repository.getAccessPointsForProject(projectId) + + assertEquals(emptyList(), result) + } + + @Test + fun `deleteAccessPoint deletes images and files`() = runTest { + val accessPointId = "ap1" + val images = listOf( + ImageEntity("img1", accessPointId, "image1.jpg", 0, null), + ImageEntity("img2", accessPointId, "image2.jpg", 1, null) + ) + + `when`(imageDao.getImagesForAccessPointFlow(accessPointId)).thenReturn(flowOf(images)) + `when`(imageDao.deleteAllImagesForAccessPoint(accessPointId)).thenReturn(2) + `when`(imageDao.checkForImagesWithAccessPoint(accessPointId)).thenReturn(emptyList()) + + // Mock file operations + val mockFile1 = mock(File::class.java) + val mockFile2 = mock(File::class.java) + `when`(mockFile1.exists()).thenReturn(true) + `when`(mockFile1.delete()).thenReturn(true) + `when`(mockFile2.exists()).thenReturn(false) + + mockStatic(File::class.java).use { mockedFile -> + mockedFile.`when` { File(mockFile, "image1.jpg") }.thenReturn(mockFile1) + mockedFile.`when` { File(mockFile, "image2.jpg") }.thenReturn(mockFile2) + + repository.deleteAccessPoint(accessPointId) + + verify(mockFile1).delete() + verify(mockFile2, never()).delete() + verify(imageDao).deleteAllImagesForAccessPoint(accessPointId) + verify(accessPointDao).deleteAccessPoint(accessPointId) + } + } + + @Test + fun `deleteProjectWithAccessPoints deletes all associated files`() = runTest { + val projectId = "project1" + val accessPoints = listOf( + AccessPointEntity("ap1", projectId, "AP 1", "Location 1") + ) + val images = listOf( + ImageEntity("img1", "ap1", "image1.jpg", 0, null) + ) + + `when`(accessPointDao.getAccessPointsForProjectFlow(projectId)).thenReturn(flowOf(accessPoints)) + `when`(imageDao.getImagesForAccessPointFlow("ap1")).thenReturn(flowOf(images)) + + val mockImageFile = mock(File::class.java) + `when`(mockImageFile.exists()).thenReturn(true) + `when`(mockImageFile.delete()).thenReturn(true) + + mockStatic(File::class.java).use { mockedFile -> + mockedFile.`when` { File(mockFile, "image1.jpg") }.thenReturn(mockImageFile) + + repository.deleteProjectWithAccessPoints(projectId) + + verify(mockImageFile).delete() + verify(projectDao).deleteProject(projectId) + } + } + + @Test + fun `getAccessPointById returns access point from dao`() = runTest { + val apId = "ap1" + val accessPoint = AccessPointEntity(apId, "project1", "AP 1", "Location 1") + `when`(accessPointDao.getAccessPointById(apId)).thenReturn(accessPoint) + + val result = repository.getAccessPointById(apId) + + assertEquals(accessPoint, result) + verify(accessPointDao).getAccessPointById(apId) + } + + @Test + fun `getAccessPointById returns null on exception`() = runTest { + val apId = "ap1" + `when`(accessPointDao.getAccessPointById(apId)).thenThrow(RuntimeException("DB Error")) + + val result = repository.getAccessPointById(apId) + + assertNull(result) + } + + @Test + fun `getImagesForAccessPointFlow returns flow from dao`() = runTest { + val apId = "ap1" + val images = listOf( + ImageEntity("img1", apId, "image1.jpg", 0, null) + ) + `when`(imageDao.getImagesForAccessPointFlow(apId)).thenReturn(flowOf(images)) + + val result = repository.getImagesForAccessPointFlow(apId).first() + + assertEquals(images, result) + verify(imageDao).getImagesForAccessPointFlow(apId) + } + + @Test + fun `insertImage calls dao insert`() = runTest { + val image = ImageEntity("img1", "ap1", "image1.jpg", 0, null) + + repository.insertImage(image) + + verify(imageDao).insertImage(image) + } + + @Test + fun `updateImage calls dao update`() = runTest { + val image = ImageEntity("img1", "ap1", "image1.jpg", 0, null) + + repository.updateImage(image) + + verify(imageDao).updateImage(image) + } + + @Test + fun `deleteImage calls dao delete`() = runTest { + val imageId = "img1" + + repository.deleteImage(imageId) + + verify(imageDao).deleteImage(imageId) + } + + @Test + fun `getImageById returns image from dao`() = runTest { + val imageId = "img1" + val image = ImageEntity(imageId, "ap1", "image1.jpg", 0, null) + `when`(imageDao.getImageById(imageId)).thenReturn(image) + + val result = repository.getImageById(imageId) + + assertEquals(image, result) + verify(imageDao).getImageById(imageId) + } + + @Test + fun `getCircledVersionOfImage returns circled image from dao`() = runTest { + val originalId = "img1" + val circledImage = ImageEntity("img2", "ap1", "image1_circle.jpg", 1, originalId) + `when`(imageDao.getCircledVersionOfImage(originalId)).thenReturn(circledImage) + + val result = repository.getCircledVersionOfImage(originalId) + + assertEquals(circledImage, result) + verify(imageDao).getCircledVersionOfImage(originalId) + } + + @Test + fun `updateImageOrder calls dao update`() = runTest { + val imageId = "img1" + val newIndex = 5 + + repository.updateImageOrder(imageId, newIndex) + + verify(imageDao).updateImageOrder(imageId, newIndex) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/wavehitech/aptracker/SimpleImageViewerStateTest.kt b/app/src/test/java/com/wavehitech/aptracker/SimpleImageViewerStateTest.kt deleted file mode 100644 index 65b157f..0000000 --- a/app/src/test/java/com/wavehitech/aptracker/SimpleImageViewerStateTest.kt +++ /dev/null @@ -1,444 +0,0 @@ -package com.wavehitech.aptracker - -import android.content.Context -import android.content.ContentResolver -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -// import androidx.compose.foundation.gestures.TransformableState // Not directly used in these state logic tests -// import androidx.compose.ui.input.pointer.PointerInputScope // Not directly used -// import androidx.compose.ui.platform.LocalDensity // Not directly used -// import androidx.compose.ui.unit.Density // Not directly used -// import androidx.compose.runtime.CompositionLocalProvider // Not directly used -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -// import androidx.test.core.app.ApplicationProvider // Not using this directly - -import io.mockk.* // Using MockK for static mocking as it's often more straightforward -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -// import org.junit.Rule // Not using JUnit rules like ComposeTestRule here -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentCaptor // Using Mockito captors as they are standard -import org.mockito.Captor -import org.mockito.Mock -import org.mockito.Mockito.* // Using Mockito for instance mocking -import org.mockito.MockitoAnnotations -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File -import java.io.InputStream - -// Renamed to avoid conflict and be more specific about testing state logic -@RunWith(RobolectricTestRunner::class) -@Config(manifest=Config.NONE, sdk = [28]) -class SimpleImageViewerStateTest { - - @Mock - lateinit var mockContext: Context - - @Mock - lateinit var mockContentResolver: ContentResolver - - @Mock - lateinit var mockBitmap: Bitmap - - // @Mock // File itself is usually not mocked, but its methods are, or use ShadowFile - // lateinit var mockFile: File - - @Mock - lateinit var mockUri: Uri - - @Mock - lateinit var mockInputStream: InputStream - - private lateinit var imageEntity: ImageEntity - - // Using a Mockito mock for the callback - @Mock - lateinit var onZoomChangedMock: (Boolean) -> Unit - - @Captor - lateinit var booleanCaptor: ArgumentCaptor - - @OptIn(ExperimentalCoroutinesApi::class) - private val testDispatcher = UnconfinedTestDispatcher() - - // State variables that mimic those in SimpleImageViewer, to test logic directly - private var scaleState: Float = 1f - private var offsetXState: Float = 0f - private var offsetYState: Float = 0f - private var targetScaleState: Float = 1f - private var targetOffsetXState: Float = 0f - private var targetOffsetYState: Float = 0f - private var isAnimatingState: Boolean = false - - // Mimic container and image size for calculations - private var containerSizeState: Size = Size.Zero - private var imageSizeState: Size = Size.Zero - - - @OptIn(ExperimentalCoroutinesApi::class) - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - Dispatchers.setMain(testDispatcher) - - `when`(mockContext.contentResolver).thenReturn(mockContentResolver) - // `when`(mockContentResolver.openInputStream(any(Uri::class.java))).thenReturn(mockInputStream) // Not strictly needed if not testing loading - `when`(mockContext.packageName).thenReturn("com.wavehitech.aptracker.test") - // `when`(mockContext.filesDir).thenReturn(File("/tmp/test_files")) // Provided by Robolectric - - `when`(mockBitmap.width).thenReturn(1000) // Default mock image size - `when`(mockBitmap.height).thenReturn(1000) - - // Static mocking with MockK for BitmapFactory - mockkStatic(BitmapFactory::class) - every { BitmapFactory.decodeStream(any()) } returns mockBitmap - - mockkStatic(FileProvider::class) - every { FileProvider.getUriForFile(any(), any(), any()) } returns mockUri - - imageEntity = ImageEntity(id = "test_image_1", filename = "test_image.jpg", accessPointId = "ap1") - - // Reset states - scaleState = 1f - offsetXState = 0f - offsetYState = 0f - targetScaleState = 1f - targetOffsetXState = 0f - targetOffsetYState = 0f - isAnimatingState = false - containerSizeState = Size(1000f, 800f) // Default container size for tests - imageSizeState = Size(1000f, 1000f) // Default image size for tests (square) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun tearDown() { - Dispatchers.resetMain() - unmockkStatic(BitmapFactory::class) // Clear MockK static mocks - unmockkStatic(FileProvider::class) - } - - // --- Helper function for SimpleImageViewer's internal calculations --- - // This mirrors `calculateVisibleImageFrame` from SimpleImageViewer - private fun calculateVisibleImageFrameTest(): SimpleImageViewer.ImageFrame { - val containerWidth = containerSizeState.width - val containerHeight = containerSizeState.height - - if (imageSizeState.width <= 0 || imageSizeState.height <= 0) { - return SimpleImageViewer.ImageFrame(0f, 0f, containerWidth, containerHeight) - } - - val imageAspect = imageSizeState.width / imageSizeState.height - val containerAspect = containerWidth / containerHeight - - val visibleWidth: Float - val visibleHeight: Float - val frameX: Float - val frameY: Float - - if (imageAspect > containerAspect) { - visibleWidth = containerWidth - visibleHeight = containerWidth / imageAspect - frameX = 0f - frameY = (containerHeight - visibleHeight) / 2f - } else { - visibleHeight = containerHeight - visibleWidth = containerHeight * imageAspect - frameX = (containerWidth - visibleWidth) / 2f - frameY = 0f - } - return SimpleImageViewer.ImageFrame(frameX, frameY, visibleWidth, visibleHeight) - } - - // This mirrors `calculateMaxOffsets` from SimpleImageViewer - private fun calculateMaxOffsetsTest(currentScale: Float): Pair { - val frame = calculateVisibleImageFrameTest() - val scaledFrameWidth = frame.width * currentScale - val scaledFrameHeight = frame.height * currentScale - val containerWidth = containerSizeState.width - val containerHeight = containerSizeState.height - - val horizontalOverflow = (scaledFrameWidth - containerWidth) / 2f - val verticalOverflow = (scaledFrameHeight - containerHeight) / 2f - return Pair(horizontalOverflow.coerceAtLeast(0f), verticalOverflow.coerceAtLeast(0f)) - } - - // --- Test Cases --- - - @Test - fun `initial state leads to onZoomChanged(false)`() { - // Assuming image is loaded, scale is 1f - val isZoomed = scaleState > 1.01f - onZoomChangedMock(isZoomed) - verify(onZoomChangedMock).invoke(false) - } - - // --- Panning Logic Tests --- - @Test - fun `panning updates offsets dividing by scale when zoomed in`() { - scaleState = 2.0f // Zoomed in - offsetXState = 0f - offsetYState = 0f - val panChange = Offset(20f, 30f) // Raw pan change - - containerSizeState = Size(1000f, 800f) - imageSizeState = Size(1000f, 800f) // Match container aspect for simpler maxOffset calculation here - // frame.width = 1000, frame.height = 800 - // maxOffsetX = (1000*2 - 1000)/2 = 500 - // maxOffsetY = (800*2 - 800)/2 = 400 - - val (maxOffsetX, maxOffsetY) = calculateMaxOffsetsTest(scaleState) - - // Logic from transformableState lambda - if (scaleState > 1.01f) { - val safeScale = if (scaleState == 0f) 1f else scaleState // scaleState won't be 0f due to coerceIn(1f,3f) - offsetXState = (offsetXState + panChange.x / safeScale).coerceIn(-maxOffsetX, maxOffsetX) // Corrected coercion - offsetYState = (offsetYState + panChange.y / safeScale).coerceIn(-maxOffsetY, maxOffsetY) // Corrected coercion - } - - assertEquals(20f / 2.0f, offsetXState, 0.01f) // 10f - assertEquals(30f / 2.0f, offsetYState, 0.01f) // 15f - } - - @Test - fun `panning offsets are coerced correctly at boundaries with division by scale`() { - scaleState = 2.0f - containerSizeState = Size(100f, 100f) - imageSizeState = Size(100f, 100f) - val (maxOffsetX, maxOffsetY) = calculateMaxOffsetsTest(scaleState) // Should be 50f for both - - assertEquals(50f, maxOffsetX, 0.01f) - assertEquals(50f, maxOffsetY, 0.01f) - - offsetXState = 45f // Near max - offsetYState = 48f // Near max - // panChange of (20, 20) will result in (10,10) after division by scale - val panChange = Offset(20f, 20f) - - // Apply panning logic - if (scaleState > 1.01f) { - val safeScale = if (scaleState == 0f) 1f else scaleState - offsetXState = (offsetXState + panChange.x / safeScale).coerceIn(-maxOffsetX, maxOffsetX) // 45 + 10 = 55 -> coerced to 50 - offsetYState = (offsetYState + panChange.y / safeScale).coerceIn(-maxOffsetY, maxOffsetY) // 48 + 10 = 58 -> coerced to 50 - } - - assertEquals(50f, offsetXState, 0.01f) - assertEquals(50f, offsetYState, 0.01f) - - // Pan other way - offsetXState = -45f - offsetYState = -48f - // panChange of (-20, -20) will result in (-10,-10) after division by scale - val negativePanChange = Offset(-20f, -20f) - if (scaleState > 1.01f) { - val safeScale = if (scaleState == 0f) 1f else scaleState - offsetXState = (offsetXState + negativePanChange.x / safeScale).coerceIn(-maxOffsetX, maxOffsetX) // -45 - 10 = -55 -> coerced to -50 - offsetYState = (offsetYState + negativePanChange.y / safeScale).coerceIn(-maxOffsetY, maxOffsetY) // -48 - 10 = -58 -> coerced to -50 - } - assertEquals(-50f, offsetXState, 0.01f) - assertEquals(-50f, offsetYState, 0.01f) - } - - @Test - fun `panning does not update offsets and resets them if scale is 1f (due to internal condition)`() { - scaleState = 1.0f // Not zoomed enough for panning logic to apply offsets - offsetXState = 10f // Simulate existing offset that should be reset - offsetYState = 15f - // val panChange = Offset(5f, 5f) // panChange is irrelevant if not panning - - // Logic from transformableState - // The canPan = { scale > 1.01f } would prevent this lambda from even processing pan for Compose. - // But the internal logic itself also has this check: - if (scaleState > 1.01f) { - // Not executed for panning part - } else { - // This part of the original lambda handles the offset reset when scale is not > 1.01f - offsetXState = 0f - offsetYState = 0f - } - assertEquals(0f, offsetXState, 0.01f) - assertEquals(0f, offsetYState, 0.01f) - } - - @Test - fun `panning does not update offsets if scale is 1_01f (due to internal condition)`() { - scaleState = 1.01f // Exactly at the threshold, so scale > 1.01f is false - offsetXState = 10f - offsetYState = 15f - // val panChange = Offset(5f, 5f) // panChange is irrelevant - - if (scaleState > 1.01f) { - // Not executed - } else { - offsetXState = 0f - offsetYState = 0f - } - assertEquals(0f, offsetXState, 0.01f) - assertEquals(0f, offsetYState, 0.01f) - } - - - // --- Pinch-to-Zoom Logic Tests --- - @Test - fun `pinch updates scale and calls onZoomChanged correctly`() { - scaleState = 1.0f - - // Zoom In - var zoomChangeFactor = 1.5f - scaleState = (scaleState * zoomChangeFactor).coerceIn(1f, 3f) - assertEquals(1.5f, scaleState, 0.01f) - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(true) - reset(onZoomChangedMock) // Mockito reset - - // Zoom further - zoomChangeFactor = 1.5f - scaleState = (scaleState * zoomChangeFactor).coerceIn(1f, 3f) // 1.5 * 1.5 = 2.25 - assertEquals(2.25f, scaleState, 0.01f) - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock, times(0)).invoke(false) // Should not be called with false - verify(onZoomChangedMock).invoke(true) // Still true - reset(onZoomChangedMock) - - // Zoom Out - zoomChangeFactor = 0.5f - scaleState = (scaleState * zoomChangeFactor).coerceIn(1f, 3f) // 2.25 * 0.5 = 1.125 - assertEquals(1.125f, scaleState, 0.01f) - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(true) // Still true - reset(onZoomChangedMock) - - // Zoom Out below threshold - zoomChangeFactor = 0.5f - scaleState = (scaleState * zoomChangeFactor).coerceIn(1f, 3f) // 1.125 * 0.5 = 0.5625 -> coerced to 1f - assertEquals(1f, scaleState, 0.01f) - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(false) - } - - @Test - fun `pinch scale is coerced to min 1f and max 3f`() { - scaleState = 1.0f - scaleState = (scaleState * 0.5f).coerceIn(1f, 3f) - assertEquals(1f, scaleState, 0.01f) - - scaleState = 2.0f - scaleState = (scaleState * 2.0f).coerceIn(1f, 3f) // 4.0f -> coerced to 3f - assertEquals(3f, scaleState, 0.01f) - - scaleState = 3.0f - scaleState = (scaleState * 1.1f).coerceIn(1f, 3f) // 3.3f -> coerced to 3f - assertEquals(3f, scaleState, 0.01f) - } - - // --- Double-Tap to Zoom Logic Tests --- - @Test - fun `double tap when zoomed out sets animation targets for zoom in`() { - scaleState = 1.0f - val tapPosition = Offset(containerSizeState.width / 2, containerSizeState.height / 2) // Tap center - - // Mimic onDoubleTap logic - if (scaleState > 1.01f) { /* no-op for this test */ } - else { - isAnimatingState = true - targetScaleState = 2.5f - imageSizeState = Size(1000f, 800f) - containerSizeState = Size(1000f, 800f) - val frame = calculateVisibleImageFrameTest() - val imageRelativeX = (tapPosition.x - frame.x) / frame.width - val imageRelativeY = (tapPosition.y - frame.y) / frame.height - val containerCenterX = containerSizeState.width / 2 - val containerCenterY = containerSizeState.height / 2 - val focusPointInFrameX = frame.x + frame.width * imageRelativeX - val focusPointInFrameY = frame.y + frame.height * imageRelativeY - val vectorX = focusPointInFrameX - containerCenterX - val vectorY = focusPointInFrameY - containerCenterY - val newScale = 2.5f - targetOffsetXState = (vectorX * (1 - 1/newScale)) - targetOffsetYState = (vectorY * (1 - 1/newScale)) - } - - assertTrue(isAnimatingState) - assertEquals(2.5f, targetScaleState, 0.01f) - assertEquals(0f, targetOffsetXState, 0.01f) - assertEquals(0f, targetOffsetYState, 0.01f) - } - - @Test - fun `double tap when zoomed in sets animation targets for zoom out`() { - scaleState = 2.5f - offsetXState = 50f - offsetYState = 30f - - if (scaleState > 1.01f) { - isAnimatingState = true - targetScaleState = 1f - targetOffsetXState = 0f - targetOffsetYState = 0f - } - - assertTrue(isAnimatingState) - assertEquals(1f, targetScaleState, 0.01f) - assertEquals(0f, targetOffsetXState, 0.01f) - assertEquals(0f, targetOffsetYState, 0.01f) - } - - @Test - fun `after double tap zoom-in animation completes, onZoomChanged(true) is called`() { - scaleState = 2.5f - isAnimatingState = false - - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(true) - } - - @Test - fun `after double tap zoom-out animation completes, onZoomChanged(false) is called`() { - scaleState = 1f - isAnimatingState = false - - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(false) - } - - // --- `isAnimating` guard test --- - @Test - fun `transform gestures are ignored if isAnimating is true`() { - isAnimatingState = true - - scaleState = 1.0f - offsetXState = 0f - offsetYState = 0f - - val zoomChange = 2.0f - val panChange = Offset(10f, 10f) - - if (!isAnimatingState) { // This guard is in SimpleImageViewer's transformableState lambda - scaleState = (scaleState * zoomChange).coerceIn(1f, 3f) - if (scaleState > 1.01f) { - val safeScale = if (scaleState == 0f) 1f else scaleState - val (maxOffsetX, maxOffsetY) = calculateMaxOffsetsTest(scaleState) - offsetXState = (offsetXState + panChange.x / safeScale).coerceIn(-maxOffsetX, maxOffsetX) - offsetYState = (offsetYState + panChange.y / safeScale).coerceIn(-maxOffsetY, maxOffsetY) - } else { - offsetXState = 0f - offsetYState = 0f - } - } - - assertEquals(1.0f, scaleState, 0.01f) - assertEquals(0f, offsetXState, 0.01f) - assertEquals(0f, offsetYState, 0.01f) - } -} diff --git a/app/src/test/java/com/wavehitech/aptracker/SimpleImageViewerTest.kt b/app/src/test/java/com/wavehitech/aptracker/SimpleImageViewerTest.kt deleted file mode 100644 index 5404244..0000000 --- a/app/src/test/java/com/wavehitech/aptracker/SimpleImageViewerTest.kt +++ /dev/null @@ -1,414 +0,0 @@ -package com.wavehitech.aptracker - -import android.content.Context -import android.content.ContentResolver -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -// import androidx.compose.foundation.gestures.TransformableState // Not directly used in these state logic tests -// import androidx.compose.ui.input.pointer.PointerInputScope // Not directly used -// import androidx.compose.ui.platform.LocalDensity // Not directly used -// import androidx.compose.ui.unit.Density // Not directly used -// import androidx.compose.runtime.CompositionLocalProvider // Not directly used -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -// import androidx.test.core.app.ApplicationProvider // Not using this directly - -import io.mockk.* // Using MockK for static mocking as it's often more straightforward -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* -import org.junit.After -import org.junit.Assert.* -import org.junit.Before -// import org.junit.Rule // Not using JUnit rules like ComposeTestRule here -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentCaptor // Using Mockito captors as they are standard -import org.mockito.Captor -import org.mockito.Mock -import org.mockito.Mockito.* // Using Mockito for instance mocking -import org.mockito.MockitoAnnotations -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.io.File -import java.io.InputStream - -// Renamed to avoid conflict and be more specific about testing state logic -@RunWith(RobolectricTestRunner::class) -@Config(manifest=Config.NONE, sdk = [28]) -class SimpleImageViewerStateTest { - - @Mock - lateinit var mockContext: Context - - @Mock - lateinit var mockContentResolver: ContentResolver - - @Mock - lateinit var mockBitmap: Bitmap - - // @Mock // File itself is usually not mocked, but its methods are, or use ShadowFile - // lateinit var mockFile: File - - @Mock - lateinit var mockUri: Uri - - @Mock - lateinit var mockInputStream: InputStream - - private lateinit var imageEntity: ImageEntity - - // Using a Mockito mock for the callback - @Mock - lateinit var onZoomChangedMock: (Boolean) -> Unit - - @Captor - lateinit var booleanCaptor: ArgumentCaptor - - @OptIn(ExperimentalCoroutinesApi::class) - private val testDispatcher = UnconfinedTestDispatcher() - - // State variables that mimic those in SimpleImageViewer, to test logic directly - private var scaleState: Float = 1f - private var offsetXState: Float = 0f - private var offsetYState: Float = 0f - private var targetScaleState: Float = 1f - private var targetOffsetXState: Float = 0f - private var targetOffsetYState: Float = 0f - private var isAnimatingState: Boolean = false - - // Mimic container and image size for calculations - private var containerSizeState: Size = Size.Zero - private var imageSizeState: Size = Size.Zero - - - @OptIn(ExperimentalCoroutinesApi::class) - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - Dispatchers.setMain(testDispatcher) - - `when`(mockContext.contentResolver).thenReturn(mockContentResolver) - // `when`(mockContentResolver.openInputStream(any(Uri::class.java))).thenReturn(mockInputStream) // Not strictly needed if not testing loading - `when`(mockContext.packageName).thenReturn("com.wavehitech.aptracker.test") - // `when`(mockContext.filesDir).thenReturn(File("/tmp/test_files")) // Provided by Robolectric - - `when`(mockBitmap.width).thenReturn(1000) // Default mock image size - `when`(mockBitmap.height).thenReturn(1000) - - // Static mocking with MockK for BitmapFactory - mockkStatic(BitmapFactory::class) - every { BitmapFactory.decodeStream(any()) } returns mockBitmap - - mockkStatic(FileProvider::class) - every { FileProvider.getUriForFile(any(), any(), any()) } returns mockUri - - imageEntity = ImageEntity(id = "test_image_1", filename = "test_image.jpg", accessPointId = "ap1") - - // Reset states - scaleState = 1f - offsetXState = 0f - offsetYState = 0f - targetScaleState = 1f - targetOffsetXState = 0f - targetOffsetYState = 0f - isAnimatingState = false - containerSizeState = Size(1000f, 800f) // Default container size for tests - imageSizeState = Size(1000f, 1000f) // Default image size for tests (square) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @After - fun tearDown() { - Dispatchers.resetMain() - unmockkStatic(BitmapFactory::class) // Clear MockK static mocks - unmockkStatic(FileProvider::class) - } - - // --- Helper function for SimpleImageViewer's internal calculations --- - // This mirrors `calculateVisibleImageFrame` from SimpleImageViewer - private fun calculateVisibleImageFrameTest(): SimpleImageViewer.ImageFrame { - val containerWidth = containerSizeState.width - val containerHeight = containerSizeState.height - - if (imageSizeState.width <= 0 || imageSizeState.height <= 0) { - return SimpleImageViewer.ImageFrame(0f, 0f, containerWidth, containerHeight) - } - - val imageAspect = imageSizeState.width / imageSizeState.height - val containerAspect = containerWidth / containerHeight - - val visibleWidth: Float - val visibleHeight: Float - val frameX: Float - val frameY: Float - - if (imageAspect > containerAspect) { - visibleWidth = containerWidth - visibleHeight = containerWidth / imageAspect - frameX = 0f - frameY = (containerHeight - visibleHeight) / 2f - } else { - visibleHeight = containerHeight - visibleWidth = containerHeight * imageAspect - frameX = (containerWidth - visibleWidth) / 2f - frameY = 0f - } - return SimpleImageViewer.ImageFrame(frameX, frameY, visibleWidth, visibleHeight) - } - - // This mirrors `calculateMaxOffsets` from SimpleImageViewer - private fun calculateMaxOffsetsTest(currentScale: Float): Pair { - val frame = calculateVisibleImageFrameTest() - val scaledFrameWidth = frame.width * currentScale - val scaledFrameHeight = frame.height * currentScale - val containerWidth = containerSizeState.width - val containerHeight = containerSizeState.height - - val horizontalOverflow = (scaledFrameWidth - containerWidth) / 2f - val verticalOverflow = (scaledFrameHeight - containerHeight) / 2f - return Pair(horizontalOverflow.coerceAtLeast(0f), verticalOverflow.coerceAtLeast(0f)) - } - - // --- Test Cases --- - - @Test - fun `initial state leads to onZoomChanged(false)`() { - // Assuming image is loaded, scale is 1f - val isZoomed = scaleState > 1.01f - onZoomChangedMock(isZoomed) - verify(onZoomChangedMock).invoke(false) - } - - // --- Panning Logic Tests --- - @Test - fun `panning updates offsets with factor 2 when zoomed in`() { - scaleState = 2.0f // Zoomed in - offsetXState = 0f - offsetYState = 0f - val panChange = Offset(10f, 15f) - - // Simulate container and image size for calculateMaxOffsets - containerSizeState = Size(1000f, 800f) - imageSizeState = Size(2000f, 1000f) // Image aspect = 2.0 - - val (maxOffsetX, maxOffsetY) = calculateMaxOffsetsTest(scaleState) - - // Logic from transformableState lambda - if (scaleState > 1.01f) { - offsetXState = (offsetXState + panChange.x * 2.0f).coerceIn(-maxOffsetX, maxOffsetY) - offsetYState = (offsetYState + panChange.y * 2.0f).coerceIn(-maxOffsetY, maxOffsetY) - } - - assertEquals(20f, offsetXState) // 10f * 2.0f - assertEquals(30f, offsetYState) // 15f * 2.0f - } - - @Test - fun `panning offsets are coerced correctly at boundaries`() { - scaleState = 2.0f - containerSizeState = Size(100f, 100f) - imageSizeState = Size(100f, 100f) // image and container same size, frame is full container - // Max offset will be (100*2 - 100)/2 = 50 for X and Y - - val (maxOffsetX, maxOffsetY) = calculateMaxOffsetsTest(scaleState) - assertEquals(50f, maxOffsetX) - assertEquals(50f, maxOffsetY) - - offsetXState = 40f - offsetYState = 45f - val panChange = Offset(10f, 10f) // panChange * 2.0f = (20f, 20f) - - // Apply panning logic - offsetXState = (offsetXState + panChange.x * 2.0f).coerceIn(-maxOffsetX, maxOffsetX) // 40 + 20 = 60 -> coerced to 50 - offsetYState = (offsetYState + panChange.y * 2.0f).coerceIn(-maxOffsetY, maxOffsetY) // 45 + 20 = 65 -> coerced to 50 - - assertEquals(50f, offsetXState) - assertEquals(50f, offsetYState) - - // Pan other way - offsetXState = -40f - offsetYState = -45f - offsetXState = (offsetXState + panChange.x * -2.0f).coerceIn(-maxOffsetX, maxOffsetX) // -40 - 20 = -60 -> coerced to -50 - offsetYState = (offsetYState + panChange.y * -2.0f).coerceIn(-maxOffsetY, maxOffsetY) // -45 - 20 = -65 -> coerced to -50 - assertEquals(-50f, offsetXState) - assertEquals(-50f, offsetYState) - } - - @Test - fun `panning does not update offsets and resets them if scale is 1f`() { - scaleState = 1.0f - offsetXState = 10f // Simulate existing offset that should be reset - offsetYState = 15f - val panChange = Offset(5f, 5f) - - // Logic from transformableState - if (scaleState > 1.01f) { - // Not executed - } else { - offsetXState = 0f - offsetYState = 0f - } - assertEquals(0f, offsetXState) - assertEquals(0f, offsetYState) - } - - // --- Pinch-to-Zoom Logic Tests --- - @Test - fun `pinch updates scale and calls onZoomChanged correctly`() { - scaleState = 1.0f - - // Zoom In - var zoomChangeFactor = 1.5f - scaleState = (scaleState * zoomChangeFactor).coerceIn(1f, 3f) - assertEquals(1.5f, scaleState) - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(true) - reset(onZoomChangedMock) // Mockito reset - - // Zoom further - zoomChangeFactor = 1.5f - scaleState = (scaleState * zoomChangeFactor).coerceIn(1f, 3f) // 1.5 * 1.5 = 2.25 - assertEquals(2.25f, scaleState) - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock, times(0)).invoke(false) // Should not be called with false - verify(onZoomChangedMock).invoke(true) // Still true - reset(onZoomChangedMock) - - // Zoom Out - zoomChangeFactor = 0.5f - scaleState = (scaleState * zoomChangeFactor).coerceIn(1f, 3f) // 2.25 * 0.5 = 1.125 - assertEquals(1.125f, scaleState) - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(true) // Still true - reset(onZoomChangedMock) - - // Zoom Out below threshold - zoomChangeFactor = 0.5f - scaleState = (scaleState * zoomChangeFactor).coerceIn(1f, 3f) // 1.125 * 0.5 = 0.5625 -> coerced to 1f - assertEquals(1f, scaleState) - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(false) - } - - @Test - fun `pinch scale is coerced to min 1f and max 3f`() { - scaleState = 1.0f - scaleState = (scaleState * 0.5f).coerceIn(1f, 3f) - assertEquals(1f, scaleState) - - scaleState = 2.0f - scaleState = (scaleState * 2.0f).coerceIn(1f, 3f) // 4.0f -> coerced to 3f - assertEquals(3f, scaleState) - - scaleState = 3.0f - scaleState = (scaleState * 1.1f).coerceIn(1f, 3f) // 3.3f -> coerced to 3f - assertEquals(3f, scaleState) - } - - // --- Double-Tap to Zoom Logic Tests --- - @Test - fun `double tap when zoomed out sets animation targets for zoom in`() { - scaleState = 1.0f - val tapPosition = Offset(containerSizeState.width / 2, containerSizeState.height / 2) // Tap center - - // Mimic onDoubleTap logic - if (scaleState > 1.01f) { /* no-op for this test */ } - else { - isAnimatingState = true - targetScaleState = 2.5f - // Centering logic (simplified, as detailed vector math is complex to replicate here without full Composable context) - // Assume centering results in some offsets if not tapping exactly on image center relative to frame - // For a center tap on a centered image, target offsets should be 0 if image aspect = container aspect - imageSizeState = Size(1000f, 800f) // image aspect = container aspect - containerSizeState = Size(1000f, 800f) - val frame = calculateVisibleImageFrameTest() // frame.x=0, frame.y=0 - val imageRelativeX = (tapPosition.x - frame.x) / frame.width - val imageRelativeY = (tapPosition.y - frame.y) / frame.height - val containerCenterX = containerSizeState.width / 2 - val containerCenterY = containerSizeState.height / 2 - val focusPointInFrameX = frame.x + frame.width * imageRelativeX - val focusPointInFrameY = frame.y + frame.height * imageRelativeY - val vectorX = focusPointInFrameX - containerCenterX // Should be 0 if tap is center - val vectorY = focusPointInFrameY - containerCenterY // Should be 0 if tap is center - val newScale = 2.5f - targetOffsetXState = (vectorX * (1 - 1/newScale)) - targetOffsetYState = (vectorY * (1 - 1/newScale)) - } - - assertTrue(isAnimatingState) - assertEquals(2.5f, targetScaleState) - assertEquals(0f, targetOffsetXState) // For center tap on matching aspect - assertEquals(0f, targetOffsetYState) - } - - @Test - fun `double tap when zoomed in sets animation targets for zoom out`() { - scaleState = 2.5f - offsetXState = 50f // Some existing offset - offsetYState = 30f - - // Mimic onDoubleTap logic - if (scaleState > 1.01f) { - isAnimatingState = true - targetScaleState = 1f - targetOffsetXState = 0f - targetOffsetYState = 0f - } - - assertTrue(isAnimatingState) - assertEquals(1f, targetScaleState) - assertEquals(0f, targetOffsetXState) - assertEquals(0f, targetOffsetYState) - } - - @Test - fun `after double tap zoom-in animation completes, onZoomChanged(true) is called`() { - // Simulate state after zoom-in animation finishes - scaleState = 2.5f // targetScale is applied - isAnimatingState = false // animation finished - - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(true) - } - - @Test - fun `after double tap zoom-out animation completes, onZoomChanged(false) is called`() { - // Simulate state after zoom-out animation finishes - scaleState = 1f // targetScale is applied - isAnimatingState = false // animation finished - - onZoomChangedMock(scaleState > 1.01f) - verify(onZoomChangedMock).invoke(false) - } - - // --- `isAnimating` guard test --- - @Test - fun `transform gestures are ignored if isAnimating is true`() { - isAnimatingState = true // Double tap animation in progress - - // Original states - scaleState = 1.0f - offsetXState = 0f - offsetYState = 0f - - // Simulate a pinch zoom and pan change that would normally alter state - val zoomChange = 2.0f - val panChange = Offset(10f, 10f) - - // The `if (!isAnimating)` guard in `transformableState` is what we're testing conceptually - if (!isAnimatingState) { - scaleState = (scaleState * zoomChange).coerceIn(1f, 3f) - // ... panning logic ... - } - - // Assert that states remain unchanged because isAnimating was true - assertEquals(1.0f, scaleState) - assertEquals(0f, offsetXState) - assertEquals(0f, offsetYState) - } -}