diff --git a/.gitignore b/.gitignore index 85eed698e..6f2c6ad72 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ kotlin-ide/ .idea/ .aider* .env +.idea/misc.xml .vscode/ \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 0bd3ec25a..74dd639e4 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,6 @@ + + diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/FileEncoder.kt b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/FileEncoder.kt index 25bcfbf44..f21a25e9e 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/FileEncoder.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/data/entities/FileEncoder.kt @@ -53,68 +53,70 @@ import java.net.URL import java.net.URLConnection import java.net.URLDecoder -//Use this to encode files not just images -//Needs to be tested sometime -//Can I modify this so that Http transfer does the majority of the encoding? -class FileEncoder {//Made by Craig. Encodes via base64. +class FileEncoder { @OptIn(ExperimentalEncodingApi::class) - fun encodebase64(ctxt: Context, inputuri: Uri): String?{ - try { + fun encodebase64(ctxt: Context, inputuri: Uri): String? { + return try { val encodedstrm: InputStream? = ctxt.contentResolver.openInputStream(inputuri) val bytes = encodedstrm?.readBytes() encodedstrm?.close() - return if (bytes != null) { - Base64.encode(bytes) - } else { - "Cannot encode file" - } - } catch(e: Exception){ + encodeBytesBase64(bytes) + } catch (e: Exception) { e.printStackTrace() - return "Cannot encode file"} + "Cannot encode file" + } + } + @OptIn(ExperimentalEncodingApi::class) + internal fun encodeBytesBase64(bytes: ByteArray?): String? { + return if (bytes != null) { + Base64.encode(bytes) + } else { + "Cannot encode file" + } } - @OptIn(ExperimentalEncodingApi::class)//Made by Craig - fun decodeBase64(inputbase64:String, output: File): File{//Decodes to a file. Uses base64 + @OptIn(ExperimentalEncodingApi::class) + fun decodeBase64(inputbase64: String, output: File): File { val decodedfilebytes = Base64.decode(inputbase64) val decodedstrm = FileOutputStream(output) decodedstrm.write(decodedfilebytes) decodedstrm.close() return output } - fun sendImage(imageURI: Uri?, tgtaddress: InetAddress, tgtport:Int, appctxt: Context): Boolean{//Testing sending images - try{//we can utilize this if we opt not to use JSON - if(imageURI != null){ - val fp = encodebase64(appctxt, imageURI)//encodes file to base64 - if(!fp.equals("Cannot encode file")) { - val efp = URLEncoder.encode(fp, "UTF-8")//ensures that the file URI is utf-8 encoded - val connection = - URL("http://${tgtaddress.hostAddress}:${tgtport}/upload?file=$efp").openConnection() as HttpURLConnection - val request = "POST"//Specifies the request as a POST - connection.doOutput = true - connection.requestMethod = request - connection.setChunkedStreamingMode(0) - val instream = appctxt.contentResolver.openInputStream(imageURI) - val outstream = connection.outputStream - val readingbuffer = ByteArray(1024) - var finishedreading: Int - while (instream?.read(readingbuffer).also { finishedreading = it!! } != -1) { - outstream.write(readingbuffer, 0, finishedreading) - } - outstream.close() - instream?.close() + fun sendImage(imageURI: Uri?, tgtaddress: InetAddress, tgtport: Int, appctxt: Context): Boolean { + try { + if (imageURI != null) { + val fp = encodebase64(appctxt, imageURI) + if (!fp.equals("Cannot encode file")) { + val efp = URLEncoder.encode(fp, "UTF-8") + val connection = + URL("http://${tgtaddress.hostAddress}:${tgtport}/upload?file=$efp").openConnection() as HttpURLConnection + val request = "POST" + connection.doOutput = true + connection.requestMethod = request + connection.setChunkedStreamingMode(0) + val instream = appctxt.contentResolver.openInputStream(imageURI) + val outstream = connection.outputStream + val readingbuffer = ByteArray(1024) + var finishedreading: Int + while (instream?.read(readingbuffer).also { finishedreading = it!! } != -1) { + outstream.write(readingbuffer, 0, finishedreading) + } + outstream.close() + instream?.close() + } else { + return false + } } else { return false - }} else { - return false } - } - catch(e: Exception){ + } catch (e: Exception) { e.printStackTrace() return false } return true } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/utils/Logger.kt b/app/src/main/java/com/greybox/projectmesh/messaging/utils/Logger.kt index 9000bc113..871374a87 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/utils/Logger.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/utils/Logger.kt @@ -8,43 +8,52 @@ import android.util.Log */ object Logger { + internal const val TAG_PREFIX = "MeshChat_" private const val LOGGING_ENABLED = true - private const val TAG_PREFIX = "MeshChat_" + + internal fun buildTag(tag: String): String { + return "$TAG_PREFIX$tag" + } + + internal fun buildCriticalTag(tag: String): String { + return "${TAG_PREFIX}${tag}_CRITICAL" + } fun d(tag: String, message: String) { if (LOGGING_ENABLED) { - Log.d("$TAG_PREFIX$tag", message) + Log.d(buildTag(tag), message) } } fun i(tag: String, message: String) { if (LOGGING_ENABLED) { - Log.i("$TAG_PREFIX$tag", message) + Log.i(buildTag(tag), message) } } fun w(tag: String, message: String) { if (LOGGING_ENABLED) { - Log.w("$TAG_PREFIX$tag", message) + Log.w(buildTag(tag), message) } } fun e(tag: String, message: String, throwable: Throwable? = null) { if (LOGGING_ENABLED) { if (throwable != null) { - Log.e("$TAG_PREFIX$tag", message, throwable) + Log.e(buildTag(tag), message, throwable) } else { - Log.e("$TAG_PREFIX$tag", message) + Log.e(buildTag(tag), message) } } } // Log important events that should be visible even in production fun critical(tag: String, message: String, throwable: Throwable? = null) { + val criticalTag = buildCriticalTag(tag) if (throwable != null) { - Log.e("$TAG_PREFIX${tag}_CRITICAL", message, throwable) + Log.e(criticalTag, message, throwable) } else { - Log.e("$TAG_PREFIX${tag}_CRITICAL", message) + Log.e(criticalTag, message) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtils.kt b/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtils.kt index a2bb342f4..22e10dcdf 100644 --- a/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtils.kt +++ b/app/src/main/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtils.kt @@ -93,7 +93,7 @@ class MessageMigrationUtils( } } - private fun createConversationId(uuid1: String, uuid2: String): String { + internal fun createConversationId(uuid1: String, uuid2: String): String { // Special cases for test devices if (uuid2 == "test-device-uuid") { return "local-user-test-device-uuid" diff --git a/app/src/main/java/com/greybox/projectmesh/server/InputStreamCounter.kt b/app/src/main/java/com/greybox/projectmesh/server/InputStreamCounter.kt index 256ba9422..df4821c7c 100644 --- a/app/src/main/java/com/greybox/projectmesh/server/InputStreamCounter.kt +++ b/app/src/main/java/com/greybox/projectmesh/server/InputStreamCounter.kt @@ -22,13 +22,6 @@ class InputStreamCounter( } } - override fun read(b: ByteArray): Int { - return super.read(b).also { - if(it != -1) - bytesRead += it - } - } - override fun read(b: ByteArray, off: Int, len: Int): Int { return super.read(b, off, len).also { if(it != -1) diff --git a/app/src/test/java/com/greybox/projectmesh/components/WifiConnectionTest.kt b/app/src/test/java/com/greybox/projectmesh/components/WifiConnectionTest.kt new file mode 100644 index 000000000..12687ec29 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/components/WifiConnectionTest.kt @@ -0,0 +1,127 @@ +package com.greybox.projectmesh.components + +import org.junit.Assert.* +import org.junit.Test +import java.lang.reflect.Field +import com.ustadmobile.meshrabiya.vnet.wifi.WifiConnectConfig + +/** + * JVM-only tests for simple data + status in WifiConnection.kt. + * No Android/Compose/Mockito required. + * + * NOTE: We allocate a dummy WifiConnectConfig via Unsafe and NEVER call its methods. + * We also avoid ConnectRequest.equals()/hashCode() because that would call + * WifiConnectConfig.hashCode() internally (which can NPE if fields are null). + */ +class WifiConnectionTest { + + // ----------------------------- + // Enum: stages must exist + // ----------------------------- + @Test + fun checkAllStatusesExist() { + val all = ConnectWifiLauncherStatus.values().toSet() + assertTrue(ConnectWifiLauncherStatus.INACTIVE in all) + assertTrue(ConnectWifiLauncherStatus.REQUESTING_PERMISSION in all) + assertTrue(ConnectWifiLauncherStatus.LOOKING_FOR_NETWORK in all) + assertTrue(ConnectWifiLauncherStatus.REQUESTING_LINK in all) + assertEquals(4, all.size) + } + + // ----------------------------------------------- + // Result model: failure shape must look correct + // ----------------------------------------------- + @Test + fun checkFailureResultLooksRight() { + val error = Exception("expected failure") + val result = ConnectWifiLauncherResult( + hotspotConfig = null, + exception = error, + isWifiConnected = false + ) + assertFalse(result.isWifiConnected) + assertNull(result.hotspotConfig) + assertNotNull(result.exception) + assertEquals("expected failure", result.exception?.message) + } + + // ------------------------------------------------------- + // Result model: data-class copy/equals/hashCode sanity + // (safe because hotspotConfig = null) + // ------------------------------------------------------- + @Test + fun checkResultCopiesAndComparesCorrectly() { + val first = ConnectWifiLauncherResult( + hotspotConfig = null, + exception = Exception("boom"), + isWifiConnected = false + ) + val same = first.copy() + val different = first.copy(exception = Exception("other")) + + assertEquals(first, same) + assertEquals(first.hashCode(), same.hashCode()) + assertNotEquals(first, different) + } + + // ========================================= + // ConnectRequest: JVM tests (no Mockito) + // Avoid equals()/hashCode() on the whole object. + // ========================================= + + @Test + fun connectRequest_defaultTimeIsZero() { + val cfg = unsafeInstance() + val req = ConnectRequest(connectConfig = cfg) + assertEquals(0L, req.receivedTime) + assertSame(cfg, req.connectConfig) // same reference + } + + @Test + fun connectRequest_customTimePreserved() { + val cfg = unsafeInstance() + val req = ConnectRequest(receivedTime = 123456789L, connectConfig = cfg) + assertEquals(123456789L, req.receivedTime) + assertSame(cfg, req.connectConfig) + } + + @Test + fun connectRequest_copyPreservesConfigAndChangesTime() { + val cfg = unsafeInstance() + val original = ConnectRequest(receivedTime = 100L, connectConfig = cfg) + val copiedSame = original.copy() + val copiedChanged = original.copy(receivedTime = 200L) + + // field-wise checks (no equals()/hashCode()) + assertEquals(100L, copiedSame.receivedTime) + assertSame(cfg, copiedSame.connectConfig) + + assertEquals(200L, copiedChanged.receivedTime) + assertSame(cfg, copiedChanged.connectConfig) + + // sanity: copies are distinct instances + assertNotSame(original, copiedSame) + assertNotSame(original, copiedChanged) + } + + // ------------------------------------------------------- + // Tiny Unsafe helper for constructor-less allocation + // ------------------------------------------------------- + @Suppress("UNCHECKED_CAST") + private inline fun unsafeInstance(): T { + val unsafe = getUnsafe() + val allocate = unsafe.javaClass.getMethod("allocateInstance", Class::class.java) + return allocate.invoke(unsafe, T::class.java) as T + } + + private fun getUnsafe(): Any { + val clazz = try { + Class.forName("sun.misc.Unsafe") + } catch (_: ClassNotFoundException) { + Class.forName("jdk.internal.misc.Unsafe") + } + val f: Field = clazz.getDeclaredField("theUnsafe") + f.isAccessible = true + return f.get(null) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/db/MeshDatabaseTest.kt b/app/src/test/java/com/greybox/projectmesh/db/MeshDatabaseTest.kt new file mode 100644 index 000000000..e8d812006 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/db/MeshDatabaseTest.kt @@ -0,0 +1,54 @@ +package com.greybox.projectmesh.db + +import org.junit.Assert.* +import org.junit.Test +import java.lang.reflect.Modifier +import com.greybox.projectmesh.messaging.data.dao.MessageDao +import com.greybox.projectmesh.messaging.data.dao.ConversationDao +import com.greybox.projectmesh.user.UserDao +import androidx.room.RoomDatabase + +/** + * JVM-only tests for MeshDatabase class shape. + * These do NOT spin up Room or touch Android runtime. + * + * We verify: + * - MeshDatabase is abstract + * - MeshDatabase extends RoomDatabase + * - Required DAO methods exist and have the correct return types + * + * Anything involving entities, queries, schema, and migrations belongs + * in src/androidTest with an in-memory RoomDatabase. + */ +class MeshDatabaseTest { + + @Test + fun meshDatabase_isAbstract_and_extendsRoomDatabase() { + val cls = MeshDatabase::class.java + + // Must be abstract + assertTrue("MeshDatabase must be abstract", + Modifier.isAbstract(cls.modifiers)) + + // Must extend androidx.room.RoomDatabase + assertTrue("MeshDatabase must extend RoomDatabase", + RoomDatabase::class.java.isAssignableFrom(cls)) + } + + @Test + fun meshDatabase_has_requiredDaoMethods_with_correctReturnTypes() { + val cls = MeshDatabase::class.java + + // messageDao(): MessageDao + val messageDaoMethod = cls.getMethod("messageDao") + assertEquals(MessageDao::class.java, messageDaoMethod.returnType) + + // userDao(): UserDao + val userDaoMethod = cls.getMethod("userDao") + assertEquals(UserDao::class.java, userDaoMethod.returnType) + + // conversationDao(): ConversationDao + val conversationDaoMethod = cls.getMethod("conversationDao") + assertEquals(ConversationDao::class.java, conversationDaoMethod.returnType) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/debug/CrashHandlerTest.kt b/app/src/test/java/com/greybox/projectmesh/debug/CrashHandlerTest.kt new file mode 100644 index 000000000..f9a18bf26 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/debug/CrashHandlerTest.kt @@ -0,0 +1,88 @@ +package com.greybox.projectmesh.debug + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test +import java.lang.Thread.UncaughtExceptionHandler + +/** + * JVM-only tests for CrashHandler. + * We DO NOT invoke Android runtime (no Context/Intent usage at runtime). + * All checks are reflection-based and safe for plain JVM. + */ +class CrashHandlerTest { + + /** + * CrashHandler must implement Thread.UncaughtExceptionHandler. + * (If someone removes/changes this, we catch it early.) + */ + @Test + fun crashHandler_implements_UncaughtExceptionHandler() { + assertTrue( + UncaughtExceptionHandler::class.java.isAssignableFrom(CrashHandler::class.java) + ) + } + + /** + * Verify the primary constructor shape: + * (Context, UncaughtExceptionHandler, Class<*>) + * We don't instantiate anything; we only look up the signature. + */ + @Test + fun crashHandler_has_expected_constructor_signature() { + val ctx = android.content.Context::class.java + val ueh = UncaughtExceptionHandler::class.java + val klass = Class::class.java + + // If the constructor is missing or signature changes, this throws NoSuchMethodException + val ctor = CrashHandler::class.java.getDeclaredConstructor(ctx, ueh, klass) + assertNotNull(ctor) + } + + /** + * Companion must expose: + * - init(Context, Class<*>) + * - getThrowableFromIntent(Intent): Throwable? + * We assert presence and parameter/return types by reflection. + */ + @Test + fun companion_has_init_and_getThrowableFromIntent_signatures() { + // Access the Kotlin "Companion" object using plain Java reflection. + val companionField = CrashHandler::class.java.getDeclaredField("Companion") + companionField.isAccessible = true + val companion = companionField.get(null) + ?: throw AssertionError("CrashHandler must have a companion object") + + val companionClass = companion::class.java + + // init(Context, Class<*>) + val ctx = android.content.Context::class.java + val klass = Class::class.java + val initMethod = companionClass.getMethod("init", ctx, klass) + assertEquals(Void.TYPE, initMethod.returnType) + + // getThrowableFromIntent(Intent): Throwable? + val intent = android.content.Intent::class.java + val getMethod = companionClass.getMethod("getThrowableFromIntent", intent) + assertTrue(Throwable::class.java.isAssignableFrom(getMethod.returnType)) + // Nullable return can't be asserted at runtime; we only check the declared type. + } + + /** + * Sanity: the Gson strategy (JSON round-trip) works in general. + * We DON'T use Throwable here because JDK 17+ blocks reflective access + * to its internal fields, which causes JsonIOException. + * Real Throwable JSON handling will be validated in instrumented tests. + */ + @Test + fun gson_can_roundtrip_simple_crash_payload() { + data class CrashPayload(val message: String) + + val gson = Gson() + val original = CrashPayload("crash-demo") + val json = gson.toJson(original) + val parsed = gson.fromJson(json, CrashPayload::class.java) + + assertEquals("crash-demo", parsed.message) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/debug/CrashScreenActivityTest.kt b/app/src/test/java/com/greybox/projectmesh/debug/CrashScreenActivityTest.kt new file mode 100644 index 000000000..4e09e27f2 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/debug/CrashScreenActivityTest.kt @@ -0,0 +1,44 @@ +package com.greybox.projectmesh.debug + +import android.content.Intent +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +class CrashScreenActivityTest { + + @Test + fun getThrowableFromIntent_returnsThrowable_whenValidJsonProvided() { + // SAFE: minimal JSON, avoids Gson reflection into private fields. + val json = """{"detailMessage":"boom-crash"}""" + + val intent = Intent().apply { + putExtra("CrashData", json) + } + + val parsed = CrashHandler.getThrowableFromIntent(intent) + + assertNotNull(parsed) + assertEquals("boom-crash", parsed?.message) + } + + @Test + fun getThrowableFromIntent_returnsNull_whenInvalidJsonProvided() { + val intent = Intent().apply { + putExtra("CrashData", "{invalid-json}") + } + + val parsed = CrashHandler.getThrowableFromIntent(intent) + + assertNull(parsed) + } + + @Test + fun getThrowableFromIntent_returnsNull_whenNoCrashDataProvided() { + val intent = Intent() + + val parsed = CrashHandler.getThrowableFromIntent(intent) + + assertNull(parsed) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/extension/ListExtensionTest.kt b/app/src/test/java/com/greybox/projectmesh/extension/ListExtensionTest.kt new file mode 100644 index 000000000..e00d0f4ad --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/extension/ListExtensionTest.kt @@ -0,0 +1,95 @@ +package com.greybox.projectmesh.extension + +import org.junit.Assert.* +import org.junit.Test + +/** + * JVM-only tests for List.updateItem extension. + * + * Verifies: + * - first matching item is updated + * - only the first match is changed + * - lists with no match return the same instance + * - empty lists behave correctly + * - update function is not invoked when no match exists + */ +class ListExtensionTest { + + // -------------------------------------- + // First match: should update first match + // -------------------------------------- + @Test + fun checkUpdateFirstItem() { + val list = listOf(1, 2, 3, 4) + val updated = list.updateItem( + condition = { it % 2 == 0 }, + function = { it * 10 } + ) + + assertEquals(listOf(1, 20, 3, 4), updated) + } + + // --------------------------------------------------- + // Only first matching item should be transformed once + // --------------------------------------------------- + @Test + fun checkOnlyFirstMatch() { + val list = listOf(2, 4, 6) + val updated = list.updateItem( + condition = { it % 2 == 0 }, + function = { it * 10 } + ) + + assertEquals(listOf(20, 4, 6), updated) + } + + // ------------------------------------------------- + // No match: must return same list instance unchanged + // ------------------------------------------------- + @Test + fun checkNoMatch() { + val list = listOf(1, 3, 5) + val updated = list.updateItem( + condition = { it % 2 == 0 }, + function = { it * 10 } + ) + + assertSame(list, updated) + assertEquals(listOf(1, 3, 5), updated) + } + + // --------------------------- + // Empty list stays unchanged + // --------------------------- + @Test + fun checkEmptyList() { + val list = emptyList() + val updated = list.updateItem( + condition = { true }, + function = { it * 10 } + ) + + assertSame(list, updated) + assertTrue(updated.isEmpty()) + } + + // ------------------------------------------------------- + // No match: function should not be executed even once + // ------------------------------------------------------- + @Test + fun checkCallingFunctionNoMatch() { + val list = listOf(1, 3, 5) + var called = false + + val updated = list.updateItem( + condition = { it % 2 == 0 }, + function = { + called = true + it * 10 + } + ) + + assertFalse(called) + assertSame(list, updated) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/FileEncoderTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/FileEncoderTest.kt new file mode 100644 index 000000000..1cef20f3c --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/messaging/data/entities/FileEncoderTest.kt @@ -0,0 +1,49 @@ +package com.greybox.projectmesh.messaging.data.entities + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class FileEncoderTest { + + private val encoder = FileEncoder() + + @Test + fun encodeBytesBase64_encodesNonNullBytes() { + val original = "hello".toByteArray() + val result = encoder.encodeBytesBase64(original) + assertEquals("aGVsbG8=", result) + } + + @Test + fun encodeBytesBase64_returnsErrorMessageForNullBytes() { + val result = encoder.encodeBytesBase64(null) + assertEquals("Cannot encode file", result) + } + + @Test + fun decodeBase64_writesDecodedBytesToFile() { + val base64 = "aGVsbG8=" + val tempFile = File.createTempFile("fileencoder_test", ".bin") + tempFile.deleteOnExit() + + encoder.decodeBase64(base64, tempFile) + + val content = tempFile.readBytes() + assertArrayEquals("hello".toByteArray(), content) + } + + @Test + fun decodeBase64_overwritesExistingFileContent() { + val tempFile = File.createTempFile("fileencoder_overwrite_test", ".bin") + tempFile.writeText("old-content") + tempFile.deleteOnExit() + + val base64 = "aGVsbG8=" + encoder.decodeBase64(base64, tempFile) + + val content = tempFile.readBytes() + assertArrayEquals("hello".toByteArray(), content) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/utils/ConversationUtilsTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/utils/ConversationUtilsTest.kt new file mode 100644 index 000000000..d5370c5a1 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/messaging/utils/ConversationUtilsTest.kt @@ -0,0 +1,56 @@ +package com.greybox.projectmesh.messaging.utils + +import org.junit.Assert.* +import org.junit.Test + +/** + * JVM-only tests for ConversationUtils.createConversationId. + * + * Verifies: + * - stable ordering of UUID pairs + * - identical UUIDs produce expected ID + * - special-case handling for device UUIDs + * - offline device UUIDs follow same special rule + */ +class ConversationUtilsTest { + + // ---------------------------------------- + // Ordering: "a","b" should equal "b","a" + // ---------------------------------------- + @Test + fun checkCreateConversationId() { + val id1 = ConversationUtils.createConversationId("a", "b") + val id2 = ConversationUtils.createConversationId("b", "a") + + assertEquals("a-b", id1) + assertEquals(id1, id2) + } + + // -------------------------------------- + // Identical values should join naturally + // -------------------------------------- + @Test + fun checkIdenticalUuids() { + val id = ConversationUtils.createConversationId("same", "same") + assertEquals("same-same", id) + } + + // ----------------------------------------------------- + // Special rule: remote UUID "test-device-uuid" maps to + // "local-user-" + // ----------------------------------------------------- + @Test + fun checkSpecialCase() { + val id = ConversationUtils.createConversationId("anything", "test-device-uuid") + assertEquals("local-user-test-device-uuid", id) + } + + // ----------------------------------------------------- + // Offline device rule: mirrors test-device behavior + // ----------------------------------------------------- + @Test + fun checkSecondSpecialCase() { + val id = ConversationUtils.createConversationId("anything", "offline-test-device-uuid") + assertEquals("local-user-offline-test-device-uuid", id) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/utils/LoggerTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/utils/LoggerTest.kt new file mode 100644 index 000000000..cef18e398 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/messaging/utils/LoggerTest.kt @@ -0,0 +1,31 @@ +package com.greybox.projectmesh.utils + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LoggerTest { + + @Test + fun buildTag_prefixesWithMeshChat() { + val result = Logger.buildTag("ChatScreen") + assertEquals("MeshChat_ChatScreen", result) + } + + @Test + fun buildTag_handlesEmptyTag() { + val result = Logger.buildTag("") + assertEquals("MeshChat_", result) + } + + @Test + fun buildCriticalTag_appendsCriticalSuffix() { + val result = Logger.buildCriticalTag("Network") + assertEquals("MeshChat_Network_CRITICAL", result) + } + + @Test + fun buildCriticalTag_handlesEmptyTag() { + val result = Logger.buildCriticalTag("") + assertEquals("MeshChat__CRITICAL", result) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtilsTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtilsTest.kt new file mode 100644 index 000000000..9dde1d24b --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageMigrationUtilsTest.kt @@ -0,0 +1,52 @@ +package com.greybox.projectmesh.messaging.utils + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.kodein.di.DI + +class MessageMigrationUtilsTest { + + private val di = DI {} + private val utils = MessageMigrationUtils(di) + + @Test + fun createConversationId_sortsNormalUuids() { + val uuid1 = "b-uuid" + val uuid2 = "a-uuid" + + val result = utils.createConversationId(uuid1, uuid2) + + assertEquals("a-uuid-b-uuid", result) + } + + @Test + fun createConversationId_handlesTestDeviceUuid() { + val uuid1 = "some-other-uuid" + val uuid2 = "test-device-uuid" + + val result = utils.createConversationId(uuid1, uuid2) + + assertEquals("local-user-test-device-uuid", result) + } + + @Test + fun createConversationId_handlesOfflineTestDeviceUuid() { + val uuid1 = "some-other-uuid" + val uuid2 = "offline-test-device-uuid" + + val result = utils.createConversationId(uuid1, uuid2) + + assertEquals("local-user-offline-test-device-uuid", result) + } + + @Test + fun createConversationId_isDeterministicForSamePair() { + val uuidA = "1111-aaaa" + val uuidB = "2222-bbbb" + + val id1 = utils.createConversationId(uuidA, uuidB) + val id2 = utils.createConversationId(uuidB, uuidA) + + assertEquals(id1, id2) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageUtilsTest.kt b/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageUtilsTest.kt new file mode 100644 index 000000000..f69ad567c --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/messaging/utils/MessageUtilsTest.kt @@ -0,0 +1,41 @@ +package com.greybox.projectmesh.messaging.utils + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.TimeZone + +class MessageUtilsTest { + + @Test + fun formatTimestamp_formatsToHoursAndMinutesInUtc() { + val originalTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + val timestamp = 0L + val result = MessageUtils.formatTimestamp(timestamp) + assertEquals("00:00", result) + } finally { + TimeZone.setDefault(originalTimeZone) + } + } + + @Test + fun generateChatId_isOrderIndependent() { + val id1 = MessageUtils.generateChatId("alice", "bob") + val id2 = MessageUtils.generateChatId("bob", "alice") + assertEquals("alice-bob", id1) + assertEquals(id1, id2) + } + + @Test + fun generateChatId_handlesSameUser() { + val id = MessageUtils.generateChatId("alice", "alice") + assertEquals("alice-alice", id) + } + + @Test + fun generateChatId_isCaseSensitive() { + val id = MessageUtils.generateChatId("Alice", "alice") + assertEquals("Alice-alice", id) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/navigation/BottomNavTest.kt b/app/src/test/java/com/greybox/projectmesh/navigation/BottomNavTest.kt new file mode 100644 index 000000000..6fad19e40 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/navigation/BottomNavTest.kt @@ -0,0 +1,90 @@ +package com.greybox.projectmesh.navigation + +import org.junit.Assert.* +import org.junit.Test + +/** + * JVM unit tests for the BottomNavItem sealed class. + * + * We only check pure data: + * - each object has the expected route and title + * - all routes are unique + * - icons are present (non-null references) + * + * No Compose runtime or Android APIs are used here, so this is safe + * as a local unit test. + */ +class BottomNavItemTest { + + private val allItems = listOf( + BottomNavItem.Home, + BottomNavItem.Network, + BottomNavItem.Send, + BottomNavItem.Receive, + BottomNavItem.Log, + BottomNavItem.Settings, + BottomNavItem.Chat + ) + + @Test + fun allItems_haveExpectedRoutesAndTitles() { + // route, title pairs we expect + val expected = mapOf( + BottomNavItem.Home to ("home" to "Home"), + BottomNavItem.Network to ("network" to "Network"), + BottomNavItem.Send to ("send" to "Send"), + BottomNavItem.Receive to ("receive" to "Receive"), + BottomNavItem.Log to ("log" to "Log"), + BottomNavItem.Settings to ("settings" to "Settings"), + BottomNavItem.Chat to ("chat" to "Chat"), + ) + + for (item in allItems) { + val (expRoute, expTitle) = expected[item] + ?: error("Missing expectations for $item") + + assertEquals("Wrong route for ${item::class.simpleName}", expRoute, item.route) + assertEquals("Wrong title for ${item::class.simpleName}", expTitle, item.title) + } + } + + @Test + fun allItems_haveNonNullIcons() { + allItems.forEach { item -> + assertNotNull( + "Icon must not be null for ${item::class.simpleName}", + item.icon + ) + } + } + + @Test + fun routes_areUniqueAcrossAllItems() { + val routes = allItems.map { it.route } + val distinctRoutes = routes.toSet() + + assertEquals( + "Each BottomNavItem should use a unique route", + distinctRoutes.size, + routes.size + ) + } + + @Test + fun sealedHierarchy_containsExactlyExpectedItems() { + // This guards against someone adding a new object without updating tests. + val classes = allItems.map { it::class.simpleName }.toSet() + + val expectedNames = setOf( + "Home", + "Network", + "Send", + "Receive", + "Log", + "Settings", + "Chat" + ) + + assertEquals(expectedNames, classes) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/navigation/NavigationItemTest.kt b/app/src/test/java/com/greybox/projectmesh/navigation/NavigationItemTest.kt new file mode 100644 index 000000000..d82e1ced5 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/navigation/NavigationItemTest.kt @@ -0,0 +1,45 @@ +package com.greybox.projectmesh.navigation + +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import org.junit.Assert.* +import org.junit.Test + +/** + * JVM unit tests for the NavigationItem data class. + * + * NOTE: + * - Composables & NavController CANNOT be JVM tested. + * - We only verify that NavigationItem behaves as a proper + * Kotlin data class (copy, equals, hashCode). + */ +class NavigationItemTest { + + // Just need some non-null ImageVector instance + private val dummyIcon: ImageVector = ImageVector.Builder( + defaultWidth = 24.dp, // <-- Dp, not Float + defaultHeight = 24.dp, // <-- Dp, not Float + viewportWidth = 24f, + viewportHeight = 24f + ).build() + + @Test + fun navigationItem_copyEqualsHashCodeCorrect() { + val original = NavigationItem( + route = "home", + label = "Home", + icon = dummyIcon + ) + + val copy = original.copy() + val modified = original.copy(route = "different") + + // Same data → equal + assertEquals(original, copy) + assertEquals(original.hashCode(), copy.hashCode()) + + // Different route → not equal + assertNotEquals(original, modified) + assertEquals("different", modified.route) + } +} diff --git a/app/src/test/java/com/greybox/projectmesh/server/InputStreamCounterTest.kt b/app/src/test/java/com/greybox/projectmesh/server/InputStreamCounterTest.kt new file mode 100644 index 000000000..3e15809e2 --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/server/InputStreamCounterTest.kt @@ -0,0 +1,109 @@ +package com.greybox.projectmesh.server + +import org.junit.Assert.* +import org.junit.Test +import java.io.ByteArrayInputStream + +/** + * JVM-only tests for InputStreamCounter. + * + * Verifies: + * - single-byte reads + * - buffered reads + * - offset reads + * - EOF behavior + * - close() flag + */ +class InputStreamCounterTest { + + // ----------------------------------------- + // Single-byte read: should count each byte + // ----------------------------------------- + @Test + fun countSingleBytes() { + val data = "hello world".toByteArray() + val input = ByteArrayInputStream(data) + val counter = InputStreamCounter(input) + + while (true) { + val result = counter.read() + if (result == -1) break + } + + assertEquals(data.size, counter.bytesRead) + assertFalse(counter.closed) + } + + // ---------------------------------------------------------- + // Buffered read: reading in chunks should count total bytes + // ---------------------------------------------------------- + @Test + fun readBufferBytes() { + val data = ByteArray(4096) { it.toByte() } + val input = ByteArrayInputStream(data) + val counter = InputStreamCounter(input) + + val buffer = ByteArray(1024) + while (true) { + val n = counter.read(buffer) + if (n == -1) break + } + + assertEquals(data.size, counter.bytesRead) + } + + // ---------------------------------------------------------------- + // Offset read: read(buffer, off, len) must still count accurately + // ---------------------------------------------------------------- + @Test + fun countOffsetBytes() { + val data = "abcdefghi".toByteArray() + val input = ByteArrayInputStream(data) + val counter = InputStreamCounter(input) + + val buffer = ByteArray(10) + while (true) { + val n = counter.read(buffer, 1, 4) + if (n == -1) break + } + + assertEquals(data.size, counter.bytesRead) + } + + // --------------------------------------------------------- + // EOF behavior: read after EOF should return -1 and not add + // --------------------------------------------------------- + @Test + fun checkEOF() { + val data = "test".toByteArray() + val input = ByteArrayInputStream(data) + val counter = InputStreamCounter(input) + + val buffer = ByteArray(2) + while (counter.read(buffer) != -1) { + // consume all data + } + + val before = counter.bytesRead + val eofRead = counter.read(buffer) + + assertEquals(-1, eofRead) + assertEquals(before, counter.bytesRead) + } + + // -------------------------- + // close(): should set flag + // -------------------------- + @Test + fun checkClose() { + val data = "xyz".toByteArray() + val input = ByteArrayInputStream(data) + val counter = InputStreamCounter(input) + + assertFalse(counter.closed) + + counter.close() + + assertTrue(counter.closed) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/greybox/projectmesh/ui/theme/ThemeLayerTest.kt b/app/src/test/java/com/greybox/projectmesh/ui/theme/ThemeLayerTest.kt new file mode 100644 index 000000000..e1cd7c5ff --- /dev/null +++ b/app/src/test/java/com/greybox/projectmesh/ui/theme/ThemeLayerTest.kt @@ -0,0 +1,77 @@ +package com.greybox.projectmesh.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import org.junit.Assert.* +import org.junit.Test + +/** + * JVM unit tests for the theme layer: + * + * - Color constants in Color.kt + * - AppTheme enum in Theme.kt + * - Typography definition in Type.kt + * + * NOTE: + * - We do NOT run the ProjectMeshTheme composable here, + * because that requires a real Compose runtime / Android. + * - Instrumented tests will check the actual MaterialTheme + * behavior (dark/light, system theme, etc.). + */ +class ThemeLayerTest { + + // ----------------------------- + // Color constants (Color.kt) + // ----------------------------- + + @Test + fun colors_haveExpectedArgbValues() { + // Light variants + assertEquals(Color(0xFFD0BCFF), Purple80) + assertEquals(Color(0xFFCCC2DC), PurpleGrey80) + assertEquals(Color(0xFFEFB8C8), Pink80) + + // Darker variants + assertEquals(Color(0xFF6650A4), Purple40) + assertEquals(Color(0xFF625B71), PurpleGrey40) + assertEquals(Color(0xFF7D5260), Pink40) + } + + // ----------------------------- + // AppTheme enum (Theme.kt) + // ----------------------------- + + @Test + fun appTheme_containsExpectedValuesInOrder() { + val values = enumValues().toList() + + assertEquals(3, values.size) + assertEquals(AppTheme.SYSTEM, values[0]) + assertEquals(AppTheme.LIGHT, values[1]) + assertEquals(AppTheme.DARK, values[2]) + + val names = values.map { it.name }.toSet() + assertEquals(setOf("SYSTEM", "LIGHT", "DARK"), names) + } + + // ----------------------------- + // Typography (Type.kt) + // ----------------------------- + + @Test + fun typography_bodyLarge_hasExpectedDefaults() { + // Typography is the object defined in Type.kt + val body = Typography.bodyLarge + + // Font family & weight + assertEquals(FontFamily.Default, body.fontFamily) + assertEquals(FontWeight.Normal, body.fontWeight) + + // Sizes (we compare the .value floats for simplicity) + assertEquals(16f, body.fontSize.value, 0.0f) + assertEquals(24f, body.lineHeight.value, 0.0f) + assertEquals(0.5f, body.letterSpacing.value, 0.0f) + } +}