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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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)
|
||||
}
|
||||
}
|
||||
+191
@@ -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)
|
||||
}
|
||||
}
|
||||
+131
@@ -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(""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user