diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index a98bb3a978..86d5532a05 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1699,9 +1699,12 @@ tak_server_status_unavailable tak_server_status_waiting tak_server_test_card_title tak_server_test_idle +tak_server_test_protocol_v1_label +tak_server_test_protocol_v2_label tak_server_test_result_bytes +tak_server_test_result_expected_drop tak_server_test_result_unknown_error -tak_server_test_results +tak_server_test_results_v2 tak_server_test_run tak_server_test_running tak_team diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index c930730dfc..e7d8ba8895 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1747,9 +1747,12 @@ Listening on 127.0.0.1:8089 — waiting for ATAK TAK Mesh Test (Debug) Send all %1$d test fixtures to mesh + V1 (legacy firmware < 2.8.0 — PLI + chat only) + V2 (firmware ≥ 2.8.0) %1$dB ✓ + Not in v1 schema (expected) - %1$d passed, %2$d failed of %3$d/%4$d + %1$d passed, %2$d expected drop, %3$d failed of %4$d/%5$d Run Running: %1$s Team Color diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt index 4fa13911be..c7f6220fb0 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt @@ -47,6 +47,30 @@ import kotlin.random.Random import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes +/** + * Outcome of a single outbound CoT dispatch attempt via [TAKMeshIntegration.sendCoTToMeshV1] / + * [TAKMeshIntegration.sendCoTToMeshV2]. Exposed (internal) so [TakMeshTestRunner] can distinguish an intentional, + * schema-driven drop (e.g. v1's TAKPacket only representing PLI/GeoChat) from a genuine send failure, rather than + * reporting both as the same opaque "failed". + */ +internal sealed interface TakSendOutcome { + /** The CoT was successfully handed to [CommandSender.sendData] as [wireBytes] bytes. */ + data class Sent(val wireBytes: Int) : TakSendOutcome + + /** + * The CoT was never sent because the active protocol's schema/size limits can't represent it. + * + * @param schemaLimited true only when the drop is because the protocol's CoT type coverage doesn't include this + * fixture's type (e.g. v1's TAKPacket only representing PLI/GeoChat) — a known, permanent limitation of that + * protocol version. False for an oversize drop or any other reason: those are real MTU/size problems that happen + * to hit a payload that *is* representable, and must not be reported as an expected/intentional limitation. + */ + data class Dropped(val reason: String, val schemaLimited: Boolean) : TakSendOutcome + + /** The CoT should have been sent but the attempt threw (radio disconnected, queue full, etc). */ + data class Failed(val reason: String) : TakSendOutcome +} + /** * Bidirectional bridge between the local TAK server and the Meshtastic mesh network. * @@ -64,6 +88,7 @@ import kotlin.time.Duration.Companion.minutes * from older nodes in mixed-firmware mesh deployments. */ @OptIn(ExperimentalAtomicApi::class) +@Suppress("TooManyFunctions") class TAKMeshIntegration( private val takServerManager: TAKServerManager, private val commandSender: CommandSender, @@ -173,20 +198,29 @@ class TAKMeshIntegration( return Capabilities(fw).supportsTakV2 } - private suspend fun sendCoTToMesh(cotMessage: CoTMessage) { - if (useTakV2()) { - sendCoTToMeshV2(cotMessage) - } else { - sendCoTToMeshV1(cotMessage) - } + private suspend fun sendCoTToMesh(cotMessage: CoTMessage): TakSendOutcome = if (useTakV2()) { + sendCoTToMeshV2(cotMessage) + } else { + sendCoTToMeshV1(cotMessage) } + /** + * Test-only entry point for [TakMeshTestRunner]'s debug self-test: dispatch [cotMessage] through the real + * production v1/v2 pipeline, but with the protocol explicitly forced via [forceV2] instead of read from the + * connected radio's firmware (as [useTakV2] does). This lets the self-test exercise BOTH real dispatch paths + * deterministically in a single run regardless of which firmware happens to be connected — closing the blind spot + * where the self-test always exercised the v2 pipeline even when a real connected radio on firmware < 2.8.0 would + * silently fall back to the much more limited v1 path. + */ + internal suspend fun sendCoTToMeshForTest(cotMessage: CoTMessage, forceV2: Boolean): TakSendOutcome = + if (forceV2) sendCoTToMeshV2(cotMessage) else sendCoTToMeshV1(cotMessage) + /** * v2 send path (firmware >= 2.8.0): SDK parser + zstd dictionary compression, full typed payload support * (DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task, plus PLI / GeoChat). Wire format: `[flags * byte][zstd-compressed TAKPacketV2 protobuf]` on port 78 (ATAK_PLUGIN_V2). */ - private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage) { + private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage): TakSendOutcome { // Prefer the sourceEventXml for shape/marker/route types — the SDK's // CotXmlParser extracts compact typed payloads (DrawnShape, Marker, // Route, etc.) that compress far better than raw_detail encoding. @@ -236,14 +270,20 @@ class TAKMeshIntegration( } } } - return + return TakSendOutcome.Dropped( + "Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)", + schemaLimited = false, + ) } } catch (e: Exception) { Logger.w(e) { "SDK parser/compressor failed for ${cotMessage.type}, trying app conversion" } val takPacketV2 = cotMessage.toTAKPacketV2() if (takPacketV2 == null) { Logger.w { "Cannot convert CoT type ${cotMessage.type} to TAKPacketV2, dropping" } - return + return TakSendOutcome.Dropped( + "Cannot convert CoT type ${cotMessage.type} to TAKPacketV2", + schemaLimited = false, + ) } try { TakV2Compressor.compress(takPacketV2) @@ -253,7 +293,7 @@ class TAKMeshIntegration( } } - try { + return try { val dataPacket = DataPacket( to = NodeAddress.ID_BROADCAST, @@ -262,6 +302,7 @@ class TAKMeshIntegration( ) commandSender.sendData(dataPacket) Logger.d { "Sent V2 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" } + TakSendOutcome.Sent(wirePayload.size) } catch (e: kotlin.coroutines.cancellation.CancellationException) { throw e } catch (e: Exception) { @@ -269,6 +310,7 @@ class TAKMeshIntegration( Logger.e(e) { "Failed to send TAKPacketV2 to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}" } + TakSendOutcome.Failed(e.message ?: "Send failed") } } @@ -277,7 +319,7 @@ class TAKMeshIntegration( * compression. Only PLI and GeoChat payloads are supported by the v1 schema — shapes, markers, routes, casevac, * emergency, and task CoT events are dropped with a warning. */ - private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage) { + private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage): TakSendOutcome { val takPacket = cotMessage.toTAKPacket() ?: run { @@ -286,7 +328,10 @@ class TAKMeshIntegration( "in v1 TAKPacket schema (only PLI and GeoChat are supported). " + "Upgrade radio firmware to >= 2.8.0 for full payload support." } - return + return TakSendOutcome.Dropped( + "Not representable in v1 TAKPacket schema (only PLI and GeoChat are supported)", + schemaLimited = true, + ) } val wirePayload = TAKPacket.ADAPTER.encode(takPacket) @@ -295,10 +340,10 @@ class TAKMeshIntegration( "Dropping oversized v1 TAK packet: type=${cotMessage.type} " + "size=${wirePayload.size}B max=$MAX_TAK_WIRE_PAYLOAD_BYTES" } - return + return TakSendOutcome.Dropped("Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)", schemaLimited = false) } - try { + return try { val dataPacket = DataPacket( to = NodeAddress.ID_BROADCAST, @@ -307,12 +352,14 @@ class TAKMeshIntegration( ) commandSender.sendData(dataPacket) Logger.d { "Sent V1 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" } + TakSendOutcome.Sent(wirePayload.size) } catch (e: kotlin.coroutines.cancellation.CancellationException) { throw e } catch (e: Exception) { Logger.e(e) { "Failed to send v1 TAKPacket to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}" } + TakSendOutcome.Failed(e.message ?: "Send failed") } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt index 01752df81b..a53571db89 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt @@ -24,11 +24,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.NodeAddress -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.proto.PortNum + +/** Which outbound TAK protocol a [TakTestResult] was dispatched through. See [TAKMeshIntegration]. */ +enum class TakProtocol { + /** Firmware >= 2.8.0: zstd-compressed TAKPacketV2 on port 78, full typed CoT support. */ + V2, + + /** Firmware <= 2.7.x: bare-protobuf legacy TAKPacket on port 72, PLI + GeoChat only. */ + V1, +} /** Result of sending a single test fixture through the TAK mesh pipeline. */ data class TakTestResult( @@ -37,15 +41,26 @@ data class TakTestResult( val compressedBytes: Int, val passed: Boolean, val error: String? = null, + val protocol: TakProtocol = TakProtocol.V2, + // Mirrors TakSendOutcome.Dropped.schemaLimited: true only when this fixture was dropped because the active + // protocol's CoT type coverage doesn't include it (e.g. v1's TAKPacket only representing PLI/GeoChat) — a + // known, permanent limitation of that protocol, so this is expected/intentional behavior, not a self-test + // regression. False for an oversize drop (a real MTU problem, even if it happened on v1) or any failure — + // never set purely because the result's protocol is V1. + val expectedDrop: Boolean = false, ) /** - * Debug-only test runner that sends the SDK's CoT XML test fixtures through the real TAK mesh pipeline: strip → parse → - * compress → send to mesh radio. + * Debug-only test runner that sends the SDK's CoT XML test fixtures through the REAL TAK mesh pipeline: strip → parse → + * dispatch, via [TAKMeshIntegration.sendCoTToMeshForTest]. Each fixture is run through BOTH the v2 (firmware >= 2.8.0) + * and v1 (legacy) dispatch paths so the self-test's pass/fail rate reflects what a real connected radio would actually + * do on either firmware generation, rather than always exercising the v2 pipeline regardless of the connected radio's + * firmware — see [TAKMeshIntegration.useTakV2]. * - * Paces sends by waiting [sendDelayMs] between each fixture to avoid flooding the radio's TX queue. + * Paces sends by waiting [sendDelayMs] between each successfully-sent fixture to avoid flooding the radio's TX queue. + * Fixtures that are dropped (not sent) incur no delay. */ -class TakMeshTestRunner(private val commandSender: CommandSender) { +class TakMeshTestRunner(private val takMeshIntegration: TAKMeshIntegration) { private val _results = MutableStateFlow>(emptyList()) val results: StateFlow> = _results.asStateFlow() @@ -118,8 +133,8 @@ class TakMeshTestRunner(private val commandSender: CommandSender) { } /** - * Run all test fixtures sequentially, sending each through the mesh pipeline. Updates [results] and - * [currentFixture] as each fixture is processed. + * Run all test fixtures sequentially through both the v2 and v1 dispatch paths. Updates [results] and + * [currentFixture] as each fixture is processed. [results] accumulates v2 runs first, then v1 runs. */ suspend fun runAll() { // Use tryLock to prevent concurrent test runs: if another coroutine is already @@ -133,74 +148,95 @@ class TakMeshTestRunner(private val commandSender: CommandSender) { val allResults = mutableListOf() - for (name in FIXTURE_NAMES) { - _currentFixture.value = name - val result = runSingleFixture(name) - allResults.add(result) - _results.value = allResults.toList() + for (protocol in listOf(TakProtocol.V2, TakProtocol.V1)) { + for (name in FIXTURE_NAMES) { + _currentFixture.value = "$name (${protocol.name.lowercase()})" + val result = runSingleFixture(name, protocol) + allResults.add(result) + _results.value = allResults.toList() - if (result.passed) { - // Wait for radio airtime + ACK before next send - delay(SEND_DELAY_MS) + if (result.passed) { + // Wait for radio airtime + ACK before next send. Dropped fixtures never + // reached the radio, so there's nothing to pace. + delay(SEND_DELAY_MS) + } } } _currentFixture.value = null - val passed = allResults.count { it.passed } - val failed = allResults.size - passed - Logger.i { "TAK Mesh Test complete: $passed/${allResults.size} passed, $failed failed" } + val v2 = allResults.filter { it.protocol == TakProtocol.V2 } + val v1 = allResults.filter { it.protocol == TakProtocol.V1 } + val v2Passed = v2.count { it.passed } + val v1Passed = v1.count { it.passed } + val v1ExpectedDrops = v1.count { it.expectedDrop } + Logger.i { + "TAK Mesh Test complete: v2=$v2Passed/${v2.size} passed; " + + "v1=$v1Passed/${v1.size} passed ($v1ExpectedDrops expected drops — " + + "v1's legacy schema only supports PLI/GeoChat)" + } } finally { _isRunning.value = false runMutex.unlock() } } - private suspend fun runSingleFixture(name: String): TakTestResult { + private suspend fun runSingleFixture(name: String, protocol: TakProtocol): TakTestResult { + val forceV2 = protocol == TakProtocol.V2 + // Load fixture XML from bundled resources via platform-specific loader val xml = try { loadTakFixtureXml(name) } catch (e: Exception) { Logger.w(e) { "Failed to load fixture $name" } - return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}") + return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}", protocol) } - // Apply the same pipeline as TAKMeshIntegration.sendCoTToMesh() - val freshXml = TAKMeshIntegration.ensureMinimumStaleForMesh(xml) - val strippedXml = TAKMeshIntegration.stripNonEssentialElements(freshXml) - - // Parse and compress via SDK - val wirePayload: ByteArray - try { - val result = TakSdkCompressor.compressCoT(strippedXml, MAX_TAK_WIRE_PAYLOAD_BYTES) - val compressed = result.wirePayload - if (compressed == null) { - Logger.w { "TAK Test: $name oversized even without remarks (xml=${xml.length}B)" } - return TakTestResult(name, xml.length, 0, false, "Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)") + // Parse into the same CoTMessage shape a real inbound TAK-client message would produce + // (sourceEventXml preserved) — see CoTXmlParser.buildCoTMessage. + val cotMessage = + CoTXmlParser(xml).parse().getOrElse { e -> + Logger.w(e) { "TAK Test: $name failed to parse as CoT XML: ${e.message}" } + return TakTestResult(name, xml.length, 0, false, "Parse failed: ${e.message}", protocol) } - wirePayload = compressed - } catch (e: Exception) { - Logger.w(e) { "TAK Test: $name compression failed: ${e.message}" } - return TakTestResult(name, xml.length, 0, false, "Compress failed: ${e.message}") - } - // Send to mesh - try { - val dataPacket = - DataPacket( - to = NodeAddress.ID_BROADCAST, - bytes = wirePayload.toByteString(), - dataType = PortNum.ATAK_PLUGIN_V2.value, - ) - commandSender.sendData(dataPacket) - Logger.i { "TAK Test: $name → ${wirePayload.size}B (xml=${xml.length}B)" } - return TakTestResult(name, xml.length, wirePayload.size, true) + // Dispatch through the REAL production pipeline (TAKMeshIntegration.sendCoTToMeshV1/V2), + // with the protocol forced rather than read from the connected radio's firmware. + return try { + when (val outcome = takMeshIntegration.sendCoTToMeshForTest(cotMessage, forceV2)) { + is TakSendOutcome.Sent -> { + Logger.i { "TAK Test: $name → ${outcome.wireBytes}B (xml=${xml.length}B) via $protocol" } + TakTestResult(name, xml.length, outcome.wireBytes, true, protocol = protocol) + } + + is TakSendOutcome.Dropped -> { + Logger.w { "TAK Test: $name dropped on $protocol: ${outcome.reason}" } + TakTestResult( + fixtureName = name, + xmlBytes = xml.length, + compressedBytes = 0, + passed = false, + error = outcome.reason, + protocol = protocol, + // Only a schema-coverage drop is an expected/intentional limitation (v1's legacy + // schema legitimately supports only PLI/GeoChat). An oversize drop is a real MTU + // problem regardless of which protocol hit it, and must never be mislabeled as + // expected just because it happened on v1. + expectedDrop = outcome.schemaLimited, + ) + } + + is TakSendOutcome.Failed -> { + Logger.w { "TAK Test: $name send failed on $protocol: ${outcome.reason}" } + TakTestResult(name, xml.length, 0, false, outcome.reason, protocol) + } + } } catch (e: kotlin.coroutines.cancellation.CancellationException) { throw e } catch (e: Exception) { Logger.w(e) { "TAK Test: $name send failed: ${e.message}" } - return TakTestResult(name, xml.length, wirePayload.size, false, "Send failed: ${e.message}") + TakTestResult(name, xml.length, 0, false, "Send failed: ${e.message}", protocol) } } } diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index 003ed61e8d..f579b9d101 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -48,6 +48,7 @@ import org.meshtastic.proto.TAKPacket import org.meshtastic.proto.User import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes @@ -331,6 +332,42 @@ class TAKMeshIntegrationTest { assertTrue(h.commandSender.sentPackets.isEmpty()) } + // ── Dropped-outcome discrimination (regression: #6583 self-test blind spot) ──────── + + @Test + fun `v1 drop of an unsupported CoT type is schema-limited`() = runTest(UnconfinedTestDispatcher()) { + // a-h-G is a shape/marker type the legacy v1 TAKPacket schema has no field for at all — + // this is the "permanent limitation" case, not a size problem. + val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0")) + val marker = CoTMessage(uid = "marker-1", type = "a-h-G", stale = Clock.System.now() + 5.minutes) + + val outcome = h.integration.sendCoTToMeshForTest(marker, forceV2 = false) + + val dropped = assertNotNull(outcome as? TakSendOutcome.Dropped, "expected a Dropped outcome, got $outcome") + assertTrue(dropped.schemaLimited, "an unsupported CoT type must be reported as schema-limited") + } + + @Test + fun `v1 drop of an oversize but schema-representable PLI is NOT schema-limited`() = + runTest(UnconfinedTestDispatcher()) { + // a-f-G (PLI) IS representable in the v1 schema — this must be dropped for size, not + // mislabeled as an expected schema gap. This is the exact blind spot the self-test's + // expectedDrop flag has to avoid: an MTU problem hiding behind "expected" v1 behavior. + val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0")) + val oversizePli = + CoTMessage( + uid = "pli-1", + type = "a-f-G-U-C", + stale = Clock.System.now() + 5.minutes, + contact = CoTContact(callsign = "X".repeat(500)), + ) + + val outcome = h.integration.sendCoTToMeshForTest(oversizePli, forceV2 = false) + + val dropped = assertNotNull(outcome as? TakSendOutcome.Dropped, "expected a Dropped outcome, got $outcome") + assertTrue(!dropped.schemaLimited, "an oversize drop of a representable type must not be schema-limited") + } + // ── GeoChat callsign enrichment ────────────────────────────────────────── @Test diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakMeshTestRunnerTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakMeshTestRunnerTest.kt new file mode 100644 index 0000000000..1be745fe4f --- /dev/null +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TakMeshTestRunnerTest.kt @@ -0,0 +1,180 @@ +/* + * 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.takserver + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.repository.RadioSessionContext +import org.meshtastic.core.testing.FakeCommandSender +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.core.testing.FakeTakPrefs +import org.meshtastic.core.testing.TestDataFactory +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.LoRaRegionPresetMap +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.PortNum +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Regression coverage for the self-test blind spot found while investigating #6583: [TakMeshTestRunner] must exercise + * the SAME real dispatch pipeline ([TAKMeshIntegration.sendCoTToMeshV1] / `sendCoTToMeshV2`) that live TAK traffic + * uses, not a hardcoded v2-only shortcut — and it must do so for BOTH protocol versions regardless of whichever + * firmware a test radio happens to report, since [TAKMeshIntegration.useTakV2] gates real traffic on the connected + * radio's firmware and older firmware silently falls back to the much more limited v1 schema. + */ +class TakMeshTestRunnerTest { + + private class FakeMeshConfigHandler : MeshConfigHandler { + override val localConfig = MutableStateFlow(LocalConfig()) + override val moduleConfig = MutableStateFlow(LocalModuleConfig()) + + override fun handleDeviceConfig(config: Config, session: RadioSessionContext) = true + + override fun handleModuleConfig(config: ModuleConfig, session: RadioSessionContext) = true + + override fun handleChannel(channel: Channel, session: RadioSessionContext) = true + + override fun handleDeviceUIConfig(config: DeviceUIConfig, session: RadioSessionContext) = true + + override fun handleRegionPresets(map: LoRaRegionPresetMap, session: RadioSessionContext) = true + } + + private class Harness(firmwareVersion: String?) { + val serverManager = FakeTAKServerManager() + val commandSender = FakeCommandSender() + val serviceRepository = FakeServiceRepository() + val meshConfigHandler = FakeMeshConfigHandler() + val nodeRepository = + FakeNodeRepository().apply { + setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = firmwareVersion)) + } + val takPrefs = FakeTakPrefs() + private val dispatcher = UnconfinedTestDispatcher() + val broadcaster = + MeshToCotBroadcaster( + serverManager, + nodeRepository, + takPrefs, + CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher), + ) + + val integration = + TAKMeshIntegration( + takServerManager = serverManager, + commandSender = commandSender, + serviceRepository = serviceRepository, + meshConfigHandler = meshConfigHandler, + nodeRepository = nodeRepository, + meshToCotBroadcaster = broadcaster, + ) + + val runner = TakMeshTestRunner(integration) + } + + // ── runAll() covers both protocols ────────────────────────────────────── + + @Test + fun `runAll exercises every fixture on both v1 and v2`() = runTest(UnconfinedTestDispatcher()) { + // Firmware is irrelevant here — the runner forces the protocol per pass rather than + // reading Capabilities.supportsTakV2, which is exactly the bug this test guards against. + val h = Harness(firmwareVersion = null) + + h.runner.runAll() + + val results = h.runner.results.value + assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size * 2, results.size) + assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size, results.count { it.protocol == TakProtocol.V2 }) + assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size, results.count { it.protocol == TakProtocol.V1 }) + } + + @Test + fun `v2 pass sends on ATAK_PLUGIN_V2 regardless of connected firmware`() = runTest(UnconfinedTestDispatcher()) { + // Connected "radio" reports legacy firmware — if the runner still read useTakV2() instead of + // forcing the protocol, this would incorrectly downgrade the v2 pass to v1. + val h = Harness(firmwareVersion = "2.7.0.0") + + h.runner.runAll() + + val v2Results = h.runner.results.value.filter { it.protocol == TakProtocol.V2 } + val v2Sent = h.commandSender.sentPackets.count { it.dataType == PortNum.ATAK_PLUGIN_V2.value } + assertEquals(v2Results.count { it.passed }, v2Sent, "every passing v2 result must correspond to a v2 send") + assertTrue(v2Sent > 0, "at least one fixture must round-trip through the real v2 pipeline") + } + + @Test + fun `v1 pass sends on legacy ATAK_PLUGIN regardless of connected firmware`() = runTest(UnconfinedTestDispatcher()) { + // Connected "radio" reports v2-capable firmware — if the runner still read useTakV2() instead + // of forcing the protocol, this would incorrectly upgrade the v1 pass to v2, hiding the exact + // blind spot reported in #6583. + val h = Harness(firmwareVersion = "2.8.0.0") + + h.runner.runAll() + + val v1Results = h.runner.results.value.filter { it.protocol == TakProtocol.V1 } + val v1Sent = h.commandSender.sentPackets.count { it.dataType == PortNum.ATAK_PLUGIN.value } + assertEquals(v1Results.count { it.passed }, v1Sent, "every passing v1 result must correspond to a v1 send") + assertTrue(v1Sent > 0, "PLI/GeoChat fixtures must still round-trip through the real v1 pipeline") + } + + @Test + fun `v1 pass reports non-PLI non-chat drops as expected not as failures`() = runTest(UnconfinedTestDispatcher()) { + val h = Harness(firmwareVersion = null) + + h.runner.runAll() + + val v1Results = h.runner.results.value.filter { it.protocol == TakProtocol.V1 } + // marker_spot.xml is type "b-m-p-s-m" — not representable in the legacy v1 TAKPacket + // schema (only PLI/"a-f-*" and chat/"b-t-f" are). This must show up as an *expected* + // drop, not an unlabeled self-test failure, per the v1 schema's documented limitations. + val markerResult = v1Results.single { it.fixtureName == "marker_spot.xml" } + assertTrue(markerResult.expectedDrop, "v1 dropping an unsupported CoT type is expected, not a bug") + assertTrue(!markerResult.passed) + + // Every v1 drop must be accounted for: either the known schema-coverage limitation + // (expectedDrop) or an oversize drop, which is a real MTU problem and correctly NOT + // labeled expected even though it happened on v1 — see TakSendOutcome.Dropped.schemaLimited. + // A drop that is neither would be a genuine, unaccounted-for failure. + assertTrue( + v1Results.filter { !it.passed }.all { it.expectedDrop || it.error?.startsWith("Oversized") == true }, + "every v1 drop in a clean run should be the known schema limitation or an oversize drop, " + + "not an unaccounted-for failure", + ) + } + + @Test + fun `v2 pass has no expected drops - v2 must support every fixture type`() = runTest(UnconfinedTestDispatcher()) { + val h = Harness(firmwareVersion = null) + + h.runner.runAll() + + val v2Results = h.runner.results.value.filter { it.protocol == TakProtocol.V2 } + assertTrue( + v2Results.none { it.expectedDrop }, + "v2 has no known schema gaps, so nothing should be labeled expected", + ) + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt index cc18c69a0e..115fb55c04 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt @@ -47,13 +47,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.koinInject import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.model.getColorFrom import org.meshtastic.core.model.getStringResFrom -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.back @@ -79,15 +79,20 @@ import org.meshtastic.core.resources.tak_server_status_unavailable import org.meshtastic.core.resources.tak_server_status_waiting import org.meshtastic.core.resources.tak_server_test_card_title import org.meshtastic.core.resources.tak_server_test_idle +import org.meshtastic.core.resources.tak_server_test_protocol_v1_label +import org.meshtastic.core.resources.tak_server_test_protocol_v2_label import org.meshtastic.core.resources.tak_server_test_result_bytes +import org.meshtastic.core.resources.tak_server_test_result_expected_drop import org.meshtastic.core.resources.tak_server_test_result_unknown_error -import org.meshtastic.core.resources.tak_server_test_results +import org.meshtastic.core.resources.tak_server_test_results_v2 import org.meshtastic.core.resources.tak_server_test_run import org.meshtastic.core.resources.tak_server_test_running import org.meshtastic.core.resources.tak_team import org.meshtastic.core.takserver.TAKDataPackageGenerator +import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServerManager import org.meshtastic.core.takserver.TakMeshTestRunner +import org.meshtastic.core.takserver.TakProtocol import org.meshtastic.core.takserver.TakTestResult import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.SwitchPreference @@ -392,8 +397,8 @@ private fun TakMeshTestCard() { val buildConfig: BuildConfigProvider = koinInject() if (!buildConfig.isDebug) return - val commandSender: CommandSender = koinInject() - val testRunner = remember { TakMeshTestRunner(commandSender) } + val takMeshIntegration: TAKMeshIntegration = koinInject() + val testRunner = remember { TakMeshTestRunner(takMeshIntegration) } val results by testRunner.results.collectAsStateWithLifecycle() val isRunning by testRunner.isRunning.collectAsStateWithLifecycle() val currentFixture by testRunner.currentFixture.collectAsStateWithLifecycle() @@ -418,8 +423,8 @@ internal fun TakMeshTestCardContent( onRunTests: () -> Unit, ) { val loadingLabel = stringResource(Res.string.tak_server_loading) - val passed = results.count { it.passed } - val failed = results.count { !it.passed } + val v2Results = results.filter { it.protocol == TakProtocol.V2 } + val v1Results = results.filter { it.protocol == TakProtocol.V1 } TitledCard(title = stringResource(Res.string.tak_server_test_card_title)) { Row( @@ -437,25 +442,6 @@ internal fun TakMeshTestCardContent( }, style = MaterialTheme.typography.bodyLarge, ) - if (results.isNotEmpty()) { - Text( - text = - stringResource( - Res.string.tak_server_test_results, - passed, - failed, - results.size, - fixtureCount, - ), - style = MaterialTheme.typography.bodySmall, - color = - if (failed > 0) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } } if (isRunning) { CircularProgressIndicator() @@ -467,7 +453,58 @@ internal fun TakMeshTestCardContent( } } - // Results list + if (v2Results.isNotEmpty()) { + TakProtocolResultsSection( + titleRes = Res.string.tak_server_test_protocol_v2_label, + results = v2Results, + fixtureCount = fixtureCount, + ) + } + if (v1Results.isNotEmpty()) { + TakProtocolResultsSection( + titleRes = Res.string.tak_server_test_protocol_v1_label, + results = v1Results, + fixtureCount = fixtureCount, + ) + } + } +} + +/** + * One protocol's fixture run within the TAK self-test card: a summary line (passed / expected-limitation drops / + * unexpected failures) followed by the per-fixture rows. Expected v1 schema drops are surfaced distinctly from real + * failures so a low v1 pass rate reads as intentional legacy-schema behavior, not a self-test regression. + */ +@Composable +private fun TakProtocolResultsSection(titleRes: StringResource, results: List, fixtureCount: Int) { + val passed = results.count { it.passed } + val expectedDrops = results.count { !it.passed && it.expectedDrop } + val unexpectedFailed = results.count { !it.passed && !it.expectedDrop } + + Column { + HorizontalDivider() + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(text = stringResource(titleRes), style = MaterialTheme.typography.titleSmall) + Text( + text = + stringResource( + Res.string.tak_server_test_results_v2, + passed, + expectedDrops, + unexpectedFailed, + results.size, + fixtureCount, + ), + style = MaterialTheme.typography.bodySmall, + color = + if (unexpectedFailed > 0) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + for (result in results) { HorizontalDivider() Row( @@ -482,13 +519,21 @@ internal fun TakMeshTestCardContent( ) Text( text = - if (result.passed) { - stringResource(Res.string.tak_server_test_result_bytes, result.compressedBytes) - } else { - result.error ?: stringResource(Res.string.tak_server_test_result_unknown_error) + when { + result.passed -> + stringResource(Res.string.tak_server_test_result_bytes, result.compressedBytes) + + result.expectedDrop -> stringResource(Res.string.tak_server_test_result_expected_drop) + + else -> result.error ?: stringResource(Res.string.tak_server_test_result_unknown_error) }, style = MaterialTheme.typography.bodySmall, - color = if (result.passed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + color = + when { + result.passed -> MaterialTheme.colorScheme.primary + result.expectedDrop -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.error + }, ) } } diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png index 124c1956ab..9544204f35 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png index 8ac5d46040..0c253506ae 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardResults_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png index 06115f878d..3b36fd017a 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png index 113b8c49e3..72ceeefa07 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakTestCardRunning_Light_b29dc7a7_0.png differ