diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 3c364fc2c..ba8d234e9 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -114,6 +114,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { #endif } + func applicationWillEnterForeground(_: UIApplication) { + StorageReadiness.whenReady { + TRCMealMutationCoordinator.shared.recoverExpiredAttempts() + } + } + // MARK: - Remote Notifications /// Called when successfully registered for remote notifications @@ -134,6 +140,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)") + handleTRCMealMutationResponse(userInfo) // Check if this is a response notification from Loop or Trio if let aps = userInfo["aps"] as? [String: Any] { @@ -183,6 +190,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + handleTRCMealMutationResponse(response.notification.request.content.userInfo) + if response.actionIdentifier == "OPEN_APP_ACTION" { // Dismiss any presented modal/sheet so the user actually sees Home UIApplication.shared.topMost?.dismiss(animated: true) @@ -196,6 +205,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler() } + private func handleTRCMealMutationResponse(_ userInfo: [AnyHashable: Any]) { + // A background push may launch the app before first unlock, when persisted + // operations are still protected. The existing readiness gate retains the + // payload in memory and correlates it after StorageValue hydration. + StorageReadiness.whenReady { + _ = TRCMealMutationCoordinator.shared.handleRemoteNotification(userInfo: userInfo) + } + } + func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask { let forcePortrait = Storage.shared.forcePortraitMode.value @@ -267,6 +285,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let content = notification.request.content + handleTRCMealMutationResponse(content.userInfo) let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log( category: .general, diff --git a/LoopFollow/Remote/TRC/MealMutationCoordinator.swift b/LoopFollow/Remote/TRC/MealMutationCoordinator.swift new file mode 100644 index 000000000..7e5d02de0 --- /dev/null +++ b/LoopFollow/Remote/TRC/MealMutationCoordinator.swift @@ -0,0 +1,839 @@ +// LoopFollow +// MealMutationCoordinator.swift + +import Combine +import Foundation + +enum TRCMealMutationType: String, Codable, Equatable, Sendable { + case edit = "edit_meal" + case delete = "delete_meal" + + var userFacingName: String { + switch self { + case .edit: return "Edit Meal" + case .delete: return "Delete Meal" + } + } +} + +struct TRCMealMutationValues: Codable, Equatable, Sendable { + let carbs: Int + let fat: Int + let protein: Int + let mealTime: TimeInterval + + var hasValidMacros: Bool { + carbs >= 0 && fat >= 0 && protein >= 0 && (carbs > 0 || fat > 0 || protein > 0) + } + + var isValid: Bool { + hasValidMacros && mealTime.isFinite + } + + func matches(_ other: TRCMealMutationValues, timeTolerance: TimeInterval = 1) -> Bool { + carbs == other.carbs && + fat == other.fat && + protein == other.protein && + abs(mealTime - other.mealTime) <= timeTolerance + } +} + +struct TRCMealMutationTimePolicy: Sendable { + static let maximumOffset: TimeInterval = 12 * 60 * 60 + + func isValid(_ timestamp: TimeInterval, at date: Date) -> Bool { + guard timestamp.isFinite else { return false } + return abs(timestamp - date.timeIntervalSince1970) <= Self.maximumOffset + } +} + +enum TRCMealMutationState: String, Codable, Equatable, Sendable { + case sending + case awaiting + case inProgress + case applied + case rejected + case transportFailed + case timedOut +} + +enum TRCMealMutationResult: String, Codable, Equatable, Sendable { + case updated + case deleted + case alreadyApplied = "already_applied" + case rejected + case inProgress = "in_progress" + + var isSuccessful: Bool { + switch self { + case .updated, .deleted, .alreadyApplied: return true + case .rejected, .inProgress: return false + } + } +} + +enum TRCMealMutationSyncStatus: String, Codable, Equatable, Sendable { + case requested + case notRequested = "not_requested" +} + +struct TRCMealMutationOperation: Codable, Equatable, Identifiable, Sendable { + var id: String { commandID } + + let commandID: String + let mealID: String + let user: String + let type: TRCMealMutationType + let expected: TRCMealMutationValues + let replacement: TRCMealMutationValues? + let createdAt: Date + + var lastAttemptAt: Date + var updatedAt: Date + var state: TRCMealMutationState + var responseBody: String? + var responseResult: TRCMealMutationResult? + var syncStatus: TRCMealMutationSyncStatus? + var lastResponseTimestamp: TimeInterval? + var reconciledAt: Date? + + var isTerminal: Bool { + state == .applied || state == .rejected + } + + var isRetryable: Bool { + switch state { + case .inProgress, .transportFailed, .timedOut: return true + case .sending, .awaiting, .applied, .rejected: return false + } + } + + /// Applied edits continue to block actions until refreshed Nightscout data + /// matches the replacement. This prevents a second edit from using a stale + /// expected-value snapshot while external synchronization catches up. + var blocksActions: Bool { + switch state { + case .sending, .awaiting, .inProgress, .transportFailed, .timedOut: + return true + case .applied: + return reconciledAt == nil + case .rejected: + return false + } + } + + var statusTitle: String { + switch state { + case .sending: return "Sending…" + case .awaiting: return "Sent — Waiting for Trio" + case .inProgress: return "Still Processing" + case .applied: + switch type { + case .edit: return "Meal Updated" + case .delete: return "Meal Deleted" + } + case .rejected: return "Rejected by Trio" + case .transportFailed: return "Couldn’t Send" + case .timedOut: return "No Response Yet" + } + } + + var statusDetail: String { + if state == .applied { + var resultText = responseBody ?? "Trio applied the meal change." + if syncStatus == .requested { + resultText += " Trio requested external-service synchronization." + } + if type == .edit, reconciledAt == nil { + resultText += " Waiting for refreshed Nightscout values before another change." + } + return resultText + } + + if let responseBody, !responseBody.isEmpty { + return responseBody + } + + switch state { + case .sending: return "Encrypting and sending the command to APNs." + case .awaiting: return "APNs accepted the command. Waiting for Trio to report the result." + case .inProgress: return "Trio has not finished processing this request. Retry the same request shortly." + case .rejected: return "Trio did not apply the meal change." + case .transportFailed: return "LoopFollow could not confirm that APNs accepted the command. Retry the same request." + case .timedOut: return "No correlated Trio response has arrived. Retry the same request." + case .applied: return "Trio applied the meal change." + } + } + + func transportRequest(at date: Date) -> TRCMealMutationTransportRequest { + TRCMealMutationTransportRequest( + commandID: commandID, + mealID: mealID, + user: user, + type: type, + expected: expected, + replacement: replacement, + transportTimestamp: date.timeIntervalSince1970 + ) + } +} + +/// Ephemeral send data. Return-notification credentials are deliberately absent; +/// the live transport recreates them for every initial attempt and retry. +struct TRCMealMutationTransportRequest: Equatable, Sendable { + let commandID: String + let mealID: String + let user: String + let type: TRCMealMutationType + let expected: TRCMealMutationValues + let replacement: TRCMealMutationValues? + let transportTimestamp: TimeInterval +} + +struct TRCMealMutationResponse: Equatable, Sendable { + let commandStatus: String? + let commandType: String? + let commandID: String? + let mealID: String? + let result: TRCMealMutationResult? + let syncStatus: TRCMealMutationSyncStatus? + let timestamp: TimeInterval? + let body: String? + + init( + commandStatus: String? = nil, + commandType: String? = nil, + commandID: String? = nil, + mealID: String? = nil, + result: TRCMealMutationResult? = nil, + syncStatus: TRCMealMutationSyncStatus? = nil, + timestamp: TimeInterval? = nil, + body: String? = nil + ) { + self.commandStatus = commandStatus + self.commandType = commandType + self.commandID = commandID + self.mealID = mealID + self.result = result + self.syncStatus = syncStatus + self.timestamp = timestamp + self.body = body + } + + init(userInfo: [AnyHashable: Any]) { + commandStatus = userInfo["command_status"] as? String + commandType = userInfo["command_type"] as? String + commandID = userInfo["command_id"] as? String + mealID = userInfo["meal_id"] as? String + result = (userInfo["result"] as? String).flatMap(TRCMealMutationResult.init(rawValue:)) + syncStatus = (userInfo["sync_status"] as? String).flatMap(TRCMealMutationSyncStatus.init(rawValue:)) + timestamp = Self.timeInterval(from: userInfo["timestamp"]) + + if let aps = userInfo["aps"] as? [String: Any], + let alert = aps["alert"] as? [String: Any] + { + body = alert["body"] as? String + } else { + body = nil + } + } + + func matches(_ operation: TRCMealMutationOperation) -> Bool { + guard commandType == operation.type.rawValue, + let canonicalCommandID = Self.canonicalUUID(commandID), + let canonicalMealID = Self.canonicalUUID(mealID) + else { + return false + } + + return canonicalCommandID == operation.commandID && canonicalMealID == operation.mealID + } + + static func canonicalUUID(_ value: String?) -> String? { + guard let value, let uuid = UUID(uuidString: value) else { return nil } + return uuid.uuidString + } + + private static func timeInterval(from value: Any?) -> TimeInterval? { + switch value { + case let value as TimeInterval: + return value.isFinite ? value : nil + case let value as NSNumber: + let result = value.doubleValue + return result.isFinite ? result : nil + default: + return nil + } + } +} + +enum TRCMealMutationEvent: Equatable, Sendable { + case retryStarted + case transportAccepted + case transportFailed(String?) + case timedOut + case response( + result: TRCMealMutationResult, + syncStatus: TRCMealMutationSyncStatus?, + body: String?, + timestamp: TimeInterval? + ) + case reconciled +} + +enum TRCMealMutationReducer { + static func reduce( + _ operation: TRCMealMutationOperation, + event: TRCMealMutationEvent, + at date: Date + ) -> TRCMealMutationOperation { + var updated = operation + + if operation.isTerminal { + if case .reconciled = event, operation.state == .applied, operation.reconciledAt == nil { + updated.reconciledAt = date + updated.updatedAt = date + } + return updated + } + + switch event { + case .retryStarted: + updated.state = .sending + updated.lastAttemptAt = date + updated.updatedAt = date + updated.responseBody = nil + + case .transportAccepted: + guard operation.state == .sending else { return operation } + updated.state = .awaiting + updated.updatedAt = date + updated.responseBody = nil + + case let .transportFailed(message): + guard operation.state == .sending else { return operation } + updated.state = .transportFailed + updated.updatedAt = date + updated.responseBody = message + + case .timedOut: + guard operation.state == .sending || operation.state == .awaiting || operation.state == .inProgress else { + return operation + } + updated.state = .timedOut + updated.updatedAt = date + + case let .response(result, syncStatus, body, timestamp): + if timestamp == operation.lastResponseTimestamp, + result == operation.responseResult, + syncStatus == operation.syncStatus + { + return operation + } + + if let timestamp, + let lastTimestamp = operation.lastResponseTimestamp, + timestamp < lastTimestamp + { + return operation + } + + updated.updatedAt = date + updated.responseBody = body + updated.responseResult = result + updated.syncStatus = syncStatus + if let timestamp { + updated.lastResponseTimestamp = timestamp + } + + switch result { + case .updated, .deleted, .alreadyApplied: + updated.state = .applied + case .rejected: + updated.state = .rejected + case .inProgress: + updated.state = .inProgress + } + + case .reconciled: + return operation + } + + return updated + } +} + +enum TRCMealMutationValidator { + static func validate( + type: TRCMealMutationType, + expected: TRCMealMutationValues, + replacement: TRCMealMutationValues?, + at date: Date + ) throws { + let timePolicy = TRCMealMutationTimePolicy() + guard expected.isValid else { + throw TRCMealMutationCoordinatorError.invalidExpectedValues + } + guard timePolicy.isValid(expected.mealTime, at: date) else { + throw TRCMealMutationCoordinatorError.expectedMealTimeOutOfRange + } + + switch type { + case .edit: + guard let replacement, replacement.isValid else { + throw TRCMealMutationCoordinatorError.invalidReplacementValues + } + guard timePolicy.isValid(replacement.mealTime, at: date) else { + throw TRCMealMutationCoordinatorError.replacementMealTimeOutOfRange + } + case .delete: + guard replacement == nil else { + throw TRCMealMutationCoordinatorError.unexpectedReplacementValues + } + } + } +} + +enum TRCMealMutationCoordinatorError: LocalizedError, Equatable { + case invalidCommandID + case invalidMealID + case missingUser + case invalidExpectedValues + case expectedMealTimeOutOfRange + case invalidReplacementValues + case replacementMealTimeOutOfRange + case unexpectedReplacementValues + case operationAlreadyExists + case operationAlreadyPending + case operationNotFound + case operationIsTerminal + case operationNotRetryable + + var errorDescription: String? { + switch self { + case .invalidCommandID: return "The meal command ID is invalid." + case .invalidMealID: return "The Trio meal ID is invalid." + case .missingUser: return "The configured remote-command user is missing." + case .invalidExpectedValues: return "The loaded meal values are incomplete or invalid." + case .expectedMealTimeOutOfRange: return "The source meal time must be within 12 hours of now." + case .invalidReplacementValues: return "The replacement meal values are incomplete or invalid." + case .replacementMealTimeOutOfRange: return "The replacement meal time must be within 12 hours of now." + case .unexpectedReplacementValues: return "A delete request cannot include replacement meal values." + case .operationAlreadyExists: return "That meal command ID has already been used." + case .operationAlreadyPending: return "A meal change for this meal is already pending." + case .operationNotFound: return "The pending meal change was not found." + case .operationIsTerminal: return "A completed meal change cannot be retried." + case .operationNotRetryable: return "This meal change is still awaiting a result and cannot be retried yet." + } + } +} + +@MainActor +final class TRCMealMutationCoordinator: ObservableObject { + static let attemptTimeout: TimeInterval = 60 + + typealias Transport = ( + TRCMealMutationTransportRequest, + @escaping (_ acceptedByAPNs: Bool, _ errorMessage: String?) -> Void + ) -> Void + + static let shared = TRCMealMutationCoordinator( + storage: Storage.shared.pendingTRCMealMutations, + transport: liveTransport + ) + + @Published private(set) var operations: [TRCMealMutationOperation] + + private let storage: StorageValue<[TRCMealMutationOperation]> + private let transport: Transport + + init( + storage: StorageValue<[TRCMealMutationOperation]>, + transport: @escaping Transport, + at date: Date = Date() + ) { + self.storage = storage + self.transport = transport + operations = storage.value + recoverExpiredAttempts(at: date) + } + + @discardableResult + func startEdit( + mealID: String, + user: String, + expectedCarbs: Int, + expectedFat: Int, + expectedProtein: Int, + expectedMealTime: TimeInterval, + carbs: Int, + fat: Int, + protein: Int, + scheduledTime: TimeInterval, + commandID: UUID = UUID(), + at date: Date = Date() + ) throws -> TRCMealMutationOperation { + let expected = TRCMealMutationValues( + carbs: expectedCarbs, + fat: expectedFat, + protein: expectedProtein, + mealTime: expectedMealTime + ) + let replacement = TRCMealMutationValues( + carbs: carbs, + fat: fat, + protein: protein, + mealTime: scheduledTime + ) + return try start( + type: .edit, + mealID: mealID, + user: user, + expected: expected, + replacement: replacement, + commandID: commandID, + at: date + ) + } + + @discardableResult + func startDelete( + mealID: String, + user: String, + expectedCarbs: Int, + expectedFat: Int, + expectedProtein: Int, + expectedMealTime: TimeInterval, + commandID: UUID = UUID(), + at date: Date = Date() + ) throws -> TRCMealMutationOperation { + let expected = TRCMealMutationValues( + carbs: expectedCarbs, + fat: expectedFat, + protein: expectedProtein, + mealTime: expectedMealTime + ) + return try start( + type: .delete, + mealID: mealID, + user: user, + expected: expected, + replacement: nil, + commandID: commandID, + at: date + ) + } + + @discardableResult + func retry(commandID: String, at date: Date = Date()) throws -> TRCMealMutationOperation { + guard let canonicalCommandID = TRCMealMutationResponse.canonicalUUID(commandID), + let index = operations.firstIndex(where: { $0.commandID == canonicalCommandID }) + else { + throw TRCMealMutationCoordinatorError.operationNotFound + } + guard !operations[index].isTerminal else { + throw TRCMealMutationCoordinatorError.operationIsTerminal + } + guard operations[index].isRetryable else { + throw TRCMealMutationCoordinatorError.operationNotRetryable + } + try TRCMealMutationValidator.validate( + type: operations[index].type, + expected: operations[index].expected, + replacement: operations[index].replacement, + at: date + ) + + let updated = TRCMealMutationReducer.reduce(operations[index], event: .retryStarted, at: date) + replaceOperation(at: index, with: updated) + send(updated.transportRequest(at: date)) + return operations[index] + } + + func operation(forMealID mealID: String) -> TRCMealMutationOperation? { + guard let canonicalMealID = TRCMealMutationResponse.canonicalUUID(mealID) else { return nil } + return operations + .filter { $0.mealID == canonicalMealID } + .max { $0.createdAt < $1.createdAt } + } + + func operation(commandID: String) -> TRCMealMutationOperation? { + guard let canonicalCommandID = TRCMealMutationResponse.canonicalUUID(commandID) else { return nil } + return operations.first { $0.commandID == canonicalCommandID } + } + + func blockingOperation(forMealID mealID: String) -> TRCMealMutationOperation? { + guard let canonicalMealID = TRCMealMutationResponse.canonicalUUID(mealID) else { return nil } + return operations + .filter { $0.mealID == canonicalMealID && $0.blocksActions } + .max { $0.createdAt < $1.createdAt } + } + + func markTimedOut(commandID: String, at date: Date = Date()) { + apply(event: .timedOut, toCommandID: commandID, at: date) + } + + func recoverExpiredAttempts(at date: Date = Date()) { + var recovered = operations + for index in recovered.indices { + let operation = recovered[index] + guard operation.state == .sending || operation.state == .awaiting, + date.timeIntervalSince(operation.lastAttemptAt) >= Self.attemptTimeout + else { + continue + } + recovered[index] = TRCMealMutationReducer.reduce(operation, event: .timedOut, at: date) + } + + guard recovered != operations else { return } + operations = recovered + storage.value = recovered + } + + /// Marks an applied edit reconciled only after freshly loaded Nightscout + /// values match the requested replacement. Returns true when it changed state. + @discardableResult + func reconcileAppliedEdit( + mealID: String, + carbs: Int, + fat: Int, + protein: Int, + mealTime: TimeInterval, + at date: Date = Date() + ) -> Bool { + guard let canonicalMealID = TRCMealMutationResponse.canonicalUUID(mealID) else { return false } + let observed = TRCMealMutationValues(carbs: carbs, fat: fat, protein: protein, mealTime: mealTime) + guard let index = operations.indices + .filter({ + let operation = operations[$0] + return operation.mealID == canonicalMealID && + operation.type == .edit && + operation.state == .applied && + operation.reconciledAt == nil && + operation.replacement?.matches(observed) == true + }) + .max(by: { operations[$0].createdAt < operations[$1].createdAt }) + else { + return false + } + + let previous = operations[index] + let updated = TRCMealMutationReducer.reduce(previous, event: .reconciled, at: date) + guard updated != previous else { return false } + replaceOperation(at: index, with: updated) + return true + } + + @discardableResult + func handleRemoteNotification(userInfo: [AnyHashable: Any], at date: Date = Date()) -> Bool { + let response = TRCMealMutationResponse(userInfo: userInfo) + guard let result = response.result, + let canonicalCommandID = TRCMealMutationResponse.canonicalUUID(response.commandID), + let index = operations.firstIndex(where: { $0.commandID == canonicalCommandID }), + response.matches(operations[index]) + else { + return false + } + + let previous = operations[index] + let updated = TRCMealMutationReducer.reduce( + previous, + event: .response( + result: result, + syncStatus: response.syncStatus, + body: response.body, + timestamp: response.timestamp + ), + at: date + ) + + if updated != previous { + replaceOperation(at: index, with: updated) + } + + if !previous.isTerminal, updated.isTerminal { + NotificationCenter.default.post( + name: .trcMealMutationDidComplete, + object: nil, + userInfo: [ + "command_id": updated.commandID, + "meal_id": updated.mealID, + "command_type": updated.type.rawValue, + ] + ) + } + + return true + } + + private func start( + type: TRCMealMutationType, + mealID: String, + user: String, + expected: TRCMealMutationValues, + replacement: TRCMealMutationValues?, + commandID: UUID, + at date: Date + ) throws -> TRCMealMutationOperation { + let canonicalCommandID = commandID.uuidString + guard let canonicalMealID = TRCMealMutationResponse.canonicalUUID(mealID) else { + throw TRCMealMutationCoordinatorError.invalidMealID + } + guard !user.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw TRCMealMutationCoordinatorError.missingUser + } + try TRCMealMutationValidator.validate( + type: type, + expected: expected, + replacement: replacement, + at: date + ) + guard !operations.contains(where: { $0.commandID == canonicalCommandID }) else { + throw TRCMealMutationCoordinatorError.operationAlreadyExists + } + guard blockingOperation(forMealID: canonicalMealID) == nil else { + throw TRCMealMutationCoordinatorError.operationAlreadyPending + } + + let operation = TRCMealMutationOperation( + commandID: canonicalCommandID, + mealID: canonicalMealID, + user: user, + type: type, + expected: expected, + replacement: replacement, + createdAt: date, + lastAttemptAt: date, + updatedAt: date, + state: .sending, + responseBody: nil, + responseResult: nil, + syncStatus: nil, + lastResponseTimestamp: nil, + reconciledAt: nil + ) + + appendAndPersist(operation) + send(operation.transportRequest(at: date)) + return self.operation(commandID: canonicalCommandID) ?? operation + } + + private func send(_ request: TRCMealMutationTransportRequest) { + transport(request) { [weak self] accepted, message in + guard let self else { return } + if accepted { + self.apply( + event: .transportAccepted, + toCommandID: request.commandID, + attemptTimestamp: request.transportTimestamp, + at: Date() + ) + } else { + self.apply( + event: .transportFailed(message), + toCommandID: request.commandID, + attemptTimestamp: request.transportTimestamp, + at: Date() + ) + } + } + } + + private func apply( + event: TRCMealMutationEvent, + toCommandID commandID: String, + attemptTimestamp: TimeInterval? = nil, + at date: Date + ) { + guard let canonicalCommandID = TRCMealMutationResponse.canonicalUUID(commandID), + let index = operations.firstIndex(where: { $0.commandID == canonicalCommandID }) + else { + return + } + if let attemptTimestamp, + operations[index].lastAttemptAt.timeIntervalSince1970 != attemptTimestamp + { + return + } + + let updated = TRCMealMutationReducer.reduce(operations[index], event: event, at: date) + guard updated != operations[index] else { return } + replaceOperation(at: index, with: updated) + } + + private func appendAndPersist(_ operation: TRCMealMutationOperation) { + operations.append(operation) + storage.value = operations + } + + private func replaceOperation(at index: Int, with operation: TRCMealMutationOperation) { + operations[index] = operation + storage.value = operations + } + + private static func liveTransport( + _ request: TRCMealMutationTransportRequest, + completion: @escaping (Bool, String?) -> Void + ) { + guard let commandID = UUID(uuidString: request.commandID) else { + completion(false, TRCMealMutationCoordinatorError.invalidCommandID.localizedDescription) + return + } + guard let mealID = UUID(uuidString: request.mealID) else { + completion(false, TRCMealMutationCoordinatorError.invalidMealID.localizedDescription) + return + } + + let manager = PushNotificationManager() + do { + let returnNotification = try manager.requireReturnNotificationInfo() + let payload: CommandPayload + switch request.type { + case .edit: + guard let replacement = request.replacement else { + completion(false, TRCMealMutationCoordinatorError.invalidReplacementValues.localizedDescription) + return + } + payload = .editMeal( + user: request.user, + timestamp: request.transportTimestamp, + commandID: commandID, + mealID: mealID, + expectedCarbs: request.expected.carbs, + expectedFat: request.expected.fat, + expectedProtein: request.expected.protein, + expectedMealTime: request.expected.mealTime, + carbs: replacement.carbs, + fat: replacement.fat, + protein: replacement.protein, + scheduledTime: replacement.mealTime, + returnNotification: returnNotification + ) + case .delete: + payload = .deleteMeal( + user: request.user, + timestamp: request.transportTimestamp, + commandID: commandID, + mealID: mealID, + expectedCarbs: request.expected.carbs, + expectedFat: request.expected.fat, + expectedProtein: request.expected.protein, + expectedMealTime: request.expected.mealTime, + returnNotification: returnNotification + ) + } + + manager.sendPreparedMealMutationPayload(payload) { accepted, message in + DispatchQueue.main.async { + completion(accepted, message) + } + } + } catch { + completion(false, error.localizedDescription) + } + } +} + +extension Notification.Name { + static let trcMealMutationDidComplete = Notification.Name("LoopFollow.trcMealMutationDidComplete") +} diff --git a/LoopFollow/Remote/TRC/MealMutationView.swift b/LoopFollow/Remote/TRC/MealMutationView.swift new file mode 100644 index 000000000..9dd8311bf --- /dev/null +++ b/LoopFollow/Remote/TRC/MealMutationView.swift @@ -0,0 +1,385 @@ +// LoopFollow +// MealMutationView.swift + +import HealthKit +import SwiftUI + +struct TRCMealEditView: View { + let meal: TrioMealTreatment + + @Environment(\.dismiss) private var dismiss + @ObservedObject private var coordinator = TRCMealMutationCoordinator.shared + + @State private var carbsText: String + @State private var fatText: String + @State private var proteinText: String + @State private var mealTime: Date + @State private var activeAlert: EditAlert? + + init(meal: TrioMealTreatment) { + self.meal = meal + _carbsText = State(initialValue: String(meal.carbs)) + _fatText = State(initialValue: String(meal.fat)) + _proteinText = State(initialValue: String(meal.protein)) + _mealTime = State(initialValue: Date(timeIntervalSince1970: meal.mealTime)) + } + + var body: some View { + NavigationStack { + Form { + Section("Current Meal") { + TRCMealMacroRows( + carbs: meal.carbs, + fat: meal.fat, + protein: meal.protein, + mealTime: meal.mealTime + ) + } + + Section("Replacement") { + integerField("Carbs", text: $carbsText) + integerField("Fat", text: $fatText) + integerField("Protein", text: $proteinText) + + DatePicker( + "Meal Time", + selection: $mealTime, + in: mealTimeRange, + displayedComponents: [.date, .hourAndMinute] + ) + .environment(\.timeZone, dateTimeUtils.displayTimeZone()) + } + + Section { + Button("Review Meal Update") { + reviewUpdate() + } + .frame(maxWidth: .infinity) + .disabled(coordinator.blockingOperation(forMealID: meal.mealID.uuidString) != nil) + } footer: { + Text("Trio will independently verify the original meal values and its configured nutrient limits before applying this update.") + } + } + .navigationTitle("Edit Meal") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .alert(item: $activeAlert) { alert in + switch alert { + case .confirm: + return Alert( + title: Text("Confirm Meal Update"), + message: Text(confirmationMessage), + primaryButton: .default(Text("Send Update"), action: sendUpdate), + secondaryButton: .cancel() + ) + case let .error(message): + return Alert( + title: Text("Unable to Update Meal"), + message: Text(message), + dismissButton: .default(Text("OK")) + ) + } + } + } + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } + + private func integerField(_ label: String, text: Binding) -> some View { + HStack { + Text(label) + Spacer() + TextField("0", text: text) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .frame(maxWidth: 100) + Text("g") + .foregroundColor(.secondary) + } + } + + private var mealTimeRange: ClosedRange { + let now = Date() + let offset = TRCMealMutationTimePolicy.maximumOffset + return now.addingTimeInterval(-offset) ... now.addingTimeInterval(offset) + } + + private var replacement: TRCMealMutationValues? { + guard let carbs = nonnegativeInteger(carbsText), + let fat = nonnegativeInteger(fatText), + let protein = nonnegativeInteger(proteinText) + else { + return nil + } + + return TRCMealMutationValues( + carbs: carbs, + fat: fat, + protein: protein, + mealTime: mealTime.timeIntervalSince1970 + ) + } + + private var confirmationMessage: String { + guard let replacement else { return "" } + return """ + Current: C \(meal.carbs)g, F \(meal.fat)g, P \(meal.protein)g at \(formatMealTime(meal.mealTime)) + + Replacement: C \(replacement.carbs)g, F \(replacement.fat)g, P \(replacement.protein)g at \(formatMealTime(replacement.mealTime)) + """ + } + + private func reviewUpdate() { + if let error = validationError(at: Date()) { + activeAlert = .error(error) + } else { + activeAlert = .confirm + } + } + + private func sendUpdate() { + let now = Date() + if let error = validationError(at: now) { + activeAlert = .error(error) + return + } + guard let replacement else { + activeAlert = .error("Enter whole-number values for carbs, fat, and protein.") + return + } + + do { + _ = try coordinator.startEdit( + mealID: meal.mealID.uuidString, + user: Storage.shared.user.value, + expectedCarbs: meal.carbs, + expectedFat: meal.fat, + expectedProtein: meal.protein, + expectedMealTime: meal.mealTime, + carbs: replacement.carbs, + fat: replacement.fat, + protein: replacement.protein, + scheduledTime: replacement.mealTime, + at: now + ) + dismiss() + } catch { + activeAlert = .error(error.localizedDescription) + } + } + + private func validationError(at now: Date) -> String? { + if let error = TRCMealMutationUIValidation.sourceError(meal: meal, at: now) { + return error + } + if let blocking = coordinator.blockingOperation(forMealID: meal.mealID.uuidString) { + return "A previous meal request is still active: \(blocking.statusTitle)." + } + guard let replacement else { + return "Enter whole-number values for carbs, fat, and protein." + } + guard replacement.hasValidMacros else { + return "At least one nutrient must be greater than zero, and none can be negative." + } + if let error = TRCMealMutationUIValidation.timeError(replacement.mealTime, at: now, label: "Replacement time") { + return error + } + + let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram()) + let maxFat = Storage.shared.maxFat.value.doubleValue(for: .gram()) + let maxProtein = Storage.shared.maxProtein.value.doubleValue(for: .gram()) + guard Double(replacement.carbs) <= maxCarbs else { + return "Carbs exceed LoopFollow’s configured maximum of \(Int(maxCarbs)) g. Trio will also enforce its own limit." + } + guard Double(replacement.fat) <= maxFat else { + return "Fat exceeds LoopFollow’s configured maximum of \(Int(maxFat)) g. Trio will also enforce its own limit." + } + guard Double(replacement.protein) <= maxProtein else { + return "Protein exceeds LoopFollow’s configured maximum of \(Int(maxProtein)) g. Trio will also enforce its own limit." + } + + let original = TRCMealMutationValues( + carbs: meal.carbs, + fat: meal.fat, + protein: meal.protein, + mealTime: meal.mealTime + ) + guard !original.matches(replacement) else { + return "Change at least one nutrient or move the meal time by at least one minute." + } + return TRCMealMutationUIValidation.configurationError() + } + + private func nonnegativeInteger(_ text: String) -> Int? { + guard !text.isEmpty, let value = Int(text), value >= 0 else { return nil } + return value + } +} + +struct TRCMealMacroRows: View { + let carbs: Int + let fat: Int + let protein: Int + let mealTime: TimeInterval + + var body: some View { + valueRow("Carbs", value: "\(carbs) g") + valueRow("Fat", value: "\(fat) g") + valueRow("Protein", value: "\(protein) g") + valueRow("Time", value: formatMealTime(mealTime)) + } + + private func valueRow(_ label: String, value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value) + .foregroundColor(.secondary) + .multilineTextAlignment(.trailing) + } + } +} + +struct TRCMealMutationStatusSection: View { + let operation: TRCMealMutationOperation + let onError: (String) -> Void + + @ObservedObject private var coordinator = TRCMealMutationCoordinator.shared + + var body: some View { + Section("Remote Meal Status") { + Label(operation.statusTitle, systemImage: statusIcon) + .foregroundColor(statusColor) + Text(operation.statusDetail) + .font(.subheadline) + .foregroundColor(.secondary) + + if operation.isRetryable { + Button("Retry Same Request") { + do { + _ = try coordinator.retry(commandID: operation.commandID) + } catch { + onError(error.localizedDescription) + } + } + } + } + .task(id: timeoutTaskID) { + guard operation.state == .sending || operation.state == .awaiting else { return } + let elapsed = Date().timeIntervalSince(operation.lastAttemptAt) + let remaining = max(0, TRCMealMutationCoordinator.attemptTimeout - elapsed) + if remaining > 0 { + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + guard !Task.isCancelled else { return } + coordinator.markTimedOut(commandID: operation.commandID) + } + } + + private var timeoutTaskID: String { + "\(operation.commandID)-\(operation.state.rawValue)-\(operation.lastAttemptAt.timeIntervalSince1970)" + } + + private var statusIcon: String { + switch operation.state { + case .sending: return "arrow.up.circle" + case .awaiting: return "clock" + case .inProgress, .timedOut: return "hourglass" + case .applied: return "checkmark.circle.fill" + case .rejected, .transportFailed: return "exclamationmark.triangle.fill" + } + } + + private var statusColor: Color { + switch operation.state { + case .applied: return .green + case .rejected, .transportFailed: return .red + case .sending, .awaiting, .inProgress, .timedOut: return .orange + } + } +} + +enum TRCMealMutationUIValidation { + static func staleSourceError( + meal: TrioMealTreatment, + after operation: TRCMealMutationOperation + ) -> String? { + guard operation.type == .edit, + operation.state == .applied, + operation.reconciledAt != nil, + let replacement = operation.replacement + else { + return nil + } + + let displayed = TRCMealMutationValues( + carbs: meal.carbs, + fat: meal.fat, + protein: meal.protein, + mealTime: meal.mealTime + ) + guard !replacement.matches(displayed) else { return nil } + return "This screen shows the previous meal values. Return to the treatment list and reopen the refreshed meal." + } + + static func sourceError(meal: TrioMealTreatment, at now: Date) -> String? { + if meal.isGeneratedFPU { + return "This is a Trio-generated FPU entry. Edit or delete its root meal instead." + } + if let error = timeError(meal.mealTime, at: now, label: "Meal") { + return error + } + guard meal.carbs >= 0, + meal.fat >= 0, + meal.protein >= 0, + meal.carbs > 0 || meal.fat > 0 || meal.protein > 0 + else { + return "This meal does not contain compatible whole-number nutrient values." + } + return nil + } + + static func timeError(_ timestamp: TimeInterval, at now: Date, label: String) -> String? { + guard !TRCMealMutationTimePolicy().isValid(timestamp, at: now) else { return nil } + return "\(label) must be within 12 hours before or after now." + } + + static func configurationError() -> String? { + guard Storage.shared.remoteType.value == .trc, Storage.shared.device.value == "Trio" else { + return "Meal editing requires Trio Remote Control with a Trio device." + } + guard !Storage.shared.user.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "The Trio Remote Control user is missing." + } + + do { + _ = try PushNotificationManager().requireReturnNotificationInfo() + return nil + } catch { + return error.localizedDescription + } + } +} + +private enum EditAlert: Identifiable { + case confirm + case error(String) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .error(message): return "error-\(message)" + } + } +} + +func formatMealTime(_ timestamp: TimeInterval) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + dateTimeUtils.applyDisplayTimeZone(to: formatter) + return formatter.string(from: Date(timeIntervalSince1970: timestamp)) +} diff --git a/LoopFollow/Remote/TRC/PushMessage.swift b/LoopFollow/Remote/TRC/PushMessage.swift index 09ea4e817..5dc500d66 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/LoopFollow/Remote/TRC/PushMessage.swift @@ -43,8 +43,18 @@ struct CommandPayload: Encodable { var fat: Int? var overrideName: String? var scheduledTime: TimeInterval? + var commandID: String? + var mealID: String? + var expectedCarbs: Int? + var expectedFat: Int? + var expectedProtein: Int? + var expectedMealTime: TimeInterval? var returnNotification: ReturnNotificationInfo? + var apnsCollapseID: String? { + commandType.isMealMutation ? commandID : commandType.rawValue + } + struct ReturnNotificationInfo: Encodable { let productionEnvironment: Bool let deviceToken: String @@ -53,6 +63,14 @@ struct CommandPayload: Encodable { let keyId: String let apnsKey: String + var isComplete: Bool { + !deviceToken.isEmpty && + !bundleId.isEmpty && + !teamId.isEmpty && + !keyId.isEmpty && + !apnsKey.isEmpty + } + enum CodingKeys: String, CodingKey { case productionEnvironment = "production_environment" case deviceToken = "device_token" @@ -75,6 +93,72 @@ struct CommandPayload: Encodable { case fat case overrideName case scheduledTime = "scheduled_time" + case commandID = "command_id" + case mealID = "meal_id" + case expectedCarbs = "expected_carbs" + case expectedFat = "expected_fat" + case expectedProtein = "expected_protein" + case expectedMealTime = "expected_meal_time" case returnNotification = "return_notification" } } + +extension CommandPayload { + static func editMeal( + user: String, + timestamp: TimeInterval, + commandID: UUID, + mealID: UUID, + expectedCarbs: Int, + expectedFat: Int, + expectedProtein: Int, + expectedMealTime: TimeInterval, + carbs: Int, + fat: Int, + protein: Int, + scheduledTime: TimeInterval, + returnNotification: ReturnNotificationInfo + ) -> CommandPayload { + CommandPayload( + user: user, + commandType: .editMeal, + timestamp: timestamp, + carbs: carbs, + protein: protein, + fat: fat, + scheduledTime: scheduledTime, + commandID: commandID.uuidString, + mealID: mealID.uuidString, + expectedCarbs: expectedCarbs, + expectedFat: expectedFat, + expectedProtein: expectedProtein, + expectedMealTime: expectedMealTime, + returnNotification: returnNotification + ) + } + + static func deleteMeal( + user: String, + timestamp: TimeInterval, + commandID: UUID, + mealID: UUID, + expectedCarbs: Int, + expectedFat: Int, + expectedProtein: Int, + expectedMealTime: TimeInterval, + returnNotification: ReturnNotificationInfo + ) -> CommandPayload { + CommandPayload( + user: user, + commandType: .deleteMeal, + timestamp: timestamp, + commandID: commandID.uuidString, + mealID: mealID.uuidString, + expectedCarbs: expectedCarbs, + expectedFat: expectedFat, + expectedProtein: expectedProtein, + expectedMealTime: expectedMealTime, + returnNotification: returnNotification + ) + } +} diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index aa1f661a2..56e774d4f 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -4,6 +4,61 @@ import Foundation import HealthKit +private enum ReturnNotificationConfigurationError: LocalizedError { + case incomplete + case invalid + + var errorDescription: String? { + switch self { + case .incomplete: + return "Return notifications are not fully configured. Configure LoopFollow APNS credentials in App Settings → APN." + case .invalid: + return "Return-notification APNS credentials are malformed. Check the LoopFollow Key ID, Team ID, and private key in App Settings → APN." + } + } +} + +enum APNSCredentialValidator { + static func validationErrors(keyID: String, teamID: String, apnsKey: String) -> [String]? { + var errors = [String]() + let identifierPattern = "^[A-Z0-9]{10}$" + if !matchesRegex(keyID, pattern: identifierPattern) { + errors.append("APNS Key ID (\(keyID)) must be 10 uppercase alphanumeric characters.") + } + if !matchesRegex(teamID, pattern: identifierPattern) { + errors.append("Team ID (\(teamID)) must be 10 uppercase alphanumeric characters.") + } + if !apnsKey.contains("-----BEGIN PRIVATE KEY-----") || !apnsKey.contains("-----END PRIVATE KEY-----") { + errors.append("APNS Key must be a valid PEM-formatted private key.") + } else if let keyData = extractKeyData(from: apnsKey) { + if Data(base64Encoded: keyData) == nil { + errors.append("APNS Key contains invalid Base64 key data.") + } + } else { + errors.append("APNS Key has invalid formatting.") + } + return errors.isEmpty ? nil : errors + } + + private static func matchesRegex(_ text: String, pattern: String) -> Bool { + let regex = try? NSRegularExpression(pattern: pattern) + let range = NSRange(location: 0, length: text.utf16.count) + return regex?.firstMatch(in: text, options: [], range: range) != nil + } + + private static func extractKeyData(from pemString: String) -> String? { + let lines = pemString.components(separatedBy: "\n") + guard let startIndex = lines.firstIndex(of: "-----BEGIN PRIVATE KEY-----"), + let endIndex = lines.firstIndex(of: "-----END PRIVATE KEY-----"), + startIndex < endIndex + else { + return nil + } + let keyLines = lines[(startIndex + 1) ..< endIndex] + return keyLines.joined() + } +} + class PushNotificationManager { private var deviceToken: String private var sharedSecret: String @@ -66,6 +121,20 @@ class PushNotificationManager { ) } + func requireReturnNotificationInfo() throws -> CommandPayload.ReturnNotificationInfo { + guard let info = createReturnNotificationInfo(), info.isComplete else { + throw ReturnNotificationConfigurationError.incomplete + } + guard APNSCredentialValidator.validationErrors( + keyID: info.keyId, + teamID: info.teamId, + apnsKey: info.apnsKey + ) == nil else { + throw ReturnNotificationConfigurationError.invalid + } + return info + } + func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) { let payload = CommandPayload( user: user, @@ -164,46 +233,31 @@ class PushNotificationManager { sendEncryptedCommand(payload: payload, completion: completion) } - private func validateCredentials() -> [String]? { - var errors = [String]() - let keyIdPattern = "^[A-Z0-9]{10}$" - if !matchesRegex(keyId, pattern: keyIdPattern) { - errors.append("APNS Key ID (\(keyId)) must be 10 uppercase alphanumeric characters.") - } - let teamIdPattern = "^[A-Z0-9]{10}$" - if !matchesRegex(teamId, pattern: teamIdPattern) { - errors.append("Team ID (\(teamId)) must be 10 uppercase alphanumeric characters.") - } - if !apnsKey.contains("-----BEGIN PRIVATE KEY-----") || !apnsKey.contains("-----END PRIVATE KEY-----") { - errors.append("APNS Key must be a valid PEM-formatted private key.") - } else { - if let keyData = extractKeyData(from: apnsKey) { - if Data(base64Encoded: keyData) == nil { - errors.append("APNS Key contains invalid Base64 key data.") - } - } else { - errors.append("APNS Key has invalid formatting.") - } + /// A successful completion means APNs accepted the command for delivery, not that Trio applied the mutation. + func sendPreparedMealMutationPayload( + _ payload: CommandPayload, + completion: @escaping (Bool, String?) -> Void + ) { + guard payload.commandType.isMealMutation, + let returnNotification = payload.returnNotification, + returnNotification.isComplete, + APNSCredentialValidator.validationErrors( + keyID: returnNotification.keyId, + teamID: returnNotification.teamId, + apnsKey: returnNotification.apnsKey + ) == nil + else { + let errorMessage = "A prepared meal mutation and usable return-notification configuration are required." + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return } - return errors.isEmpty ? nil : errors - } - private func matchesRegex(_ text: String, pattern: String) -> Bool { - let regex = try? NSRegularExpression(pattern: pattern) - let range = NSRange(location: 0, length: text.utf16.count) - return regex?.firstMatch(in: text, options: [], range: range) != nil + sendEncryptedCommand(payload: payload, completion: completion) } - private func extractKeyData(from pemString: String) -> String? { - let lines = pemString.components(separatedBy: "\n") - guard let startIndex = lines.firstIndex(of: "-----BEGIN PRIVATE KEY-----"), - let endIndex = lines.firstIndex(of: "-----END PRIVATE KEY-----"), - startIndex < endIndex - else { - return nil - } - let keyLines = lines[(startIndex + 1) ..< endIndex] - return keyLines.joined() + private func validateCredentials() -> [String]? { + APNSCredentialValidator.validationErrors(keyID: keyId, teamID: teamId, apnsKey: apnsKey) } private func sendEncryptedCommand(payload: CommandPayload, completion: @escaping (Bool, String?) -> Void) { @@ -211,7 +265,7 @@ class PushNotificationManager { if sharedSecret.isEmpty { missingFields.append("sharedSecret") } if apnsKey.isEmpty { missingFields.append("apnsKey") } if keyId.isEmpty { missingFields.append("keyId") } - if user.isEmpty { missingFields.append("user") } + if payload.user.isEmpty { missingFields.append("user") } if deviceToken.isEmpty { missingFields.append("deviceToken") } if bundleId.isEmpty { missingFields.append("bundleId") } if teamId.isEmpty { missingFields.append("teamId") } @@ -263,7 +317,9 @@ class PushNotificationManager { request.setValue("600", forHTTPHeaderField: "apns-expiration") request.setValue(bundleId, forHTTPHeaderField: "apns-topic") request.setValue("alert", forHTTPHeaderField: "apns-push-type") - request.setValue(payload.commandType.rawValue, forHTTPHeaderField: "apns-collapse-id") + if let collapseID = payload.apnsCollapseID { + request.setValue(collapseID, forHTTPHeaderField: "apns-collapse-id") + } request.httpBody = try JSONEncoder().encode(finalMessage) diff --git a/LoopFollow/Remote/TRC/TRCCommandType.swift b/LoopFollow/Remote/TRC/TRCCommandType.swift index 520f8a43a..b54fa6164 100644 --- a/LoopFollow/Remote/TRC/TRCCommandType.swift +++ b/LoopFollow/Remote/TRC/TRCCommandType.swift @@ -8,6 +8,8 @@ enum TRCCommandType: String, Encodable { case tempTarget = "temp_target" case cancelTempTarget = "cancel_temp_target" case meal + case editMeal = "edit_meal" + case deleteMeal = "delete_meal" case startOverride = "start_override" case cancelOverride = "cancel_override" @@ -17,8 +19,14 @@ enum TRCCommandType: String, Encodable { case .tempTarget: return "Temp Target" case .cancelTempTarget: return "Cancel Temp Target" case .meal: return "Meal" + case .editMeal: return "Edit Meal" + case .deleteMeal: return "Delete Meal" case .startOverride: return "Start Override" case .cancelOverride: return "Cancel Override" } } + + var isMealMutation: Bool { + self == .editMeal || self == .deleteMeal + } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..9a862a44e 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -244,6 +244,7 @@ class Storage { var remoteBolusHistory = StorageValue<[RemoteBolusHistoryEntry]>(key: "remoteBolusHistory", defaultValue: []) var remoteMealHistory = StorageValue<[RemoteMealHistoryEntry]>(key: "remoteMealHistory", defaultValue: []) + var pendingTRCMealMutations = StorageValue<[TRCMealMutationOperation]>(key: "pendingTRCMealMutations", defaultValue: []) // Statistics display preferences var showGMI = StorageValue(key: "showGMI", defaultValue: true) var showStdDev = StorageValue(key: "showStdDev", defaultValue: true) diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index f1b1c7595..0d29173a9 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -121,7 +121,10 @@ struct TreatmentsView: View { .padding(.bottom, 2) .background(Color(.systemBackground)) } else if let treatment = row.treatment { - TreatmentRow(treatment: treatment) + TreatmentRow( + treatment: treatment, + rootMealTreatment: viewModel.loadedFPURoot(for: treatment) + ) } } } header: { @@ -199,6 +202,9 @@ struct TreatmentsView: View { .onChange(of: device.value) { newValue in normalizeSelectedFilter(for: newValue) } + .onReceive(NotificationCenter.default.publisher(for: .trcMealMutationDidComplete)) { _ in + viewModel.refreshTreatments() + } } } } @@ -393,7 +399,19 @@ private struct DayRow: Identifiable { struct TreatmentDetailView: View { let treatment: Treatment + let rootMealTreatment: Treatment? @StateObject private var viewModel = TreatmentDetailViewModel() + @ObservedObject private var remoteType = Storage.shared.remoteType + @ObservedObject private var device = Storage.shared.device + @ObservedObject private var mutationCoordinator = TRCMealMutationCoordinator.shared + @State private var isShowingMealEditor = false + @State private var isShowingDeleteConfirmation = false + @State private var mutationErrorMessage: String? + + init(treatment: Treatment, rootMealTreatment: Treatment? = nil) { + self.treatment = treatment + self.rootMealTreatment = rootMealTreatment + } var body: some View { List { @@ -415,6 +433,79 @@ struct TreatmentDetailView: View { } } + if let meal = treatment.trioMeal { + Section(header: Text("Meal")) { + TRCMealMacroRows( + carbs: meal.carbs, + fat: meal.fat, + protein: meal.protein, + mealTime: meal.mealTime + ) + + if let note = mealNote(meal) { + HStack(alignment: .top) { + Text("Notes") + Spacer() + Text(note) + .foregroundColor(.secondary) + .multilineTextAlignment(.trailing) + } + } + + if meal.isGeneratedFPU { + if let rootMealTreatment { + NavigationLink(destination: TreatmentDetailView(treatment: rootMealTreatment)) { + generatedFPUNotice( + detail: "Tap here to manage the root meal entry.", + isAction: true + ) + } + .accessibilityLabel("Generated FPU entry. Manage root meal entry") + .accessibilityHint("Opens the original meal that generated this FPU entry.") + } else { + generatedFPUNotice( + detail: "Its root meal entry isn’t currently available. Return to Treatments, then refresh or load more.", + isAction: false + ) + } + } + } + + if shouldShowRemoteMealActions(for: meal) { + if let operation = mutationCoordinator.operation(forMealID: meal.mealID.uuidString) { + TRCMealMutationStatusSection(operation: operation) { message in + mutationErrorMessage = message + } + } + + Section(header: Text("Remote Meal Actions")) { + Button("Edit Meal") { + if let reason = remoteMealDisabledReason(for: meal) { + mutationErrorMessage = reason + } else { + isShowingMealEditor = true + } + } + .disabled(remoteMealDisabledReason(for: meal) != nil) + + Button("Delete Meal", role: .destructive) { + if let reason = remoteMealDisabledReason(for: meal) { + mutationErrorMessage = reason + } else { + isShowingDeleteConfirmation = true + } + } + .disabled(remoteMealDisabledReason(for: meal) != nil) + + if let reason = remoteMealDisabledReason(for: meal) { + Text(reason) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + // Glucose at time if viewModel.isLoading { Section { @@ -557,7 +648,135 @@ struct TreatmentDetailView: View { .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .onAppear { viewModel.loadDetails(for: treatment) + reconcileAppliedEditIfNeeded() + } + .sheet(isPresented: $isShowingMealEditor) { + if let meal = treatment.trioMeal { + TRCMealEditView(meal: meal) + } + } + .confirmationDialog( + "Delete Meal in Trio?", + isPresented: $isShowingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete Meal", role: .destructive) { + sendDelete() + } + Button("Cancel", role: .cancel) {} + } message: { + if let meal = treatment.trioMeal { + Text(deleteConfirmationMessage(for: meal)) + } + } + .alert( + "Remote Meal Error", + isPresented: Binding( + get: { mutationErrorMessage != nil }, + set: { if !$0 { mutationErrorMessage = nil } } + ) + ) { + Button("OK", role: .cancel) { + mutationErrorMessage = nil + } + } message: { + Text(mutationErrorMessage ?? "") + } + } + + private func generatedFPUNotice(detail: String, isAction: Bool) -> some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text("Generated FPU entry.") + .foregroundColor(.secondary) + Text(detail) + .foregroundColor(isAction ? .accentColor : .secondary) + } + + Spacer(minLength: 0) + } + .font(.subheadline) + } + + private func shouldShowRemoteMealActions(for meal: TrioMealTreatment) -> Bool { + remoteType.value == .trc && device.value == "Trio" && !meal.isGeneratedFPU + } + + private func remoteMealDisabledReason(for meal: TrioMealTreatment, at now: Date = Date()) -> String? { + if let operation = mutationCoordinator.blockingOperation(forMealID: meal.mealID.uuidString) { + if operation.state == .applied { + return "Waiting for refreshed Nightscout values. Return to the treatment list and reopen this meal after synchronization." + } + return "A previous request is still active: \(operation.statusTitle)." + } + if let operation = mutationCoordinator.operation(forMealID: meal.mealID.uuidString), + let error = TRCMealMutationUIValidation.staleSourceError(meal: meal, after: operation) + { + return error + } + if let error = TRCMealMutationUIValidation.sourceError(meal: meal, at: now) { + return error + } + return TRCMealMutationUIValidation.configurationError() + } + + private func sendDelete() { + guard let meal = treatment.trioMeal else { return } + let now = Date() + if let reason = remoteMealDisabledReason(for: meal, at: now) { + mutationErrorMessage = reason + return } + + do { + _ = try mutationCoordinator.startDelete( + mealID: meal.mealID.uuidString, + user: Storage.shared.user.value, + expectedCarbs: meal.carbs, + expectedFat: meal.fat, + expectedProtein: meal.protein, + expectedMealTime: meal.mealTime, + at: now + ) + } catch { + mutationErrorMessage = error.localizedDescription + } + } + + private func reconcileAppliedEditIfNeeded() { + guard let meal = treatment.trioMeal else { return } + mutationCoordinator.reconcileAppliedEdit( + mealID: meal.mealID.uuidString, + carbs: meal.carbs, + fat: meal.fat, + protein: meal.protein, + mealTime: meal.mealTime + ) + } + + private func mealNote(_ meal: TrioMealTreatment) -> String? { + if let notes = meal.notes, !notes.isEmpty { + return notes + } + if let foodType = meal.foodType, !foodType.isEmpty { + return foodType + } + return nil + } + + private func deleteConfirmationMessage(for meal: TrioMealTreatment) -> String { + """ + \(formatMealTime(meal.mealTime)) + Carbs: \(meal.carbs) g + Fat: \(meal.fat) g + Protein: \(meal.protein) g + + Any associated bolus will not be changed. + """ } private func formatNavigationTitle(_ timeInterval: TimeInterval) -> String { @@ -832,9 +1051,18 @@ class TreatmentDetailViewModel: ObservableObject { struct TreatmentRow: View { let treatment: Treatment + let rootMealTreatment: Treatment? + + init(treatment: Treatment, rootMealTreatment: Treatment? = nil) { + self.treatment = treatment + self.rootMealTreatment = rootMealTreatment + } var body: some View { - NavigationLink(destination: TreatmentDetailView(treatment: treatment)) { + NavigationLink(destination: TreatmentDetailView( + treatment: treatment, + rootMealTreatment: rootMealTreatment + )) { HStack { Image(systemName: treatment.icon) .foregroundColor(treatment.color) @@ -947,8 +1175,9 @@ struct Treatment: Identifiable { let icon: String let color: Color let bgValue: Int + let trioMeal: TrioMealTreatment? - init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int) { + init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int, trioMeal: TrioMealTreatment? = nil) { self.id = id ?? "\(type)-\(date)-\(title)" self.type = type self.date = date @@ -957,6 +1186,7 @@ struct Treatment: Identifiable { self.icon = icon self.color = color self.bgValue = bgValue + self.trioMeal = trioMeal } var hourKey: String { @@ -981,6 +1211,35 @@ class TreatmentsViewModel: ObservableObject { private let pageSize = 100 private var isFetching = false + func loadedFPURoot(for treatment: Treatment) -> Treatment? { + Self.uniqueFPURoot(for: treatment, among: allTreatments) + } + + static func uniqueFPURoot(for treatment: Treatment, among candidates: [Treatment]) -> Treatment? { + guard let meal = treatment.trioMeal, + case let .generatedChild(fpuID) = meal.fpuClassification + else { + return nil + } + + var matchingRoot: Treatment? + for candidate in candidates { + guard let candidateMeal = candidate.trioMeal, + case let .familyRoot(candidateFPUID) = candidateMeal.fpuClassification, + candidateFPUID == fpuID + else { + continue + } + + guard matchingRoot == nil else { + return nil + } + matchingRoot = candidate + } + + return matchingRoot + } + func loadInitialTreatments() { guard !isInitialLoading, !isFetching else { return @@ -995,11 +1254,19 @@ class TreatmentsViewModel: ObservableObject { hasSMBEntries = false hasAutomaticEntries = false - // Start from now and go backwards - fetchTreatments(endDate: Date()) { [weak self] treatments, rawCount in + // Include the mutation window only when this installation can use Trio + // Remote Control, preserving the historical query for everyone else. + let includesFutureMutationWindow = Self.includesFutureMutationWindow( + remoteType: Storage.shared.remoteType.value, + device: Storage.shared.device.value + ) + let now = Date() + let endDate = includesFutureMutationWindow ? Self.initialFetchEndDate(at: now) : now + fetchTreatments(endDate: endDate, inclusiveEnd: includesFutureMutationWindow) { [weak self] treatments, rawCount in guard let self = self else { return } DispatchQueue.main.async { + Self.reconcileAppliedEdits(in: treatments) self.allTreatments = treatments self.regroupTreatments() @@ -1025,6 +1292,32 @@ class TreatmentsViewModel: ObservableObject { loadInitialTreatments() } + static func initialFetchEndDate(at date: Date = Date()) -> Date { + date.addingTimeInterval(TRCMealMutationTimePolicy.maximumOffset) + } + + static func includesFutureMutationWindow(remoteType: RemoteType, device: String) -> Bool { + remoteType == .trc && device == "Trio" + } + + @MainActor + static func reconcileAppliedEdits( + in treatments: [Treatment], + using coordinator: TRCMealMutationCoordinator = .shared, + at date: Date = Date() + ) { + for meal in treatments.compactMap(\.trioMeal) { + coordinator.reconcileAppliedEdit( + mealID: meal.mealID.uuidString, + carbs: meal.carbs, + fat: meal.fat, + protein: meal.protein, + mealTime: meal.mealTime, + at: date + ) + } + } + func loadMoreIfNeeded() { guard !isLoadingMore, !isFetching, hasMoreData, let oldestDate = oldestFetchedDate else { return @@ -1054,7 +1347,11 @@ class TreatmentsViewModel: ObservableObject { } } - private func fetchTreatments(endDate: Date, completion: @escaping ([Treatment], Int) -> Void) { + private func fetchTreatments( + endDate: Date, + inclusiveEnd: Bool = false, + completion: @escaping ([Treatment], Int) -> Void + ) { guard IsNightscoutEnabled() else { completion([], 0) return @@ -1073,18 +1370,20 @@ class TreatmentsViewModel: ObservableObject { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.timeZone = TimeZone(abbreviation: "UTC") - // For pagination: fetch treatments with created_at < endDate + // The initial request includes the exact future mutation boundary; + // pagination remains exclusive so pages cannot repeat their boundary. // Go back up to 365 days from endDate to ensure we get enough data let startDate = Calendar.current.date(byAdding: .day, value: -365, to: endDate)! let endDateString = formatter.string(from: endDate) let startDateString = formatter.string(from: startDate) // Build parameters with date filtering - let parameters: [String: String] = [ - "find[created_at][$gte]": startDateString, - "find[created_at][$lt]": endDateString, - "count": "\(pageSize)", - ] + let parameters = Self.treatmentQueryParameters( + startDateString: startDateString, + endDateString: endDateString, + pageSize: pageSize, + inclusiveEnd: inclusiveEnd + ) // Construct URL guard let url = NightscoutUtils.constructURL( @@ -1145,6 +1444,20 @@ class TreatmentsViewModel: ObservableObject { task.resume() } + static func treatmentQueryParameters( + startDateString: String, + endDateString: String, + pageSize: Int, + inclusiveEnd: Bool + ) -> [String: String] { + let upperBoundKey = inclusiveEnd ? "find[created_at][$lte]" : "find[created_at][$lt]" + return [ + "find[created_at][$gte]": startDateString, + upperBoundKey: endDateString, + "count": "\(pageSize)", + ] + } + private func parseTreatments(from entries: [[String: AnyObject]]) -> (treatments: [Treatment], detectedSMB: Bool, detectedAutomatic: Bool) { var treatments: [Treatment] = [] var detectedSMB = false @@ -1153,13 +1466,15 @@ class TreatmentsViewModel: ObservableObject { for entry in entries { guard let eventType = entry["eventType"] as? String, - let createdAt = entry["created_at"] as? String, - let date = NightscoutUtils.parseDate(createdAt) + let createdAt = entry["created_at"] as? String else { continue } - let timestamp = date.timeIntervalSince1970 + let trioMeal = TrioMealTreatment(nightscoutEntry: entry) + guard let timestamp = trioMeal?.mealTime ?? NightscoutUtils.parseDate(createdAt)?.timeIntervalSince1970 else { + continue + } let nsId = entry["_id"] as? String ?? "unknown-\(timestamp)" // Skip if we've already processed this Nightscout entry @@ -1172,18 +1487,14 @@ class TreatmentsViewModel: ObservableObject { switch eventType { case "Carb Correction", "Meal Bolus": - if let carbs = entry["carbs"] as? Double, carbs > 0 { - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-carb", - type: .carb, - date: timestamp, - title: "\(Int(carbs))g", - subtitle: "Carbs", - icon: "circle.fill", - color: .orange, - bgValue: actualBG - ) + let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) + if let treatment = Self.makeCarbTreatment( + from: entry, + trioMeal: trioMeal, + nightscoutID: nsId, + timestamp: timestamp, + actualBG: actualBG + ) { treatments.append(treatment) } @@ -1351,6 +1662,43 @@ class TreatmentsViewModel: ObservableObject { return (treatments.sorted { $0.date > $1.date }, detectedSMB, detectedAutomatic) } + static func makeCarbTreatment( + from entry: [String: AnyObject], + trioMeal: TrioMealTreatment?, + nightscoutID: String, + timestamp: TimeInterval, + actualBG: Int + ) -> Treatment? { + let carbs = trioMeal.map { Double($0.carbs) } ?? (entry["carbs"] as? Double) + let hasPositiveNutrients = (carbs ?? 0) > 0 || (trioMeal.map { $0.fat > 0 || $0.protein > 0 } ?? false) + guard let carbs, hasPositiveNutrients else { return nil } + + let title: String + let subtitle: String + if let trioMeal, trioMeal.carbs == 0 { + title = "Meal" + subtitle = [ + trioMeal.fat > 0 ? "\(trioMeal.fat)g Fat" : nil, + trioMeal.protein > 0 ? "\(trioMeal.protein)g Protein" : nil, + ].compactMap { $0 }.joined(separator: " • ") + } else { + title = "\(Int(carbs))g" + subtitle = "Carbs" + } + + return Treatment( + id: "\(nightscoutID)-carb", + type: .carb, + date: timestamp, + title: title, + subtitle: subtitle, + icon: "circle.fill", + color: .orange, + bgValue: actualBG, + trioMeal: trioMeal + ) + } + private func regroupTreatments() { var grouped: [String: [Treatment]] = [:] diff --git a/LoopFollow/Treatments/TrioMealTreatment.swift b/LoopFollow/Treatments/TrioMealTreatment.swift new file mode 100644 index 000000000..446b40f83 --- /dev/null +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -0,0 +1,156 @@ +// LoopFollow +// TrioMealTreatment.swift + +import CoreFoundation +import Foundation + +/// Mutation-safe metadata retained from a Trio Nightscout meal treatment. +/// +/// Nightscout's `_id` remains the treatment's display/deduplication identity, +/// while `mealID` is Trio's stable Core Data UUID used by remote meal commands. +struct TrioMealTreatment: Equatable, Sendable { + static let mutationWindow = TRCMealMutationTimePolicy.maximumOffset + + enum FPUClassification: Equatable, Sendable { + /// A root with a generated FPU family. Its meal and family IDs differ. + case familyRoot(fpuID: UUID) + + /// A generated FPU child. Trio publishes its family ID as both fields. + case generatedChild(fpuID: UUID) + + /// A root under the current marker contract. Legacy records without the + /// marker remain structurally ambiguous, so callers may gate this case. + case unmarkedRoot + + var isRootUnderCurrentContract: Bool { + switch self { + case .familyRoot, .unmarkedRoot: + return true + case .generatedChild: + return false + } + } + + var hasLegacyAmbiguity: Bool { + self == .unmarkedRoot + } + + var fpuID: UUID? { + switch self { + case let .familyRoot(fpuID), let .generatedChild(fpuID): + return fpuID + case .unmarkedRoot: + return nil + } + } + } + + let nightscoutID: String + let mealID: UUID + let enteredBy: String + let eventType: String + let mealTime: TimeInterval + let carbs: Int + let fat: Int + let protein: Int + let notes: String? + let foodType: String? + let fpuClassification: FPUClassification + + var fpuID: UUID? { + fpuClassification.fpuID + } + + var isGeneratedFPU: Bool { + if case .generatedChild = fpuClassification { + return true + } + return false + } + + var hasLegacyFPUAmbiguity: Bool { + fpuClassification.hasLegacyAmbiguity + } + + init?(nightscoutEntry entry: [String: AnyObject]) { + guard let enteredBy = entry["enteredBy"] as? String, + enteredBy == "Trio", + let eventType = entry["eventType"] as? String, + eventType == "Carb Correction", + let nightscoutID = entry["_id"] as? String, + !nightscoutID.isEmpty, + let rawMealID = entry["id"] as? String, + let mealID = UUID(uuidString: rawMealID), + let mealTime = Self.preciseTimestamp(from: entry["created_at"]), + let carbs = Self.exactInteger(from: entry["carbs"]), + let fat = Self.exactInteger(from: entry["fat"]), + let protein = Self.exactInteger(from: entry["protein"]), + carbs >= 0, + fat >= 0, + protein >= 0, + carbs > 0 || fat > 0 || protein > 0 + else { + return nil + } + + let fpuClassification: FPUClassification + if let rawFPUIDValue = entry["fpuID"], !(rawFPUIDValue is NSNull) { + guard let rawFPUID = rawFPUIDValue as? String, + let fpuID = UUID(uuidString: rawFPUID) + else { + return nil + } + fpuClassification = mealID == fpuID ? .generatedChild(fpuID: fpuID) : .familyRoot(fpuID: fpuID) + } else { + fpuClassification = .unmarkedRoot + } + + self.nightscoutID = nightscoutID + self.mealID = mealID + self.enteredBy = enteredBy + self.eventType = eventType + self.mealTime = mealTime + self.carbs = carbs + self.fat = fat + self.protein = protein + notes = entry["notes"] as? String + foodType = entry["foodType"] as? String + self.fpuClassification = fpuClassification + } + + func isWithinMutationWindow(at now: Date) -> Bool { + TRCMealMutationTimePolicy().isValid(mealTime, at: now) + } + + func isEligibleForMutation(at now: Date) -> Bool { + fpuClassification.isRootUnderCurrentContract && isWithinMutationWindow(at: now) + } + + private static func preciseTimestamp(from value: AnyObject?) -> TimeInterval? { + guard let rawTimestamp = value as? String else { return nil } + + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractionalFormatter.date(from: rawTimestamp) { + return date.timeIntervalSince1970 + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: rawTimestamp)?.timeIntervalSince1970 + } + + private static func exactInteger(from value: AnyObject?) -> Int? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.doubleValue.isFinite, + !number.decimalValue.isNaN + else { + return nil + } + + let integer = number.int64Value + guard number.decimalValue == Decimal(integer) else { return nil } + return Int(exactly: integer) + } +} diff --git a/Tests/RemoteMealMutationCoordinatorTests.swift b/Tests/RemoteMealMutationCoordinatorTests.swift new file mode 100644 index 000000000..b33915ea7 --- /dev/null +++ b/Tests/RemoteMealMutationCoordinatorTests.swift @@ -0,0 +1,548 @@ +// LoopFollow +// RemoteMealMutationCoordinatorTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct RemoteMealMutationCoordinatorTests { + private let commandID = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA" + private let mealID = "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB" + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test("APNs acceptance means awaiting Trio, not applied") + func transportAcceptanceIsNotApplication() { + let sending = operation(state: .sending) + let awaiting = TRCMealMutationReducer.reduce(sending, event: .transportAccepted, at: now) + + #expect(awaiting.state == .awaiting) + #expect(awaiting.responseResult == nil) + #expect(awaiting.blocksActions) + #expect(TRCMealMutationReducer.reduce(awaiting, event: .transportAccepted, at: now) == awaiting) + } + + @Test("A persisted sending attempt can time out and become retryable") + func interruptedSendingAttemptCanRecover() { + let sending = operation(state: .sending) + let timedOut = TRCMealMutationReducer.reduce(sending, event: .timedOut, at: now) + + #expect(timedOut.state == .timedOut) + #expect(timedOut.isRetryable) + #expect(timedOut.blocksActions) + } + + @Test("Coordinator startup recovers expired persisted attempts") + @MainActor + func startupRecoversExpiredAttempt() { + let sending = operation(state: .sending) + let storage = StorageValue<[TRCMealMutationOperation]>( + key: "RemoteMealMutationCoordinatorTests-\(UUID().uuidString)", + defaultValue: [sending] + ) + defer { storage.remove() } + + let coordinator = TRCMealMutationCoordinator(storage: storage, transport: { _, _ in }, at: now) + let recovered = coordinator.operations[0] + + #expect(recovered.state == .timedOut) + #expect(recovered.isRetryable) + #expect(storage.value == [recovered]) + } + + @Test("Every Trio result reduces to its contract state") + func resultStates() { + let expectedStates: [(TRCMealMutationResult, TRCMealMutationState)] = [ + (.updated, .applied), + (.deleted, .applied), + (.alreadyApplied, .applied), + (.rejected, .rejected), + (.inProgress, .inProgress), + ] + + for (result, expectedState) in expectedStates { + let reduced = TRCMealMutationReducer.reduce( + operation(state: .awaiting), + event: .response( + result: result, + syncStatus: .requested, + body: "Trio response", + timestamp: now.timeIntervalSince1970 + ), + at: now + ) + + #expect(reduced.state == expectedState) + #expect(reduced.responseResult == result) + #expect(reduced.syncStatus == .requested) + #expect(reduced.responseBody == "Trio response") + } + } + + @Test("Response decoding uses result and supports both sync states") + func responseDecoding() { + for result in ["updated", "deleted", "already_applied", "rejected", "in_progress"] { + for syncStatus in ["requested", "not_requested"] { + let response = TRCMealMutationResponse(userInfo: [ + "command_status": result == "in_progress" ? "failed" : "success", + "command_type": "edit_meal", + "command_id": commandID.lowercased(), + "meal_id": mealID.lowercased(), + "result": result, + "sync_status": syncStatus, + "timestamp": 1_800_000_005, + "aps": ["alert": ["body": "Meal response"]], + ]) + + #expect(response.result?.rawValue == result) + #expect(response.syncStatus?.rawValue == syncStatus) + #expect(response.body == "Meal response") + #expect(response.timestamp == 1_800_000_005) + #expect(response.matches(operation(state: .awaiting))) + } + } + } + + @Test("Correlation requires command type and both canonical UUIDs") + func strictCorrelation() { + let pending = operation(state: .awaiting) + let matching = response() + + #expect(matching.matches(pending)) + #expect(!response(commandType: "delete_meal").matches(pending)) + #expect(!response(commandID: "33333333-3333-3333-3333-333333333333").matches(pending)) + #expect(!response(mealID: "33333333-3333-3333-3333-333333333333").matches(pending)) + #expect(!response(commandID: nil).matches(pending)) + #expect(!response(mealID: nil).matches(pending)) + } + + @Test("Terminal results absorb duplicates and later contradictory delivery") + func terminalResultsAreAbsorbing() { + let pending = operation(state: .awaiting) + let applied = TRCMealMutationReducer.reduce( + pending, + event: .response(result: .updated, syncStatus: .requested, body: "Updated", timestamp: 200), + at: now + ) + let duplicate = TRCMealMutationReducer.reduce( + applied, + event: .response(result: .updated, syncStatus: .requested, body: "Updated", timestamp: 200), + at: now.addingTimeInterval(1) + ) + let contradictory = TRCMealMutationReducer.reduce( + applied, + event: .response(result: .inProgress, syncStatus: nil, body: "Still processing", timestamp: 201), + at: now.addingTimeInterval(1) + ) + + #expect(duplicate == applied) + #expect(contradictory == applied) + + let rejected = TRCMealMutationReducer.reduce( + pending, + event: .response(result: .rejected, syncStatus: .notRequested, body: "Stale meal", timestamp: 200), + at: now + ) + #expect(TRCMealMutationReducer.reduce( + rejected, + event: .response(result: .updated, syncStatus: .requested, body: "Updated", timestamp: 201), + at: now.addingTimeInterval(1) + ) == rejected) + } + + @Test("Older nonterminal responses cannot replace newer response state") + func outOfOrderResponseIsIgnored() { + let inProgress = TRCMealMutationReducer.reduce( + operation(state: .awaiting), + event: .response(result: .inProgress, syncStatus: nil, body: "Working", timestamp: 300), + at: now + ) + let older = TRCMealMutationReducer.reduce( + inProgress, + event: .response(result: .rejected, syncStatus: nil, body: "Old rejection", timestamp: 299), + at: now.addingTimeInterval(1) + ) + + #expect(older == inProgress) + } + + @Test("A duplicate in-progress response cannot undo a retry attempt") + func duplicateInProgressDoesNotUndoRetry() { + let original = operation(state: .inProgress) + let retry = TRCMealMutationReducer.reduce(original, event: .retryStarted, at: now) + let duplicate = TRCMealMutationReducer.reduce( + retry, + event: .response( + result: .inProgress, + syncStatus: nil, + body: "Working", + timestamp: 1_800_000_001 + ), + at: now.addingTimeInterval(1) + ) + + #expect(duplicate == retry) + #expect(duplicate.state == .sending) + } + + @Test("Retry preserves the logical mutation and refreshes only attempt time") + func retryPreservesLogicalMutation() { + let original = operation(state: .inProgress) + let retryDate = now.addingTimeInterval(60) + let retry = TRCMealMutationReducer.reduce(original, event: .retryStarted, at: retryDate) + let request = retry.transportRequest(at: retryDate) + + #expect(retry.state == .sending) + #expect(retry.commandID == original.commandID) + #expect(retry.mealID == original.mealID) + #expect(retry.user == original.user) + #expect(retry.type == original.type) + #expect(retry.expected == original.expected) + #expect(retry.replacement == original.replacement) + #expect(retry.createdAt == original.createdAt) + #expect(retry.lastAttemptAt == retryDate) + #expect(request.commandID == original.commandID) + #expect(request.mealID == original.mealID) + #expect(request.user == original.user) + #expect(request.expected == original.expected) + #expect(request.replacement == original.replacement) + #expect(request.transportTimestamp == retryDate.timeIntervalSince1970) + } + + @Test("A stale transport completion cannot overwrite a newer retry") + @MainActor + func staleTransportCompletionIsIgnored() throws { + let storage = StorageValue<[TRCMealMutationOperation]>( + key: "RemoteMealMutationCoordinatorTests-\(UUID().uuidString)", + defaultValue: [] + ) + defer { storage.remove() } + + var completions: [(Bool, String?) -> Void] = [] + let coordinator = TRCMealMutationCoordinator(storage: storage, transport: { _, completion in + completions.append(completion) + }, at: now) + let started = try coordinator.startEdit( + mealID: mealID, + user: "Original User", + expectedCarbs: 30, + expectedFat: 10, + expectedProtein: 5, + expectedMealTime: now.timeIntervalSince1970, + carbs: 25, + fat: 12, + protein: 6, + scheduledTime: now.timeIntervalSince1970, + at: now + ) + + coordinator.markTimedOut( + commandID: started.commandID, + at: now.addingTimeInterval(TRCMealMutationCoordinator.attemptTimeout) + ) + let retryDate = now.addingTimeInterval(TRCMealMutationCoordinator.attemptTimeout + 1) + _ = try coordinator.retry(commandID: started.commandID, at: retryDate) + + #expect(completions.count == 2) + completions[0](false, "Late failure from first attempt") + #expect(coordinator.operation(commandID: started.commandID)?.state == .sending) + completions[1](true, nil) + #expect(coordinator.operation(commandID: started.commandID)?.state == .awaiting) + } + + @Test("Initial validation accepts exact boundaries, generates IDs, and persists before transport") + @MainActor + func newMutationsAreUniqueAndPersistBeforeTransport() throws { + let storage = StorageValue<[TRCMealMutationOperation]>( + key: "RemoteMealMutationCoordinatorTests-\(UUID().uuidString)", + defaultValue: [] + ) + defer { storage.remove() } + + var sentRequests: [TRCMealMutationTransportRequest] = [] + let coordinator = TRCMealMutationCoordinator(storage: storage) { request, _ in + #expect(storage.value.contains { $0.commandID == request.commandID }) + sentRequests.append(request) + } + + let first = try coordinator.startEdit( + mealID: mealID, + user: "Original User", + expectedCarbs: 30, + expectedFat: 10, + expectedProtein: 5, + expectedMealTime: now.timeIntervalSince1970 - TRCMealMutationTimePolicy.maximumOffset, + carbs: 25, + fat: 12, + protein: 6, + scheduledTime: now.timeIntervalSince1970 + TRCMealMutationTimePolicy.maximumOffset, + at: now + ) + let second = try coordinator.startDelete( + mealID: "CCCCCCCC-CCCC-4CCC-8CCC-CCCCCCCCCCCC", + user: "Original User", + expectedCarbs: 20, + expectedFat: 0, + expectedProtein: 0, + expectedMealTime: now.timeIntervalSince1970 - 1800, + at: now + ) + + #expect(first.commandID != second.commandID) + #expect(Set(storage.value.map(\.commandID)).count == 2) + #expect(sentRequests.map(\.commandID) == [first.commandID, second.commandID]) + let firstRequest = try #require(sentRequests.first) + #expect(firstRequest.commandID == first.commandID) + #expect(firstRequest.mealID == first.mealID) + #expect(firstRequest.expected == first.expected) + #expect(firstRequest.replacement == first.replacement) + + var duplicateError: TRCMealMutationCoordinatorError? + do { + _ = try coordinator.startDelete( + mealID: mealID, + user: "Original User", + expectedCarbs: 30, + expectedFat: 10, + expectedProtein: 5, + expectedMealTime: now.timeIntervalSince1970 - 3600, + at: now + ) + } catch let error as TRCMealMutationCoordinatorError { + duplicateError = error + } + #expect(duplicateError == .operationAlreadyPending) + } + + @Test("Applied edits remain blocked until refreshed source values reconcile") + func reconciliationUnblocksAppliedEdit() { + let applied = operation(state: .applied) + let reconciled = TRCMealMutationReducer.reduce(applied, event: .reconciled, at: now) + + #expect(applied.blocksActions) + #expect(reconciled.state == .applied) + #expect(reconciled.reconciledAt == now) + #expect(!reconciled.blocksActions) + } + + @Test("Source and replacement accept exact past and future twelve-hour boundaries") + func exactTwelveHourBoundariesAreValid() throws { + let policy = TRCMealMutationTimePolicy() + let pastBoundary = now.timeIntervalSince1970 - TRCMealMutationTimePolicy.maximumOffset + let futureBoundary = now.timeIntervalSince1970 + TRCMealMutationTimePolicy.maximumOffset + + #expect(policy.isValid(pastBoundary, at: now)) + #expect(policy.isValid(futureBoundary, at: now)) + + try TRCMealMutationValidator.validate( + type: .edit, + expected: values(mealTime: pastBoundary), + replacement: values(mealTime: futureBoundary), + at: now + ) + try TRCMealMutationValidator.validate( + type: .edit, + expected: values(mealTime: futureBoundary), + replacement: values(mealTime: pastBoundary), + at: now + ) + try TRCMealMutationValidator.validate( + type: .delete, + expected: values(mealTime: futureBoundary), + replacement: nil, + at: now + ) + } + + @Test("Source and replacement reject either side outside twelve hours by epsilon") + func timestampsOutsideTwelveHoursAreRejected() { + let policy = TRCMealMutationTimePolicy() + let epsilon = 0.001 + let pastOutside = now.timeIntervalSince1970 - TRCMealMutationTimePolicy.maximumOffset - epsilon + let futureOutside = now.timeIntervalSince1970 + TRCMealMutationTimePolicy.maximumOffset + epsilon + let current = values(mealTime: now.timeIntervalSince1970) + + #expect(!policy.isValid(pastOutside, at: now)) + #expect(!policy.isValid(futureOutside, at: now)) + #expect(validationError( + expected: values(mealTime: pastOutside), + replacement: current, + at: now + ) == .expectedMealTimeOutOfRange) + #expect(validationError( + expected: values(mealTime: futureOutside), + replacement: current, + at: now + ) == .expectedMealTimeOutOfRange) + #expect(validationError( + expected: current, + replacement: values(mealTime: pastOutside), + at: now + ) == .replacementMealTimeOutOfRange) + #expect(validationError( + expected: current, + replacement: values(mealTime: futureOutside), + at: now + ) == .replacementMealTimeOutOfRange) + } + + @Test("Retry revalidates while preserving the stored logical mutation") + @MainActor + func retryUsesTimePolicyAndPreservesStoredValues() throws { + let retryDate = now + let original = operation( + state: .timedOut, + expectedMealTime: retryDate.timeIntervalSince1970 - TRCMealMutationTimePolicy.maximumOffset, + replacementMealTime: retryDate.timeIntervalSince1970 + TRCMealMutationTimePolicy.maximumOffset + ) + let storage = StorageValue<[TRCMealMutationOperation]>( + key: "RemoteMealMutationCoordinatorTests-\(UUID().uuidString)", + defaultValue: [original] + ) + defer { storage.remove() } + + var sentRequest: TRCMealMutationTransportRequest? + let coordinator = TRCMealMutationCoordinator(storage: storage) { request, _ in + sentRequest = request + } + + let retried = try coordinator.retry(commandID: original.commandID, at: retryDate) + let request = try #require(sentRequest) + + #expect(retried.state == .sending) + #expect(retried.commandID == original.commandID) + #expect(retried.mealID == original.mealID) + #expect(retried.user == original.user) + #expect(retried.expected == original.expected) + #expect(retried.replacement == original.replacement) + #expect(request.commandID == original.commandID) + #expect(request.mealID == original.mealID) + #expect(request.user == original.user) + #expect(request.expected == original.expected) + #expect(request.replacement == original.replacement) + #expect(request.transportTimestamp == retryDate.timeIntervalSince1970) + #expect(storage.value == [retried]) + } + + @Test("An aged-out retry neither mutates persistence nor sends") + @MainActor + func agedOutRetryIsRejectedWithoutSideEffects() { + let original = operation( + state: .timedOut, + expectedMealTime: now.timeIntervalSince1970 - TRCMealMutationTimePolicy.maximumOffset, + replacementMealTime: now.timeIntervalSince1970 + ) + let storage = StorageValue<[TRCMealMutationOperation]>( + key: "RemoteMealMutationCoordinatorTests-\(UUID().uuidString)", + defaultValue: [original] + ) + defer { storage.remove() } + + var sentRequest: TRCMealMutationTransportRequest? + let coordinator = TRCMealMutationCoordinator(storage: storage) { request, _ in + sentRequest = request + } + var retryError: TRCMealMutationCoordinatorError? + + do { + _ = try coordinator.retry(commandID: original.commandID, at: now.addingTimeInterval(0.001)) + } catch let error as TRCMealMutationCoordinatorError { + retryError = error + } catch {} + + #expect(retryError == .expectedMealTimeOutOfRange) + #expect(sentRequest == nil) + #expect(coordinator.operations == [original]) + #expect(storage.value == [original]) + } + + @Test("Persisted operation round-trips without transport credentials") + func persistenceRoundTrip() throws { + let original = operation(state: .inProgress) + let data = try JSONEncoder().encode(original) + let json = try #require(String(data: data, encoding: .utf8)) + let decoded = try JSONDecoder().decode(TRCMealMutationOperation.self, from: data) + + #expect(decoded == original) + #expect(!json.contains("return_notification")) + #expect(!json.contains("device_token")) + #expect(!json.contains("apns_key")) + } + + @Test("Older generic responses remain unrelated to mutation state") + func legacyResponseIsIgnored() { + let response = TRCMealMutationResponse(userInfo: [ + "command_status": "failed", + "command_type": "edit_meal", + "timestamp": 1_800_000_005, + "aps": ["alert": ["body": "Timestamp rejected"]], + ]) + + #expect(response.result == nil) + #expect(!response.matches(operation(state: .awaiting))) + } + + private func operation( + state: TRCMealMutationState, + expectedMealTime: TimeInterval = 1_799_996_400, + replacementMealTime: TimeInterval = 1_799_996_700 + ) -> TRCMealMutationOperation { + TRCMealMutationOperation( + commandID: commandID, + mealID: mealID, + user: "Original User", + type: .edit, + expected: TRCMealMutationValues(carbs: 30, fat: 10, protein: 5, mealTime: expectedMealTime), + replacement: TRCMealMutationValues(carbs: 25, fat: 12, protein: 6, mealTime: replacementMealTime), + createdAt: now.addingTimeInterval(-120), + lastAttemptAt: now.addingTimeInterval(-60), + updatedAt: now.addingTimeInterval(-60), + state: state, + responseBody: state == .inProgress ? "Working" : nil, + responseResult: state == .inProgress ? .inProgress : nil, + syncStatus: nil, + lastResponseTimestamp: state == .inProgress ? 1_800_000_001 : nil, + reconciledAt: nil + ) + } + + private func response( + commandType: String? = "edit_meal", + commandID: String? = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", + mealID: String? = "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB" + ) -> TRCMealMutationResponse { + TRCMealMutationResponse( + commandStatus: "success", + commandType: commandType, + commandID: commandID?.lowercased(), + mealID: mealID?.lowercased(), + result: .updated, + syncStatus: .requested, + timestamp: 1_800_000_005, + body: "Meal updated" + ) + } + + private func values(mealTime: TimeInterval) -> TRCMealMutationValues { + TRCMealMutationValues(carbs: 30, fat: 10, protein: 5, mealTime: mealTime) + } + + private func validationError( + expected: TRCMealMutationValues, + replacement: TRCMealMutationValues, + at date: Date + ) -> TRCMealMutationCoordinatorError? { + do { + try TRCMealMutationValidator.validate( + type: .edit, + expected: expected, + replacement: replacement, + at: date + ) + return nil + } catch let error as TRCMealMutationCoordinatorError { + return error + } catch { + return nil + } + } +} diff --git a/Tests/RemoteMealMutationPayloadTests.swift b/Tests/RemoteMealMutationPayloadTests.swift new file mode 100644 index 000000000..2e78f24c7 --- /dev/null +++ b/Tests/RemoteMealMutationPayloadTests.swift @@ -0,0 +1,279 @@ +// LoopFollow +// RemoteMealMutationPayloadTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct RemoteMealMutationPayloadTests { + private let commandID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + private let mealID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + + @Test("Edit meal JSON includes required zeros and exact Trio field names") + func editMealEncoding() throws { + let payload = CommandPayload.editMeal( + user: "Configured User", + timestamp: 1_800_000_000, + commandID: commandID, + mealID: mealID, + expectedCarbs: 0, + expectedFat: 10, + expectedProtein: 0, + expectedMealTime: 1_799_996_400, + carbs: 0, + fat: 12, + protein: 0, + scheduledTime: 1_800_043_200, + returnNotification: returnNotification + ) + + let json = try encodedJSONObject(payload) + + #expect(Set(json.keys) == [ + "user", + "command_type", + "timestamp", + "command_id", + "meal_id", + "expected_carbs", + "expected_fat", + "expected_protein", + "expected_meal_time", + "carbs", + "fat", + "protein", + "scheduled_time", + "return_notification", + ]) + #expect(json["user"] as? String == "Configured User") + #expect(json["command_type"] as? String == "edit_meal") + #expect(json["timestamp"] as? TimeInterval == 1_800_000_000) + #expect(json["command_id"] as? String == commandID.uuidString) + #expect(json["meal_id"] as? String == mealID.uuidString) + #expect(json["expected_carbs"] as? Int == 0) + #expect(json["expected_fat"] as? Int == 10) + #expect(json["expected_protein"] as? Int == 0) + #expect(json["expected_meal_time"] as? TimeInterval == 1_799_996_400) + #expect(json["carbs"] as? Int == 0) + #expect(json["fat"] as? Int == 12) + #expect(json["protein"] as? Int == 0) + #expect(json["scheduled_time"] as? TimeInterval == 1_800_043_200) + + let notification = try #require(json["return_notification"] as? [String: Any]) + #expect(Set(notification.keys) == [ + "production_environment", + "device_token", + "bundle_id", + "team_id", + "key_id", + "apns_key", + ]) + } + + @Test("Delete meal JSON omits replacement and unrelated command fields") + func deleteMealEncoding() throws { + let payload = CommandPayload.deleteMeal( + user: "Configured User", + timestamp: 1_800_000_000, + commandID: commandID, + mealID: mealID, + expectedCarbs: 0, + expectedFat: 10, + expectedProtein: 0, + expectedMealTime: 1_799_996_400, + returnNotification: returnNotification + ) + + let json = try encodedJSONObject(payload) + + #expect(Set(json.keys) == [ + "user", + "command_type", + "timestamp", + "command_id", + "meal_id", + "expected_carbs", + "expected_fat", + "expected_protein", + "expected_meal_time", + "return_notification", + ]) + #expect(json["command_type"] as? String == "delete_meal") + #expect(json["expected_carbs"] as? Int == 0) + #expect(json["expected_fat"] as? Int == 10) + #expect(json["expected_protein"] as? Int == 0) + + let omittedKeys = [ + "carbs", + "fat", + "protein", + "scheduled_time", + "bolus_amount", + "target", + "duration", + "overrideName", + "override_name", + ] + #expect(omittedKeys.allSatisfy { json[$0] == nil }) + } + + @Test("Mutation collapse IDs use command IDs while legacy commands remain unchanged") + func collapseIDs() { + let edit = CommandPayload.editMeal( + user: "Configured User", + timestamp: 1_800_000_000, + commandID: commandID, + mealID: mealID, + expectedCarbs: 30, + expectedFat: 10, + expectedProtein: 5, + expectedMealTime: 1_799_996_400, + carbs: 25, + fat: 12, + protein: 6, + scheduledTime: 1_799_996_700, + returnNotification: returnNotification + ) + let delete = CommandPayload.deleteMeal( + user: "Configured User", + timestamp: 1_800_000_000, + commandID: commandID, + mealID: mealID, + expectedCarbs: 30, + expectedFat: 10, + expectedProtein: 5, + expectedMealTime: 1_799_996_400, + returnNotification: returnNotification + ) + + #expect(edit.apnsCollapseID == commandID.uuidString) + #expect(delete.apnsCollapseID == commandID.uuidString) + + let legacyTypes: [TRCCommandType] = [ + .bolus, + .tempTarget, + .cancelTempTarget, + .meal, + .startOverride, + .cancelOverride, + ] + for commandType in legacyTypes { + let legacy = CommandPayload(user: "Configured User", commandType: commandType, timestamp: 1_800_000_000) + #expect(legacy.apnsCollapseID == commandType.rawValue) + } + } + + @Test("Existing meal-create JSON does not acquire mutation fields") + func existingMealEncodingIsUnchanged() throws { + let payload = CommandPayload( + user: "Configured User", + commandType: .meal, + timestamp: 1_800_000_000, + carbs: 30, + protein: 5, + fat: 10, + scheduledTime: 1_799_996_700, + returnNotification: returnNotification + ) + + let json = try encodedJSONObject(payload) + + #expect(Set(json.keys) == [ + "user", + "command_type", + "timestamp", + "carbs", + "protein", + "fat", + "scheduled_time", + "return_notification", + ]) + #expect(json["command_type"] as? String == "meal") + } + + @Test("Existing bolus, target, and override JSON shapes remain unchanged") + func otherLegacyEncodingIsUnchanged() throws { + let bolus = try encodedJSONObject(CommandPayload( + user: "Configured User", + commandType: .bolus, + timestamp: 1_800_000_000, + bolusAmount: Decimal(string: "1.25"), + returnNotification: returnNotification + )) + #expect(Set(bolus.keys) == [ + "user", "command_type", "timestamp", "bolus_amount", "return_notification", + ]) + + let target = try encodedJSONObject(CommandPayload( + user: "Configured User", + commandType: .tempTarget, + timestamp: 1_800_000_000, + target: 100, + duration: 30, + returnNotification: returnNotification + )) + #expect(Set(target.keys) == [ + "user", "command_type", "timestamp", "target", "duration", "return_notification", + ]) + + let override = try encodedJSONObject(CommandPayload( + user: "Configured User", + commandType: .startOverride, + timestamp: 1_800_000_000, + overrideName: "Exercise", + returnNotification: returnNotification + )) + #expect(Set(override.keys) == [ + "user", "command_type", "timestamp", "overrideName", "return_notification", + ]) + } + + @Test("Mutation return-notification configuration must be complete") + func returnNotificationCompleteness() { + #expect(returnNotification.isComplete) + let missingDeviceToken = CommandPayload.ReturnNotificationInfo( + productionEnvironment: true, + deviceToken: "", + bundleId: "com.example.LoopFollow", + teamId: "ABCDEFGHIJ", + keyId: "KLMNOPQRST", + apnsKey: "private-key" + ) + #expect(!missingDeviceToken.isComplete) + } + + @Test("Return-notification APNS credentials must be usable, not merely nonempty") + func returnNotificationCredentialValidation() { + let validPEM = """ + -----BEGIN PRIVATE KEY----- + AQIDBA== + -----END PRIVATE KEY----- + """ + #expect(APNSCredentialValidator.validationErrors( + keyID: "KLMNOPQRST", + teamID: "ABCDEFGHIJ", + apnsKey: validPEM + ) == nil) + #expect(APNSCredentialValidator.validationErrors( + keyID: "bad", + teamID: "ABCDEFGHIJ", + apnsKey: "not-a-private-key" + ) != nil) + } + + private var returnNotification: CommandPayload.ReturnNotificationInfo { + CommandPayload.ReturnNotificationInfo( + productionEnvironment: true, + deviceToken: "device-token", + bundleId: "com.example.LoopFollow", + teamId: "ABCDEFGHIJ", + keyId: "KLMNOPQRST", + apnsKey: "private-key" + ) + } + + private func encodedJSONObject(_ payload: CommandPayload) throws -> [String: Any] { + let data = try JSONEncoder().encode(payload) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} diff --git a/Tests/Treatments/TrioMealTreatmentTests.swift b/Tests/Treatments/TrioMealTreatmentTests.swift new file mode 100644 index 000000000..3d48e01b8 --- /dev/null +++ b/Tests/Treatments/TrioMealTreatmentTests.swift @@ -0,0 +1,353 @@ +// LoopFollow +// TrioMealTreatmentTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TrioMealTreatmentTests { + private let mealID = "11111111-2222-3333-4444-555555555555" + private let fpuID = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE" + + @Test("Nightscout and Trio identities remain distinct and raw meal fields are retained") + func retainsIdentityMacrosNotesAndPreciseTime() throws { + let treatment = try #require(TrioMealTreatment(nightscoutEntry: entry( + nightscoutID: "nightscout-object-id", + createdAt: "2026-08-16T01:02:03.456+02:30", + carbs: 0, + fat: 12, + protein: 0, + notes: "Late snack", + foodType: "Cheese" + ))) + + #expect(treatment.nightscoutID == "nightscout-object-id") + #expect(treatment.mealID == UUID(uuidString: mealID)) + #expect(treatment.enteredBy == "Trio") + #expect(treatment.eventType == "Carb Correction") + #expect(treatment.carbs == 0) + #expect(treatment.fat == 12) + #expect(treatment.protein == 0) + #expect(treatment.notes == "Late snack") + #expect(treatment.foodType == "Cheese") + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let expected = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 8, + day: 15, + hour: 22, + minute: 32, + second: 3, + nanosecond: 456_000_000 + ))) + #expect(abs(treatment.mealTime - expected.timeIntervalSince1970) < 0.001) + } + + @Test("Only exact Trio Carb Correction ownership is accepted") + func requiresExactOwnerAndEventType() { + var wrongOwner = entry() + wrongOwner["enteredBy"] = "trio" as AnyObject + #expect(TrioMealTreatment(nightscoutEntry: wrongOwner) == nil) + + var wrongEvent = entry() + wrongEvent["eventType"] = "Meal Bolus" as AnyObject + #expect(TrioMealTreatment(nightscoutEntry: wrongEvent) == nil) + } + + @Test("Macros must be explicit nonnegative integers with at least one positive value") + func validatesMacrosLosslessly() throws { + let fatOnly = try #require(TrioMealTreatment(nightscoutEntry: entry(carbs: 0, fat: 10, protein: 0))) + #expect(fatOnly.carbs == 0) + #expect(fatOnly.fat == 10) + #expect(fatOnly.protein == 0) + + var missingMacro = entry() + missingMacro.removeValue(forKey: "protein") + #expect(TrioMealTreatment(nightscoutEntry: missingMacro) == nil) + + var fractionalMacro = entry() + fractionalMacro["fat"] = NSNumber(value: 1.5) + #expect(TrioMealTreatment(nightscoutEntry: fractionalMacro) == nil) + + var booleanMacro = entry() + booleanMacro["carbs"] = true as AnyObject + #expect(TrioMealTreatment(nightscoutEntry: booleanMacro) == nil) + + #expect(TrioMealTreatment(nightscoutEntry: entry(carbs: 0, fat: 0, protein: 0)) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(carbs: -1, fat: 0, protein: 0)) == nil) + } + + @Test("Fat/protein-only Trio roots become selectable treatment rows") + func fatOnlyRootBecomesTreatmentRow() throws { + let source = entry(carbs: 0, fat: 12, protein: 0) + let meal = try #require(TrioMealTreatment(nightscoutEntry: source)) + let treatment = try #require(TreatmentsViewModel.makeCarbTreatment( + from: source, + trioMeal: meal, + nightscoutID: meal.nightscoutID, + timestamp: meal.mealTime, + actualBG: 123 + )) + + #expect(treatment.id == "nightscout-object-id-carb") + #expect(treatment.title == "Meal") + #expect(treatment.subtitle == "12g Fat") + #expect(treatment.bgValue == 123) + #expect(treatment.trioMeal == meal) + } + + @Test("FPU marker contract distinguishes roots, generated children, and ambiguous unmarked roots") + func classifiesFPURecords() throws { + let root = try #require(TrioMealTreatment(nightscoutEntry: entry(fpuID: fpuID))) + #expect(root.fpuClassification == .familyRoot(fpuID: UUID(uuidString: fpuID)!)) + #expect(!root.isGeneratedFPU) + #expect(!root.hasLegacyFPUAmbiguity) + + let child = try #require(TrioMealTreatment(nightscoutEntry: entry( + mealID: fpuID.lowercased(), + fpuID: fpuID + ))) + #expect(child.fpuClassification == .generatedChild(fpuID: UUID(uuidString: fpuID)!)) + #expect(child.isGeneratedFPU) + #expect(!child.hasLegacyFPUAmbiguity) + + let unmarked = try #require(TrioMealTreatment(nightscoutEntry: entry())) + #expect(unmarked.fpuClassification == .unmarkedRoot) + #expect(!unmarked.isGeneratedFPU) + #expect(unmarked.hasLegacyFPUAmbiguity) + } + + @Test("Generated FPU entries resolve only a unique loaded family root") + func resolvesUniqueLoadedFPURoot() throws { + let root = try treatment( + nightscoutID: "root-treatment", + fpuID: fpuID + ) + let child = try treatment( + nightscoutID: "generated-child", + mealID: fpuID.lowercased(), + fpuID: fpuID + ) + let sibling = try treatment( + nightscoutID: "generated-sibling", + mealID: fpuID, + fpuID: fpuID + ) + let unrelatedRoot = try treatment( + nightscoutID: "unrelated-root", + fpuID: "BBBBBBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF" + ) + let unmarkedRoot = try treatment(nightscoutID: "unmarked-root") + + let resolved = TreatmentsViewModel.uniqueFPURoot( + for: child, + among: [sibling, unrelatedRoot, unmarkedRoot, root] + ) + + #expect(resolved?.id == root.id) + #expect(TreatmentsViewModel.uniqueFPURoot( + for: child, + among: [sibling, unrelatedRoot, unmarkedRoot] + ) == nil) + #expect(TreatmentsViewModel.uniqueFPURoot(for: root, among: [root]) == nil) + } + + @Test("Ambiguous generated FPU roots fail closed") + func ambiguousFPURootsFailClosed() throws { + let root = try treatment( + nightscoutID: "first-root", + fpuID: fpuID + ) + let duplicateRoot = try treatment( + nightscoutID: "second-root", + mealID: "22222222-3333-4444-5555-666666666666", + fpuID: fpuID + ) + let child = try treatment( + nightscoutID: "generated-child", + mealID: fpuID, + fpuID: fpuID + ) + + #expect(TreatmentsViewModel.uniqueFPURoot( + for: child, + among: [root, duplicateRoot] + ) == nil) + } + + @Test("Mutation eligibility uses the closed symmetric twelve-hour window and excludes generated children") + func mutationEligibilityBoundaries() throws { + let root = try #require(TrioMealTreatment(nightscoutEntry: entry())) + let mealTime = root.mealTime + + #expect(root.isEligibleForMutation(at: Date(timeIntervalSince1970: mealTime))) + #expect(root.isEligibleForMutation(at: Date( + timeIntervalSince1970: mealTime + TrioMealTreatment.mutationWindow + ))) + #expect(root.isEligibleForMutation(at: Date( + timeIntervalSince1970: mealTime - TrioMealTreatment.mutationWindow + ))) + #expect(!root.isEligibleForMutation(at: Date( + timeIntervalSince1970: mealTime + TrioMealTreatment.mutationWindow + 0.001 + ))) + #expect(!root.isEligibleForMutation(at: Date( + timeIntervalSince1970: mealTime - TrioMealTreatment.mutationWindow - 0.001 + ))) + + let child = try #require(TrioMealTreatment(nightscoutEntry: entry(mealID: fpuID, fpuID: fpuID))) + #expect(!child.isEligibleForMutation(at: Date(timeIntervalSince1970: child.mealTime))) + } + + @Test("Initial treatment fetch includes the full future mutation window") + func initialFetchEndMatchesMutationWindow() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + #expect( + TreatmentsViewModel.initialFetchEndDate(at: now).timeIntervalSince(now) == + TRCMealMutationTimePolicy.maximumOffset + ) + #expect(TreatmentsViewModel.includesFutureMutationWindow(remoteType: .trc, device: "Trio")) + #expect(!TreatmentsViewModel.includesFutureMutationWindow(remoteType: .trc, device: "Loop")) + #expect(!TreatmentsViewModel.includesFutureMutationWindow(remoteType: .none, device: "Trio")) + + let initialParameters = TreatmentsViewModel.treatmentQueryParameters( + startDateString: "start", + endDateString: "end", + pageSize: 50, + inclusiveEnd: true + ) + let paginationParameters = TreatmentsViewModel.treatmentQueryParameters( + startDateString: "start", + endDateString: "end", + pageSize: 50, + inclusiveEnd: false + ) + + #expect(initialParameters["find[created_at][$lte]"] == "end") + #expect(initialParameters["find[created_at][$lt]"] == nil) + #expect(paginationParameters["find[created_at][$lt]"] == "end") + #expect(paginationParameters["find[created_at][$lte]"] == nil) + } + + @Test("Refreshed replacement values reconcile an applied edit") + @MainActor + func refreshedReplacementReconcilesAppliedEdit() throws { + let meal = try #require(TrioMealTreatment(nightscoutEntry: entry())) + let operation = TRCMealMutationOperation( + commandID: "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", + mealID: meal.mealID.uuidString, + user: "Original User", + type: .edit, + expected: TRCMealMutationValues( + carbs: meal.carbs + 1, + fat: meal.fat, + protein: meal.protein, + mealTime: meal.mealTime - 60 + ), + replacement: TRCMealMutationValues( + carbs: meal.carbs, + fat: meal.fat, + protein: meal.protein, + mealTime: meal.mealTime + ), + createdAt: Date(timeIntervalSince1970: meal.mealTime - 120), + lastAttemptAt: Date(timeIntervalSince1970: meal.mealTime - 60), + updatedAt: Date(timeIntervalSince1970: meal.mealTime - 30), + state: .applied, + responseBody: "Updated", + responseResult: .updated, + syncStatus: .requested, + lastResponseTimestamp: meal.mealTime - 30, + reconciledAt: nil + ) + let storage = StorageValue<[TRCMealMutationOperation]>( + key: "TrioMealTreatmentTests-\(UUID().uuidString)", + defaultValue: [operation] + ) + defer { storage.remove() } + let coordinator = TRCMealMutationCoordinator(storage: storage) { _, _ in } + let treatment = try #require(TreatmentsViewModel.makeCarbTreatment( + from: entry(), + trioMeal: meal, + nightscoutID: meal.nightscoutID, + timestamp: meal.mealTime, + actualBG: 100 + )) + let reconciliationDate = Date(timeIntervalSince1970: meal.mealTime + 1) + + TreatmentsViewModel.reconcileAppliedEdits( + in: [treatment], + using: coordinator, + at: reconciliationDate + ) + + let reconciled = try #require(coordinator.operation(commandID: operation.commandID)) + let staleMeal = try #require(TrioMealTreatment(nightscoutEntry: entry(carbs: meal.carbs + 1))) + #expect(reconciled.reconciledAt == reconciliationDate) + #expect(!reconciled.blocksActions) + #expect(storage.value == [reconciled]) + #expect(TRCMealMutationUIValidation.staleSourceError(meal: staleMeal, after: reconciled) != nil) + #expect(TRCMealMutationUIValidation.staleSourceError(meal: meal, after: reconciled) == nil) + } + + @Test("Malformed Trio and FPU UUIDs are rejected") + func rejectsMalformedUUIDs() { + #expect(TrioMealTreatment(nightscoutEntry: entry(mealID: "not-a-uuid")) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(fpuID: "not-a-uuid")) == nil) + } + + private func entry( + nightscoutID: String = "nightscout-object-id", + mealID: String? = nil, + fpuID: String? = nil, + createdAt: String = "2026-08-16T01:02:03.456Z", + carbs: Int = 30, + fat: Int = 10, + protein: Int = 5, + notes: String? = nil, + foodType: String? = nil + ) -> [String: AnyObject] { + var result: [String: AnyObject] = [ + "_id": nightscoutID as AnyObject, + "id": (mealID ?? self.mealID) as AnyObject, + "enteredBy": "Trio" as AnyObject, + "eventType": "Carb Correction" as AnyObject, + "created_at": createdAt as AnyObject, + "carbs": NSNumber(value: carbs), + "fat": NSNumber(value: fat), + "protein": NSNumber(value: protein), + ] + if let fpuID { + result["fpuID"] = fpuID as AnyObject + } + if let notes { + result["notes"] = notes as AnyObject + } + if let foodType { + result["foodType"] = foodType as AnyObject + } + return result + } + + private func treatment( + nightscoutID: String, + mealID: String? = nil, + fpuID: String? = nil + ) throws -> Treatment { + let source = entry( + nightscoutID: nightscoutID, + mealID: mealID, + fpuID: fpuID + ) + let meal = try #require(TrioMealTreatment(nightscoutEntry: source)) + return try #require(TreatmentsViewModel.makeCarbTreatment( + from: source, + trioMeal: meal, + nightscoutID: meal.nightscoutID, + timestamp: meal.mealTime, + actualBG: 100 + )) + } +}