diff --git a/MyHeartCounts.xcodeproj/project.pbxproj b/MyHeartCounts.xcodeproj/project.pbxproj index e98b22c4..147e6ce9 100644 --- a/MyHeartCounts.xcodeproj/project.pbxproj +++ b/MyHeartCounts.xcodeproj/project.pbxproj @@ -1306,8 +1306,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SchmiedmayerLab/Spezi.git"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.1.6; + kind = exactVersion; + version = 0.1.6; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/MyHeartCounts/Account/AccountSheet.swift b/MyHeartCounts/Account/AccountSheet.swift index bca4f2e0..7554ee66 100644 --- a/MyHeartCounts/Account/AccountSheet.swift +++ b/MyHeartCounts/Account/AccountSheet.swift @@ -179,6 +179,7 @@ struct AccountSheet: View { .foregroundStyle(.textLabel) } PostTrialNudgesToggle() + UpdateComorbiditiesButton() NavigationLink("Review Consent Forms") { SignedConsentForms() } diff --git a/MyHeartCounts/Account/Demographics/DemographicsData.swift b/MyHeartCounts/Account/Demographics/DemographicsData.swift index 74b52dde..aae220ab 100644 --- a/MyHeartCounts/Account/Demographics/DemographicsData.swift +++ b/MyHeartCounts/Account/Demographics/DemographicsData.swift @@ -122,6 +122,14 @@ final class DemographicsData { populate(from: account.details ?? AccountDetails()) } + /// Populates the instance, unless it already has been populated before. + func populateIfEmpty(from account: Account) { + guard self.account == nil else { + return + } + populate(from: account) + } + private func populate(from details: AccountDetails) { shouldHandleUpdates = false defer { diff --git a/MyHeartCounts/Account/UpdateComorbiditiesButton.swift b/MyHeartCounts/Account/UpdateComorbiditiesButton.swift new file mode 100644 index 00000000..8519912b --- /dev/null +++ b/MyHeartCounts/Account/UpdateComorbiditiesButton.swift @@ -0,0 +1,71 @@ +// +// This source file is part of the My Heart Counts iOS application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2026 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SFSafeSymbols +import SpeziAccount +import SpeziViews +import SwiftUI + + +struct UpdateComorbiditiesButton: View { + @Environment(Account.self) + private var account + + @State private var showSheet = false + @State private var demographicsData = DemographicsData() + @State private var viewState: ViewState = .idle + + var body: some View { + Button { + showSheet = true + } label: { + HStack { + Text("UPDATE_COMORBIDITIES_BUTTON_TITLE") + .foregroundStyle(.textLabel) + Spacer() + DisclosureIndicator() + } + } + .disabled(account.details == nil) + .sheet(isPresented: $showSheet) { + @Bindable var data = demographicsData + NavigationStack { + ComorbiditiesPicker(selection: $data[\.comorbidities].withDefault(Comorbidities())) + .navigationBarTitleDisplayMode(.inline) + .interactiveDismissDisabled() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + closeSheetButton + } + } + } + .onAppear { + demographicsData.populateIfEmpty(from: account) + } + } + } + + @ViewBuilder private var closeSheetButton: some View { + if #available(iOS 26, *) { + AsyncButton(role: .confirm, state: $viewState) { + try await closeSheet() + } label: { + Label("Done", systemSymbol: .checkmark) + } + } else { + AsyncButton("Done", state: $viewState) { + try await closeSheet() + } + } + } + + private func closeSheet() async throws { + showSheet = false + try await demographicsData.flush() + } +} diff --git a/MyHeartCounts/Heart Health Dashboard/CVHScore.swift b/MyHeartCounts/Heart Health Dashboard/CVHScore.swift index d323b1f2..be3bcac5 100644 --- a/MyHeartCounts/Heart Health Dashboard/CVHScore.swift +++ b/MyHeartCounts/Heart Health Dashboard/CVHScore.swift @@ -400,12 +400,15 @@ extension ScoreDefinition { ]) }() + /// LDL Cholesterol static let cvhBloodLipids = ScoreDefinition(default: 0, scoringBands: [ - .inRange(..<130, score: 1, explainer: "< 130"), - .inRange(130..<160, score: 0.6, explainer: "130 – 159"), - .inRange(160..<190, score: 0.4, explainer: "160 – 189"), - .inRange(190..<220, score: 0.2, explainer: "190 – 219"), - .inRange(220..., score: 0, explainer: "220+") + .inRange(..<56, score: 1, explainer: "< 55"), + .inRange(56..<70, score: 0.9, explainer: "56 – 69"), + .inRange(70..<100, score: 0.8, explainer: "70 – 99"), + .inRange(100..<130, score: 0.6, explainer: "100 – 129"), + .inRange(130..<160, score: 0.4, explainer: "130 – 159"), + .inRange(160..<190, score: 0.2, explainer: "160 – 189"), + .inRange(190..., score: 0, explainer: "190+") ]) static let cvhBloodGlucose = ScoreDefinition(default: 0, scoringBands: [ diff --git a/MyHeartCounts/Heart Health Dashboard/PastTimedWalkTestResultInfo.swift b/MyHeartCounts/Heart Health Dashboard/PastTimedWalkTestResultInfo.swift new file mode 100644 index 00000000..984a60bb --- /dev/null +++ b/MyHeartCounts/Heart Health Dashboard/PastTimedWalkTestResultInfo.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the My Heart Counts iOS application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2026 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziStudyDefinition +import SwiftUI + + +struct TimedWalkRunTestResultInfo: View { + let result: TimedWalkingTestResult + + var body: some View { + LabeledContent("Date", value: result.startDate, format: .dateTime) + LabeledContent("Steps", value: result.numberOfSteps, format: .number) + LabeledContent( + "Distance", + value: Measurement(value: result.distanceCovered, unit: .meters), + format: .measurement(width: .abbreviated) + ) + switch result.test.kind { + case .walking: + let _ = () // swiftlint:disable:this redundant_discardable_let + case .running: + LabeledContent( + "VO2MAX_ESTIMATE_LABEL", + value: Measurement( + value: (result.distanceCovered - 504.9) / 44.73, + unit: .mlPerKgPerMin + ), + format: .measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number.precision(.fractionLength(...1))) + ) + } + } +} diff --git a/MyHeartCounts/Heart Health Dashboard/PastTimedWalkTestResults.swift b/MyHeartCounts/Heart Health Dashboard/PastTimedWalkTestResults.swift index 3b27559d..fb158858 100644 --- a/MyHeartCounts/Heart Health Dashboard/PastTimedWalkTestResults.swift +++ b/MyHeartCounts/Heart Health Dashboard/PastTimedWalkTestResults.swift @@ -70,14 +70,7 @@ struct PastTimedWalkTestResults: View { @ViewBuilder private func makeDetailsView(for result: TimedWalkingTestResult) -> some View { Form { - LabeledContent("Start", value: result.startDate, format: .dateTime) - LabeledContent("End", value: result.endDate, format: .dateTime) - LabeledContent("Number of Steps", value: result.numberOfSteps, format: .number) - LabeledContent( - "Distance", - value: Measurement(value: result.distanceCovered, unit: .meters), - format: .measurement(width: .abbreviated) - ) + TimedWalkRunTestResultInfo(result: result) } .navigationTitle(result.test.displayTitle.localizedString()) .navigationBarTitleDisplayMode(.inline) diff --git a/MyHeartCounts/Modules/SetupTestEnvironment.swift b/MyHeartCounts/Modules/SetupTestEnvironment.swift index 613d62a5..22a6f242 100644 --- a/MyHeartCounts/Modules/SetupTestEnvironment.swift +++ b/MyHeartCounts/Modules/SetupTestEnvironment.swift @@ -159,7 +159,7 @@ final class SetupTestEnvironment: Module, EnvironmentAccessible, Sendable { } - private func loginAndEnroll( // swiftlint:disable:this function_body_length + private func loginAndEnroll( // swiftlint:disable:this function_body_length cyclomatic_complexity _ credentials: SetupTestEnvironmentConfig.Credentials ) async throws { logger.notice("Logging in and enrolling into Study using credentials \(String(describing: credentials))") @@ -245,7 +245,16 @@ final class SetupTestEnvironment: Module, EnvironmentAccessible, Sendable { newDetails.weightInKG = 67 newDetails.raceEthnicity = .white newDetails.latinoStatus = LatinoStatusOption.options[0] - newDetails.comorbidities = Comorbidities() + newDetails.comorbidities = { () -> Comorbidities in + var value = Comorbidities() + guard let heartFailureOption = Comorbidities.Comorbidity.primaryComorbidities.first(where: { + $0.title.localizedString(for: .enUS).contains("Heart Failure") + }) else { + return value + } + value[heartFailureOption] = .selected(startDate: DateComponents()) + return value + }() newDetails.usRegion = .dc newDetails.householdIncomeUS = HouseholdIncomeUS.options[0] newDetails.educationUS = EducationStatusUS.options[0] diff --git a/MyHeartCounts/Resources/Localizable.xcstrings b/MyHeartCounts/Resources/Localizable.xcstrings index c6dd8a47..63033ef1 100644 --- a/MyHeartCounts/Resources/Localizable.xcstrings +++ b/MyHeartCounts/Resources/Localizable.xcstrings @@ -485,10 +485,10 @@ "< 7" : { "shouldTranslate" : false }, - "< 85" : { - "shouldTranslate" : false + "< 55" : { + }, - "< 130" : { + "< 85" : { "shouldTranslate" : false }, "<120 / <80" : { @@ -752,9 +752,15 @@ }, "30 – 59" : { "shouldTranslate" : false + }, + "56 – 69" : { + }, "60 – 89" : { "shouldTranslate" : false + }, + "70 – 99" : { + }, "85 – 99" : { "shouldTranslate" : false @@ -767,6 +773,9 @@ }, "100 – 109" : { "shouldTranslate" : false + }, + "100 – 129" : { + }, "110 – 125" : { "shouldTranslate" : false @@ -780,8 +789,8 @@ "126 – 140" : { "shouldTranslate" : false }, - "130 – 159" : { - "shouldTranslate" : false + "130 – 159" : { + }, "130–139 / 90–99" : { "shouldTranslate" : false @@ -795,11 +804,8 @@ "160 – 189" : { "shouldTranslate" : false }, - "190 – 219" : { - "shouldTranslate" : false - }, - "220+" : { - "shouldTranslate" : false + "190+" : { + }, "Abdominal Aortic Aneurysm" : { "localizations" : { @@ -2311,22 +2317,6 @@ } } }, - "Disclosure Indicator" : { - "localizations" : { - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Disclosure Indicator" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Indicador de Divulgación" - } - } - } - }, "Distance" : { "localizations" : { "en-GB" : { @@ -2753,22 +2743,6 @@ } } }, - "End" : { - "localizations" : { - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "End" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Finalizar" - } - } - } - }, "England (%@)" : { "localizations" : { "en-GB" : { @@ -5920,22 +5894,6 @@ } } }, - "Number of Steps" : { - "localizations" : { - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Number of Steps" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Número de pasos" - } - } - } - }, "Ohio" : { "localizations" : { "en-GB" : { @@ -9421,6 +9379,28 @@ } } }, + "UPDATE_COMORBIDITIES_BUTTON_TITLE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Health Conditions" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Health Conditions" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizar afecciones médicas" + } + } + } + }, "US State / Region" : { "localizations" : { "en-GB" : { @@ -9569,6 +9549,28 @@ } } }, + "VO2MAX_ESTIMATE_LABEL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VO₂ max (estimate)" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "VO₂ max (estimate)" + } + }, + "es" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "VO₂ máx. (estimación)" + } + } + } + }, "Vocational Training" : { "localizations" : { "en-GB" : { diff --git a/MyHeartCounts/Task Handling/Active Tasks/Timed Walk Test/TimedWalkingTestView.swift b/MyHeartCounts/Task Handling/Active Tasks/Timed Walk Test/TimedWalkingTestView.swift index 3fe88ed3..3b6145c2 100644 --- a/MyHeartCounts/Task Handling/Active Tasks/Timed Walk Test/TimedWalkingTestView.swift +++ b/MyHeartCounts/Task Handling/Active Tasks/Timed Walk Test/TimedWalkingTestView.swift @@ -197,13 +197,7 @@ private struct TimedWalkingTestView: View { @ViewBuilder private var resultsSection: some View { if !testIsRunning, let result = mostRecentResult { Section("Test Complete") { - LabeledContent("Date", value: result.startDate, format: .dateTime) - LabeledContent("Steps", value: result.numberOfSteps, format: .number) - LabeledContent( - "Distance", - value: Measurement(value: result.distanceCovered, unit: .meters), - format: .measurement(width: .abbreviated) - ) + TimedWalkRunTestResultInfo(result: result) } } } diff --git a/MyHeartCounts/Utils/DisclosureIndicator.swift b/MyHeartCounts/Utils/DisclosureIndicator.swift index 7370baeb..7dd2fee6 100644 --- a/MyHeartCounts/Utils/DisclosureIndicator.swift +++ b/MyHeartCounts/Utils/DisclosureIndicator.swift @@ -19,7 +19,7 @@ struct DisclosureIndicator: View { .foregroundStyle(Color.disclosureIndicator) .font(Font(UIFont.preferredFont(forTextStyle: .emphasizedBody) as CTFont)) .imageScale(.small) - .accessibilityLabel("Disclosure Indicator") + .accessibilityHidden(true) } } diff --git a/MyHeartCounts/Utils/UnitOxygenConsumption.swift b/MyHeartCounts/Utils/UnitOxygenConsumption.swift new file mode 100644 index 00000000..ac1450cc --- /dev/null +++ b/MyHeartCounts/Utils/UnitOxygenConsumption.swift @@ -0,0 +1,21 @@ +// +// This source file is part of the My Heart Counts iOS application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2026 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +final class UnitOxygenConsumption: Dimension, @unchecked Sendable { + static let mlPerKgPerMin = UnitOxygenConsumption( + symbol: "mL/kg·min", + converter: UnitConverterLinear(coefficient: 1) + ) + + override class func baseUnit() -> UnitOxygenConsumption { + mlPerKgPerMin + } +} diff --git a/MyHeartCountsShared/Package.swift b/MyHeartCountsShared/Package.swift index 93a36ffb..2372ffef 100644 --- a/MyHeartCountsShared/Package.swift +++ b/MyHeartCountsShared/Package.swift @@ -11,7 +11,7 @@ import PackageDescription var packageDeps: [Package.Dependency] = [ - .package(url: "https://github.com/SchmiedmayerLab/Spezi.git", .upToNextMinor(from: "0.1.1")), + .package(url: "https://github.com/SchmiedmayerLab/Spezi.git", exact: "0.1.6"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.93.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0") ] diff --git a/MyHeartCountsUITests/ConsentTests.swift b/MyHeartCountsUITests/ConsentTests.swift index 094a0d8b..ea21f4d3 100644 --- a/MyHeartCountsUITests/ConsentTests.swift +++ b/MyHeartCountsUITests/ConsentTests.swift @@ -194,7 +194,7 @@ final class ConsentTests: MHCTestCase, Sendable { skipGoingToHomeTab: true ) // the study bundle now contains a new consent version than what was signed before, which we expect to trigger the renewal flow. - XCTAssert(app.otherElements["MHC:ConsentRenewalFlow"].waitForExistence(timeout: 7)) + XCTAssert(app.otherElements["MHC:ConsentRenewalFlow"].waitForExistence(timeout: 10)) XCTAssert(app.staticTexts["Consent Renewal"].exists) app.buttons["Continue"].tap() @@ -203,7 +203,7 @@ final class ConsentTests: MHCTestCase, Sendable { expectedName: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), signUpForExtraTrial: false ) - XCTAssert(app.otherElements["MHC:ConsentRenewalFlow"].waitForNonExistence(timeout: 7)) + XCTAssert(app.otherElements["MHC:ConsentRenewalFlow"].waitForNonExistence(timeout: 10)) // verify that the user is now recorded as having signed the new version XCTAssertEqual(try XCTUnwrap(determineLastSignedConsentVersion()), nextConsentVersion) app.terminate() diff --git a/MyHeartCountsUITests/MHCTestCase.swift b/MyHeartCountsUITests/MHCTestCase.swift index 6059c140..b41dfc06 100644 --- a/MyHeartCountsUITests/MHCTestCase.swift +++ b/MyHeartCountsUITests/MHCTestCase.swift @@ -183,7 +183,7 @@ class MHCTestCase: XCTestCase, Sendable { } likelyHasUndecidedPermissions = false } - if !skipGoingToHomeTab { + if !skipGoingToHomeTab && !(testEnvironmentConfig == .init(resetExistingData: true, loginAndEnroll: .skip)) { XCTAssert(app.tabBars.element.waitForExistence(timeout: 10)) goToTab(.home) XCTAssert(app.staticTexts["My Heart Counts"].waitForExistence(timeout: 1)) diff --git a/MyHeartCountsUITests/OnboardingNavigator.swift b/MyHeartCountsUITests/OnboardingNavigator.swift index 162463dc..674fcd1e 100644 --- a/MyHeartCountsUITests/OnboardingNavigator.swift +++ b/MyHeartCountsUITests/OnboardingNavigator.swift @@ -319,7 +319,6 @@ struct OnboardingNavigator { // swiftlint:disable:this type_body_length private func navigateDemographics() { let navigator = DemographicsNavigator(testCase: testCase) navigator.fillInDemographics() - let continueButton = app.buttons["Continue"] while !continueButton.exists { app.swipeUp() diff --git a/MyHeartCountsUITests/OtherTests.swift b/MyHeartCountsUITests/OtherTests.swift index 3920ec1c..76d096ef 100644 --- a/MyHeartCountsUITests/OtherTests.swift +++ b/MyHeartCountsUITests/OtherTests.swift @@ -59,4 +59,36 @@ final class OtherTests: MHCTestCase, Sendable { ) XCTAssert(app.staticTexts["Completed"].waitForExistence(timeout: 7)) } + + + func testUpdateComorbidities() throws { + let credentials: SetupTestEnvironmentConfig.Credentials = .random() + try supplyHealthCharacteristics() + try launchAppAndEnrollIntoStudy( + testEnvironmentConfig: .init(resetExistingData: true, loginAndEnroll: .skip) + ) + do { + let navigator = OnboardingNavigator(testCase: self) + try navigator.navigateFullOnboardingFlow( + region: try XCTUnwrap(appLocale.region), + name: .init(givenName: "Leland", familyName: "Stanford"), + credentials: credentials, + signUpForExtraTrial: false, + consentPresenceCheck: .dontCare + ) + } + try launchAppAndEnrollIntoStudy(testEnvironmentConfig: .init(resetExistingData: false, loginAndEnroll: .enable(credentials))) + openAccountSheet() + app.swipeUp() + app.buttons["Update Health Conditions"].tap() + XCTAssert(app.buttons["Heart Failure, Selected"].waitForExistence(timeout: 4)) + app.buttons["Diabetes"].tap() + app.navigationBars["Diabetes"].buttons["Done"].tap() + XCTAssert(app.buttons["Diabetes, Selected"].waitForExistence(timeout: 4)) + app.navigationBars.buttons["Done"].tap() + sleep(for: .seconds(2)) // give it time to sync + app.buttons["Update Health Conditions"].tap() + XCTAssert(app.buttons["Heart Failure, Selected"].waitForExistence(timeout: 4)) + XCTAssert(app.buttons["Diabetes, Selected"].waitForExistence(timeout: 4)) + } } diff --git a/MyHeartCountsUITests/TaskHandlingTests.swift b/MyHeartCountsUITests/TaskHandlingTests.swift index 54f687cf..ec7fc209 100644 --- a/MyHeartCountsUITests/TaskHandlingTests.swift +++ b/MyHeartCountsUITests/TaskHandlingTests.swift @@ -6,55 +6,11 @@ // SPDX-License-Identifier: MIT // -import HealthKit -import SpeziFoundation import XCTest -import XCTestExtensions import XCTHealthKit final class TaskHandlingTests: MHCTestCase, Sendable { - private var timedWalkTestDistance: String { - switch appLocale.measurementSystem { - case .us: - "2,762 ft" - default: - "842 m" - } - } - - - // also tests the Timed Walk Test UI - func testCompleteScheduledTaskViaAlwaysAvailableMenu() throws { - try launchAppAndEnrollIntoStudy() - goToTab(.upcoming) - - let completionMessage = app.collectionViews.staticTexts["Six-Minute Walk Test, Completed"] - - do { - var numTries = 0 - while numTries < 10, !app.collectionViews.buttons["Take Test: Six-Minute Walk Test"].waitForExistence(timeout: 2) { - app.swipeUp() - numTries += 1 - } - } - XCTAssert(app.collectionViews.buttons["Take Test: Six-Minute Walk Test"].waitForExistence(timeout: 2)) - XCTAssertFalse(completionMessage.exists) - - app.navigationBars["Tasks"].buttons["Perform Always Available Task"].tap() - XCTAssert(app.buttons["Six-Minute Walk Test"].waitForExistence(timeout: 2)) - app.buttons["Six-Minute Walk Test"].tap() - XCTAssert(app.buttons["Start Test"].waitForExistence(timeout: 2)) - app.buttons["Start Test"].tap() - handleMotionAndFitnessAccessPrompt(timeout: .seconds(2)) - XCTAssert(app.staticTexts["Test Complete"].waitForExistence(timeout: 15)) - XCTAssert(app.staticTexts["Steps, 624"].exists) - XCTAssert(app.staticTexts["Distance, \(timedWalkTestDistance)"].exists) - app.navigationBars.buttons["Close"].tap() - XCTAssert(completionMessage.exists) - } - - func testECG() throws { try launchAppAndEnrollIntoStudy() goToTab(.upcoming) @@ -79,62 +35,6 @@ final class TaskHandlingTests: MHCTestCase, Sendable { } - func testHHDPastTimedWalkTests() throws { - try launchAppAndEnrollIntoStudy() - goToTab(.heartHealth) - app.swipeUp() - app.swipeUp() - app.swipeUp() - app.staticTexts["Past Timed Walk/Run Test Results"].tap() - sleep(for: .seconds(1)) - - let testCellPred = NSPredicate(format: "label LIKE 'Six-Minute Walk Test, *'") - var numTests: Int { - app.buttons.matching(testCellPred).count - } - let numTestsBefore = numTests - - if case let button = app.buttons.element(matching: testCellPred), - button.waitForExistence(timeout: 2) { - button.firstMatch.tap() - XCTAssert(app.navigationBars["Six-Minute Walk Test"].waitForExistence(timeout: 2)) - XCTAssert(app.staticTexts["Number of Steps, 624"].waitForExistence(timeout: 2)) - XCTAssert(app.staticTexts["Distance, \(timedWalkTestDistance)"].waitForExistence(timeout: 2)) - app.navigationBars["Six-Minute Walk Test"].buttons["BackButton"].tap() - } - - app.buttons["Six-Minute Walk Test"].tap() - - app.buttons["Start Test"].tap() - handleMotionAndFitnessAccessPrompt(timeout: .seconds(2)) - XCTAssert(app.staticTexts["Test Complete"].waitForExistence(timeout: 10)) - XCTAssert(app.staticTexts["Steps, 624"].exists) - XCTAssert(app.staticTexts["Distance, \(timedWalkTestDistance)"].exists) - app.otherElements["MHC.TimedWalkTestView"].navigationBars.buttons["Close"].tap() - let numTestsAfter = numTests - XCTAssertEqual(numTestsAfter, numTestsBefore + 1) - } - - - func testRecoverTimedWalkTest() throws { - try launchAppAndEnrollIntoStudy() - goToTab(.upcoming) - - app.navigationBars["Tasks"].buttons["Perform Always Available Task"].tap() - XCTAssert(app.buttons["Six-Minute Walk Test"].waitForExistence(timeout: 2)) - app.buttons["Six-Minute Walk Test"].tap() - XCTAssert(app.buttons["Start Test"].waitForExistence(timeout: 2)) - app.buttons["Start Test"].tap() - handleMotionAndFitnessAccessPrompt(timeout: .seconds(2)) - XCTAssert(app.staticTexts["Your 6-Minute Walk Test is in progress."].waitForExistence(timeout: 5)) - app.terminate() - try launchAppAndEnrollIntoStudy( - skipGoingToHomeTab: true - ) - XCTAssert(app.staticTexts["Your 6-Minute Walk Test is in progress."].waitForExistence(timeout: 5)) - } - - func testHomeTabTaskSheetLifetime() throws { try launchAppAndEnrollIntoStudy() goToTab(.home) @@ -151,18 +51,3 @@ final class TaskHandlingTests: MHCTestCase, Sendable { XCTAssert(dietIntroTextElement.waitForExistence(timeout: 2)) } } - - -extension MHCTestCase { - func handleMotionAndFitnessAccessPrompt(timeout: Duration) { - let app = XCUIApplication.springboard - let alert = app.alerts.element(matching: "label LIKE %@", "“*” would like to access your Motion & Fitness activity.") - if alert.waitForExistence(timeout: timeout.timeInterval) { - // wait a little longer. sometimes the "exists" above resolves to true while the alert is still being presented, - // in which case the tap is a little too early and doesn't actually get registered. - let allowButton = alert.buttons["Allow"] - XCTAssert(allowButton.wait(for: \.isHittable, toEqual: true, timeout: 5)) - allowButton.tap() - } - } -} diff --git a/MyHeartCountsUITests/TimedWalkTestTests.swift b/MyHeartCountsUITests/TimedWalkTestTests.swift new file mode 100644 index 00000000..83647cbd --- /dev/null +++ b/MyHeartCountsUITests/TimedWalkTestTests.swift @@ -0,0 +1,154 @@ +// +// This source file is part of the My Heart Counts iOS application based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2026 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import MyHeartCountsShared +import SpeziFoundation +import XCTest + + +final class TimedWalkTestTests: MHCTestCase { + private enum WalkRunTestKind: String { + case sixMWT = "Six-Minute Walk Test" + case twelveMRT = "12-Minute Run Test" + } + + + private var timedWalkTestDistance: String { + switch appLocale.measurementSystem { + case .us: + "2,762 ft" + default: + "842 m" + } + } + + + /// Tests the siz minute walk test, and the display of past tests. + func test6MWT() throws { + try _testTimedWalkRunTest(kind: .sixMWT) + } + + + /// Tests the 12 minute run test, and the display of past tests. + func test12MRT() throws { + try _testTimedWalkRunTest(kind: .twelveMRT) + } + + + // also tests the Timed Walk Test UI + func testCompleteScheduledTaskViaAlwaysAvailableMenu() throws { + try launchAppAndEnrollIntoStudy() + goToTab(.upcoming) + + let completionMessage = app.collectionViews.staticTexts["Six-Minute Walk Test, Completed"] + + do { + var numTries = 0 + while numTries < 10, !app.collectionViews.buttons["Take Test: Six-Minute Walk Test"].waitForExistence(timeout: 2) { + app.swipeUp() + numTries += 1 + } + } + XCTAssert(app.collectionViews.buttons["Take Test: Six-Minute Walk Test"].waitForExistence(timeout: 2)) + XCTAssertFalse(completionMessage.exists) + + app.navigationBars["Tasks"].buttons["Perform Always Available Task"].tap() + XCTAssert(app.buttons["Six-Minute Walk Test"].waitForExistence(timeout: 2)) + app.buttons["Six-Minute Walk Test"].tap() + XCTAssert(app.buttons["Start Test"].waitForExistence(timeout: 2)) + app.buttons["Start Test"].tap() + handleMotionAndFitnessAccessPrompt(timeout: .seconds(2)) + XCTAssert(app.staticTexts["Test Complete"].waitForExistence(timeout: 15)) + XCTAssert(app.staticTexts["Steps, 624"].exists) + XCTAssert(app.staticTexts["Distance, \(timedWalkTestDistance)"].exists) + app.navigationBars.buttons["Close"].tap() + XCTAssert(completionMessage.exists) + } + + + private func _testTimedWalkRunTest(kind: WalkRunTestKind) throws { + try launchAppAndEnrollIntoStudy() + goToTab(.heartHealth) + app.swipeUp() + app.swipeUp() + app.swipeUp() + app.staticTexts["Past Timed Walk/Run Test Results"].tap() + sleep(for: .seconds(1)) + + let testCellPred = NSPredicate(format: "label LIKE '\(kind.rawValue), *'") + var numTests: Int { + app.buttons.matching(testCellPred).count + } + let numTestsBefore = numTests + + let assertTestDetails = { [self] in + XCTAssert(app.staticTexts["Steps, 624"].waitForExistence(timeout: 2)) + XCTAssert(app.staticTexts["Distance, \(timedWalkTestDistance)"].waitForExistence(timeout: 2)) + let vo2MaxLabel = app.staticTexts["VO₂ max (estimate), 7.5 mL/kg·min"] + switch kind { + case .sixMWT: + XCTAssertFalse(vo2MaxLabel.exists) + case .twelveMRT: + XCTAssert(vo2MaxLabel.exists) + } + } + + if case let button = app.buttons.element(matching: testCellPred), + button.waitForExistence(timeout: 2) { + button.firstMatch.tap() + XCTAssert(app.navigationBars[kind.rawValue].waitForExistence(timeout: 2)) + assertTestDetails() + app.navigationBars[kind.rawValue].buttons["BackButton"].tap() + } + + app.buttons[kind.rawValue].tap() + app.swipeUp() + app.buttons["Start Test"].tap() + handleMotionAndFitnessAccessPrompt(timeout: .seconds(2)) + XCTAssert(app.staticTexts["Test Complete"].waitForExistence(timeout: 10)) + assertTestDetails() + app.otherElements["MHC.TimedWalkTestView"].navigationBars.buttons["Close"].tap() + let numTestsAfter = numTests + XCTAssertEqual(numTestsAfter, numTestsBefore + 1) + } + + + func testRecoverTimedWalkTest() throws { + try launchAppAndEnrollIntoStudy() + goToTab(.upcoming) + + app.navigationBars["Tasks"].buttons["Perform Always Available Task"].tap() + XCTAssert(app.buttons["Six-Minute Walk Test"].waitForExistence(timeout: 2)) + app.buttons["Six-Minute Walk Test"].tap() + XCTAssert(app.buttons["Start Test"].waitForExistence(timeout: 2)) + app.buttons["Start Test"].tap() + handleMotionAndFitnessAccessPrompt(timeout: .seconds(2)) + XCTAssert(app.staticTexts["Your 6-Minute Walk Test is in progress."].waitForExistence(timeout: 5)) + app.terminate() + try launchAppAndEnrollIntoStudy( + skipGoingToHomeTab: true + ) + XCTAssert(app.staticTexts["Your 6-Minute Walk Test is in progress."].waitForExistence(timeout: 5)) + } +} + + +extension MHCTestCase { + func handleMotionAndFitnessAccessPrompt(timeout: Duration) { + let app = XCUIApplication.springboard + let alert = app.alerts.element(matching: "label LIKE %@", "“*” would like to access your Motion & Fitness activity.") + if alert.waitForExistence(timeout: timeout.timeInterval) { + // wait a little longer. sometimes the "exists" above resolves to true while the alert is still being presented, + // in which case the tap is a little too early and doesn't actually get registered. + let allowButton = alert.buttons["Allow"] + XCTAssert(allowButton.wait(for: \.isHittable, toEqual: true, timeout: 5)) + allowButton.tap() + } + } +}