diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
new file mode 100644
index 000000000..a6ead2605
--- /dev/null
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt
@@ -0,0 +1,410 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.navigation
+
+import org.meshtastic.core.common.util.CommonUri
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class DeepLinkRouterTest {
+
+ private fun route(path: String): List<*>? {
+ val uri = CommonUri.parse("$DEEP_LINK_BASE_URI$path")
+ return DeepLinkRouter.route(uri)
+ }
+
+ // region empty / unrecognized
+
+ @Test
+ fun `empty path returns null`() {
+ assertNull(route(""))
+ }
+
+ @Test
+ fun `unrecognized segment returns null`() {
+ assertNull(route("/unknown-page"))
+ }
+
+ // endregion
+
+ // region contacts / messages
+
+ @Test
+ fun `share with message`() {
+ assertEquals(
+ listOf(ContactsRoute.ContactsGraph, ContactsRoute.Share("hello world")),
+ route("/share?message=hello%20world"),
+ )
+ }
+
+ @Test
+ fun `share without message defaults to empty string`() {
+ assertEquals(listOf(ContactsRoute.ContactsGraph, ContactsRoute.Share("")), route("/share"))
+ }
+
+ @Test
+ fun `quickchat routes to QuickChat`() {
+ assertEquals(listOf(ContactsRoute.ContactsGraph, ContactsRoute.QuickChat), route("/quickchat"))
+ }
+
+ @Test
+ fun `messages with contactKey path segment`() {
+ assertEquals(
+ listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "abc123", message = "")),
+ route("/messages/abc123"),
+ )
+ }
+
+ @Test
+ fun `messages with contactKey query param`() {
+ assertEquals(
+ listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "contact1", message = "")),
+ route("/messages?contactKey=contact1"),
+ )
+ }
+
+ @Test
+ fun `messages with contactKey and message`() {
+ assertEquals(
+ listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "contact1", message = "hi")),
+ route("/messages/contact1?message=hi"),
+ )
+ }
+
+ @Test
+ fun `messages without contactKey returns graph only`() {
+ assertEquals(listOf(ContactsRoute.ContactsGraph), route("/messages"))
+ }
+
+ // endregion
+
+ // region connections
+
+ @Test
+ fun `connections routes to ConnectionsGraph`() {
+ assertEquals(listOf(ConnectionsRoute.ConnectionsGraph), route("/connections"))
+ }
+
+ // endregion
+
+ // region map
+
+ @Test
+ fun `map without waypointId`() {
+ assertEquals(listOf(MapRoute.Map(waypointId = null)), route("/map"))
+ }
+
+ @Test
+ fun `map with waypointId path segment`() {
+ assertEquals(listOf(MapRoute.Map(waypointId = 42)), route("/map/42"))
+ }
+
+ @Test
+ fun `map with waypointId query param`() {
+ assertEquals(listOf(MapRoute.Map(waypointId = 99)), route("/map?waypointId=99"))
+ }
+
+ @Test
+ fun `map with invalid waypointId falls back to null`() {
+ assertEquals(listOf(MapRoute.Map(waypointId = null)), route("/map/not-a-number"))
+ }
+
+ // endregion
+
+ // region nodes
+
+ @Test
+ fun `nodes root returns NodesGraph`() {
+ assertEquals(listOf(NodesRoute.NodesGraph), route("/nodes"))
+ }
+
+ @Test
+ fun `nodes with destNum returns NodeDetail`() {
+ assertEquals(listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 1234)), route("/nodes/1234"))
+ }
+
+ @Test
+ fun `nodes with destNum and device-metrics sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 1234),
+ NodeDetailRoute.DeviceMetrics(destNum = 1234),
+ ),
+ route("/nodes/1234/device-metrics"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and map sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 5678),
+ NodeDetailRoute.NodeMap(destNum = 5678),
+ ),
+ route("/nodes/5678/map"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and position sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 100),
+ NodeDetailRoute.PositionLog(destNum = 100),
+ ),
+ route("/nodes/100/position"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and environment sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 100),
+ NodeDetailRoute.EnvironmentMetrics(destNum = 100),
+ ),
+ route("/nodes/100/environment"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and signal sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 100),
+ NodeDetailRoute.SignalMetrics(destNum = 100),
+ ),
+ route("/nodes/100/signal"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and power sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 100),
+ NodeDetailRoute.PowerMetrics(destNum = 100),
+ ),
+ route("/nodes/100/power"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and traceroute sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 100),
+ NodeDetailRoute.TracerouteLog(destNum = 100),
+ ),
+ route("/nodes/100/traceroute"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and host-metrics sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 100),
+ NodeDetailRoute.HostMetricsLog(destNum = 100),
+ ),
+ route("/nodes/100/host-metrics"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and pax sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 100),
+ NodeDetailRoute.PaxMetrics(destNum = 100),
+ ),
+ route("/nodes/100/pax"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and neighbors sub-route`() {
+ assertEquals(
+ listOf(
+ NodesRoute.NodesGraph,
+ NodesRoute.NodeDetailGraph(destNum = 100),
+ NodeDetailRoute.NeighborInfoLog(destNum = 100),
+ ),
+ route("/nodes/100/neighbors"),
+ )
+ }
+
+ @Test
+ fun `nodes with destNum and unknown sub-route falls back to NodeDetail`() {
+ assertEquals(
+ listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 1234)),
+ route("/nodes/1234/unknown-sub"),
+ )
+ }
+
+ @Test
+ fun `nodes with non-numeric destNum returns NodesGraph only`() {
+ assertEquals(listOf(NodesRoute.NodesGraph), route("/nodes/not-a-number"))
+ }
+
+ @Test
+ fun `nodes with destNum query param`() {
+ assertEquals(listOf(NodesRoute.NodesGraph, NodesRoute.NodeDetail(destNum = 9999)), route("/nodes?destNum=9999"))
+ }
+
+ // endregion
+
+ // region settings
+
+ @Test
+ fun `settings root returns SettingsGraph`() {
+ assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null)), route("/settings"))
+ }
+
+ @Test
+ fun `settings with destNum`() {
+ assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = 1234)), route("/settings/1234"))
+ }
+
+ @Test
+ fun `settings with destNum and sub-route`() {
+ assertEquals(
+ listOf(SettingsRoute.SettingsGraph(destNum = 1234), SettingsRoute.About),
+ route("/settings/1234/about"),
+ )
+ }
+
+ @Test
+ fun `settings with sub-route without destNum`() {
+ assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null), SettingsRoute.LoRa), route("/settings/lora"))
+ }
+
+ @Test
+ fun `settings with unknown sub-route returns SettingsGraph only`() {
+ assertEquals(listOf(SettingsRoute.SettingsGraph(destNum = null)), route("/settings/nonexistent-page"))
+ }
+
+ @Test
+ fun `settings all known sub-routes resolve correctly`() {
+ val expectedSubRoutes =
+ mapOf(
+ "device-config" to SettingsRoute.DeviceConfiguration,
+ "module-config" to SettingsRoute.ModuleConfiguration,
+ "admin" to SettingsRoute.Administration,
+ "user" to SettingsRoute.User,
+ "channel" to SettingsRoute.ChannelConfig,
+ "device" to SettingsRoute.Device,
+ "position" to SettingsRoute.Position,
+ "power" to SettingsRoute.Power,
+ "network" to SettingsRoute.Network,
+ "display" to SettingsRoute.Display,
+ "lora" to SettingsRoute.LoRa,
+ "bluetooth" to SettingsRoute.Bluetooth,
+ "security" to SettingsRoute.Security,
+ "mqtt" to SettingsRoute.MQTT,
+ "serial" to SettingsRoute.Serial,
+ "ext-notification" to SettingsRoute.ExtNotification,
+ "store-forward" to SettingsRoute.StoreForward,
+ "range-test" to SettingsRoute.RangeTest,
+ "telemetry" to SettingsRoute.Telemetry,
+ "canned-message" to SettingsRoute.CannedMessage,
+ "audio" to SettingsRoute.Audio,
+ "remote-hardware" to SettingsRoute.RemoteHardware,
+ "neighbor-info" to SettingsRoute.NeighborInfo,
+ "ambient-lighting" to SettingsRoute.AmbientLighting,
+ "detection-sensor" to SettingsRoute.DetectionSensor,
+ "paxcounter" to SettingsRoute.Paxcounter,
+ "status-message" to SettingsRoute.StatusMessage,
+ "traffic-management" to SettingsRoute.TrafficManagement,
+ "tak" to SettingsRoute.TAK,
+ "clean-node-db" to SettingsRoute.CleanNodeDb,
+ "debug-panel" to SettingsRoute.DebugPanel,
+ "about" to SettingsRoute.About,
+ "filter-settings" to SettingsRoute.FilterSettings,
+ )
+
+ expectedSubRoutes.forEach { (slug, expectedRoute) ->
+ assertEquals(
+ listOf(SettingsRoute.SettingsGraph(destNum = null), expectedRoute),
+ route("/settings/$slug"),
+ "Settings sub-route '$slug' did not resolve to $expectedRoute",
+ )
+ }
+ }
+
+ // endregion
+
+ // region channels
+
+ @Test
+ fun `channels routes to ChannelsGraph`() {
+ assertEquals(listOf(ChannelsRoute.ChannelsGraph), route("/channels"))
+ }
+
+ // endregion
+
+ // region firmware
+
+ @Test
+ fun `firmware root returns FirmwareGraph`() {
+ assertEquals(listOf(FirmwareRoute.FirmwareGraph), route("/firmware"))
+ }
+
+ @Test
+ fun `firmware update returns FirmwareGraph and FirmwareUpdate`() {
+ assertEquals(listOf(FirmwareRoute.FirmwareGraph, FirmwareRoute.FirmwareUpdate), route("/firmware/update"))
+ }
+
+ // endregion
+
+ // region wifi-provision
+
+ @Test
+ fun `wifi-provision without address`() {
+ assertEquals(listOf(WifiProvisionRoute.WifiProvision(address = null)), route("/wifi-provision"))
+ }
+
+ @Test
+ fun `wifi-provision with address query param`() {
+ assertEquals(
+ listOf(WifiProvisionRoute.WifiProvision(address = "AA:BB:CC:DD:EE:FF")),
+ route("/wifi-provision?address=AA:BB:CC:DD:EE:FF"),
+ )
+ }
+
+ // endregion
+
+ // region case insensitivity
+
+ @Test
+ fun `route segments are case insensitive`() {
+ assertEquals(listOf(NodesRoute.NodesGraph), route("/Nodes"))
+ assertEquals(listOf(ConnectionsRoute.ConnectionsGraph), route("/CONNECTIONS"))
+ }
+
+ // endregion
+}
diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt
new file mode 100644
index 000000000..2f013a39c
--- /dev/null
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavBackStackExtTest.kt
@@ -0,0 +1,146 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.navigation
+
+import androidx.navigation3.runtime.NavKey
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class NavBackStackExtTest {
+
+ // region replaceLast
+
+ @Test
+ fun `replaceLast on non-empty list replaces the last element`() {
+ val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes)
+ stack.replaceLast(NodesRoute.NodeDetail(destNum = 42))
+
+ assertEquals(2, stack.size)
+ assertEquals(NodesRoute.NodesGraph, stack[0])
+ assertEquals(NodesRoute.NodeDetail(destNum = 42), stack[1])
+ }
+
+ @Test
+ fun `replaceLast on single-element list replaces that element`() {
+ val stack = mutableListOf(NodesRoute.NodesGraph)
+ stack.replaceLast(SettingsRoute.SettingsGraph())
+
+ assertEquals(1, stack.size)
+ assertEquals(SettingsRoute.SettingsGraph(), stack[0])
+ }
+
+ @Test
+ fun `replaceLast on empty list adds the element`() {
+ val stack = mutableListOf()
+ stack.replaceLast(NodesRoute.Nodes)
+
+ assertEquals(1, stack.size)
+ assertEquals(NodesRoute.Nodes, stack[0])
+ }
+
+ @Test
+ fun `replaceLast with same element does not mutate`() {
+ val route = NodesRoute.Nodes
+ val stack = mutableListOf(NodesRoute.NodesGraph, route)
+ stack.replaceLast(route)
+
+ assertEquals(2, stack.size)
+ assertEquals(route, stack[1])
+ }
+
+ // endregion
+
+ // region replaceAll
+
+ @Test
+ fun `replaceAll replaces entire stack with new routes`() {
+ val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes)
+ val newRoutes = listOf(SettingsRoute.SettingsGraph(), SettingsRoute.About)
+
+ stack.replaceAll(newRoutes)
+
+ assertEquals(newRoutes, stack)
+ }
+
+ @Test
+ fun `replaceAll with shorter list trims excess elements`() {
+ val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 42))
+ val newRoutes = listOf(SettingsRoute.SettingsGraph())
+
+ stack.replaceAll(newRoutes)
+
+ assertEquals(1, stack.size)
+ assertEquals(SettingsRoute.SettingsGraph(), stack[0])
+ }
+
+ @Test
+ fun `replaceAll with longer list appends new elements`() {
+ val stack = mutableListOf(NodesRoute.NodesGraph)
+ val newRoutes = listOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 99))
+
+ stack.replaceAll(newRoutes)
+
+ assertEquals(newRoutes, stack)
+ }
+
+ @Test
+ fun `replaceAll with empty list clears the stack`() {
+ val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes)
+
+ stack.replaceAll(emptyList())
+
+ assertEquals(0, stack.size)
+ }
+
+ @Test
+ fun `replaceAll on empty stack with new routes populates it`() {
+ val stack = mutableListOf()
+ val newRoutes = listOf(ContactsRoute.ContactsGraph, ContactsRoute.Contacts)
+
+ stack.replaceAll(newRoutes)
+
+ assertEquals(newRoutes, stack)
+ }
+
+ @Test
+ fun `replaceAll with identical routes does not mutate entries`() {
+ val routes = listOf(NodesRoute.NodesGraph, NodesRoute.Nodes)
+ val stack = routes.toMutableList()
+
+ stack.replaceAll(routes)
+
+ assertEquals(routes, stack)
+ }
+
+ @Test
+ fun `replaceAll with partial overlap only changes differing elements`() {
+ val stack = mutableListOf(NodesRoute.NodesGraph, NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 1))
+ val newRoutes =
+ listOf(
+ NodesRoute.NodesGraph, // same
+ SettingsRoute.About, // different
+ )
+
+ stack.replaceAll(newRoutes)
+
+ assertEquals(2, stack.size)
+ assertEquals(NodesRoute.NodesGraph, stack[0])
+ assertEquals(SettingsRoute.About, stack[1])
+ }
+
+ // endregion
+}
diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt
new file mode 100644
index 000000000..e89879613
--- /dev/null
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt
@@ -0,0 +1,210 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.navigation
+
+import androidx.navigation3.runtime.NavKey
+import androidx.savedstate.serialization.decodeFromSavedState
+import androidx.savedstate.serialization.encodeToSavedState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/**
+ * Verifies that all route subclasses registered in [MeshtasticNavSavedStateConfig] can round-trip through SavedState
+ * serialization. This catches:
+ * - Missing `@Serializable` annotations on new route subclasses
+ * - Sealed interfaces not registered in [NavigationConfig.kt]
+ * - Breaking changes in the `subclassesOfSealed` experimental API
+ */
+class NavigationConfigTest {
+
+ /**
+ * Every concrete route instance that can appear in a backstack. When adding a new route, add a representative
+ * instance here — the test will fail if serialization is misconfigured.
+ */
+ private val allRouteInstances: List =
+ listOf(
+ // ChannelsRoute
+ ChannelsRoute.ChannelsGraph,
+ ChannelsRoute.Channels,
+ // ConnectionsRoute
+ ConnectionsRoute.ConnectionsGraph,
+ ConnectionsRoute.Connections,
+ // ContactsRoute
+ ContactsRoute.ContactsGraph,
+ ContactsRoute.Contacts,
+ ContactsRoute.Messages(contactKey = "test-contact", message = "hello"),
+ ContactsRoute.Messages(contactKey = "test-contact"),
+ ContactsRoute.Share(message = "share-text"),
+ ContactsRoute.QuickChat,
+ // MapRoute
+ MapRoute.Map(),
+ MapRoute.Map(waypointId = 42),
+ // NodesRoute
+ NodesRoute.NodesGraph,
+ NodesRoute.Nodes,
+ NodesRoute.NodeDetailGraph(destNum = 1234),
+ NodesRoute.NodeDetailGraph(),
+ NodesRoute.NodeDetail(destNum = 5678),
+ NodesRoute.NodeDetail(),
+ // NodeDetailRoute
+ NodeDetailRoute.DeviceMetrics(destNum = 100),
+ NodeDetailRoute.NodeMap(destNum = 100),
+ NodeDetailRoute.PositionLog(destNum = 100),
+ NodeDetailRoute.EnvironmentMetrics(destNum = 100),
+ NodeDetailRoute.SignalMetrics(destNum = 100),
+ NodeDetailRoute.PowerMetrics(destNum = 100),
+ NodeDetailRoute.TracerouteLog(destNum = 100),
+ NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 200, logUuid = "uuid-123"),
+ NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 200),
+ NodeDetailRoute.HostMetricsLog(destNum = 100),
+ NodeDetailRoute.PaxMetrics(destNum = 100),
+ NodeDetailRoute.NeighborInfoLog(destNum = 100),
+ // SettingsRoute
+ SettingsRoute.SettingsGraph(),
+ SettingsRoute.SettingsGraph(destNum = 999),
+ SettingsRoute.Settings(),
+ SettingsRoute.Settings(destNum = 999),
+ SettingsRoute.DeviceConfiguration,
+ SettingsRoute.ModuleConfiguration,
+ SettingsRoute.Administration,
+ SettingsRoute.User,
+ SettingsRoute.ChannelConfig,
+ SettingsRoute.Device,
+ SettingsRoute.Position,
+ SettingsRoute.Power,
+ SettingsRoute.Network,
+ SettingsRoute.Display,
+ SettingsRoute.LoRa,
+ SettingsRoute.Bluetooth,
+ SettingsRoute.Security,
+ SettingsRoute.MQTT,
+ SettingsRoute.Serial,
+ SettingsRoute.ExtNotification,
+ SettingsRoute.StoreForward,
+ SettingsRoute.RangeTest,
+ SettingsRoute.Telemetry,
+ SettingsRoute.CannedMessage,
+ SettingsRoute.Audio,
+ SettingsRoute.RemoteHardware,
+ SettingsRoute.NeighborInfo,
+ SettingsRoute.AmbientLighting,
+ SettingsRoute.DetectionSensor,
+ SettingsRoute.Paxcounter,
+ SettingsRoute.StatusMessage,
+ SettingsRoute.TrafficManagement,
+ SettingsRoute.TAK,
+ SettingsRoute.CleanNodeDb,
+ SettingsRoute.DebugPanel,
+ SettingsRoute.About,
+ SettingsRoute.FilterSettings,
+ // FirmwareRoute
+ FirmwareRoute.FirmwareGraph,
+ FirmwareRoute.FirmwareUpdate,
+ // WifiProvisionRoute
+ WifiProvisionRoute.WifiProvisionGraph,
+ WifiProvisionRoute.WifiProvision(address = "AA:BB:CC:DD:EE:FF"),
+ WifiProvisionRoute.WifiProvision(),
+ )
+
+ @Test
+ fun `all route instances round-trip through SavedState serialization`() {
+ allRouteInstances.forEach { route ->
+ val savedState = encodeToSavedState(route, MeshtasticNavSavedStateConfig)
+ val decoded = decodeFromSavedState(savedState, MeshtasticNavSavedStateConfig)
+ assertEquals(
+ route,
+ decoded,
+ "Round-trip failed for ${route::class.simpleName}: encoded $route but decoded $decoded",
+ )
+ }
+ }
+
+ @Test
+ fun `all sealed route interfaces are represented in the route instances list`() {
+ // Verify we have at least one instance from each sealed route interface.
+ // This catches the case where a new sealed interface is added to Routes.kt
+ // but no instances are added to allRouteInstances above.
+ val representedInterfaces =
+ allRouteInstances
+ .map { route ->
+ when (route) {
+ is ChannelsRoute -> "ChannelsRoute"
+ is ConnectionsRoute -> "ConnectionsRoute"
+ is ContactsRoute -> "ContactsRoute"
+ is MapRoute -> "MapRoute"
+ is NodesRoute -> "NodesRoute"
+ is NodeDetailRoute -> "NodeDetailRoute"
+ is SettingsRoute -> "SettingsRoute"
+ is FirmwareRoute -> "FirmwareRoute"
+ is WifiProvisionRoute -> "WifiProvisionRoute"
+ else -> "Unknown(${route::class.simpleName})"
+ }
+ }
+ .toSet()
+
+ val expectedInterfaces =
+ setOf(
+ "ChannelsRoute",
+ "ConnectionsRoute",
+ "ContactsRoute",
+ "MapRoute",
+ "NodesRoute",
+ "NodeDetailRoute",
+ "SettingsRoute",
+ "FirmwareRoute",
+ "WifiProvisionRoute",
+ )
+
+ assertEquals(
+ expectedInterfaces,
+ representedInterfaces,
+ "Missing sealed route interfaces in test coverage. " +
+ "Missing: ${expectedInterfaces - representedInterfaces}",
+ )
+ }
+
+ @Test
+ fun `route instances with default parameters serialize correctly`() {
+ // Specifically test routes with nullable/default params to catch
+ // serialization issues with optional fields.
+ val routesWithDefaults: List> =
+ listOf(
+ MapRoute.Map() to MapRoute.Map(waypointId = null),
+ NodesRoute.NodeDetailGraph() to NodesRoute.NodeDetailGraph(destNum = null),
+ NodesRoute.NodeDetail() to NodesRoute.NodeDetail(destNum = null),
+ SettingsRoute.SettingsGraph() to SettingsRoute.SettingsGraph(destNum = null),
+ SettingsRoute.Settings() to SettingsRoute.Settings(destNum = null),
+ WifiProvisionRoute.WifiProvision() to WifiProvisionRoute.WifiProvision(address = null),
+ )
+
+ routesWithDefaults.forEach { (defaultInstance, explicitNullInstance) ->
+ assertEquals(
+ defaultInstance,
+ explicitNullInstance,
+ "Default and explicit null should be equal for ${defaultInstance::class.simpleName}",
+ )
+
+ val savedDefault = encodeToSavedState(defaultInstance, MeshtasticNavSavedStateConfig)
+ val savedExplicit = encodeToSavedState(explicitNullInstance, MeshtasticNavSavedStateConfig)
+
+ val decodedDefault = decodeFromSavedState(savedDefault, MeshtasticNavSavedStateConfig)
+ val decodedExplicit = decodeFromSavedState(savedExplicit, MeshtasticNavSavedStateConfig)
+
+ assertEquals(decodedDefault, decodedExplicit)
+ }
+ }
+}