diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 6cebec83..c99819c3 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0516B3FE2F68892000D0FC40 /* NodeFilterParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05DCA1AE2F646B3B00D0724C /* NodeFilterParametersTests.swift */; }; 102B5EAB2E172F41003D191E /* DatadogCore in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAA2E172F41003D191E /* DatadogCore */; }; 102B5EAD2E172F41003D191E /* DatadogCrashReporting in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAC2E172F41003D191E /* DatadogCrashReporting */; }; 102B5EAF2E172F41003D191E /* DatadogLogs in Frameworks */ = {isa = PBXBuildFile; productRef = 102B5EAE2E172F41003D191E /* DatadogLogs */; }; @@ -347,6 +348,7 @@ /* Begin PBXFileReference section */ 01028778B8BFD81F7A039593 /* TAKConnection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKConnection.swift; sourceTree = ""; }; + 05DCA1AE2F646B3B00D0724C /* NodeFilterParametersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeFilterParametersTests.swift; sourceTree = ""; }; 0618E6D0DF90B74EE32E6C06 /* TAKServerConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerConfig.swift; sourceTree = ""; }; 09936BEBD6D82479B2360FDC /* TAKCertificateManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKCertificateManager.swift; sourceTree = ""; }; 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = ""; }; @@ -904,6 +906,7 @@ children = ( AA00010022E2730EC0060000 /* ConnectViewTests.swift */, 25F5D5D02C4375DF008036E3 /* RouterTests.swift */, + 05DCA1AE2F646B3B00D0724C /* NodeFilterParametersTests.swift */, ); path = MeshtasticTests; sourceTree = ""; @@ -1662,6 +1665,7 @@ files = ( AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */, 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, + 0516B3FE2F68892000D0FC40 /* NodeFilterParametersTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift b/Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift index 762f59e7..64b6496a 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeFilterParameters.swift @@ -9,22 +9,27 @@ import SwiftUI @MainActor final class NodeFilterParameters: ObservableObject { - // Public variables - @Published var searchText = "" - @Published var isOnline = false - @Published var isPkiEncrypted = false - @Published var isFavorite = false - @Published var isIgnored = false - @Published var isEnvironment = false - @Published var distanceFilter = false - @Published var maxDistance: Double = 800_000 - @Published var hopsAway: Double = -1.0 - @Published var roleFilter = false - @Published var deviceRoles: Set = [] + @AppStorage("nodeFilter.searchText") var searchText = "" + @AppStorage("nodeFilter.isOnline") var isOnline = false + @AppStorage("nodeFilter.isPkiEncrypted") var isPkiEncrypted = false + @AppStorage("nodeFilter.isFavorite") var isFavorite = false + @AppStorage("nodeFilter.isIgnored") var isIgnored = false + @AppStorage("nodeFilter.isEnvironment") var isEnvironment = false + @AppStorage("nodeFilter.distanceFilter") var distanceFilter = false + @AppStorage("nodeFilter.maxDistance") var maxDistance: Double = 800_000 + @AppStorage("nodeFilter.hopsAway") var hopsAway: Double = -1.0 + @AppStorage("nodeFilter.roleFilter") var roleFilter = false - // Private backing vars - @Published private var _viaLora = true - @Published private var _viaMqtt = true + // deviceRoles requires custom storage since Set isn't directly supported by @AppStorage + @Published var deviceRoles: Set = [] { + didSet { + let array = Array(deviceRoles) + UserDefaults.standard.set(array, forKey: "nodeFilter.deviceRoles") + } + } + + @AppStorage("nodeFilter.viaLora") private var _viaLora = true + @AppStorage("nodeFilter.viaMqtt") private var _viaMqtt = true // Public computed wrappers with enforcement var viaLora: Bool { @@ -48,4 +53,11 @@ final class NodeFilterParameters: ObservableObject { } } } + + // Initialize and load the deviceRoles from UserDefaults + init() { + if let storedRoles = UserDefaults.standard.array(forKey: "nodeFilter.deviceRoles") as? [Int] { + self.deviceRoles = Set(storedRoles) + } + } } diff --git a/MeshtasticTests/NodeFilterParametersTests.swift b/MeshtasticTests/NodeFilterParametersTests.swift new file mode 100644 index 00000000..bd4593f7 --- /dev/null +++ b/MeshtasticTests/NodeFilterParametersTests.swift @@ -0,0 +1,417 @@ +// +// NodeFilterParametersTests.swift +// Meshtastic +// +// Created on 3/16/26. +// + +import Foundation +import XCTest + +@testable import Meshtastic + +@MainActor +class NodeFilterParametersTests: XCTestCase { + + // MARK: - Initialization Tests + + func testDefaultInitialization() async throws { + // Clean up UserDefaults before test + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + + XCTAssertEqual(filters.searchText, "") + XCTAssertEqual(filters.isOnline, false) + XCTAssertEqual(filters.isPkiEncrypted, false) + XCTAssertEqual(filters.isFavorite, false) + XCTAssertEqual(filters.isIgnored, false) + XCTAssertEqual(filters.isEnvironment, false) + XCTAssertEqual(filters.distanceFilter, false) + XCTAssertEqual(filters.maxDistance, 800_000) + XCTAssertEqual(filters.hopsAway, -1.0) + XCTAssertEqual(filters.roleFilter, false) + XCTAssertTrue(filters.deviceRoles.isEmpty) + XCTAssertEqual(filters.viaLora, true) + XCTAssertEqual(filters.viaMqtt, true) + } + + func testInitializationWithPersistedDeviceRoles() async throws { + clearAllFilterDefaults() + + // Store device roles in UserDefaults + let expectedRoles = [1, 2, 3, 5, 8] + UserDefaults.standard.set(expectedRoles, forKey: "nodeFilter.deviceRoles") + + let filters = NodeFilterParameters() + + XCTAssertEqual(filters.deviceRoles, Set(expectedRoles)) + } + + // MARK: - @AppStorage Persistence Tests + + func testSearchTextPersistence() async throws { + clearAllFilterDefaults() + + let filters1 = NodeFilterParameters() + filters1.searchText = "Test Node" + + let filters2 = NodeFilterParameters() + XCTAssertEqual(filters2.searchText, "Test Node") + } + + func testBooleanFiltersPersistence() async throws { + clearAllFilterDefaults() + + let filters1 = NodeFilterParameters() + filters1.isOnline = true + filters1.isPkiEncrypted = true + filters1.isFavorite = true + filters1.isIgnored = true + filters1.isEnvironment = true + filters1.distanceFilter = true + filters1.roleFilter = true + + let filters2 = NodeFilterParameters() + XCTAssertEqual(filters2.isOnline, true) + XCTAssertEqual(filters2.isPkiEncrypted, true) + XCTAssertEqual(filters2.isFavorite, true) + XCTAssertEqual(filters2.isIgnored, true) + XCTAssertEqual(filters2.isEnvironment, true) + XCTAssertEqual(filters2.distanceFilter, true) + XCTAssertEqual(filters2.roleFilter, true) + } + + func testNumericFiltersPersistence() async throws { + clearAllFilterDefaults() + + let filters1 = NodeFilterParameters() + filters1.maxDistance = 500_000 + filters1.hopsAway = 3.0 + + let filters2 = NodeFilterParameters() + XCTAssertEqual(filters2.maxDistance, 500_000) + XCTAssertEqual(filters2.hopsAway, 3.0) + } + + // MARK: - Device Roles Tests + + func testDeviceRolesPersistence() async throws { + clearAllFilterDefaults() + + let filters1 = NodeFilterParameters() + filters1.deviceRoles = [1, 3, 5, 7] + + // Verify it's stored in UserDefaults + let storedRoles = UserDefaults.standard.array(forKey: "nodeFilter.deviceRoles") as? [Int] + XCTAssertNotNil(storedRoles) + XCTAssertEqual(Set(storedRoles!), Set([1, 3, 5, 7])) + + // Verify it persists to new instance + let filters2 = NodeFilterParameters() + XCTAssertEqual(filters2.deviceRoles, Set([1, 3, 5, 7])) + } + + func testAddingDeviceRoles() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + filters.deviceRoles.insert(2) + filters.deviceRoles.insert(4) + filters.deviceRoles.insert(6) + + let newFilters = NodeFilterParameters() + XCTAssertTrue(newFilters.deviceRoles.contains(2)) + XCTAssertTrue(newFilters.deviceRoles.contains(4)) + XCTAssertTrue(newFilters.deviceRoles.contains(6)) + XCTAssertEqual(newFilters.deviceRoles.count, 3) + } + + func testRemovingDeviceRoles() async throws { + clearAllFilterDefaults() + + let filters1 = NodeFilterParameters() + filters1.deviceRoles = [1, 2, 3, 4, 5] + + filters1.deviceRoles.remove(2) + filters1.deviceRoles.remove(4) + + let filters2 = NodeFilterParameters() + XCTAssertEqual(filters2.deviceRoles, Set([1, 3, 5])) + XCTAssertFalse(filters2.deviceRoles.contains(2)) + XCTAssertFalse(filters2.deviceRoles.contains(4)) + } + + func testEmptyDeviceRolesPersistence() async throws { + clearAllFilterDefaults() + + let filters1 = NodeFilterParameters() + filters1.deviceRoles = [1, 2, 3] + + // Clear the set + filters1.deviceRoles = [] + + let filters2 = NodeFilterParameters() + XCTAssertTrue(filters2.deviceRoles.isEmpty) + } + + // MARK: - Via Lora/MQTT Enforcement Tests + + func testViaLoraEnforcesViaMqtt() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + + // Start with both true + XCTAssertEqual(filters.viaLora, true) + XCTAssertEqual(filters.viaMqtt, true) + + // Set viaLora to false + filters.viaLora = false + + // viaMqtt should remain true + XCTAssertEqual(filters.viaLora, false) + XCTAssertEqual(filters.viaMqtt, true) + + // Try to set viaMqtt to false - it should enforce viaLora to true + filters.viaMqtt = false + + XCTAssertEqual(filters.viaLora, true) + XCTAssertEqual(filters.viaMqtt, false) + } + + func testViaMqttEnforcesViaLora() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + + // Start with both true + XCTAssertEqual(filters.viaLora, true) + XCTAssertEqual(filters.viaMqtt, true) + + // Set viaMqtt to false + filters.viaMqtt = false + + // viaLora should remain true + XCTAssertEqual(filters.viaLora, true) + XCTAssertEqual(filters.viaMqtt, false) + + // Try to set viaLora to false - it should enforce viaMqtt to true + filters.viaLora = false + + XCTAssertEqual(filters.viaLora, false) + XCTAssertEqual(filters.viaMqtt, true) + } + + func testBothViaTrue() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + filters.viaLora = true + filters.viaMqtt = true + + XCTAssertEqual(filters.viaLora, true) + XCTAssertEqual(filters.viaMqtt, true) + } + + func testViaSettingsPersistence() async throws { + clearAllFilterDefaults() + + let filters1 = NodeFilterParameters() + filters1.viaLora = false + // viaMqtt should be enforced to true + + let filters2 = NodeFilterParameters() + XCTAssertEqual(filters2.viaLora, false) + XCTAssertEqual(filters2.viaMqtt, true) + } + + func testCannotHaveBothViaFalse() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + + // Set viaLora to false first + filters.viaLora = false + XCTAssertEqual(filters.viaLora, false) + XCTAssertEqual(filters.viaMqtt, true) + + // Try to set viaMqtt to false + filters.viaMqtt = false + + // viaLora should be enforced back to true + XCTAssertEqual(filters.viaLora, true) + XCTAssertEqual(filters.viaMqtt, false) + } + + // MARK: - ObservableObject Tests + + func testObjectWillChange() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + var changeCount = 0 + + let cancellable = filters.objectWillChange.sink { + changeCount += 1 + } + + // Modify various properties + filters.searchText = "Test" + filters.isOnline = true + filters.deviceRoles.insert(1) + filters.viaLora = false + + // Should have triggered changes + XCTAssertGreaterThan(changeCount, 0) + + cancellable.cancel() + } + + // MARK: - Edge Cases + + func testLargeMaxDistance() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + filters.maxDistance = 10_000_000 + + let newFilters = NodeFilterParameters() + XCTAssertEqual(newFilters.maxDistance, 10_000_000) + } + + func testNegativeHopsAway() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + filters.hopsAway = -1.0 + + let newFilters = NodeFilterParameters() + XCTAssertEqual(newFilters.hopsAway, -1.0) + } + + func testZeroHopsAway() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + filters.hopsAway = 0.0 + + let newFilters = NodeFilterParameters() + XCTAssertEqual(newFilters.hopsAway, 0.0) + } + + func testSpecialCharactersInSearchText() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + filters.searchText = "Test!@#$%^&*()_+-=[]{}|;':\",./<>?" + + let newFilters = NodeFilterParameters() + XCTAssertEqual(newFilters.searchText, "Test!@#$%^&*()_+-=[]{}|;':\",./<>?") + } + + func testUnicodeInSearchText() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + filters.searchText = "测试 Тест 🎉🚀" + + let newFilters = NodeFilterParameters() + XCTAssertEqual(newFilters.searchText, "测试 Тест 🎉🚀") + } + + func testLongSearchText() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + let longText = String(repeating: "A", count: 1000) + filters.searchText = longText + + let newFilters = NodeFilterParameters() + XCTAssertEqual(newFilters.searchText, longText) + } + + func testLargeDeviceRolesSet() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + filters.deviceRoles = Set(0...100) + + let newFilters = NodeFilterParameters() + XCTAssertEqual(newFilters.deviceRoles, Set(0...100)) + XCTAssertEqual(newFilters.deviceRoles.count, 101) + } + + // MARK: - Reset/Clear Tests + + func testResetAllFilters() async throws { + clearAllFilterDefaults() + + let filters = NodeFilterParameters() + + // Set all values to non-default + filters.searchText = "Test" + filters.isOnline = true + filters.isPkiEncrypted = true + filters.isFavorite = true + filters.isIgnored = true + filters.isEnvironment = true + filters.distanceFilter = true + filters.maxDistance = 500_000 + filters.hopsAway = 5.0 + filters.roleFilter = true + filters.deviceRoles = [1, 2, 3] + filters.viaLora = false + + // Reset to defaults + filters.searchText = "" + filters.isOnline = false + filters.isPkiEncrypted = false + filters.isFavorite = false + filters.isIgnored = false + filters.isEnvironment = false + filters.distanceFilter = false + filters.maxDistance = 800_000 + filters.hopsAway = -1.0 + filters.roleFilter = false + filters.deviceRoles = [] + filters.viaLora = true + filters.viaMqtt = true + + // Verify all are back to defaults + let newFilters = NodeFilterParameters() + XCTAssertEqual(newFilters.searchText, "") + XCTAssertEqual(newFilters.isOnline, false) + XCTAssertEqual(newFilters.isPkiEncrypted, false) + XCTAssertEqual(newFilters.isFavorite, false) + XCTAssertEqual(newFilters.isIgnored, false) + XCTAssertEqual(newFilters.isEnvironment, false) + XCTAssertEqual(newFilters.distanceFilter, false) + XCTAssertEqual(newFilters.maxDistance, 800_000) + XCTAssertEqual(newFilters.hopsAway, -1.0) + XCTAssertEqual(newFilters.roleFilter, false) + XCTAssertTrue(newFilters.deviceRoles.isEmpty) + XCTAssertEqual(newFilters.viaLora, true) + XCTAssertEqual(newFilters.viaMqtt, true) + } + + // MARK: - Helper Functions + + /// Clears all NodeFilter-related UserDefaults to ensure clean test state + private func clearAllFilterDefaults() { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: "nodeFilter.searchText") + defaults.removeObject(forKey: "nodeFilter.isOnline") + defaults.removeObject(forKey: "nodeFilter.isPkiEncrypted") + defaults.removeObject(forKey: "nodeFilter.isFavorite") + defaults.removeObject(forKey: "nodeFilter.isIgnored") + defaults.removeObject(forKey: "nodeFilter.isEnvironment") + defaults.removeObject(forKey: "nodeFilter.distanceFilter") + defaults.removeObject(forKey: "nodeFilter.maxDistance") + defaults.removeObject(forKey: "nodeFilter.hopsAway") + defaults.removeObject(forKey: "nodeFilter.roleFilter") + defaults.removeObject(forKey: "nodeFilter.deviceRoles") + defaults.removeObject(forKey: "nodeFilter.viaLora") + defaults.removeObject(forKey: "nodeFilter.viaMqtt") + } +}