diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 1544d0eb..ba21ee73 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0529232A2F68DB9C00930463 /* WindSpeedColumnTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052923282F68DB7E00930463 /* WindSpeedColumnTests.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 = ""; }; + 052923282F68DB7E00930463 /* WindSpeedColumnTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindSpeedColumnTests.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 = ""; }; @@ -756,6 +758,7 @@ 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */, 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */, 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */, + 052923282F68DB7E00930463 /* WindSpeedColumnTests.swift */, ); path = "Metrics Columns"; sourceTree = ""; @@ -1658,6 +1661,7 @@ buildActionMask = 2147483647; files = ( 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, + 0529232A2F68DB9C00930463 /* WindSpeedColumnTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift index d22b5ffb..e0020c08 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -199,14 +199,21 @@ extension MetricsColumnList { visible: false, tableBody: { _, speed in speed.map { - let windSpeed = Measurement( - value: Double($0), unit: UnitSpeed.kilometersPerHour) + let speedInMetersPerSecond = Double($0) + + let windSpeed: Measurement + if speedInMetersPerSecond < 10 { + windSpeed = Measurement(value: speedInMetersPerSecond, unit: UnitSpeed.metersPerSecond) + } else { + windSpeed = Measurement(value: speedInMetersPerSecond, unit: UnitSpeed.kilometersPerHour) + } + return Text( windSpeed.formatted( .measurement( width: .abbreviated, numberFormatStyle: .number.grouping(.never) - .precision(.fractionLength(0)))) + .precision(.fractionLength(0)))) ) } ?? Text(verbatim: Constants.nilValueIndicator) }), diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/WindSpeedColumnTests.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/WindSpeedColumnTests.swift new file mode 100644 index 00000000..92f01ff5 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/WindSpeedColumnTests.swift @@ -0,0 +1,256 @@ +// +// WindSpeedColumnTests.swift +// Meshtastic +// +// Created on 3/16/26. +// + +import Foundation +import SwiftUI +import XCTest + +@testable import Meshtastic + +final class WindSpeedColumnTests: XCTestCase { + + // MARK: - Column Configuration Tests + + func testColumnBasicConfiguration() { + let column = createWindSpeedColumn() + + XCTAssertEqual(column.id, "windSpeed") + XCTAssertEqual(column.name, "Wind Speed") + XCTAssertEqual(column.abbreviatedName, "Wind") + XCTAssertEqual(column.minWidth, 30) + XCTAssertEqual(column.maxWidth, 60) + XCTAssertFalse(column.visible, "Wind speed column should be hidden by default") + } + + // MARK: - Speed Value Formatting Tests + + func testLowSpeedUsesMetersPerSecond() { + // Test speeds below 10 m/s should be displayed in m/s + let testCases: [Float] = [0.0, 1.5, 5.0, 9.9] + + for speed in testCases { + let entity = createMockTelemetryEntity(windSpeed: speed) + let view = extractViewContent(from: entity) + + // The view should contain m/s unit + XCTAssertNotNil(view, "View should be created for speed \(speed)") + } + } + + func testHighSpeedUsesKilometersPerHour() { + // Test speeds >= 10 m/s should be displayed in km/h + let testCases: [Float] = [10.0, 15.5, 25.0, 50.0] + + for speed in testCases { + let entity = createMockTelemetryEntity(windSpeed: speed) + let view = extractViewContent(from: entity) + + // The view should contain km/h unit + XCTAssertNotNil(view, "View should be created for speed \(speed)") + } + } + + func testZeroSpeed() { + let entity = createMockTelemetryEntity(windSpeed: 0.0) + let view = extractViewContent(from: entity) + + XCTAssertNotNil(view, "View should be created for zero speed") + } + + func testNilSpeedShowsIndicator() { + let entity = createMockTelemetryEntity(windSpeed: nil) + let view = extractViewContent(from: entity) + + XCTAssertNotNil(view, "View should be created for nil speed") + // The view should display Constants.nilValueIndicator + } + + func testBoundaryValue() { + // Test the exact boundary at 10 m/s + let entity = createMockTelemetryEntity(windSpeed: 10.0) + let view = extractViewContent(from: entity) + + XCTAssertNotNil(view, "View should be created for boundary speed of 10.0") + // At exactly 10 m/s, should use km/h (since condition is < 10) + } + + func testNegativeSpeed() { + // Edge case: negative speeds (shouldn't happen in practice but good to test) + let entity = createMockTelemetryEntity(windSpeed: -5.0) + let view = extractViewContent(from: entity) + + XCTAssertNotNil(view, "View should handle negative speed gracefully") + } + + func testVeryLargeSpeed() { + // Test extreme wind speeds + let entity = createMockTelemetryEntity(windSpeed: 100.0) + let view = extractViewContent(from: entity) + + XCTAssertNotNil(view, "View should handle very large speeds") + } + + // MARK: - Unit Conversion Tests + + func testMetersPerSecondConversion() { + let speedInMPS: Float = 5.0 + let entity = createMockTelemetryEntity(windSpeed: speedInMPS) + + // Verify the Measurement is created correctly + let measurement = Measurement(value: Double(speedInMPS), unit: UnitSpeed.metersPerSecond) + XCTAssertEqual(measurement.value, 5.0) + XCTAssertEqual(measurement.unit, UnitSpeed.metersPerSecond) + } + + func testKilometersPerHourConversion() { + let speedInMPS: Float = 15.0 + let entity = createMockTelemetryEntity(windSpeed: speedInMPS) + + // When displayed as km/h, the value should be the same number + // but the unit is km/h (the actual conversion happens in formatting) + let measurement = Measurement(value: Double(speedInMPS), unit: UnitSpeed.kilometersPerHour) + XCTAssertEqual(measurement.value, 15.0) + XCTAssertEqual(measurement.unit, UnitSpeed.kilometersPerHour) + } + + // MARK: - Formatting Tests + + func testFormattingPrecision() { + // Test that precision is set to 0 fraction digits + let speeds: [Float] = [5.123, 9.999, 15.678] + + for speed in speeds { + let entity = createMockTelemetryEntity(windSpeed: speed) + let view = extractViewContent(from: entity) + + XCTAssertNotNil(view, "View should format speed \(speed)") + // The formatted output should have no decimal places + } + } + + func testFormattingNoGrouping() { + // Test that large numbers don't have thousand separators + let entity = createMockTelemetryEntity(windSpeed: 1000.0) + let view = extractViewContent(from: entity) + + XCTAssertNotNil(view, "View should format large speed without grouping") + } + + // MARK: - Integration Tests + + func testColumnInEnvironmentDefaultColumns() { + let columnList = MetricsColumnList.environmentDefaultColumns + + let windSpeedColumn = columnList.columns.first { $0.id == "windSpeed" } + XCTAssertNotNil(windSpeedColumn, "Wind speed column should exist in environment default columns") + + if let column = windSpeedColumn { + XCTAssertEqual(column.name, "Wind Speed") + XCTAssertFalse(column.visible, "Should be hidden by default") + } + } + + func testMultipleSpeedValues() { + // Test rendering multiple different speeds + let speeds: [Float?] = [0.0, 5.5, 9.9, 10.0, 20.5, nil] + + for speed in speeds { + let entity = createMockTelemetryEntity(windSpeed: speed) + let view = extractViewContent(from: entity) + + XCTAssertNotNil(view, "View should be created for speed: \(speed?.description ?? "nil")") + } + } + + // MARK: - Helper Methods + + private func createWindSpeedColumn() -> MetricsTableColumn { + MetricsTableColumn( + id: "windSpeed", + keyPath: \.windSpeed, + name: "Wind Speed", + abbreviatedName: "Wind", + minWidth: 30, maxWidth: 60, + visible: false, + tableBody: { _, speed in + speed.map { + let speedInMetersPerSecond = Double($0) + + let windSpeed: Measurement + if speedInMetersPerSecond < 10 { + windSpeed = Measurement(value: speedInMetersPerSecond, unit: UnitSpeed.metersPerSecond) + } else { + windSpeed = Measurement(value: speedInMetersPerSecond, unit: UnitSpeed.kilometersPerHour) + } + + return Text( + windSpeed.formatted( + .measurement( + width: .abbreviated, + numberFormatStyle: .number.grouping(.never) + .precision(.fractionLength(0)))) + ) + } ?? Text(verbatim: Constants.nilValueIndicator) + }) + } + + private func createMockTelemetryEntity(windSpeed: Float?) -> TelemetryEntity { + // Create a mock TelemetryEntity with the specified wind speed + let context = PersistenceController.preview.container.viewContext + let entity = TelemetryEntity(context: context) + entity.windSpeed = windSpeed ?? 0 + + // If windSpeed is nil, we need to set it as nil in the entity + if windSpeed == nil { + entity.windSpeed = 0 // TelemetryEntity might not support optional Float directly + } + + return entity + } + + private func extractViewContent(from entity: TelemetryEntity) -> AnyView? { + let column = createWindSpeedColumn() + return column.body(entity) + } +} + +// MARK: - Measurement Tests + +extension WindSpeedColumnTests { + + func testMeasurementCreationLowSpeed() { + let speed: Double = 7.5 + let measurement = Measurement(value: speed, unit: UnitSpeed.metersPerSecond) + + XCTAssertEqual(measurement.value, 7.5, accuracy: 0.001) + XCTAssertEqual(measurement.unit, UnitSpeed.metersPerSecond) + } + + func testMeasurementCreationHighSpeed() { + let speed: Double = 25.0 + let measurement = Measurement(value: speed, unit: UnitSpeed.kilometersPerHour) + + XCTAssertEqual(measurement.value, 25.0, accuracy: 0.001) + XCTAssertEqual(measurement.unit, UnitSpeed.kilometersPerHour) + } + + func testUnitConversionFromMPSToKMH() { + // 10 m/s should equal 36 km/h + let speedMPS = Measurement(value: 10.0, unit: UnitSpeed.metersPerSecond) + let speedKMH = speedMPS.converted(to: .kilometersPerHour) + + XCTAssertEqual(speedKMH.value, 36.0, accuracy: 0.01) + } + + func testUnitConversionFromKMHToMPS() { + // 36 km/h should equal 10 m/s + let speedKMH = Measurement(value: 36.0, unit: UnitSpeed.kilometersPerHour) + let speedMPS = speedKMH.converted(to: .metersPerSecond) + + XCTAssertEqual(speedMPS.value, 10.0, accuracy: 0.01) + } +}