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,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(""))
}
}