From cb3b354e809e96021281d017632c3557ca0f8d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 10 Oct 2026 11:00:08 +0200 Subject: [PATCH] Add Siri and Shortcuts support for status readouts and alarm snoozing App Intents for the current status, glucose, single status values, and local alarm snooze, end snooze, mute, unmute and snooze current alarm. The status readout is built from the saved glucose snapshot when no live glucose is in memory, so it also works when Siri launches the app in the background. Six of the intents are registered as App Shortcuts with Siri phrases. Includes unit tests for the spoken text and an in-process test of the intents. --- LoopFollow/AppIntents/AlarmIntents.swift | 139 +++++++++ LoopFollow/AppIntents/IntentDataSource.swift | 51 +++ .../AppIntents/LoopFollowAppShortcuts.swift | 85 +++++ .../AppIntents/LoopFollowIntentError.swift | 18 ++ LoopFollow/AppIntents/SpokenDuration.swift | 48 +++ LoopFollow/AppIntents/StatusIntents.swift | 50 +++ LoopFollow/AppIntents/StatusItem.swift | 63 ++++ LoopFollow/AppIntents/StatusReadout.swift | 292 ++++++++++++++++++ .../RestartLiveActivityIntent.swift | 23 -- Tests/AppIntentsIntegrationTests.swift | 125 ++++++++ Tests/SpokenDurationTests.swift | 32 ++ Tests/StatusReadoutTests.swift | 244 +++++++++++++++ 12 files changed, 1147 insertions(+), 23 deletions(-) create mode 100644 LoopFollow/AppIntents/AlarmIntents.swift create mode 100644 LoopFollow/AppIntents/IntentDataSource.swift create mode 100644 LoopFollow/AppIntents/LoopFollowAppShortcuts.swift create mode 100644 LoopFollow/AppIntents/LoopFollowIntentError.swift create mode 100644 LoopFollow/AppIntents/SpokenDuration.swift create mode 100644 LoopFollow/AppIntents/StatusIntents.swift create mode 100644 LoopFollow/AppIntents/StatusItem.swift create mode 100644 LoopFollow/AppIntents/StatusReadout.swift create mode 100644 Tests/AppIntentsIntegrationTests.swift create mode 100644 Tests/SpokenDurationTests.swift create mode 100644 Tests/StatusReadoutTests.swift diff --git a/LoopFollow/AppIntents/AlarmIntents.swift b/LoopFollow/AppIntents/AlarmIntents.swift new file mode 100644 index 000000000..47165fd36 --- /dev/null +++ b/LoopFollow/AppIntents/AlarmIntents.swift @@ -0,0 +1,139 @@ +// LoopFollow +// AlarmIntents.swift + +import AppIntents +import Foundation + +enum SnoozeDuration: String, AppEnum { + case thirtyMinutes, oneHour, twoHours, fourHours, eightHours, twelveHours + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Snooze Duration") + + static var caseDisplayRepresentations: [SnoozeDuration: DisplayRepresentation] = [ + .thirtyMinutes: DisplayRepresentation(title: "30 minutes", synonyms: ["half an hour"]), + .oneHour: DisplayRepresentation(title: "1 hour", synonyms: ["an hour", "one hour"]), + .twoHours: DisplayRepresentation(title: "2 hours", synonyms: ["two hours"]), + .fourHours: DisplayRepresentation(title: "4 hours", synonyms: ["four hours"]), + .eightHours: DisplayRepresentation(title: "8 hours", synonyms: ["eight hours"]), + .twelveHours: DisplayRepresentation(title: "12 hours", synonyms: ["twelve hours"]), + ] + + var seconds: TimeInterval { + switch self { + case .thirtyMinutes: return 30 * 60 + case .oneHour: return 60 * 60 + case .twoHours: return 2 * 60 * 60 + case .fourHours: return 4 * 60 * 60 + case .eightHours: return 8 * 60 * 60 + case .twelveHours: return 12 * 60 * 60 + } + } +} + +struct SnoozeAllAlarmsIntent: AppIntent { + static var title: LocalizedStringResource = "Snooze All Alarms" + static var description = IntentDescription("Silences every LoopFollow alarm for the chosen time, like the Snooze All button in the app.") + + @Parameter(title: "Duration", default: .oneHour) + var duration: SnoozeDuration + + static var parameterSummary: some ParameterSummary { + Summary("Snooze all alarms for \(\.$duration)") + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try IntentDataSource.ensureStorageAvailable() + if Observable.shared.currentAlarm.value != nil { + AlarmManager.shared.performSnooze() + } + let until = Date().addingTimeInterval(duration.seconds) + Storage.shared.alarmConfiguration.value.snoozeUntil = until + return .result(dialog: "All alarms are snoozed until \(SpokenDuration.clockTime(until)).") + } +} + +struct EndSnoozeIntent: AppIntent { + static var title: LocalizedStringResource = "End Snooze" + static var description = IntentDescription("Ends an active Snooze All so LoopFollow alarms can sound again.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try IntentDataSource.ensureStorageAvailable() + let wasActive = (Storage.shared.alarmConfiguration.value.snoozeUntil ?? .distantPast) > Date() + Storage.shared.alarmConfiguration.value.snoozeUntil = nil + return .result(dialog: wasActive ? "Alarms are active again." : "No snooze was active.") + } +} + +struct MuteAlarmSoundsIntent: AppIntent { + static var title: LocalizedStringResource = "Mute Alarm Sounds" + static var description = IntentDescription("Mutes LoopFollow alarm sounds for the chosen time. Notifications and vibration continue.") + + @Parameter(title: "Duration", default: .oneHour) + var duration: SnoozeDuration + + static var parameterSummary: some ParameterSummary { + Summary("Mute alarm sounds for \(\.$duration)") + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try IntentDataSource.ensureStorageAvailable() + let until = Date().addingTimeInterval(duration.seconds) + Storage.shared.alarmConfiguration.value.muteUntil = until + return .result(dialog: "Alarm sounds are muted until \(SpokenDuration.clockTime(until)). Notifications and vibration continue.") + } +} + +struct UnmuteAlarmSoundsIntent: AppIntent { + static var title: LocalizedStringResource = "Unmute Alarm Sounds" + static var description = IntentDescription("Ends an active mute so LoopFollow alarms play sound again.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try IntentDataSource.ensureStorageAvailable() + let wasActive = (Storage.shared.alarmConfiguration.value.muteUntil ?? .distantPast) > Date() + Storage.shared.alarmConfiguration.value.muteUntil = nil + return .result(dialog: wasActive ? "Alarm sounds are on again." : "Alarm sounds were not muted.") + } +} + +struct SnoozeCurrentAlarmIntent: AppIntent { + static var title: LocalizedStringResource = "Snooze Current Alarm" + static var description = IntentDescription("Snoozes the alarm that is sounding right now, using that alarm's own snooze duration.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try IntentDataSource.ensureStorageAvailable() + guard let alarm = IntentDataSource.soundingAlarm() else { + return .result(dialog: "No alarm is sounding.") + } + let seconds = Double(alarm.snoozeDuration) * alarm.type.snoozeTimeUnit.seconds + AlarmManager.shared.performSnooze() + if seconds > 0 { + return .result(dialog: "\(alarm.name) is snoozed for \(SpokenDuration.duration(seconds)).") + } + return .result(dialog: "\(alarm.name) is acknowledged.") + } +} + +struct GetAlarmStatusIntent: AppIntent { + static var title: LocalizedStringResource = "Get Alarm Status" + static var description = IntentDescription("Tells you whether LoopFollow alarms are snoozed or muted, and whether one is sounding.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + try IntentDataSource.ensureStorageAvailable() + let input = StatusReadoutInput( + snapshot: nil, + lastLoopTime: 0, + loopAppName: "", + hasNightscout: false, + alarms: IntentDataSource.alarmState(), + now: Date() + ) + let text = StatusReadoutFormatter.alarmSentence(input) + return .result(value: text, dialog: IntentDialog("\(text)")) + } +} diff --git a/LoopFollow/AppIntents/IntentDataSource.swift b/LoopFollow/AppIntents/IntentDataSource.swift new file mode 100644 index 000000000..1115f671f --- /dev/null +++ b/LoopFollow/AppIntents/IntentDataSource.swift @@ -0,0 +1,51 @@ +// LoopFollow +// IntentDataSource.swift + +import Foundation + +/// Gathers the app state an intent needs. Runs on the main actor because +/// `Storage` and `Observable` publish on the main thread. +@MainActor +enum IntentDataSource { + /// Storage holds defaults during a suspected before-first-unlock launch and + /// the App Group snapshot file is unreadable then, so intents refuse to answer. + static func ensureStorageAvailable() throws { + guard !StorageReadiness.isSuppressingWrites else { + throw LoopFollowIntentError.storageUnavailable + } + } + + static func readoutInput(now: Date = Date()) throws -> StatusReadoutInput { + try ensureStorageAvailable() + + // The live builder needs the in-memory glucose value, which is empty when + // Siri launched the app in the background; the saved snapshot covers that. + let live = Observable.shared.bg.value != nil + ? GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider()) + : nil + let snapshot = live ?? GlucoseSnapshotStore.shared.load() + + return StatusReadoutInput( + snapshot: snapshot, + lastLoopTime: Storage.shared.lastLoopTime.value, + loopAppName: Storage.shared.device.value, + hasNightscout: !Storage.shared.url.value.isEmpty, + alarms: alarmState(), + now: now + ) + } + + static func alarmState() -> AlarmStateInput { + let config = Storage.shared.alarmConfiguration.value + return AlarmStateInput( + snoozeUntil: config.snoozeUntil, + muteUntil: config.muteUntil, + soundingAlarmName: soundingAlarm()?.name + ) + } + + static func soundingAlarm() -> Alarm? { + guard let id = Observable.shared.currentAlarm.value else { return nil } + return Storage.shared.alarms.value.first { $0.id == id } + } +} diff --git a/LoopFollow/AppIntents/LoopFollowAppShortcuts.swift b/LoopFollow/AppIntents/LoopFollowAppShortcuts.swift new file mode 100644 index 000000000..04d861ef3 --- /dev/null +++ b/LoopFollow/AppIntents/LoopFollowAppShortcuts.swift @@ -0,0 +1,85 @@ +// LoopFollow +// LoopFollowAppShortcuts.swift + +#if !targetEnvironment(macCatalyst) + import AppIntents + + /// Siri phrases. Apple allows at most ten App Shortcuts per app; the intents + /// without an entry here are still available in the Shortcuts app. + struct LoopFollowAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: GetStatusIntent(), + phrases: [ + "What's the status in \(.applicationName)", + "Check \(.applicationName)", + "\(.applicationName) status", + "How are they doing in \(.applicationName)", + ], + shortTitle: "Get Status", + systemImageName: "waveform.path.ecg" + ) + AppShortcut( + intent: GetGlucoseIntent(), + phrases: [ + "What's the glucose in \(.applicationName)", + "What's the blood sugar in \(.applicationName)", + "What's the BG in \(.applicationName)", + ], + shortTitle: "Get Glucose", + systemImageName: "drop" + ) + AppShortcut( + intent: GetStatusItemIntent(), + phrases: [ + "What's the \(\.$item) in \(.applicationName)", + "Check the \(\.$item) in \(.applicationName)", + ], + shortTitle: "Get Status Item", + systemImageName: "list.bullet" + ) + AppShortcut( + intent: SnoozeAllAlarmsIntent(), + phrases: [ + "Snooze all alarms in \(.applicationName)", + "Snooze all alarms in \(.applicationName) for \(\.$duration)", + ], + shortTitle: "Snooze All Alarms", + systemImageName: "bell.slash" + ) + AppShortcut( + intent: EndSnoozeIntent(), + phrases: [ + "End snooze in \(.applicationName)", + "Resume alarms in \(.applicationName)", + ], + shortTitle: "End Snooze", + systemImageName: "bell" + ) + AppShortcut( + intent: SnoozeCurrentAlarmIntent(), + phrases: ["Snooze the alarm in \(.applicationName)"], + shortTitle: "Snooze Current Alarm", + systemImageName: "bell.badge.slash" + ) + AppShortcut( + intent: RestartLiveActivityIntent(), + phrases: ["Restart Live Activity in \(.applicationName)"], + shortTitle: "Restart Live Activity", + systemImageName: "dot.radiowaves.left.and.right" + ) + AppShortcut( + intent: EnableSpeakBGIntent(), + phrases: ["Turn on Speak BG in \(.applicationName)"], + shortTitle: "Turn On Speak BG", + systemImageName: "speaker.wave.2" + ) + AppShortcut( + intent: DisableSpeakBGIntent(), + phrases: ["Turn off Speak BG in \(.applicationName)"], + shortTitle: "Turn Off Speak BG", + systemImageName: "speaker.slash" + ) + } + } +#endif diff --git a/LoopFollow/AppIntents/LoopFollowIntentError.swift b/LoopFollow/AppIntents/LoopFollowIntentError.swift new file mode 100644 index 000000000..da462a451 --- /dev/null +++ b/LoopFollow/AppIntents/LoopFollowIntentError.swift @@ -0,0 +1,18 @@ +// LoopFollow +// LoopFollowIntentError.swift + +import AppIntents + +enum LoopFollowIntentError: Error, CustomLocalizedStringResourceConvertible { + case storageUnavailable + case noGlucoseData + + var localizedStringResource: LocalizedStringResource { + switch self { + case .storageUnavailable: + return "LoopFollow can't read its data until your iPhone has been unlocked once after restarting." + case .noGlucoseData: + return "LoopFollow has no glucose data yet. Open the app to check its setup." + } + } +} diff --git a/LoopFollow/AppIntents/SpokenDuration.swift b/LoopFollow/AppIntents/SpokenDuration.swift new file mode 100644 index 000000000..3a7a4c33d --- /dev/null +++ b/LoopFollow/AppIntents/SpokenDuration.swift @@ -0,0 +1,48 @@ +// LoopFollow +// SpokenDuration.swift + +import Foundation + +/// English phrases for durations and clock times, written for Siri dialogs. +enum SpokenDuration { + private static let components: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.maximumUnitCount = 2 + formatter.allowedUnits = [.day, .hour, .minute] + formatter.zeroFormattingBehavior = .dropAll + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US") + formatter.calendar = calendar + return formatter + }() + + /// "1 hour, 30 minutes". Seconds are truncated to whole minutes; under a minute + /// reads "less than a minute". + static func duration(_ seconds: TimeInterval) -> String { + let wholeMinutes = (max(seconds, 0) / 60).rounded(.down) * 60 + guard wholeMinutes >= 60 else { return "less than a minute" } + return components.string(from: wholeMinutes) ?? "\(Int(wholeMinutes / 60)) minutes" + } + + /// "3 minutes ago"; under a minute reads "just now". + static func ago(_ seconds: TimeInterval) -> String { + guard seconds >= 60 else { return "just now" } + return "\(duration(seconds)) ago" + } + + /// Age of an insertion timestamp (epoch seconds) relative to `now`. + static func age(since insertTime: TimeInterval, now: Date) -> String { + duration(now.timeIntervalSince1970 - insertTime) + } + + /// Clock time in the device's 12/24-hour style, e.g. "2:30 PM" or "14:30". + static func clockTime(_ date: Date, locale: Locale = .current, timeZone: TimeZone = .current) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter.string(from: date).replacingOccurrences(of: "\u{202F}", with: " ") + } +} diff --git a/LoopFollow/AppIntents/StatusIntents.swift b/LoopFollow/AppIntents/StatusIntents.swift new file mode 100644 index 000000000..15c94da7b --- /dev/null +++ b/LoopFollow/AppIntents/StatusIntents.swift @@ -0,0 +1,50 @@ +// LoopFollow +// StatusIntents.swift + +import AppIntents + +struct GetStatusIntent: AppIntent { + static var title: LocalizedStringResource = "Get Status" + static var description = IntentDescription("Reads out the latest glucose, trend, IOB, COB and loop status that LoopFollow has received.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let input = try IntentDataSource.readoutInput() + let text = StatusReadoutFormatter.fullReadout(input) + return .result(value: text, dialog: IntentDialog("\(text)")) + } +} + +struct GetGlucoseIntent: AppIntent { + static var title: LocalizedStringResource = "Get Glucose" + static var description = IntentDescription("Reads out the latest glucose reading and returns it as a number in your display unit, for use in Shortcuts automations.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let input = try IntentDataSource.readoutInput() + guard let snapshot = input.snapshot, let text = StatusReadoutFormatter.glucoseLine(input) else { + throw LoopFollowIntentError.noGlucoseData + } + let value = StatusReadoutFormatter.displayValue(snapshot.glucose, unit: snapshot.unit) + return .result(value: value, dialog: IntentDialog("\(text)")) + } +} + +struct GetStatusItemIntent: AppIntent { + static var title: LocalizedStringResource = "Get Status Item" + static var description = IntentDescription("Reads out one value, such as IOB, pump reservoir, sensor age or last loop time.") + + @Parameter(title: "Item") + var item: StatusItem + + static var parameterSummary: some ParameterSummary { + Summary("Get \(\.$item)") + } + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let input = try IntentDataSource.readoutInput() + let text = StatusReadoutFormatter.text(for: item.field, input) + return .result(value: text, dialog: IntentDialog("\(text)")) + } +} diff --git a/LoopFollow/AppIntents/StatusItem.swift b/LoopFollow/AppIntents/StatusItem.swift new file mode 100644 index 000000000..214c7c409 --- /dev/null +++ b/LoopFollow/AppIntents/StatusItem.swift @@ -0,0 +1,63 @@ +// LoopFollow +// StatusItem.swift + +import AppIntents + +/// The values a user can ask Siri for by name. +enum StatusItem: String, AppEnum { + case glucose, delta, iob, cob, basal, predictedGlucose + case pumpReservoir, pumpBattery, phoneBattery + case sensorAge, cannulaAge, insulinAge + case override, tempTarget, profile, lastLoop + case carbsToday, totalDailyDose, recommendedBolus, alarms + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Status Item") + + static var caseDisplayRepresentations: [StatusItem: DisplayRepresentation] = [ + .glucose: DisplayRepresentation(title: "glucose", synonyms: ["blood sugar", "BG", "blood glucose"]), + .delta: DisplayRepresentation(title: "delta", synonyms: ["glucose change"]), + .iob: DisplayRepresentation(title: "IOB", synonyms: ["insulin on board", "active insulin"]), + .cob: DisplayRepresentation(title: "COB", synonyms: ["carbs on board", "active carbs"]), + .basal: DisplayRepresentation(title: "basal rate", synonyms: ["basal"]), + .predictedGlucose: DisplayRepresentation(title: "predicted glucose", synonyms: ["eventual glucose", "projected glucose"]), + .pumpReservoir: DisplayRepresentation(title: "pump reservoir", synonyms: ["reservoir", "pump insulin"]), + .pumpBattery: DisplayRepresentation(title: "pump battery"), + .phoneBattery: DisplayRepresentation(title: "phone battery", synonyms: ["looping phone battery", "uploader battery"]), + .sensorAge: DisplayRepresentation(title: "sensor age", synonyms: ["SAGE"]), + .cannulaAge: DisplayRepresentation(title: "cannula age", synonyms: ["CAGE", "pod age"]), + .insulinAge: DisplayRepresentation(title: "insulin age", synonyms: ["IAGE"]), + .override: DisplayRepresentation(title: "override", synonyms: ["active override"]), + .tempTarget: DisplayRepresentation(title: "temp target", synonyms: ["temporary target"]), + .profile: DisplayRepresentation(title: "profile", synonyms: ["active profile"]), + .lastLoop: DisplayRepresentation(title: "last loop", synonyms: ["loop status"]), + .carbsToday: DisplayRepresentation(title: "carbs today"), + .totalDailyDose: DisplayRepresentation(title: "total daily dose", synonyms: ["TDD"]), + .recommendedBolus: DisplayRepresentation(title: "recommended bolus"), + .alarms: DisplayRepresentation(title: "alarm status", synonyms: ["alarms", "snooze status"]), + ] + + var field: StatusField { + switch self { + case .glucose: return .glucose + case .delta: return .delta + case .iob: return .iob + case .cob: return .cob + case .basal: return .basal + case .predictedGlucose: return .predictedGlucose + case .pumpReservoir: return .pumpReservoir + case .pumpBattery: return .pumpBattery + case .phoneBattery: return .phoneBattery + case .sensorAge: return .sensorAge + case .cannulaAge: return .cannulaAge + case .insulinAge: return .insulinAge + case .override: return .override + case .tempTarget: return .tempTarget + case .profile: return .profile + case .lastLoop: return .lastLoop + case .carbsToday: return .carbsToday + case .totalDailyDose: return .totalDailyDose + case .recommendedBolus: return .recommendedBolus + case .alarms: return .alarms + } + } +} diff --git a/LoopFollow/AppIntents/StatusReadout.swift b/LoopFollow/AppIntents/StatusReadout.swift new file mode 100644 index 000000000..2a68fd8cd --- /dev/null +++ b/LoopFollow/AppIntents/StatusReadout.swift @@ -0,0 +1,292 @@ +// LoopFollow +// StatusReadout.swift + +import Foundation + +struct AlarmStateInput: Equatable { + var snoozeUntil: Date? + var muteUntil: Date? + var soundingAlarmName: String? +} + +/// Everything the spoken status needs, captured at one point in time so the +/// formatter is a pure function of its input. +struct StatusReadoutInput { + /// nil when LoopFollow has never received a glucose reading. + var snapshot: GlucoseSnapshot? + /// Epoch seconds of the last completed loop cycle; 0 when unknown. + var lastLoopTime: TimeInterval + /// Name of the looping app as reported by Nightscout ("Loop", "Trio", ...). + var loopAppName: String + /// False for Dexcom Share followers, who have no loop, pump or treatment data. + var hasNightscout: Bool + var alarms: AlarmStateInput + var now: Date +} + +/// A single value a user can ask for. Mirrors `StatusItem` (the Siri-facing enum) +/// so the formatter stays free of AppIntents. +enum StatusField { + case glucose, delta, iob, cob, basal, predictedGlucose + case pumpReservoir, pumpBattery, phoneBattery + case sensorAge, cannulaAge, insulinAge + case override, tempTarget, profile, lastLoop + case carbsToday, totalDailyDose, recommendedBolus, alarms +} + +enum StatusReadoutFormatter { + /// A reading at least this old is announced with its age first. + static let staleAfter: TimeInterval = 12 * 60 + /// Matches the app's "Not Looping" detection. + static let notLoopingAfter: TimeInterval = 15 * 60 + /// Deltas within this band (mg/dL) are not spoken in the glucose line. + static let stableDeltaMgdl: Double = 3 + + static let noDataText = "LoopFollow has no glucose data yet. Open the app to check its setup." + + // MARK: - Full readout + + static func fullReadout(_ input: StatusReadoutInput) -> String { + guard let glucose = glucoseLine(input) else { return noDataText } + var sentences = [glucose] + if input.hasNightscout { + sentences.append(contentsOf: [ + iobSentence(input), + cobSentence(input), + loopSentence(input), + overrideSentence(input), + tempTargetSentence(input), + ].compactMap { $0 }) + } + return sentences.joined(separator: " ") + } + + // MARK: - Glucose + + static func glucoseLine(_ input: StatusReadoutInput) -> String? { + guard let snapshot = input.snapshot else { return nil } + let age = readingAge(input) + var parts = [glucoseValuePhrase(snapshot.glucose, unit: snapshot.unit)] + if let trend = trendWords(snapshot.trend) { + parts.append(trend) + } + if abs(snapshot.delta) > stableDeltaMgdl { + parts.append(deltaPhrase(snapshot.delta, unit: snapshot.unit)) + } + + if age >= staleAfter { + let description = parts.joined(separator: ", ") + return "The latest reading is \(SpokenDuration.duration(age)) old. It was \(description)." + } + parts.append(SpokenDuration.ago(age)) + return "Glucose is \(parts.joined(separator: ", "))." + } + + /// Glucose in the user's display unit, rounded the way the screen rounds it. + static func displayValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> Double { + switch unit { + case .mgdl: return mgdl.rounded() + case .mmol: return (GlucoseConversion.toMmol(mgdl) * 10).rounded() / 10 + } + } + + static func formatGlucose(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: return number(mgdl, fractionDigits: 0) + case .mmol: return number(GlucoseConversion.toMmol(mgdl), fractionDigits: 1, minimumFractionDigits: 1) + } + } + + static func trendWords(_ trend: GlucoseSnapshot.Trend) -> String? { + switch trend { + case .upFast: return "rising fast" + case .up: return "rising" + case .upSlight: return "rising slowly" + case .flat: return "steady" + case .downSlight: return "falling slowly" + case .down: return "falling" + case .downFast: return "falling fast" + case .unknown: return nil + } + } + + // MARK: - Single fields + + static func text(for field: StatusField, _ input: StatusReadoutInput) -> String { + if field == .alarms { + return alarmSentence(input) + } + guard let snapshot = input.snapshot else { return noDataText } + + switch field { + case .alarms: + return alarmSentence(input) + case .glucose: + return glucoseLine(input) ?? noDataText + case .delta: + if snapshot.delta == 0 { + return "Glucose is unchanged since the previous reading." + } + return "Glucose is \(deltaPhrase(snapshot.delta, unit: snapshot.unit)) since the previous reading." + case .iob: + return iobSentence(input) ?? "IOB is not available." + case .cob: + return cobSentence(input) ?? "COB is not available." + case .basal: + return basalSentence(snapshot.basalRate) ?? "The basal rate is not available." + case .predictedGlucose: + guard let projected = snapshot.projected else { return "The predicted glucose is not available." } + return "The predicted glucose is \(formatGlucose(projected, unit: snapshot.unit))." + case .pumpReservoir: + guard let units = snapshot.pumpReservoirU else { return "The pump reservoir is above 50 units or not reported." } + return "The pump reservoir has \(number(units, fractionDigits: 0)) units." + case .pumpBattery: + guard let level = snapshot.pumpBattery else { return "The pump battery level is not available." } + return "The pump battery is at \(number(level, fractionDigits: 0)) percent." + case .phoneBattery: + guard let level = snapshot.battery else { return "The looping phone battery level is not available." } + return "The looping phone battery is at \(number(level, fractionDigits: 0)) percent." + case .sensorAge: + return ageSentence("Sensor age", insertTime: snapshot.sageInsertTime, input) + case .cannulaAge: + return ageSentence("Cannula age", insertTime: snapshot.cageInsertTime, input) + case .insulinAge: + return ageSentence("Insulin age", insertTime: snapshot.iageInsertTime, input) + case .override: + return overrideSentence(input) ?? "No override is active." + case .tempTarget: + return tempTargetSentence(input) ?? "No temp target is active." + case .profile: + guard let name = snapshot.profileName else { return "The profile name is not available." } + return "The active profile is \(name)." + case .lastLoop: + return loopSentence(input) ?? "No loop data is available." + case .carbsToday: + guard let grams = snapshot.carbsToday else { return "Carbs today are not available." } + return "\(number(grams, fractionDigits: 0)) grams of carbs have been entered today." + case .totalDailyDose: + guard let tdd = snapshot.tdd else { return "The total daily dose is not available." } + return "The total daily dose is \(number(tdd, fractionDigits: 1)) units." + case .recommendedBolus: + guard let bolus = snapshot.recBolus else { return "No recommended bolus is reported." } + let app = input.loopAppName.isEmpty ? "The looping app" : input.loopAppName + return "\(app) reports a recommended bolus of \(number(bolus, fractionDigits: 2)) units. The reading is \(SpokenDuration.duration(readingAge(input))) old." + } + } + + // MARK: - Sentences + + static func iobSentence(_ input: StatusReadoutInput) -> String? { + guard let iob = input.snapshot?.iob else { return nil } + return "IOB is \(number(iob, fractionDigits: 2)) units." + } + + static func cobSentence(_ input: StatusReadoutInput) -> String? { + guard let cob = input.snapshot?.cob else { return nil } + return "COB is \(number(cob, fractionDigits: 0)) grams." + } + + static func loopSentence(_ input: StatusReadoutInput) -> String? { + guard input.hasNightscout, input.lastLoopTime > 0 else { return nil } + let age = max(input.now.timeIntervalSince1970 - input.lastLoopTime, 0) + if age >= notLoopingAfter { + return "The loop hasn't completed in \(SpokenDuration.duration(age))." + } + return "The loop last completed \(SpokenDuration.ago(age))." + } + + static func overrideSentence(_ input: StatusReadoutInput) -> String? { + guard let snapshot = input.snapshot, let name = snapshot.override else { return nil } + guard let remaining = remaining(until: snapshot.overrideEndAt, input) else { + return snapshot.overrideEndAt == nil ? "Override \(name) is active." : nil + } + return "Override \(name) is active for another \(remaining)." + } + + static func tempTargetSentence(_ input: StatusReadoutInput) -> String? { + guard let snapshot = input.snapshot, let target = snapshot.tempTargetMgdl else { return nil } + let value = formatGlucose(target, unit: snapshot.unit) + guard let remaining = remaining(until: snapshot.tempTargetEndAt, input) else { + return snapshot.tempTargetEndAt == nil ? "A temp target of \(value) is active." : nil + } + return "A temp target of \(value) is active for another \(remaining)." + } + + static func alarmSentence(_ input: StatusReadoutInput) -> String { + var sentences: [String] = [] + if let until = input.alarms.snoozeUntil, until > input.now { + sentences.append("All alarms are snoozed until \(SpokenDuration.clockTime(until)).") + } + if let until = input.alarms.muteUntil, until > input.now { + sentences.append("Alarm sounds are muted until \(SpokenDuration.clockTime(until)).") + } + if sentences.isEmpty { + sentences.append("No alarms are snoozed.") + } + if let name = input.alarms.soundingAlarmName { + sentences.append("\(name) is sounding.") + } + return sentences.joined(separator: " ") + } + + /// Spoken form of the stored basal string, which is either "1.2" or + /// "0.8 → 1.2" (profile rate, then the rate currently running). + static func basalSentence(_ raw: String, locale: Locale = .current) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let parts = trimmed.components(separatedBy: "→").map { $0.trimmingCharacters(in: .whitespaces) } + let parser = NumberFormatter() + parser.numberStyle = .decimal + parser.locale = locale + let values = parts.compactMap { parser.number(from: $0)?.doubleValue } + + guard values.count == parts.count else { + return "The basal rate is \(trimmed.replacingOccurrences(of: ",", with: ".")) units per hour." + } + if values.count == 2 { + return "The basal rate is \(number(values[1], fractionDigits: 2)) units per hour, temporarily changed from \(number(values[0], fractionDigits: 2))." + } + return "The basal rate is \(number(values[0], fractionDigits: 2)) units per hour." + } + + // MARK: - Helpers + + static func readingAge(_ input: StatusReadoutInput) -> TimeInterval { + guard let snapshot = input.snapshot else { return 0 } + return max(input.now.timeIntervalSince(snapshot.updatedAt), 0) + } + + private static func glucoseValuePhrase(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + if Int(mgdl.rounded()) <= globalVariables.minDisplayGlucose { return "low" } + if Int(mgdl.rounded()) >= globalVariables.maxDisplayGlucose { return "high" } + return formatGlucose(mgdl, unit: unit) + } + + private static func deltaPhrase(_ deltaMgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + let direction = deltaMgdl < 0 ? "down" : "up" + return "\(direction) \(formatGlucose(abs(deltaMgdl), unit: unit))" + } + + private static func ageSentence(_ label: String, insertTime: TimeInterval, _ input: StatusReadoutInput) -> String { + guard insertTime > 0 else { return "\(label) is not set." } + return "\(label) is \(SpokenDuration.age(since: insertTime, now: input.now))." + } + + /// Remaining time until an end timestamp; nil when there is no end or it has passed. + private static func remaining(until endAt: TimeInterval?, _ input: StatusReadoutInput) -> String? { + guard let endAt else { return nil } + let remaining = endAt - input.now.timeIntervalSince1970 + guard remaining > 0 else { return nil } + return SpokenDuration.duration(remaining) + } + + static func number(_ value: Double, fractionDigits: Int, minimumFractionDigits: Int = 0) -> String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = false + formatter.maximumFractionDigits = fractionDigits + formatter.minimumFractionDigits = minimumFractionDigits + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } +} diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index 5a25afdae..9f4933e4e 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -31,27 +31,4 @@ return .result(dialog: "Live Activity restarted.") } } - - struct LoopFollowAppShortcuts: AppShortcutsProvider { - static var appShortcuts: [AppShortcut] { - AppShortcut( - intent: RestartLiveActivityIntent(), - phrases: ["Restart Live Activity in \(.applicationName)"], - shortTitle: "Restart Live Activity", - systemImageName: "dot.radiowaves.left.and.right" - ) - AppShortcut( - intent: EnableSpeakBGIntent(), - phrases: ["Turn on Speak BG in \(.applicationName)"], - shortTitle: "Turn On Speak BG", - systemImageName: "speaker.wave.2" - ) - AppShortcut( - intent: DisableSpeakBGIntent(), - phrases: ["Turn off Speak BG in \(.applicationName)"], - shortTitle: "Turn Off Speak BG", - systemImageName: "speaker.slash" - ) - } - } #endif diff --git a/Tests/AppIntentsIntegrationTests.swift b/Tests/AppIntentsIntegrationTests.swift new file mode 100644 index 000000000..d0ac6dd74 --- /dev/null +++ b/Tests/AppIntentsIntegrationTests.swift @@ -0,0 +1,125 @@ +// LoopFollow +// AppIntentsIntegrationTests.swift + +import AppIntents +import Foundation +@testable import LoopFollow +import Testing + +/// Runs the intents inside the hosted app, so they read and write the real +/// Storage, Observable and App Group snapshot file. +@Suite(.serialized) +@MainActor +struct AppIntentsIntegrationTests { + private func stringValue(_ result: some IntentResult) -> String? { + (result as? IntentResultContainer)?.value + } + + private func doubleValue(_ result: some IntentResult) -> Double? { + (result as? IntentResultContainer)?.value + } + + /// Seeds the state a Siri cold launch sees: no in-memory glucose, a saved + /// snapshot, and persisted Nightscout settings. Restores everything after `body`. + private func withColdLaunchState(_ body: () async throws -> Void) async throws { + let previousBg = Observable.shared.bg.value + let previousUrl = Storage.shared.url.value + let previousLoop = Storage.shared.lastLoopTime.value + let previousDevice = Storage.shared.device.value + let previousSnapshot = GlucoseSnapshotStore.shared.load() + defer { + Observable.shared.bg.value = previousBg + Storage.shared.url.value = previousUrl + Storage.shared.lastLoopTime.value = previousLoop + Storage.shared.device.value = previousDevice + if let previousSnapshot { + GlucoseSnapshotStore.shared.save(previousSnapshot) + } else { + GlucoseSnapshotStore.shared.delete() + } + } + + Observable.shared.bg.value = nil + Storage.shared.url.value = "http://127.0.0.1:1" + Storage.shared.lastLoopTime.value = Date().timeIntervalSince1970 - 4 * 60 + Storage.shared.device.value = "Loop" + GlucoseSnapshotStore.shared.save(GlucoseSnapshot( + glucose: 115, + delta: 5, + trend: .upSlight, + updatedAt: Date().addingTimeInterval(-3 * 60), + iob: 1.25, + cob: 15, + projected: nil, + unit: .mmol, + isNotLooping: false + )) + try await Task.sleep(for: .milliseconds(500)) + + try await body() + } + + @Test("status intent reads the saved snapshot when no live glucose is in memory") + func statusFromSavedSnapshot() async throws { + try await withColdLaunchState { + let result = try await GetStatusIntent().perform() + #expect(stringValue(result) == "Glucose is 6.4, rising slowly, up 0.3, 3 minutes ago. IOB is 1.25 units. COB is 15 grams. The loop last completed 4 minutes ago.") + } + } + + @Test("glucose intent returns the number in display units") + func glucoseValue() async throws { + try await withColdLaunchState { + let result = try await GetGlucoseIntent().perform() + #expect(doubleValue(result) == 6.4) + } + } + + @Test("item intent answers a single field") + func itemIntent() async throws { + try await withColdLaunchState { + var intent = GetStatusItemIntent() + intent.item = .iob + let iob = try await intent.perform() + #expect(stringValue(iob) == "IOB is 1.25 units.") + intent.item = .lastLoop + let lastLoop = try await intent.perform() + #expect(stringValue(lastLoop) == "The loop last completed 4 minutes ago.") + intent.item = .recommendedBolus + let recBolus = try await intent.perform() + #expect(stringValue(recBolus) == "No recommended bolus is reported.") + } + } + + @Test("snooze all, end snooze, mute and unmute update the alarm configuration") + func snoozeIntents() async throws { + let previous = Storage.shared.alarmConfiguration.value + defer { Storage.shared.alarmConfiguration.value = previous } + + var snooze = SnoozeAllAlarmsIntent() + snooze.duration = .twoHours + _ = try await snooze.perform() + let until = try #require(Storage.shared.alarmConfiguration.value.snoozeUntil) + #expect(abs(until.timeIntervalSinceNow - 2 * 60 * 60) < 5) + + _ = try await EndSnoozeIntent().perform() + #expect(Storage.shared.alarmConfiguration.value.snoozeUntil == nil) + + var mute = MuteAlarmSoundsIntent() + mute.duration = .thirtyMinutes + _ = try await mute.perform() + let mutedUntil = try #require(Storage.shared.alarmConfiguration.value.muteUntil) + #expect(abs(mutedUntil.timeIntervalSinceNow - 30 * 60) < 5) + + _ = try await UnmuteAlarmSoundsIntent().perform() + #expect(Storage.shared.alarmConfiguration.value.muteUntil == nil) + } + + @Test("snoozing the current alarm with none sounding leaves alarms untouched") + func snoozeCurrentWithoutAlarm() async throws { + let previousAlarms = Storage.shared.alarms.value + Observable.shared.currentAlarm.value = nil + _ = try await SnoozeCurrentAlarmIntent().perform() + #expect(Storage.shared.alarms.value == previousAlarms) + } +} diff --git a/Tests/SpokenDurationTests.swift b/Tests/SpokenDurationTests.swift new file mode 100644 index 000000000..67fb413ae --- /dev/null +++ b/Tests/SpokenDurationTests.swift @@ -0,0 +1,32 @@ +// LoopFollow +// SpokenDurationTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct SpokenDurationTests { + @Test("durations are spoken in whole minutes with at most two units") + func durations() { + #expect(SpokenDuration.duration(30) == "less than a minute") + #expect(SpokenDuration.duration(60) == "1 minute") + #expect(SpokenDuration.duration(5 * 60 + 59) == "5 minutes") + #expect(SpokenDuration.duration(90 * 60) == "1 hour, 30 minutes") + #expect(SpokenDuration.duration(2 * 60 * 60) == "2 hours") + #expect(SpokenDuration.duration(3 * 86400 + 4 * 3600 + 20 * 60) == "3 days, 4 hours") + } + + @Test("ago phrases") + func ago() { + #expect(SpokenDuration.ago(10) == "just now") + #expect(SpokenDuration.ago(3 * 60) == "3 minutes ago") + } + + @Test("clock time follows the given locale") + func clockTime() { + let date = Date(timeIntervalSince1970: 1_700_000_000) // 2023-11-14 22:13:20 UTC + let utc = TimeZone(identifier: "UTC")! + #expect(SpokenDuration.clockTime(date, locale: Locale(identifier: "en_US"), timeZone: utc) == "10:13 PM") + #expect(SpokenDuration.clockTime(date, locale: Locale(identifier: "sv_SE"), timeZone: utc) == "22:13") + } +} diff --git a/Tests/StatusReadoutTests.swift b/Tests/StatusReadoutTests.swift new file mode 100644 index 000000000..dcfe33833 --- /dev/null +++ b/Tests/StatusReadoutTests.swift @@ -0,0 +1,244 @@ +// LoopFollow +// StatusReadoutTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct StatusReadoutTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func snapshot( + glucose: Double = 115, + delta: Double = 5, + trend: GlucoseSnapshot.Trend = .upSlight, + ageMinutes: Double = 3, + iob: Double? = 1.25, + cob: Double? = 15, + projected: Double? = nil, + override: String? = nil, + overrideEndAt: TimeInterval? = nil, + tempTargetMgdl: Double? = nil, + tempTargetEndAt: TimeInterval? = nil, + recBolus: Double? = nil, + battery: Double? = nil, + pumpBattery: Double? = nil, + basalRate: String = "", + pumpReservoirU: Double? = nil, + tdd: Double? = nil, + carbsToday: Double? = nil, + profileName: String? = nil, + sageInsertTime: TimeInterval = 0, + unit: GlucoseSnapshot.Unit = .mgdl + ) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: now.addingTimeInterval(-ageMinutes * 60), + iob: iob, + cob: cob, + projected: projected, + override: override, + overrideEndAt: overrideEndAt, + tempTargetMgdl: tempTargetMgdl, + tempTargetEndAt: tempTargetEndAt, + recBolus: recBolus, + battery: battery, + pumpBattery: pumpBattery, + basalRate: basalRate, + pumpReservoirU: pumpReservoirU, + tdd: tdd, + carbsToday: carbsToday, + profileName: profileName, + sageInsertTime: sageInsertTime, + unit: unit, + isNotLooping: false + ) + } + + private func input( + _ snapshot: GlucoseSnapshot?, + lastLoopMinutesAgo: Double? = 4, + loopAppName: String = "Loop", + hasNightscout: Bool = true, + alarms: AlarmStateInput = AlarmStateInput() + ) -> StatusReadoutInput { + StatusReadoutInput( + snapshot: snapshot, + lastLoopTime: lastLoopMinutesAgo.map { now.timeIntervalSince1970 - $0 * 60 } ?? 0, + loopAppName: loopAppName, + hasNightscout: hasNightscout, + alarms: alarms, + now: now + ) + } + + // MARK: - Glucose line + + @Test("mg/dL glucose line with trend, delta and age") + func glucoseLineMgdl() { + let line = StatusReadoutFormatter.glucoseLine(input(snapshot())) + #expect(line == "Glucose is 115, rising slowly, up 5, 3 minutes ago.") + } + + @Test("mmol/L uses one decimal and converts the delta") + func glucoseLineMmol() { + let line = StatusReadoutFormatter.glucoseLine(input(snapshot(glucose: 115, delta: -9, trend: .down, unit: .mmol))) + #expect(line == "Glucose is 6.4, falling, down 0.5, 3 minutes ago.") + } + + @Test("small deltas and unknown trends are omitted") + func stableGlucose() { + let line = StatusReadoutFormatter.glucoseLine(input(snapshot(delta: 2, trend: .unknown))) + #expect(line == "Glucose is 115, 3 minutes ago.") + } + + @Test("every trend has words") + func trendWords() { + let expected: [GlucoseSnapshot.Trend: String?] = [ + .upFast: "rising fast", .up: "rising", .upSlight: "rising slowly", .flat: "steady", + .downSlight: "falling slowly", .down: "falling", .downFast: "falling fast", .unknown: nil, + ] + for (trend, words) in expected { + #expect(StatusReadoutFormatter.trendWords(trend) == words) + } + } + + @Test("stale readings lead with their age") + func staleReading() { + let line = StatusReadoutFormatter.glucoseLine(input(snapshot(ageMinutes: 25))) + #expect(line == "The latest reading is 25 minutes old. It was 115, rising slowly, up 5.") + } + + @Test("display bounds are spoken as low and high") + func lowHigh() { + #expect(StatusReadoutFormatter.glucoseLine(input(snapshot(glucose: 39, delta: 0, trend: .flat))) == "Glucose is low, steady, 3 minutes ago.") + #expect(StatusReadoutFormatter.glucoseLine(input(snapshot(glucose: 401, delta: 0, trend: .flat))) == "Glucose is high, steady, 3 minutes ago.") + } + + @Test("display value rounds like the screen") + func displayValue() { + #expect(StatusReadoutFormatter.displayValue(115.4, unit: .mgdl) == 115) + #expect(StatusReadoutFormatter.displayValue(115, unit: .mmol) == 6.4) + } + + // MARK: - Full readout + + @Test("full readout for a Nightscout follower") + func fullReadout() { + let text = StatusReadoutFormatter.fullReadout(input(snapshot())) + #expect(text == "Glucose is 115, rising slowly, up 5, 3 minutes ago. IOB is 1.25 units. COB is 15 grams. The loop last completed 4 minutes ago.") + } + + @Test("not looping is recomputed from the loop time") + func notLooping() { + let text = StatusReadoutFormatter.fullReadout(input(snapshot(iob: nil, cob: nil), lastLoopMinutesAgo: 22)) + #expect(text == "Glucose is 115, rising slowly, up 5, 3 minutes ago. The loop hasn't completed in 22 minutes.") + } + + @Test("Dexcom-only followers hear only the glucose line") + func dexcomOnly() { + let text = StatusReadoutFormatter.fullReadout(input(snapshot(), hasNightscout: false)) + #expect(text == "Glucose is 115, rising slowly, up 5, 3 minutes ago.") + } + + @Test("no data fallback") + func noData() { + #expect(StatusReadoutFormatter.fullReadout(input(nil)) == StatusReadoutFormatter.noDataText) + #expect(StatusReadoutFormatter.glucoseLine(input(nil)) == nil) + } + + @Test("active override and temp target are included with remaining time") + func overrideAndTempTarget() { + let snap = snapshot( + iob: nil, cob: nil, + override: "Exercise", + overrideEndAt: now.timeIntervalSince1970 + 42 * 60, + tempTargetMgdl: 140, + tempTargetEndAt: now.timeIntervalSince1970 + 30 * 60 + ) + let text = StatusReadoutFormatter.fullReadout(input(snap, lastLoopMinutesAgo: nil)) + #expect(text == "Glucose is 115, rising slowly, up 5, 3 minutes ago. Override Exercise is active for another 42 minutes. A temp target of 140 is active for another 30 minutes.") + } + + @Test("indefinite override has no countdown; an expired one is omitted") + func overrideEnds() { + #expect(StatusReadoutFormatter.overrideSentence(input(snapshot(override: "Sleep"))) == "Override Sleep is active.") + let expired = snapshot(override: "Sleep", overrideEndAt: now.timeIntervalSince1970 - 60) + #expect(StatusReadoutFormatter.overrideSentence(input(expired)) == nil) + #expect(StatusReadoutFormatter.text(for: .override, input(expired)) == "No override is active.") + } + + // MARK: - Single fields + + @Test("delta field speaks small changes too") + func deltaField() { + #expect(StatusReadoutFormatter.text(for: .delta, input(snapshot(delta: 2))) == "Glucose is up 2 since the previous reading.") + #expect(StatusReadoutFormatter.text(for: .delta, input(snapshot(delta: 0))) == "Glucose is unchanged since the previous reading.") + } + + @Test("missing values say so") + func missingValues() { + let snap = snapshot(iob: nil, cob: nil) + #expect(StatusReadoutFormatter.text(for: .iob, input(snap)) == "IOB is not available.") + #expect(StatusReadoutFormatter.text(for: .cob, input(snap)) == "COB is not available.") + #expect(StatusReadoutFormatter.text(for: .pumpReservoir, input(snap)) == "The pump reservoir is above 50 units or not reported.") + #expect(StatusReadoutFormatter.text(for: .sensorAge, input(snap)) == "Sensor age is not set.") + #expect(StatusReadoutFormatter.text(for: .recommendedBolus, input(snap)) == "No recommended bolus is reported.") + #expect(StatusReadoutFormatter.text(for: .lastLoop, input(snap, lastLoopMinutesAgo: nil)) == "No loop data is available.") + #expect(StatusReadoutFormatter.text(for: .iob, input(nil)) == StatusReadoutFormatter.noDataText) + } + + @Test("pump, battery, age, profile and totals") + func pumpFields() { + let snap = snapshot( + projected: 125, + recBolus: 0.8, + battery: 81.4, + pumpBattery: 50, + pumpReservoirU: 85.6, + tdd: 32.46, + carbsToday: 120, + profileName: "Default", + sageInsertTime: now.timeIntervalSince1970 - (3 * 86400 + 4 * 3600) + ) + let i = input(snap) + #expect(StatusReadoutFormatter.text(for: .predictedGlucose, i) == "The predicted glucose is 125.") + #expect(StatusReadoutFormatter.text(for: .pumpReservoir, i) == "The pump reservoir has 86 units.") + #expect(StatusReadoutFormatter.text(for: .pumpBattery, i) == "The pump battery is at 50 percent.") + #expect(StatusReadoutFormatter.text(for: .phoneBattery, i) == "The looping phone battery is at 81 percent.") + #expect(StatusReadoutFormatter.text(for: .sensorAge, i) == "Sensor age is 3 days, 4 hours.") + #expect(StatusReadoutFormatter.text(for: .profile, i) == "The active profile is Default.") + #expect(StatusReadoutFormatter.text(for: .carbsToday, i) == "120 grams of carbs have been entered today.") + #expect(StatusReadoutFormatter.text(for: .totalDailyDose, i) == "The total daily dose is 32.5 units.") + #expect(StatusReadoutFormatter.text(for: .recommendedBolus, i) == "Loop reports a recommended bolus of 0.8 units. The reading is 3 minutes old.") + } + + @Test("basal strings in both stored forms") + func basal() { + let en = Locale(identifier: "en_US") + #expect(StatusReadoutFormatter.basalSentence("0.8", locale: en) == "The basal rate is 0.8 units per hour.") + #expect(StatusReadoutFormatter.basalSentence("0.8 → 1.2", locale: en) == "The basal rate is 1.2 units per hour, temporarily changed from 0.8.") + #expect(StatusReadoutFormatter.basalSentence("0,8 → 1,25", locale: Locale(identifier: "sv_SE")) == "The basal rate is 1.25 units per hour, temporarily changed from 0.8.") + #expect(StatusReadoutFormatter.basalSentence("", locale: en) == nil) + } + + // MARK: - Alarms + + @Test("alarm state sentences") + func alarms() { + let none = StatusReadoutFormatter.alarmSentence(input(nil)) + #expect(none == "No alarms are snoozed.") + + let sounding = StatusReadoutFormatter.alarmSentence(input(nil, alarms: AlarmStateInput(soundingAlarmName: "Low BG Alert"))) + #expect(sounding == "No alarms are snoozed. Low BG Alert is sounding.") + + let snoozed = StatusReadoutFormatter.alarmSentence(input(nil, alarms: AlarmStateInput(snoozeUntil: now.addingTimeInterval(3600)))) + #expect(snoozed.hasPrefix("All alarms are snoozed until ")) + + let expired = StatusReadoutFormatter.alarmSentence(input(nil, alarms: AlarmStateInput(snoozeUntil: now.addingTimeInterval(-60), muteUntil: now.addingTimeInterval(600)))) + #expect(expired.hasPrefix("Alarm sounds are muted until ")) + #expect(!expired.contains("snoozed until")) + } +}