feat(audio-rooms): moq-lite codec primitives + message types (phase 5a/5b)
First two phases of the moq-lite (Lite-03) implementation per
nestsClient/plans/2026-04-26-moq-lite-gap.md. No production wiring
yet — the production helpers still call the IETF MoqSession.
What landed:
- MoqLitePath — mandatory wire-boundary normalisation (strip
leading/trailing/duplicate `/`), join, path-component-aware
stripPrefix. Mirrors rs/moq-lite/src/path.rs semantics.
- MoqLiteAlpn / MoqLiteControlType / MoqLiteDataType /
MoqLiteAnnounceStatus / MoqLiteSubscribeResponseType — wire
enums for ALPN ("moq-lite-03"), per-bidi ControlType varints,
Group uni-stream type byte, announce status, subscribe
response type.
- Message data classes — AnnouncePlease, Announce, Subscribe,
SubscribeOk, SubscribeDrop, GroupHeader, Probe.
- MoqLiteCodec — encode/decode for each message, with the
size-prefix envelope baked in. Handles the off-by-one
`0 = None, n = Some(n−1)` trick for startGroup/endGroup,
coerces booleans to 0/1, validates priority fits in u8,
rejects unknown status/response bytes. Path normalisation
applied at every encode + decode boundary.
- MoqLitePathTest, MoqLiteCodecTest — round-trip tests for
every codec entry point + negative paths (oversized priority,
invalid ordered byte, unknown status, trailing garbage).
All MoqWriter / MoqReader / MoqCodecException primitives reused
from the IETF MoQ codec — same varint and length-prefix shapes.
This commit is contained in:
+264
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* 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.nestsclient.moq.lite
|
||||
|
||||
import com.vitorpamplona.nestsclient.moq.MoqCodecException
|
||||
import com.vitorpamplona.nestsclient.moq.MoqReader
|
||||
import com.vitorpamplona.quic.Varint
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Encode/decode round-trip tests for every moq-lite (Lite-03) message
|
||||
* type the client uses. Each test encodes a hand-built data class to
|
||||
* bytes, peels off the size-prefix to confirm the framing envelope,
|
||||
* then decodes the payload and asserts equality with the input.
|
||||
*
|
||||
* Wire-format expectations are anchored against the byte layout
|
||||
* documented in `nestsClient/plans/2026-04-26-moq-lite-gap.md` (which
|
||||
* cites `kixelated/moq-rs/rs/moq-lite/src/`).
|
||||
*/
|
||||
class MoqLiteCodecTest {
|
||||
@Test
|
||||
fun announcePlease_round_trips() {
|
||||
val msg = MoqLiteAnnouncePlease(prefix = "nests/30312:abc:room")
|
||||
val encoded = MoqLiteCodec.encodeAnnouncePlease(msg)
|
||||
val payload = peelSizePrefix(encoded)
|
||||
assertEquals(msg, MoqLiteCodec.decodeAnnouncePlease(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun announcePlease_normalizes_prefix_on_encode_and_decode() {
|
||||
val raw = MoqLiteAnnouncePlease(prefix = "/nests//30312:abc:room/")
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeAnnouncePlease(raw))
|
||||
// The wire bytes are normalised — no leading/trailing/duplicate `/`.
|
||||
val r = MoqReader(payload)
|
||||
assertEquals("nests/30312:abc:room", r.readLengthPrefixedString())
|
||||
assertTrue(!r.hasMore())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun announce_active_round_trips() {
|
||||
val msg = MoqLiteAnnounce(status = MoqLiteAnnounceStatus.Active, suffix = "speakerPubkey", hops = 0L)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeAnnounce(msg))
|
||||
// Byte 0 is the status byte (literal 1), then a 13-byte string,
|
||||
// then a varint hops.
|
||||
assertEquals(1, payload[0].toInt())
|
||||
assertEquals(msg, MoqLiteCodec.decodeAnnounce(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun announce_ended_with_relay_hops_round_trips() {
|
||||
val msg = MoqLiteAnnounce(status = MoqLiteAnnounceStatus.Ended, suffix = "fff", hops = 7L)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeAnnounce(msg))
|
||||
assertEquals(0, payload[0].toInt())
|
||||
assertEquals(msg, MoqLiteCodec.decodeAnnounce(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun announce_rejects_unknown_status_byte() {
|
||||
// Hand-craft a payload with an invalid status byte. Wrap in the
|
||||
// size-prefix envelope just like a real message would arrive.
|
||||
val handCraft = byteArrayOf(0x05.toByte()) + lengthPrefixed("x") + Varint.encode(0L)
|
||||
assertFailsWith<MoqCodecException> { MoqLiteCodec.decodeAnnounce(handCraft) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribe_round_trips_with_all_groups_set() {
|
||||
val msg =
|
||||
MoqLiteSubscribe(
|
||||
id = 42L,
|
||||
broadcast = "nests/30312:abc:room",
|
||||
track = "audio/data",
|
||||
priority = 0x80,
|
||||
ordered = true,
|
||||
maxLatencyMillis = 250L,
|
||||
startGroup = 5L,
|
||||
endGroup = 9L,
|
||||
)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg))
|
||||
assertEquals(msg, MoqLiteCodec.decodeSubscribe(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribe_round_trips_with_null_group_bounds() {
|
||||
val msg =
|
||||
MoqLiteSubscribe(
|
||||
id = 1L,
|
||||
broadcast = "speakerPubkey",
|
||||
track = "catalog.json",
|
||||
priority = 0,
|
||||
ordered = false,
|
||||
maxLatencyMillis = 0L,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg))
|
||||
assertEquals(msg, MoqLiteCodec.decodeSubscribe(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribe_off_by_one_group_encoding_uses_zero_for_none() {
|
||||
val msg = subscribeWith(startGroup = null, endGroup = null)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg))
|
||||
// Skip past id (varint), broadcast/track strings, priority byte,
|
||||
// ordered byte, maxLatency varint — pull the last two varints.
|
||||
val r = MoqReader(payload)
|
||||
r.readVarint() // id
|
||||
r.readLengthPrefixedString() // broadcast
|
||||
r.readLengthPrefixedString() // track
|
||||
r.readByte() // priority
|
||||
r.readByte() // ordered
|
||||
r.readVarint() // maxLatency
|
||||
assertEquals(0L, r.readVarint(), "startGroup=None encodes as 0 on the wire")
|
||||
assertEquals(0L, r.readVarint(), "endGroup=None encodes as 0 on the wire")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribe_off_by_one_some_zero_encodes_as_one() {
|
||||
val msg = subscribeWith(startGroup = 0L, endGroup = 0L)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg))
|
||||
val r = MoqReader(payload)
|
||||
r.readVarint()
|
||||
r.readLengthPrefixedString()
|
||||
r.readLengthPrefixedString()
|
||||
r.readByte()
|
||||
r.readByte()
|
||||
r.readVarint()
|
||||
assertEquals(1L, r.readVarint(), "Some(0) encodes as 1 (off-by-one trick)")
|
||||
assertEquals(1L, r.readVarint(), "Some(0) encodes as 1 (off-by-one trick)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribe_rejects_oversized_priority_at_construction() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
MoqLiteSubscribe(
|
||||
id = 0L,
|
||||
broadcast = "x",
|
||||
track = "y",
|
||||
priority = 256,
|
||||
ordered = false,
|
||||
maxLatencyMillis = 0L,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribeOk_round_trips() {
|
||||
val msg =
|
||||
MoqLiteSubscribeOk(
|
||||
priority = 0x80,
|
||||
ordered = true,
|
||||
maxLatencyMillis = 100L,
|
||||
startGroup = null,
|
||||
endGroup = null,
|
||||
)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribeOk(msg))
|
||||
val resp = MoqLiteCodec.decodeSubscribeResponse(payload)
|
||||
val ok = assertIs<MoqLiteCodec.SubscribeResponse.Ok>(resp)
|
||||
assertEquals(msg, ok.ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscribeDrop_round_trips() {
|
||||
val msg = MoqLiteSubscribeDrop(errorCode = 0x12L, reasonPhrase = "publisher gone")
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribeDrop(msg))
|
||||
val resp = MoqLiteCodec.decodeSubscribeResponse(payload)
|
||||
val drop = assertIs<MoqLiteCodec.SubscribeResponse.Dropped>(resp)
|
||||
assertEquals(msg, drop.drop)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupHeader_round_trips() {
|
||||
val msg = MoqLiteGroupHeader(subscribeId = 42L, sequence = 7L)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeGroupHeader(msg))
|
||||
assertEquals(msg, MoqLiteCodec.decodeGroupHeader(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoders_reject_trailing_garbage() {
|
||||
// Build a valid AnnouncePlease payload then append a stray byte.
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeAnnouncePlease(MoqLiteAnnouncePlease("foo")))
|
||||
val tampered = payload + byteArrayOf(0)
|
||||
assertFailsWith<MoqCodecException> { MoqLiteCodec.decodeAnnouncePlease(tampered) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decoders_reject_invalid_ordered_byte() {
|
||||
val msg = subscribeWith(startGroup = null, endGroup = null)
|
||||
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg)).copyOf()
|
||||
// Patch the ordered byte. Layout: varint id (1B for id=0),
|
||||
// string broadcast (1B len + bytes), string track (1B len + bytes),
|
||||
// priority byte, ordered byte. So index = 1 + 1 + b.length + 1 + t.length + 1.
|
||||
val idx = 1 + 1 + msg.broadcast.length + 1 + msg.track.length + 1
|
||||
payload[idx] = 7
|
||||
assertFailsWith<MoqCodecException> { MoqLiteCodec.decodeSubscribe(payload) }
|
||||
}
|
||||
|
||||
private fun subscribeWith(
|
||||
startGroup: Long?,
|
||||
endGroup: Long?,
|
||||
) = MoqLiteSubscribe(
|
||||
id = 0L,
|
||||
broadcast = "b",
|
||||
track = "t",
|
||||
priority = 0,
|
||||
ordered = false,
|
||||
maxLatencyMillis = 0L,
|
||||
startGroup = startGroup,
|
||||
endGroup = endGroup,
|
||||
)
|
||||
|
||||
private fun lengthPrefixed(s: String): ByteArray {
|
||||
val bytes = s.encodeToByteArray()
|
||||
return Varint.encode(bytes.size.toLong()) + bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Peel the varint size-prefix off an encoded message. Tests work
|
||||
* directly on the *payload* (no envelope) since the session-layer
|
||||
* is the one that frames the envelope onto/off the wire.
|
||||
*/
|
||||
private fun peelSizePrefix(encoded: ByteArray): ByteArray {
|
||||
val sizeDecode = Varint.decode(encoded) ?: error("envelope missing size prefix")
|
||||
val payload = encoded.copyOfRange(sizeDecode.bytesConsumed, encoded.size)
|
||||
assertEquals(
|
||||
sizeDecode.value.toInt(),
|
||||
payload.size,
|
||||
"size prefix must equal payload length",
|
||||
)
|
||||
return payload
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun byteHex(bytes: ByteArray): String = bytes.joinToString(" ") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }
|
||||
|
||||
@Test
|
||||
fun checks_byteHex_helper_compiles() {
|
||||
assertContentEquals(byteArrayOf(0x10, 0x20), byteArrayOf(0x10, 0x20))
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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.nestsclient.moq.lite
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class MoqLitePathTest {
|
||||
@Test
|
||||
fun normalize_strips_leading_trailing_and_collapses_duplicates() {
|
||||
assertEquals("foo/bar", MoqLitePath.normalize("/foo/bar/"))
|
||||
assertEquals("foo/bar", MoqLitePath.normalize("foo//bar"))
|
||||
assertEquals("foo/bar", MoqLitePath.normalize("///foo///bar///"))
|
||||
assertEquals("foo", MoqLitePath.normalize("/foo/"))
|
||||
assertEquals("", MoqLitePath.normalize(""))
|
||||
assertEquals("", MoqLitePath.normalize("/"))
|
||||
assertEquals("", MoqLitePath.normalize("/////"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalize_preserves_already_canonical_paths() {
|
||||
assertEquals("foo/bar", MoqLitePath.normalize("foo/bar"))
|
||||
assertEquals(
|
||||
"nests/30312:abc:room/${"f".repeat(64)}",
|
||||
MoqLitePath.normalize("nests/30312:abc:room/${"f".repeat(64)}"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun join_handles_empty_sides() {
|
||||
assertEquals("foo", MoqLitePath.join("", "foo"))
|
||||
assertEquals("foo", MoqLitePath.join("foo", ""))
|
||||
assertEquals("", MoqLitePath.join("", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun join_inserts_exactly_one_slash() {
|
||||
assertEquals("a/b", MoqLitePath.join("a", "b"))
|
||||
// Sides are normalized first — the join can't produce `//`.
|
||||
assertEquals("a/b", MoqLitePath.join("a/", "/b"))
|
||||
assertEquals("nests/30312:abc:room/pubkey", MoqLitePath.join("nests/30312:abc:room", "pubkey"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripPrefix_is_path_component_aware() {
|
||||
assertEquals("baz", MoqLitePath.stripPrefix("foo/bar", "foo/bar/baz"))
|
||||
assertEquals("", MoqLitePath.stripPrefix("foo/bar", "foo/bar"))
|
||||
// Prefix must end on a path-component boundary.
|
||||
assertNull(MoqLitePath.stripPrefix("foo", "foobar"))
|
||||
assertNull(MoqLitePath.stripPrefix("foo/bar", "foo/barbaz"))
|
||||
// Disjoint paths return null.
|
||||
assertNull(MoqLitePath.stripPrefix("foo/bar", "baz"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripPrefix_normalizes_both_sides() {
|
||||
assertEquals("baz", MoqLitePath.stripPrefix("/foo/bar/", "/foo//bar/baz/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripPrefix_with_empty_prefix_returns_normalized_path() {
|
||||
assertEquals("foo/bar", MoqLitePath.stripPrefix("", "/foo//bar/"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user