From 322073ed1fada4fde6bda77f402999a77a547325 Mon Sep 17 00:00:00 2001 From: Brandon Ruschill Date: Fri, 13 Mar 2026 11:04:16 -0500 Subject: [PATCH] feat: persist node list filters - add tests --- Meshtastic.xcodeproj/project.pbxproj | 4 + .../Helpers/NodeFilterParametersTests.swift | 332 ++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 Meshtastic/Views/Nodes/Helpers/NodeFilterParametersTests.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 1544d0eb..487e6cae 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 05DCA1AF2F646B3B00D0724C /* 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 */; }; @@ -346,6 +347,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 = ""; }; @@ -1400,6 +1402,7 @@ 231A53772E69ADB900216B99 /* NodeFilterParameters.swift */, 251926882C3BAF2E00249DF5 /* Actions */, BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */, + 05DCA1AE2F646B3B00D0724C /* NodeFilterParametersTests.swift */, ); path = Helpers; sourceTree = ""; @@ -1881,6 +1884,7 @@ DD6F65742C6CB80A0053C113 /* View.swift in Sources */, DD1933762B0835D500771CD5 /* PositionAltitudeChart.swift in Sources */, DD415828285859C4009B0E59 /* TelemetryConfig.swift in Sources */, + 05DCA1AF2F646B3B00D0724C /* NodeFilterParametersTests.swift in Sources */, DD6D5A332CA1178300ED3032 /* TraceRoute.swift in Sources */, DDB6CCFB2AAF805100945AF6 /* NodeMapSwiftUI.swift in Sources */, BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */, diff --git a/Meshtastic/Views/Nodes/Helpers/NodeFilterParametersTests.swift b/Meshtastic/Views/Nodes/Helpers/NodeFilterParametersTests.swift new file mode 100644 index 00000000..eb6add21 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/NodeFilterParametersTests.swift @@ -0,0 +1,332 @@ +// +// NodeFilterParametersTests.swift +// MeshtasticTests +// +// Created on 3/13/26. +// + +import XCTest + +@MainActor +final class NodeFilterParametersTests: XCTestCase { + + var sut: NodeFilterParameters! + let testSuiteName = "NodeFilterParametersTests" + + override func setUp() async throws { + try await super.setUp() + + // Clear UserDefaults before each test to ensure clean state + clearUserDefaults() + + // Create a fresh instance + sut = NodeFilterParameters() + } + + override func tearDown() async throws { + sut = nil + clearUserDefaults() + try await super.tearDown() + } + + private func clearUserDefaults() { + let defaults = UserDefaults.standard + let keys = [ + "nodeFilter.searchText", + "nodeFilter.isOnline", + "nodeFilter.isPkiEncrypted", + "nodeFilter.isFavorite", + "nodeFilter.isIgnored", + "nodeFilter.isEnvironment", + "nodeFilter.distanceFilter", + "nodeFilter.maxDistance", + "nodeFilter.hopsAway", + "nodeFilter.roleFilter", + "nodeFilter.deviceRoles", + "nodeFilter.viaLora", + "nodeFilter.viaMqtt" + ] + keys.forEach { defaults.removeObject(forKey: $0) } + } + + func testDefaultValues() { + XCTAssertEqual(sut.searchText, "", "Search text should default to empty string") + XCTAssertFalse(sut.isOnline, "isOnline should default to false") + XCTAssertFalse(sut.isPkiEncrypted, "isPkiEncrypted should default to false") + XCTAssertFalse(sut.isFavorite, "isFavorite should default to false") + XCTAssertFalse(sut.isIgnored, "isIgnored should default to false") + XCTAssertFalse(sut.isEnvironment, "isEnvironment should default to false") + XCTAssertFalse(sut.distanceFilter, "distanceFilter should default to false") + XCTAssertEqual(sut.maxDistance, 800_000, "maxDistance should default to 800,000") + XCTAssertEqual(sut.hopsAway, -1.0, "hopsAway should default to -1.0") + XCTAssertFalse(sut.roleFilter, "roleFilter should default to false") + XCTAssertTrue(sut.deviceRoles.isEmpty, "deviceRoles should default to empty set") + XCTAssertTrue(sut.viaLora, "viaLora should default to true") + XCTAssertTrue(sut.viaMqtt, "viaMqtt should default to true") + } + + func testSearchTextPersistence() { + sut.searchText = "Test Node" + + // Create new instance to test persistence + let newInstance = NodeFilterParameters() + XCTAssertEqual(newInstance.searchText, "Test Node", "Search text should persist") + } + + func testBooleanFiltersPersistence() { + sut.isOnline = true + sut.isPkiEncrypted = true + sut.isFavorite = true + sut.isIgnored = true + sut.isEnvironment = true + + let newInstance = NodeFilterParameters() + XCTAssertTrue(newInstance.isOnline, "isOnline should persist") + XCTAssertTrue(newInstance.isPkiEncrypted, "isPkiEncrypted should persist") + XCTAssertTrue(newInstance.isFavorite, "isFavorite should persist") + XCTAssertTrue(newInstance.isIgnored, "isIgnored should persist") + XCTAssertTrue(newInstance.isEnvironment, "isEnvironment should persist") + } + + func testDistanceFilterPersistence() { + sut.distanceFilter = true + sut.maxDistance = 50_000 + + let newInstance = NodeFilterParameters() + XCTAssertTrue(newInstance.distanceFilter, "distanceFilter should persist") + XCTAssertEqual(newInstance.maxDistance, 50_000, "maxDistance should persist") + } + + func testHopsAwayPersistence() { + sut.hopsAway = 3.0 + + let newInstance = NodeFilterParameters() + XCTAssertEqual(newInstance.hopsAway, 3.0, "hopsAway should persist") + } + + func testRoleFilterPersistence() { + sut.roleFilter = true + + let newInstance = NodeFilterParameters() + XCTAssertTrue(newInstance.roleFilter, "roleFilter should persist") + } + + func testDeviceRolesInitiallyEmpty() { + XCTAssertTrue(sut.deviceRoles.isEmpty, "deviceRoles should be empty initially") + } + + func testDeviceRolesPersistence() { + // Add some roles + sut.deviceRoles = [0, 1, 2] + + // Verify they're stored in UserDefaults + let stored = UserDefaults.standard.array(forKey: "nodeFilter.deviceRoles") as? [Int] + XCTAssertNotNil(stored, "deviceRoles should be stored in UserDefaults") + XCTAssertEqual(Set(stored ?? []), Set([0, 1, 2]), "Stored roles should match") + + // Create new instance to test persistence + let newInstance = NodeFilterParameters() + XCTAssertEqual(newInstance.deviceRoles, [0, 1, 2], "deviceRoles should persist") + } + + func testDeviceRolesAddAndRemove() { + XCTAssertTrue(sut.deviceRoles.isEmpty, "Should start empty") + + // Add roles + sut.deviceRoles.insert(1) + sut.deviceRoles.insert(3) + sut.deviceRoles.insert(5) + XCTAssertEqual(sut.deviceRoles.count, 3, "Should have 3 roles") + + // Remove a role + sut.deviceRoles.remove(3) + XCTAssertEqual(sut.deviceRoles.count, 2, "Should have 2 roles after removal") + XCTAssertTrue(sut.deviceRoles.contains(1), "Should still contain role 1") + XCTAssertTrue(sut.deviceRoles.contains(5), "Should still contain role 5") + XCTAssertFalse(sut.deviceRoles.contains(3), "Should not contain removed role 3") + + // Verify persistence after changes + let newInstance = NodeFilterParameters() + XCTAssertEqual(newInstance.deviceRoles, sut.deviceRoles, "Changes should persist") + } + + func testViaLoraAndMqttBothTrueByDefault() { + XCTAssertTrue(sut.viaLora, "viaLora should default to true") + XCTAssertTrue(sut.viaMqtt, "viaMqtt should default to true") + } + + func testCanSetViaLoraToFalseWhenMqttIsTrue() { + sut.viaLora = false + + XCTAssertFalse(sut.viaLora, "viaLora should be false") + XCTAssertTrue(sut.viaMqtt, "viaMqtt should remain true") + } + + func testCanSetViaMqttToFalseWhenLoraIsTrue() { + sut.viaMqtt = false + + XCTAssertFalse(sut.viaMqtt, "viaMqtt should be false") + XCTAssertTrue(sut.viaLora, "viaLora should remain true") + } + + func testEnforcesAtLeastOneViaLoraOrMqtt_WhenSettingLoraFalse() { + // First set MQTT to false + sut.viaMqtt = false + XCTAssertFalse(sut.viaMqtt, "viaMqtt should be false") + XCTAssertTrue(sut.viaLora, "viaLora should be true") + + // Try to set LoRa to false - should enforce MQTT back to true + sut.viaLora = false + + XCTAssertFalse(sut.viaLora, "viaLora should be false") + XCTAssertTrue(sut.viaMqtt, "viaMqtt should be enforced to true") + } + + func testEnforcesAtLeastOneViaLoraOrMqtt_WhenSettingMqttFalse() { + // First set LoRa to false + sut.viaLora = false + XCTAssertFalse(sut.viaLora, "viaLora should be false") + XCTAssertTrue(sut.viaMqtt, "viaMqtt should be true") + + // Try to set MQTT to false - should enforce LoRa back to true + sut.viaMqtt = false + + XCTAssertFalse(sut.viaMqtt, "viaMqtt should be false") + XCTAssertTrue(sut.viaLora, "viaLora should be enforced to true") + } + + func testViaLoraAndMqttPersistence() { + sut.viaLora = false + sut.viaMqtt = true + + let newInstance = NodeFilterParameters() + XCTAssertFalse(newInstance.viaLora, "viaLora state should persist") + XCTAssertTrue(newInstance.viaMqtt, "viaMqtt state should persist") + } + + // MARK: - ObservableObject Tests + + func testObjectWillChangeTriggeredOnViaLoraChange() { + let expectation = XCTestExpectation(description: "objectWillChange should trigger") + + let cancellable = sut.objectWillChange.sink { + expectation.fulfill() + } + + sut.viaLora = false + + wait(for: [expectation], timeout: 0.1) + cancellable.cancel() + } + + func testObjectWillChangeTriggeredOnViaMqttChange() { + let expectation = XCTestExpectation(description: "objectWillChange should trigger") + + let cancellable = sut.objectWillChange.sink { + expectation.fulfill() + } + + sut.viaMqtt = false + + wait(for: [expectation], timeout: 0.1) + cancellable.cancel() + } + + func testObjectWillChangeTriggeredOnDeviceRolesChange() { + let expectation = XCTestExpectation(description: "objectWillChange should trigger") + expectation.expectedFulfillmentCount = 1 + + let cancellable = sut.objectWillChange.sink { + expectation.fulfill() + } + + sut.deviceRoles = [1, 2, 3] + + wait(for: [expectation], timeout: 0.1) + cancellable.cancel() + } + + func testMaxDistanceBoundaryValues() { + sut.maxDistance = 0 + XCTAssertEqual(sut.maxDistance, 0, "Should handle zero distance") + + sut.maxDistance = Double.greatestFiniteMagnitude + XCTAssertEqual(sut.maxDistance, Double.greatestFiniteMagnitude, "Should handle large distances") + } + + func testHopsAwayBoundaryValues() { + sut.hopsAway = -1.0 + XCTAssertEqual(sut.hopsAway, -1.0, "Should handle -1 (all hops)") + + sut.hopsAway = 0.0 + XCTAssertEqual(sut.hopsAway, 0.0, "Should handle 0 (direct)") + + sut.hopsAway = 7.0 + XCTAssertEqual(sut.hopsAway, 7.0, "Should handle maximum hops") + } + + func testSearchTextWithSpecialCharacters() { + let specialStrings = [ + "Test Node #1", + "Node@123", + "Node with spaces", + "Node_with_underscores", + "Node-with-dashes", + "Node.with.dots", + "🎯 Node with emoji" + ] + + for testString in specialStrings { + sut.searchText = testString + XCTAssertEqual(sut.searchText, testString, "Should handle: \(testString)") + } + } + + func testDeviceRolesWithDuplicates() { + sut.deviceRoles = [1, 2, 3] + sut.deviceRoles.insert(2) // Try to insert duplicate + + XCTAssertEqual(sut.deviceRoles.count, 3, "Set should not contain duplicates") + XCTAssertTrue(sut.deviceRoles.contains(1), "Should contain 1") + XCTAssertTrue(sut.deviceRoles.contains(2), "Should contain 2") + XCTAssertTrue(sut.deviceRoles.contains(3), "Should contain 3") + } + + func testMultipleFiltersActive() { + sut.searchText = "Test" + sut.isOnline = true + sut.isFavorite = true + sut.distanceFilter = true + sut.maxDistance = 100_000 + sut.hopsAway = 2.0 + sut.roleFilter = true + sut.deviceRoles = [0, 1] + sut.viaLora = true + sut.viaMqtt = false + + // Verify all settings + XCTAssertEqual(sut.searchText, "Test") + XCTAssertTrue(sut.isOnline) + XCTAssertTrue(sut.isFavorite) + XCTAssertTrue(sut.distanceFilter) + XCTAssertEqual(sut.maxDistance, 100_000) + XCTAssertEqual(sut.hopsAway, 2.0) + XCTAssertTrue(sut.roleFilter) + XCTAssertEqual(sut.deviceRoles, [0, 1]) + XCTAssertTrue(sut.viaLora) + XCTAssertFalse(sut.viaMqtt) + + // Verify persistence + let newInstance = NodeFilterParameters() + XCTAssertEqual(newInstance.searchText, "Test") + XCTAssertTrue(newInstance.isOnline) + XCTAssertTrue(newInstance.isFavorite) + XCTAssertTrue(newInstance.distanceFilter) + XCTAssertEqual(newInstance.maxDistance, 100_000) + XCTAssertEqual(newInstance.hopsAway, 2.0) + XCTAssertTrue(newInstance.roleFilter) + XCTAssertEqual(newInstance.deviceRoles, [0, 1]) + XCTAssertTrue(newInstance.viaLora) + XCTAssertFalse(newInstance.viaMqtt) + } +}