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
@@ -127,12 +127,13 @@ class RoomSpeakerCatalogTest {
@Test
fun stripPrefixRoundTripsCanonicalCatalog() {
// The catalog payload [com.vitorpamplona.nestsclient.MoqLiteNestsSpeaker]
// emits MUST round-trip through the parser — guards against
// either side drifting from the kixelated/hang shape.
// SPEAKER_CATALOG_JSON lives in nestsClient and isn't
// accessible from commons; keep an inline literal that mirrors
// it verbatim so a desync triggers this assertion.
// The catalog payload `MoqLiteHangCatalog.opusMono48k(...)` emits
// (in `:nestsClient`) MUST round-trip through this parser — the
// two classes target the same wire shape independently because
// `:nestsClient` does not depend on `:commons` and vice versa.
// `MoqLiteHangCatalog` isn't reachable from this module; keep an
// inline literal that mirrors what the publisher emits verbatim
// so a desync on either side trips this assertion.
val emitted =
"{\"audio\":{\"renditions\":{\"audio/data\":{" +
"\"codec\":\"opus\",\"container\":{\"kind\":\"legacy\"}," +