Resolve merge conflicts for PR #1603 (TAK server improvements) (#1645)

* Delete Messages fix

* Bump version to 2.7.9

* Bump widgets version

* TAK Server channel index picker

Create a settings picker for the TAK Server's channel index. This allows users to specify TAK traffic to use the non-primary channel to help reduce channel congestion.

* Changed capitalization from 'environment' to 'Environment' for section header. (#1591)

* Add Danish (da) translations — resolves merge conflicts from PR #1609 (#1612)

* Initial plan

* Add Danish (da) translations from PR #1609

Resolves merge conflicts from PR #1609 by adding Danish translations to the
Localizable.xcstrings file. The PR adds Danish translation strings throughout
the app while preserving all existing translations for other languages.

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Migrate test project to Swift Testing and add connect view and router tests (#1643)

* Migrate to Swift Testing and add connect view tests

- Convert RouterTests.swift from XCTest to Swift Testing (@Suite, @Test, #expect, #require)
- Create ConnectViewTests.swift with tests for connect view child types:
  - Device struct (creation, signal strength, RSSI, description, codable)
  - TransportType enum (cases, raw values, codable)
  - ConnectionState enum (equality, codable)
  - BLESignalStrength enum (raw values, init)
  - TransportStatus enum (equality)
  - NavigationState (defaults, tabs, sub-states)
  - InvalidVersion view (creation with versions)
  - ConnectedDevice view (connected/disconnected/MQTT states)
  - CircleText view (default/custom sizes, emoji)
  - BatteryCompact view (levels, nil, charging, plugged in)
  - SignalStrengthIndicator view (dimensions, strength levels)
- Update Xcode project to include new test file

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/d7bb7a89-2105-4fcb-96bc-7ec794467c74

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Fix signal strength test boundary conditions

The getSignalStrength() method uses NSNumber.compare(.orderedDescending),
which is a strict greater-than check. Fix the boundary test cases:
- RSSI -65 is .normal (not .strong), since -65 is not > -65
- RSSI -85 is .weak (not .normal), since -85 is not > -85
- Add -64 → .strong and -84 → .normal as adjacent boundary tests

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/4fcbc01e-cbea-4d11-b2c0-e923c6730d69

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Improve and complete router tests with comprehensive coverage

Added tests for:
- Custom initial state
- Invalid scheme / unknown path handling (state unchanged)
- navigateToNodeDetail public method
- Messages edge cases: channelId only, userNum only, messageId only, non-numeric params
- Nodes with non-numeric nodenum
- Map with both nodenum+waypointId (nodeId priority), non-numeric params
- Parameterized settings test covering all 31 SettingsNavigationState cases
- State transitions: consecutive routes, invalid scheme preserves existing state

Agent-Logs-Url: https://github.com/meshtastic/Meshtastic-Apple/sessions/f69b7352-21aa-494c-8864-31fc0f4b21b8

Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* Localizable update

* Merge translations file

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>

* Fix merge conflicts in PR #1614 (Spanish translations) (#1644)

* 20% of strings translated to spanish

* add more translations

* add rest of translations

* small fixes

---------

Co-authored-by: Joel Pérez Izquierdo <joelperez91@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>

* fix typo in hop limit option description (#1631)

O hop -> 0 hop

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: Garth Vander Houwen <garthvh@yahoo.com>
Co-authored-by: niccellular <79813408+niccellular@users.noreply.github.com>
Co-authored-by: Austin Hargis <25471876+austinhargis@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
Co-authored-by: Joel Pérez Izquierdo <joelperez91@gmail.com>
Co-authored-by: axunes <axunes@axunes.net>
This commit is contained in:
Copilot 2026-04-02 08:28:37 -07:00 committed by GitHub
parent 6ee6579cdb
commit 54ff386c03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 13949 additions and 185 deletions

File diff suppressed because it is too large Load diff

View file

@ -82,6 +82,7 @@
25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; };
25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; };
25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; };
AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; };
2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; };
300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; };
3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; };
@ -411,6 +412,7 @@
25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = "<group>"; };
AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = "<group>"; };
2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = "<group>"; };
3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = "<group>"; };
3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = "<group>"; };
@ -900,6 +902,7 @@
25F5D5C82C4375A8008036E3 /* MeshtasticTests */ = {
isa = PBXGroup;
children = (
AA00010022E2730EC0060000 /* ConnectViewTests.swift */,
25F5D5D02C4375DF008036E3 /* RouterTests.swift */,
);
path = MeshtasticTests;
@ -1657,6 +1660,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */,
25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -2170,7 +2174,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.8;
MARKETING_VERSION = 2.7.9;
OTHER_LDFLAGS = (
"-weak_framework",
SwiftUI,
@ -2209,7 +2213,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.8;
MARKETING_VERSION = 2.7.9;
OTHER_LDFLAGS = (
"-weak_framework",
SwiftUI,
@ -2245,7 +2249,7 @@
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.8;
MARKETING_VERSION = 2.7.9;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -2278,7 +2282,7 @@
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.8;
MARKETING_VERSION = 2.7.9;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -157,6 +157,8 @@ final class TAKMeshtasticBridge {
return
}
let channel = UInt32(TAKServerManager.shared.channel)
// Determine send method based on CoT type
let sendMethod = GenericCoTHandler.shared.classifySendMethod(for: cotMessage)
@ -169,7 +171,7 @@ final class TAKMeshtasticBridge {
}
do {
try await accessoryManager.sendTAKPacket(takPacket)
try await accessoryManager.sendTAKPacket(takPacket, channel: channel)
Logger.tak.info("Sent TAKPacket to mesh: \(cotMessage.type)")
} catch {
Logger.tak.error("Failed to send TAKPacket to mesh: \(error.localizedDescription)")
@ -179,7 +181,7 @@ final class TAKMeshtasticBridge {
// Use EXI compression on ATAK_FORWARDER port (257)
GenericCoTHandler.shared.accessoryManager = accessoryManager
do {
try await GenericCoTHandler.shared.sendGenericCoT(cotMessage)
try await GenericCoTHandler.shared.sendGenericCoT(cotMessage, channel: channel)
Logger.tak.info("Sent generic CoT to mesh via ATAK_FORWARDER: \(cotMessage.type)")
} catch {
Logger.tak.error("Failed to send generic CoT to mesh: \(error.localizedDescription)")

View file

@ -72,6 +72,8 @@ final class TAKServerManager: ObservableObject {
// MARK: - Configuration (persisted via AppStorage)
@AppStorage("takServerChannel") var channel: Int = 0
@AppStorage("takServerEnabled") var enabled = false {
didSet {
Task {

View file

@ -156,10 +156,16 @@ extension MeshPackets {
nonisolated public func deleteChannelMessages(channel: ChannelEntity, context: NSManagedObjectContext) {
do {
let objects = channel.allPrivateMessages
// Copied logic from ChannelEntity.allPrivateMessages, which is always on the MainActor
// But this code may not be on the MainActor.
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", channel.index)
let objects = (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
for object in objects {
context.delete(object)
}
try context.save()
} catch let error as NSError {
Logger.data.error("\(error.localizedDescription, privacy: .public)")

View file

@ -162,8 +162,13 @@ struct ChannelList: View {
Button(role: .destructive) {
Task {
await MeshPackets.shared.deleteChannelMessages(channel: channelToDeleteMessages!)
context.refresh(myInfo, mergeChanges: true)
channelToDeleteMessages = nil
await MainActor.run {
context.refresh(channel, mergeChanges: true)
context.refresh(myInfo, mergeChanges: true)
// Reset state
channelToDeleteMessages = nil
}
}
} label: {
Text("Delete")

View file

@ -70,7 +70,7 @@ struct AppSettings: View {
}
#endif
}
Section(header: Text("environment")) {
Section(header: Text("Environment")) {
VStack(alignment: .leading) {
Toggle(isOn: $environmentEnableWeatherKit) {
Label("Weather Conditions", systemImage: "cloud.sun")

View file

@ -142,7 +142,7 @@ struct LoRaConfig: View {
.tag($0)
}
}
Text("Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. O hop broadcast messages will not get ACKs.")
Text("Sets the maximum number of hops, default is 3. Increasing hops also increases congestion and should be used carefully. 0 hop broadcast messages will not get ACKs.")
.foregroundColor(.gray)
.font(.callout)
}

View file

@ -8,6 +8,7 @@
import SwiftUI
import UniformTypeIdentifiers
import OSLog
import CoreData
enum CertificateImportType {
case p12
@ -15,6 +16,15 @@ enum CertificateImportType {
}
struct TAKServerConfig: View {
@Environment(\.managedObjectContext) var context
@EnvironmentObject var accessoryManager: AccessoryManager
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \ChannelEntity.index, ascending: true)],
predicate: NSPredicate(format: "role > 0"),
animation: .default
) private var channels: FetchedResults<ChannelEntity>
@StateObject private var takServer = TAKServerManager.shared
@EnvironmentObject var accessoryManager: AccessoryManager
@Environment(\.managedObjectContext) var context
@ -267,6 +277,17 @@ struct TAKServerConfig: View {
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
if !channels.isEmpty {
Picker(selection: $takServer.channel) {
ForEach(channels, id: \.index) { channel in
channelLabel(channel)
.tag(Int(channel.index))
}
} label: {
Label("TAK Channel Index", systemImage: "bubble.left.and.bubble.right")
}
}
if takServer.isRunning {
Button {
Task {
@ -279,7 +300,7 @@ struct TAKServerConfig: View {
} header: {
Text("Configuration")
} footer: {
Text("Secure mTLS connection on port 8089. Both server and client certificates are required.")
Text("Secure mTLS connection on port 8089. Both server and client certificates are required. TAK Channel Index selects the channel index where TAK messages will be sent.")
}
}
@ -407,6 +428,21 @@ struct TAKServerConfig: View {
}
// MARK: - Channel Label
@ViewBuilder
private func channelLabel(_ channel: ChannelEntity) -> some View {
if channel.name?.isEmpty ?? false {
if channel.role == 1 {
Text(String("PrimaryChannel").camelCaseToWords())
} else {
Text(String("Channel \(channel.index)").camelCaseToWords())
}
} else {
Text(String(channel.name ?? "Channel \(channel.index)").camelCaseToWords())
}
}
// MARK: - Import Handlers
private func handleP12Import(_ result: Result<[URL], Error>) {

View file

@ -0,0 +1,493 @@
import Foundation
import SwiftUI
import Testing
@testable import Meshtastic
// MARK: - Device Tests
@Suite("Device")
struct DeviceTests {
static let testUUID = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
@Test func creation() {
let device = Device(
id: DeviceTests.testUUID,
name: "Test Radio",
transportType: .ble,
identifier: "BLE-001"
)
#expect(device.id == DeviceTests.testUUID)
#expect(device.name == "Test Radio")
#expect(device.transportType == .ble)
#expect(device.identifier == "BLE-001")
#expect(device.connectionState == .disconnected)
#expect(device.rssi == nil)
#expect(device.num == nil)
#expect(device.wasRestored == false)
#expect(device.isManualConnection == false)
}
@Test func creationWithAllProperties() {
let device = Device(
id: DeviceTests.testUUID,
name: "Full Radio",
transportType: .tcp,
identifier: "192.168.1.1:4403",
connectionState: .connected,
rssi: -60,
num: 123456,
wasRestored: true,
isManualConnection: true
)
#expect(device.connectionState == .connected)
#expect(device.rssi == -60)
#expect(device.num == 123456)
#expect(device.wasRestored == true)
#expect(device.isManualConnection == true)
}
@Test(arguments: [
(-50, BLESignalStrength.strong),
(-64, BLESignalStrength.strong),
(-65, BLESignalStrength.normal),
(-80, BLESignalStrength.normal),
(-84, BLESignalStrength.normal),
(-85, BLESignalStrength.weak),
(-100, BLESignalStrength.weak),
])
func signalStrength(rssi: Int, expected: BLESignalStrength) {
let device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001",
rssi: rssi
)
#expect(device.getSignalStrength() == expected)
}
@Test func signalStrengthNilWhenNoRSSI() {
let device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001"
)
#expect(device.getSignalStrength() == nil)
}
@Test func rssiStringWithValue() {
var device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001",
rssi: -72
)
#expect(device.rssiString == "-72 dBm")
device.rssi = -100
#expect(device.rssiString == "-100 dBm")
}
@Test func rssiStringWithoutValue() {
let device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001"
)
#expect(device.rssiString == "n/a")
}
@Test func descriptionWithBothNames() {
var device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001"
)
device.shortName = "TST"
device.longName = "Test Node"
#expect(device.description == "Test Node (TST)")
}
@Test func descriptionWithShortNameOnly() {
var device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001"
)
device.shortName = "TST"
#expect(device.description == "TST")
}
@Test func descriptionWithLongNameOnly() {
var device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001"
)
device.longName = "Test Node"
#expect(device.description == "Test Node")
}
@Test func descriptionWithNoNames() {
let device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001"
)
#expect(device.description == "Device(id: \(DeviceTests.testUUID))")
}
@Test func hashEquality() {
let device1 = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001"
)
let device2 = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001"
)
#expect(device1 == device2)
#expect(device1.hashValue == device2.hashValue)
}
@Test func codableRoundTrip() throws {
var device = Device(
id: DeviceTests.testUUID,
name: "Radio",
transportType: .ble,
identifier: "BLE-001",
connectionState: .connected,
rssi: -70,
num: 99
)
device.shortName = "RDO"
device.longName = "My Radio"
device.firmwareVersion = "2.5.0"
let data = try JSONEncoder().encode(device)
let decoded = try JSONDecoder().decode(Device.self, from: data)
#expect(decoded.id == device.id)
#expect(decoded.name == device.name)
#expect(decoded.transportType == device.transportType)
#expect(decoded.identifier == device.identifier)
#expect(decoded.connectionState == device.connectionState)
#expect(decoded.rssi == device.rssi)
#expect(decoded.num == device.num)
#expect(decoded.shortName == device.shortName)
#expect(decoded.longName == device.longName)
#expect(decoded.firmwareVersion == device.firmwareVersion)
}
}
// MARK: - TransportType Tests
@Suite("TransportType")
struct TransportTypeTests {
@Test func allCases() {
let cases = TransportType.allCases
#expect(cases.count == 3)
#expect(cases.contains(.ble))
#expect(cases.contains(.tcp))
#expect(cases.contains(.serial))
}
@Test(arguments: [
(TransportType.ble, "BLE"),
(TransportType.tcp, "TCP"),
(TransportType.serial, "Serial"),
])
func rawValues(type: TransportType, expected: String) {
#expect(type.rawValue == expected)
}
@Test func initFromRawValue() {
#expect(TransportType(rawValue: "BLE") == .ble)
#expect(TransportType(rawValue: "TCP") == .tcp)
#expect(TransportType(rawValue: "Serial") == .serial)
#expect(TransportType(rawValue: "invalid") == nil)
}
@Test func codableRoundTrip() throws {
for type in TransportType.allCases {
let data = try JSONEncoder().encode(type)
let decoded = try JSONDecoder().decode(TransportType.self, from: data)
#expect(decoded == type)
}
}
}
// MARK: - ConnectionState Tests
@Suite("ConnectionState")
struct ConnectionStateTests {
@Test func equality() {
#expect(ConnectionState.disconnected == .disconnected)
#expect(ConnectionState.connecting == .connecting)
#expect(ConnectionState.connected == .connected)
#expect(ConnectionState.disconnected != .connected)
#expect(ConnectionState.connecting != .disconnected)
}
@Test func codableRoundTrip() throws {
let states: [ConnectionState] = [.disconnected, .connecting, .connected]
for state in states {
let data = try JSONEncoder().encode(state)
let decoded = try JSONDecoder().decode(ConnectionState.self, from: data)
#expect(decoded == state)
}
}
}
// MARK: - BLESignalStrength Tests
@Suite("BLESignalStrength")
struct BLESignalStrengthTests {
@Test func rawValues() {
#expect(BLESignalStrength.weak.rawValue == 0)
#expect(BLESignalStrength.normal.rawValue == 1)
#expect(BLESignalStrength.strong.rawValue == 2)
}
@Test func initFromRawValue() {
#expect(BLESignalStrength(rawValue: 0) == .weak)
#expect(BLESignalStrength(rawValue: 1) == .normal)
#expect(BLESignalStrength(rawValue: 2) == .strong)
#expect(BLESignalStrength(rawValue: 3) == nil)
}
}
// MARK: - TransportStatus Tests
@Suite("TransportStatus")
struct TransportStatusTests {
@Test func equality() {
#expect(TransportStatus.uninitialized == .uninitialized)
#expect(TransportStatus.ready == .ready)
#expect(TransportStatus.discovering == .discovering)
#expect(TransportStatus.error("test") == .error("test"))
#expect(TransportStatus.error("a") != .error("b"))
#expect(TransportStatus.ready != .discovering)
}
}
// MARK: - NavigationState Tests
@Suite("NavigationState")
struct NavigationStateTests {
@Test func defaultState() {
let state = NavigationState()
#expect(state.selectedTab == .connect)
#expect(state.messages == nil)
#expect(state.nodeListSelectedNodeNum == nil)
#expect(state.map == nil)
#expect(state.settings == nil)
}
@Test(arguments: [
NavigationState.Tab.messages,
NavigationState.Tab.connect,
NavigationState.Tab.nodes,
NavigationState.Tab.map,
NavigationState.Tab.settings,
])
func tabRawValues(tab: NavigationState.Tab) {
#expect(NavigationState.Tab(rawValue: tab.rawValue) == tab)
}
@Test func messagesNavigationState() {
let channels = MessagesNavigationState.channels(channelId: 1, messageId: 100)
let directMessages = MessagesNavigationState.directMessages(userNum: 42, messageId: 200)
let state1 = NavigationState(selectedTab: .messages, messages: channels)
let state2 = NavigationState(selectedTab: .messages, messages: directMessages)
#expect(state1 != state2)
#expect(state1.messages != nil)
#expect(state2.messages != nil)
}
@Test func mapNavigationState() {
let selectedNode = MapNavigationState.selectedNode(12345)
let waypoint = MapNavigationState.waypoint(67890)
#expect(selectedNode != waypoint)
#expect(MapNavigationState.selectedNode(12345) == selectedNode)
}
@Test func settingsNavigationState() {
#expect(SettingsNavigationState(rawValue: "about") == .about)
#expect(SettingsNavigationState(rawValue: "appSettings") == .appSettings)
#expect(SettingsNavigationState(rawValue: "lora") == .lora)
#expect(SettingsNavigationState(rawValue: "mqtt") == .mqtt)
#expect(SettingsNavigationState(rawValue: "nonexistent") == nil)
}
@Test func hashable() {
let state1 = NavigationState(selectedTab: .connect)
let state2 = NavigationState(selectedTab: .connect)
let state3 = NavigationState(selectedTab: .messages)
#expect(state1 == state2)
#expect(state1 != state3)
#expect(state1.hashValue == state2.hashValue)
}
}
// MARK: - InvalidVersion View Tests
@Suite("InvalidVersion")
struct InvalidVersionTests {
@Test func viewCreation() {
let view = InvalidVersion(minimumVersion: "2.5.0", version: "2.3.0")
#expect(view.minimumVersion == "2.5.0")
#expect(view.version == "2.3.0")
}
@Test func viewCreationWithEmptyVersions() {
let view = InvalidVersion()
#expect(view.minimumVersion == "")
#expect(view.version == "")
}
}
// MARK: - ConnectedDevice View Tests
@Suite("ConnectedDevice")
struct ConnectedDeviceTests {
@Test func connectedState() {
let view = ConnectedDevice(deviceConnected: true, name: "TEST")
#expect(view.deviceConnected == true)
#expect(view.name == "TEST")
#expect(view.mqttProxyConnected == false)
#expect(view.showActivityLights == true)
}
@Test func disconnectedState() {
let view = ConnectedDevice(deviceConnected: false, name: "?")
#expect(view.deviceConnected == false)
#expect(view.name == "?")
}
@Test func withMQTTOptions() {
let view = ConnectedDevice(
deviceConnected: true,
name: "MQTT",
mqttProxyConnected: true,
mqttUplinkEnabled: true,
mqttDownlinkEnabled: true,
mqttTopic: "msh/US/2/e/#"
)
#expect(view.mqttProxyConnected == true)
#expect(view.mqttUplinkEnabled == true)
#expect(view.mqttDownlinkEnabled == true)
#expect(view.mqttTopic == "msh/US/2/e/#")
}
@Test func phoneOnlyMode() {
let view = ConnectedDevice(
deviceConnected: true,
name: "PHON",
phoneOnly: true,
showActivityLights: false
)
#expect(view.phoneOnly == true)
#expect(view.showActivityLights == false)
}
}
// MARK: - CircleText View Tests
@Suite("CircleText")
struct CircleTextTests {
@Test func defaultCircleSize() {
let view = CircleText(text: "AB", color: .blue)
#expect(view.text == "AB")
#expect(view.circleSize == 45)
}
@Test func customCircleSize() {
let view = CircleText(text: "XY", color: .red, circleSize: 90)
#expect(view.text == "XY")
#expect(view.circleSize == 90)
}
@Test func emojiText() {
let view = CircleText(text: "😝", color: .orange, circleSize: 80)
#expect(view.text == "😝")
#expect(view.circleSize == 80)
}
}
// MARK: - BatteryCompact View Tests
@Suite("BatteryCompact")
struct BatteryCompactTests {
@Test func creationWithLevel() {
let view = BatteryCompact(batteryLevel: 75, font: .caption, iconFont: .callout, color: .accentColor)
#expect(view.batteryLevel == 75)
}
@Test func creationWithNilLevel() {
let view = BatteryCompact(batteryLevel: nil, font: .caption, iconFont: .callout, color: .accentColor)
#expect(view.batteryLevel == nil)
}
@Test func pluggedInLevel() {
let view = BatteryCompact(batteryLevel: 101, font: .caption, iconFont: .callout, color: .accentColor)
#expect(view.batteryLevel! > 100)
}
@Test func chargingLevel() {
let view = BatteryCompact(batteryLevel: 100, font: .caption, iconFont: .callout, color: .accentColor)
#expect(view.batteryLevel == 100)
}
}
// MARK: - SignalStrengthIndicator View Tests
@Suite("SignalStrengthIndicator")
struct SignalStrengthIndicatorTests {
@Test func defaultDimensions() {
let view = SignalStrengthIndicator(signalStrength: .strong)
#expect(view.signalStrength == .strong)
#expect(view.width == 8)
#expect(view.height == 40)
}
@Test func customDimensions() {
let view = SignalStrengthIndicator(signalStrength: .weak, width: 5, height: 20)
#expect(view.signalStrength == .weak)
#expect(view.width == 5)
#expect(view.height == 20)
}
@Test(arguments: [BLESignalStrength.weak, .normal, .strong])
func allStrengthLevels(strength: BLESignalStrength) {
let view = SignalStrengthIndicator(signalStrength: strength)
#expect(view.signalStrength == strength)
}
}

View file

@ -1,148 +1,300 @@
import Foundation
import XCTest
import Testing
@testable import Meshtastic
final class RouterTests: XCTestCase {
@Suite("Router")
struct RouterTests {
func testInitialState() async throws {
// MARK: - Initialization
@Test func defaultInitialState() async {
let router = await Router()
let state = await router.navigationState
#expect(state.selectedTab == .connect)
#expect(state.messages == nil)
#expect(state.nodeListSelectedNodeNum == nil)
#expect(state.map == nil)
#expect(state.settings == nil)
}
@Test func customInitialState() async {
let custom = NavigationState(selectedTab: .map, map: .waypoint(42))
let router = await Router(navigationState: custom)
let state = await router.navigationState
#expect(state == custom)
}
// MARK: - Invalid URL Handling
@Test func invalidSchemeIsIgnored() async throws {
let router = await Router()
let url = try #require(URL(string: "https:///messages"))
await router.route(url: url)
let tab = await router.navigationState.selectedTab
XCTAssertEqual(tab, .connect)
#expect(tab == .connect)
}
func testRouteMessages() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///messages",
NavigationState(selectedTab: .messages)
)
@Test func unknownPathIsIgnored() async throws {
let router = await Router()
let url = try #require(URL(string: "meshtastic:///unknown"))
await router.route(url: url)
let state = await router.navigationState
#expect(state == NavigationState(selectedTab: .connect))
}
func testRouteMessagesWithChannelIdAndMessageId() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///messages?channelId=0&messageId=1122334455",
NavigationState(
selectedTab: .messages,
messages: .channels(
channelId: 0,
messageId: 1122334455
)
)
)
}
// MARK: - Connect
func testRouteMessagesWithUserNumAndMessageId() async throws {
@Test func routeConnect() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///messages?userNum=123456789&messageId=9876543210",
NavigationState(
selectedTab: .messages,
messages: .directMessages(
userNum: 123456789,
messageId: 9876543210
)
)
)
}
func testRouteConnect() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///connect",
NavigationState(selectedTab: .connect)
)
}
func testRouteNodes() async throws {
// MARK: - Messages
@Test func routeMessages() async throws {
try await assertRoute(
"meshtastic:///messages",
NavigationState(selectedTab: .messages)
)
}
@Test func routeMessagesWithChannelIdAndMessageId() async throws {
try await assertRoute(
"meshtastic:///messages?channelId=0&messageId=1122334455",
NavigationState(
selectedTab: .messages,
messages: .channels(channelId: 0, messageId: 1122334455)
)
)
}
@Test func routeMessagesWithChannelIdOnly() async throws {
try await assertRoute(
"meshtastic:///messages?channelId=5",
NavigationState(
selectedTab: .messages,
messages: .channels(channelId: 5, messageId: nil)
)
)
}
@Test func routeMessagesWithUserNumAndMessageId() async throws {
try await assertRoute(
"meshtastic:///messages?userNum=123456789&messageId=9876543210",
NavigationState(
selectedTab: .messages,
messages: .directMessages(userNum: 123456789, messageId: 9876543210)
)
)
}
@Test func routeMessagesWithUserNumOnly() async throws {
try await assertRoute(
"meshtastic:///messages?userNum=42",
NavigationState(
selectedTab: .messages,
messages: .directMessages(userNum: 42, messageId: nil)
)
)
}
@Test func routeMessagesWithOnlyMessageIdIgnoresIt() async throws {
try await assertRoute(
"meshtastic:///messages?messageId=999",
NavigationState(selectedTab: .messages)
)
}
@Test func routeMessagesWithNonNumericParamsIgnoresThem() async throws {
try await assertRoute(
"meshtastic:///messages?channelId=abc&messageId=xyz",
NavigationState(selectedTab: .messages)
)
}
// MARK: - Nodes
@Test func routeNodes() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///nodes",
NavigationState(selectedTab: .nodes)
)
}
func testRouteNodesWithNodeNum() async throws {
@Test func routeNodesWithNodeNum() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///nodes?nodenum=1234567890",
NavigationState(
selectedTab: .nodes,
nodeListSelectedNodeNum: 1234567890
)
NavigationState(selectedTab: .nodes, nodeListSelectedNodeNum: 1234567890)
)
}
func testRouteMap() async throws {
@Test func routeNodesWithNonNumericNodeNum() async throws {
try await assertRoute(
"meshtastic:///nodes?nodenum=abc",
NavigationState(selectedTab: .nodes)
)
}
// MARK: - Map
@Test func routeMap() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///map",
NavigationState(selectedTab: .map)
)
}
func testRouteMapWithWaypointId() async throws {
@Test func routeMapWithWaypointId() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///map?waypointId=123456",
NavigationState(
selectedTab: .map,
map: .waypoint(123456)
)
NavigationState(selectedTab: .map, map: .waypoint(123456))
)
}
func testRouteMapWithNodeNum() async throws {
@Test func routeMapWithNodeNum() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///map?nodenum=1234567890",
NavigationState(
selectedTab: .map,
map: .selectedNode(1234567890)
)
NavigationState(selectedTab: .map, map: .selectedNode(1234567890))
)
}
func testRouteSettings() async throws {
@Test func routeMapWithBothNodeNumAndWaypointIdPrefersNode() async throws {
try await assertRoute(
"meshtastic:///map?nodenum=111&waypointId=222",
NavigationState(selectedTab: .map, map: .selectedNode(111))
)
}
@Test func routeMapWithNonNumericParamsIgnoresThem() async throws {
try await assertRoute(
"meshtastic:///map?nodenum=abc&waypointId=xyz",
NavigationState(selectedTab: .map)
)
}
// MARK: - Settings
@Test func routeSettings() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///settings",
NavigationState(
selectedTab: .settings
)
NavigationState(selectedTab: .settings)
)
}
func testRouteSettingsAbout() async throws {
@Test(arguments: [
("about", SettingsNavigationState.about),
("appSettings", SettingsNavigationState.appSettings),
("routes", SettingsNavigationState.routes),
("routeRecorder", SettingsNavigationState.routeRecorder),
("lora", SettingsNavigationState.lora),
("channels", SettingsNavigationState.channels),
("shareQRCode", SettingsNavigationState.shareQRCode),
("user", SettingsNavigationState.user),
("bluetooth", SettingsNavigationState.bluetooth),
("device", SettingsNavigationState.device),
("display", SettingsNavigationState.display),
("network", SettingsNavigationState.network),
("position", SettingsNavigationState.position),
("power", SettingsNavigationState.power),
("ambientLighting", SettingsNavigationState.ambientLighting),
("cannedMessages", SettingsNavigationState.cannedMessages),
("detectionSensor", SettingsNavigationState.detectionSensor),
("externalNotification", SettingsNavigationState.externalNotification),
("mqtt", SettingsNavigationState.mqtt),
("rangeTest", SettingsNavigationState.rangeTest),
("paxCounter", SettingsNavigationState.paxCounter),
("ringtone", SettingsNavigationState.ringtone),
("serial", SettingsNavigationState.serial),
("security", SettingsNavigationState.security),
("storeAndForward", SettingsNavigationState.storeAndForward),
("telemetry", SettingsNavigationState.telemetry),
("debugLogs", SettingsNavigationState.debugLogs),
("appFiles", SettingsNavigationState.appFiles),
("firmwareUpdates", SettingsNavigationState.firmwareUpdates),
("tak", SettingsNavigationState.tak),
])
func routeSettingsPage(path: String, expected: SettingsNavigationState) async throws {
try await assertRoute(
router: Router(),
"meshtastic:///settings/about",
NavigationState(
selectedTab: .settings,
settings: .about
)
"meshtastic:///settings/\(path)",
NavigationState(selectedTab: .settings, settings: expected)
)
}
func testRouteSettingsInvalidSetting() async throws {
@Test func routeSettingsInvalidSetting() async throws {
try await assertRoute(
router: Router(),
"meshtastic:///settings/invalidSetting",
NavigationState(
selectedTab: .settings
)
NavigationState(selectedTab: .settings)
)
}
// MARK: - navigateToNodeDetail
@Test func navigateToNodeDetail() async {
let router = await Router()
await router.navigateToNodeDetail(nodeNum: 9876543210)
let state = await router.navigationState
#expect(state.selectedTab == .nodes)
#expect(state.nodeListSelectedNodeNum == 9876543210)
}
// MARK: - State Transitions
@Test func routingToNewTabClearsPreviousState() async throws {
let router = await Router()
// First, route to messages with channel state
let messagesURL = try #require(URL(string: "meshtastic:///messages?channelId=1&messageId=100"))
await router.route(url: messagesURL)
let messagesState = await router.navigationState
#expect(messagesState.selectedTab == .messages)
#expect(messagesState.messages != nil)
// Then route to map messages state should remain but tab changes
let mapURL = try #require(URL(string: "meshtastic:///map?waypointId=42"))
await router.route(url: mapURL)
let mapState = await router.navigationState
#expect(mapState.selectedTab == .map)
#expect(mapState.map == .waypoint(42))
}
@Test func consecutiveRoutesUpdateState() async throws {
let router = await Router()
let nodesURL = try #require(URL(string: "meshtastic:///nodes?nodenum=111"))
await router.route(url: nodesURL)
let first = await router.navigationState
#expect(first.selectedTab == .nodes)
#expect(first.nodeListSelectedNodeNum == 111)
let nodesURL2 = try #require(URL(string: "meshtastic:///nodes?nodenum=222"))
await router.route(url: nodesURL2)
let second = await router.navigationState
#expect(second.selectedTab == .nodes)
#expect(second.nodeListSelectedNodeNum == 222)
}
@Test func invalidSchemeDoesNotMutateExistingState() async throws {
let initial = NavigationState(selectedTab: .map, map: .waypoint(99))
let router = await Router(navigationState: initial)
let badURL = try #require(URL(string: "https:///messages"))
await router.route(url: badURL)
let state = await router.navigationState
#expect(state == initial)
}
// MARK: - Helpers
private func assertRoute(
router: Router,
_ urlString: String,
_ destination: NavigationState
) async throws {
let url = try XCTUnwrap(URL(string: urlString))
let router = await Router()
let url = try #require(URL(string: urlString))
await router.route(url: url)
let state = await router.navigationState
XCTAssertEqual(state, destination)
#expect(state == destination)
}
}