refactor(nests): replace hand-built catalog JSON with typed MoqLiteHangCatalog

The two SPEAKER_CATALOG_JSON call sites (MoqLiteNestsSpeaker for the
non-reconnecting path, ReconnectingNestsSpeaker for the JWT-refresh hot-
swap path) both built the manifest as a `\\` + `\"` + `+`-concatenated
string literal. One missed escape and the watcher's parser fails
silently — the broadcast becomes invisible to hang.js with no
indication of why. The two literals had also drifted independently in
review (same content today; nothing prevents drift tomorrow).

Replace both with `MoqLiteHangCatalog.opusMono48k(track).encodeJsonBytes()`,
a Serializable data class that encodes via kotlinx.serialization with
`encodeDefaults = false` + `explicitNulls = false` pinned (so a future
global default-flip can't surface `"bitrate": null` on hang.js's
parser).

The new type lives in :nestsClient because :nestsClient does not depend
on :commons and the parser-side `RoomSpeakerCatalog` (in :commons)
isn't reachable from the publish path. The two classes target the same
wire shape independently; a byte-exact assertion in the new
MoqLiteHangCatalogTest plus the existing RoomSpeakerCatalogTest's
inline-literal round-trip together pin both sides — desync on either
end fails one of the two tests immediately.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
This commit is contained in:
Claude
2026-05-06 15:13:47 +00:00
parent aa37a931e1
commit ff9bb25921
5 changed files with 196 additions and 34 deletions
@@ -0,0 +1,51 @@
/*
* 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
class MoqLiteHangCatalogTest {
@Test
fun opusMono48kEmitsCanonicalHangShape() {
// Byte-exact assertion: `:commons`'s `RoomSpeakerCatalogTest`
// round-trips this same string against the parser. If either
// side drifts from the kixelated/hang wire shape, both tests
// fail at once.
val expected =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +
"\"sampleRate\":48000,\"numberOfChannels\":1}}}}"
val actual =
MoqLiteHangCatalog.opusMono48k("audio/data").encodeJsonBytes().decodeToString()
assertEquals(expected, actual)
}
@Test
fun renditionKeyMatchesCallerSuppliedTrackName() {
val actual =
MoqLiteHangCatalog.opusMono48k("custom/track").encodeJsonBytes().decodeToString()
// The rendition map MUST be keyed on the caller-supplied track
// name — the watcher uses this string verbatim as the
// SUBSCRIBE.track on the audio subscription.
assertEquals(true, actual.contains("\"custom/track\":{"))
}
}