From f344f00e9daca9f753e2d83d75f2b4c6bab42094 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 02:27:23 +0000 Subject: [PATCH] test: add previews and unit tests for call feature Previews (CallScreenPreviews.kt): - PreviewCallingScreen: outgoing call "Calling..." state - PreviewConnectingScreen: WebRTC connecting state - PreviewCallEndedScreen: call ended state - PreviewIncomingCallScreen: incoming call with accept/reject - PreviewConnectedCallScreen: active call with controls - PreviewConnectedCallMuted: muted mic + speaker on state All use ThemeComparisonColumn for dark/light side-by-side Unit tests - quartz (CallTagsTest.kt): - CallIdTag parse/assemble/roundtrip, edge cases (empty, wrong name) - CallTypeTag parse voice/video, unknown types, roundtrip - CallType.fromString validation Unit tests - quartz (CallEventsTest.kt): - All 6 event kinds have correct constants (25050-25055) - CallOfferEvent.build includes call-id, call-type, p, expiration tags - Expiration is 20 seconds - Answer/ICE/Hangup/Reject/Renegotiate template content verification Unit tests - amethyst (IceCandidateSerializationTest.kt): - Parse ICE candidate JSON with all fields - Parse with different sdpMLineIndex - Parse with missing fields (defaults) - Serialize produces valid JSON - Roundtrip serialization preserves all fields https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- .../amethyst/ui/call/CallScreenPreviews.kt | 362 ++++++++++++++++++ .../call/IceCandidateSerializationTest.kt | 78 ++++ .../quartz/nipACWebRtcCalls/CallEventsTest.kt | 191 +++++++++ .../quartz/nipACWebRtcCalls/CallTagsTest.kt | 131 +++++++ 4 files changed, 762 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt new file mode 100644 index 000000000..85d832c63 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.call + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VideocamOff +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +private fun PreviewCallInProgress(statusText: String) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Alice", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = statusText, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewCallingScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Calling...") + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectingScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Connecting...") + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewCallEndedScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Call ended") + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewIncomingCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Bob", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Incoming voice call...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Reject", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + FloatingActionButton( + onClick = {}, + containerColor = Color(0xFF4CAF50), + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.Call, + contentDescription = "Accept", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectedCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = Color.White.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Alice", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = Color.White, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "02:45", + color = Color.White.copy(alpha = 0.7f), + fontSize = 16.sp, + ) + } + + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton( + onClick = {}, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "Mute", + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + IconButton( + onClick = {}, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.Default.VideocamOff, + contentDescription = "Camera off", + tint = Color.Red, + modifier = Modifier.size(28.dp), + ) + } + IconButton( + onClick = {}, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.Default.VolumeOff, + contentDescription = "Speaker", + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectedCallMuted() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = Color.White.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + Text("05:12", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) + } + + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon(Icons.Default.MicOff, "Unmute", tint = Color.Red, modifier = Modifier.size(28.dp)) + } + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon(Icons.Default.Videocam, "Camera on", tint = Color.White, modifier = Modifier.size(28.dp)) + } + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon(Icons.Default.VolumeUp, "Earpiece", tint = Color.Cyan, modifier = Modifier.size(28.dp)) + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt new file mode 100644 index 000000000..f073a8e02 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import org.junit.Assert.assertEquals +import org.junit.Test + +class IceCandidateSerializationTest { + @Test + fun parseIceCandidateFromJson() { + val json = """{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx","sdpMid":"0","sdpMLineIndex":0}""" + val candidate = CallController.parseIceCandidate(json) + + assertEquals("candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx", candidate.sdp) + assertEquals("0", candidate.sdpMid) + assertEquals(0, candidate.sdpMLineIndex) + } + + @Test + fun parseIceCandidateWithDifferentIndex() { + val json = """{"candidate":"candidate:1 1 tcp 1234","sdpMid":"audio","sdpMLineIndex":1}""" + val candidate = CallController.parseIceCandidate(json) + + assertEquals("candidate:1 1 tcp 1234", candidate.sdp) + assertEquals("audio", candidate.sdpMid) + assertEquals(1, candidate.sdpMLineIndex) + } + + @Test + fun parseIceCandidateHandlesMissingFields() { + val json = """{"candidate":"test"}""" + val candidate = CallController.parseIceCandidate(json) + + assertEquals("test", candidate.sdp) + assertEquals("0", candidate.sdpMid) // default + assertEquals(0, candidate.sdpMLineIndex) // default + } + + @Test + fun serializeIceCandidateProducesValidJson() { + val candidate = org.webrtc.IceCandidate("audio", 1, "candidate:123 1 udp 456") + val json = CallController.serializeIceCandidate(candidate) + + // Verify it contains the expected fields + assert(json.contains(""""candidate":"candidate:123 1 udp 456"""")) + assert(json.contains(""""sdpMid":"audio"""")) + assert(json.contains(""""sdpMLineIndex":1""")) + } + + @Test + fun roundTripIceCandidate() { + val original = org.webrtc.IceCandidate("video", 2, "candidate:999 1 udp 789 10.0.0.1 5000 typ host") + val json = CallController.serializeIceCandidate(original) + val parsed = CallController.parseIceCandidate(json) + + assertEquals(original.sdp, parsed.sdp) + assertEquals(original.sdpMid, parsed.sdpMid) + assertEquals(original.sdpMLineIndex, parsed.sdpMLineIndex) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt new file mode 100644 index 000000000..ca69124e1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipACWebRtcCalls + +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import kotlin.test.Test +import kotlin.test.assertEquals + +class CallEventsTest { + @Test + fun callOfferEventHasCorrectKind() { + assertEquals(25050, CallOfferEvent.KIND) + } + + @Test + fun callAnswerEventHasCorrectKind() { + assertEquals(25051, CallAnswerEvent.KIND) + } + + @Test + fun callIceCandidateEventHasCorrectKind() { + assertEquals(25052, CallIceCandidateEvent.KIND) + } + + @Test + fun callHangupEventHasCorrectKind() { + assertEquals(25053, CallHangupEvent.KIND) + } + + @Test + fun callRejectEventHasCorrectKind() { + assertEquals(25054, CallRejectEvent.KIND) + } + + @Test + fun callRenegotiateEventHasCorrectKind() { + assertEquals(25055, CallRenegotiateEvent.KIND) + } + + @Test + fun callOfferBuildIncludesCallIdTag() { + val template = + CallOfferEvent.build( + sdpOffer = "v=0\r\n...", + calleePubKey = "abc123", + callId = "test-call-id", + type = CallType.VOICE, + ) + assertEquals(CallOfferEvent.KIND, template.kind) + assertEquals("v=0\r\n...", template.content) + + val callIdTag = template.tags.firstOrNull { it[0] == "call-id" } + assertEquals("test-call-id", callIdTag?.get(1)) + } + + @Test + fun callOfferBuildIncludesCallTypeTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKey = "abc123", + callId = "id", + type = CallType.VIDEO, + ) + val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" } + assertEquals("video", callTypeTag?.get(1)) + } + + @Test + fun callOfferBuildIncludesPTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKey = "recipient-hex-key", + callId = "id", + type = CallType.VOICE, + ) + val pTag = template.tags.firstOrNull { it[0] == "p" } + assertEquals("recipient-hex-key", pTag?.get(1)) + } + + @Test + fun callOfferBuildIncludesExpirationTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKey = "abc", + callId = "id", + type = CallType.VOICE, + createdAt = 1000L, + ) + val expirationTag = template.tags.firstOrNull { it[0] == "expiration" } + assertEquals((1000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1)) + } + + @Test + fun callOfferExpirationIs20Seconds() { + assertEquals(20L, CallOfferEvent.EXPIRATION_SECONDS) + } + + @Test + fun callAnswerBuildContainsSdp() { + val template = + CallAnswerEvent.build( + sdpAnswer = "answer-sdp", + callerPubKey = "caller", + callId = "call-1", + ) + assertEquals("answer-sdp", template.content) + assertEquals(CallAnswerEvent.KIND, template.kind) + } + + @Test + fun callIceCandidateBuildContainsJson() { + val candidateJson = """{"candidate":"candidate:1","sdpMid":"0","sdpMLineIndex":0}""" + val template = + CallIceCandidateEvent.build( + candidateJson = candidateJson, + peerPubKey = "peer", + callId = "call-1", + ) + assertEquals(candidateJson, template.content) + } + + @Test + fun callHangupBuildAllowsEmptyReason() { + val template = + CallHangupEvent.build( + peerPubKey = "peer", + callId = "call-1", + ) + assertEquals("", template.content) + } + + @Test + fun callHangupBuildAllowsCustomReason() { + val template = + CallHangupEvent.build( + peerPubKey = "peer", + callId = "call-1", + reason = "busy", + ) + assertEquals("busy", template.content) + } + + @Test + fun callRejectBuildHasCorrectKind() { + val template = + CallRejectEvent.build( + callerPubKey = "caller", + callId = "call-1", + ) + assertEquals(CallRejectEvent.KIND, template.kind) + } + + @Test + fun callRenegotiateBuildContainsSdp() { + val template = + CallRenegotiateEvent.build( + sdpOffer = "new-sdp-offer", + peerPubKey = "peer", + callId = "call-1", + ) + assertEquals("new-sdp-offer", template.content) + assertEquals(CallRenegotiateEvent.KIND, template.kind) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt new file mode 100644 index 000000000..1d5f90109 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipACWebRtcCalls + +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallTypeTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class CallTagsTest { + @Test + fun parseCallIdTag() { + val tag = arrayOf("call-id", "550e8400-e29b-41d4-a716-446655440000") + val result = CallIdTag.parse(tag) + assertNotNull(result) + assertEquals("550e8400-e29b-41d4-a716-446655440000", result) + } + + @Test + fun parseCallIdTagRejectsEmpty() { + val tag = arrayOf("call-id", "") + assertNull(CallIdTag.parse(tag)) + } + + @Test + fun parseCallIdTagRejectsMissingValue() { + val tag = arrayOf("call-id") + assertNull(CallIdTag.parse(tag)) + } + + @Test + fun parseCallIdTagRejectsWrongName() { + val tag = arrayOf("other", "some-id") + assertNull(CallIdTag.parse(tag)) + } + + @Test + fun assembleCallIdTag() { + val tag = CallIdTag.assemble("test-id-123") + assertEquals("call-id", tag[0]) + assertEquals("test-id-123", tag[1]) + } + + @Test + fun roundTripCallIdTag() { + val original = "550e8400-e29b-41d4-a716-446655440000" + val assembled = CallIdTag.assemble(original) + val parsed = CallIdTag.parse(assembled) + assertEquals(original, parsed) + } + + @Test + fun parseCallTypeVoice() { + val tag = arrayOf("call-type", "voice") + val result = CallTypeTag.parse(tag) + assertNotNull(result) + assertEquals(CallType.VOICE, result) + } + + @Test + fun parseCallTypeVideo() { + val tag = arrayOf("call-type", "video") + val result = CallTypeTag.parse(tag) + assertNotNull(result) + assertEquals(CallType.VIDEO, result) + } + + @Test + fun parseCallTypeRejectsUnknown() { + val tag = arrayOf("call-type", "screenshare") + assertNull(CallTypeTag.parse(tag)) + } + + @Test + fun parseCallTypeRejectsMissing() { + val tag = arrayOf("call-type") + assertNull(CallTypeTag.parse(tag)) + } + + @Test + fun assembleCallTypeVoice() { + val tag = CallTypeTag.assemble(CallType.VOICE) + assertEquals("call-type", tag[0]) + assertEquals("voice", tag[1]) + } + + @Test + fun assembleCallTypeVideo() { + val tag = CallTypeTag.assemble(CallType.VIDEO) + assertEquals("call-type", tag[0]) + assertEquals("video", tag[1]) + } + + @Test + fun roundTripCallType() { + for (type in CallType.entries) { + val assembled = CallTypeTag.assemble(type) + val parsed = CallTypeTag.parse(assembled) + assertEquals(type, parsed) + } + } + + @Test + fun callTypeFromString() { + assertEquals(CallType.VOICE, CallType.fromString("voice")) + assertEquals(CallType.VIDEO, CallType.fromString("video")) + assertNull(CallType.fromString("audio")) + assertNull(CallType.fromString("")) + } +}