diff --git a/Sources/BetterFit/Models/WorkoutStatistics.swift b/Sources/BetterFit/Models/WorkoutStatistics.swift index 3c3c30f..57b2b1a 100644 --- a/Sources/BetterFit/Models/WorkoutStatistics.swift +++ b/Sources/BetterFit/Models/WorkoutStatistics.swift @@ -87,11 +87,12 @@ public struct WorkoutStatistics: Codable, Equatable, Sendable { case "j": return sum / 4184 default: - return sum + return nil } } - /// Sum expressed in metres, or nil when this is not a distance statistic. + /// Sum expressed in metres, or nil when this is not a distance statistic + /// or the unit cannot be normalised. public var sumMetres: Double? { guard kind == .distanceWalkingRunning || kind == .distanceCycling, let sum else { return nil @@ -104,7 +105,7 @@ public struct WorkoutStatistics: Codable, Equatable, Sendable { case "m", nil: return sum default: - return sum + return nil } } } diff --git a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift index c00a4dd..6435b17 100644 --- a/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift +++ b/Sources/BetterFit/Services/HealthKit/HealthExportParser.swift @@ -137,7 +137,7 @@ public enum HealthExportParser { case "m", nil: return value default: - return value + return nil } } @@ -151,7 +151,7 @@ public enum HealthExportParser { case "kcal", "cal", nil: return value default: - return value + return nil } } } diff --git a/Tests/BetterFitTests/WorkoutUnitValidationTests.swift b/Tests/BetterFitTests/WorkoutUnitValidationTests.swift new file mode 100644 index 0000000..832434b --- /dev/null +++ b/Tests/BetterFitTests/WorkoutUnitValidationTests.swift @@ -0,0 +1,143 @@ +import Foundation +import XCTest + +import BetterFit + +final class WorkoutUnitValidationTests: XCTestCase { + + // MARK: - Supported Units + + func testSupportedEnergyUnitsAndOmittedUnitKeepExistingConversions() throws { + let cases: [(String?, Double)] = [ + ("kcal", 4184), ("cal", 4184), (nil, 4184), ("KJ", 1000), ("J", 1) + ] + for (unit, expected) in cases { + for type in ["ActiveEnergyBurned", "BasalEnergyBurned"] { + let statistic = WorkoutStatistics( + type: "HKQuantityTypeIdentifier" + type, sum: 4184, unit: unit + ) + XCTAssertEqual(try XCTUnwrap(statistic.sumKilocalories), expected, accuracy: 0.000001) + } + let workout = try parseWorkout(energyUnit: unit, distanceUnit: "m") + XCTAssertEqual(try XCTUnwrap(workout.totalEnergyBurned), expected, accuracy: 0.000001) + } + } + + func testSupportedDistanceUnitsAndOmittedUnitKeepExistingConversions() throws { + let cases: [(String?, Double)] = [("m", 1), (nil, 1), ("KM", 1000), ("MI", 1609.344)] + for (unit, expected) in cases { + for type in ["DistanceWalkingRunning", "DistanceCycling"] { + let statistic = WorkoutStatistics( + type: "HKQuantityTypeIdentifier" + type, sum: 1, unit: unit + ) + XCTAssertEqual(try XCTUnwrap(statistic.sumMetres), expected, accuracy: 0.000001) + } + let workout = try parseWorkout(energyUnit: "kcal", distanceUnit: unit) + XCTAssertEqual(try XCTUnwrap(workout.totalDistance), expected, accuracy: 0.000001) + } + } + + // MARK: - Unsupported Units + + func testUnsupportedStatisticUnitsPreserveRawValuesButHaveNoNormalisedValue() throws { + for unit in ["unknown", "", "count"] { + let statistics = ["ActiveEnergyBurned", "BasalEnergyBurned", "DistanceWalkingRunning", "DistanceCycling"] + .map { WorkoutStatistics(type: "HKQuantityTypeIdentifier" + $0, sum: 42, unit: unit) } + + for statistic in statistics { + XCTAssertNil(statistic.sumKilocalories) + XCTAssertNil(statistic.sumMetres) + XCTAssertEqual(statistic.sum, 42) + XCTAssertEqual(statistic.unit, unit) + } + + let data = try JSONEncoder().encode(statistics) + XCTAssertEqual(try JSONDecoder().decode([WorkoutStatistics].self, from: data), statistics) + } + } + + func testUnsupportedChildUnitsFallBackToRecognisedWorkoutTotals() throws { + let workout = try parseWorkout(energyUnit: "kcal", distanceUnit: "km", statistics: """ + + + + """) + + XCTAssertEqual(workout.activeEnergyKilocalories, 4184) + XCTAssertEqual(workout.distanceMetres, 1000) + XCTAssertNil(workout.basalEnergyKilocalories) + XCTAssertEqual(workout.statistics[0].sum, 99) + XCTAssertEqual(workout.statistics[0].unit, "unknown") + } + + func testUnsupportedWalkingDistanceFallsBackToRecognisedCyclingStatistic() throws { + let workout = try parseWorkout(energyUnit: "kcal", distanceUnit: "m", statistics: """ + + + """) + + XCTAssertEqual(workout.distanceMetres, 2000) + } + + func testRecognisedChildStatisticsSurviveUnsupportedWorkoutTotalUnits() throws { + let workout = try parseWorkout(energyUnit: "unknown", distanceUnit: "unknown", statistics: """ + + + """) + + XCTAssertNil(workout.totalEnergyBurned) + XCTAssertNil(workout.totalDistance) + XCTAssertEqual(try XCTUnwrap(workout.activeEnergyKilocalories), 100, accuracy: 0.000001) + XCTAssertEqual(workout.distanceMetres, 5000) + } + + // MARK: - Weekly Analytics + + func testUnsupportedUnitsDoNotContaminateWeeklyTotals() throws { + for unit in ["unknown", "", "count"] { + let unsupported = try parseWorkout(energyUnit: unit, distanceUnit: unit, statistics: """ + + + """) + let missing = try XCTUnwrap(HealthExport(workouts: [unsupported]).weeklyTrainingSummaries().first) + XCTAssertEqual(missing.workoutCount, 1) + XCTAssertNil(missing.totalActiveEnergyKilocalories) + XCTAssertNil(missing.totalDistanceMetres) + + let recognised = try parseWorkout(energyUnit: "kcal", distanceUnit: "km") + let mixed = try XCTUnwrap( + HealthExport(workouts: [unsupported, recognised]).weeklyTrainingSummaries().first + ) + XCTAssertEqual(mixed.workoutCount, 2) + XCTAssertEqual(mixed.totalActiveEnergyKilocalories, 4184) + XCTAssertEqual(mixed.totalDistanceMetres, 1000) + + let zero = ParsedWorkout( + activityType: "Running", startDate: unsupported.startDate, totalEnergyBurned: 0, totalDistance: 0 + ) + let measuredZero = try XCTUnwrap( + HealthExport(workouts: [unsupported, zero]).weeklyTrainingSummaries().first + ) + XCTAssertEqual(measuredZero.totalActiveEnergyKilocalories, 0) + XCTAssertEqual(measuredZero.totalDistanceMetres, 0) + } + } + + // MARK: - Fixtures + + private func parseWorkout( + energyUnit: String?, distanceUnit: String?, statistics: String = "" + ) throws -> ParsedWorkout { + let energyAttribute = energyUnit.map { "totalEnergyBurnedUnit=\"\($0)\"" } ?? "" + let distanceAttribute = distanceUnit.map { "totalDistanceUnit=\"\($0)\"" } ?? "" + let xml = """ + + + \(statistics) + + + """ + return try XCTUnwrap(HealthExportParser.parseWorkouts(xml: xml).first) + } +}