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
This commit is contained in:
Claude
2026-04-02 02:27:23 +00:00
parent 8354bcd616
commit f344f00e9d
4 changed files with 762 additions and 0 deletions
@@ -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))
}
}
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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(""))
}
}