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:
Claude
2026-04-26 16:46:55 +00:00
parent 7f48e52541
commit fb47a4cf75
5 changed files with 948 additions and 0 deletions
@@ -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))
}
}
@@ -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/"))
}
}