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")) + } +}