diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 10b326d..f6df3a3 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -11,8 +11,19 @@ A550A8342F3C5C9300136F2B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = A550A6812F3B730000136F2B /* GRDB */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A586FF5530122589002CFF95 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A550A65A2F3B72EA00136F2B /* Project object */; + proxyType = 1; + remoteGlobalIDString = A550A6612F3B72EA00136F2B; + remoteInfo = oAI; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ A550A6622F3B72EA00136F2B /* oAI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = oAI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A586FF5130122589002CFF95 /* oAITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = oAITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -34,6 +45,11 @@ path = oAI; sourceTree = ""; }; + A586FF5230122589002CFF95 /* oAITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = oAITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -46,6 +62,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A586FF4E30122589002CFF95 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -53,6 +76,7 @@ isa = PBXGroup; children = ( A550A6642F3B72EA00136F2B /* oAI */, + A586FF5230122589002CFF95 /* oAITests */, A550A6632F3B72EA00136F2B /* Products */, ); sourceTree = ""; @@ -61,6 +85,7 @@ isa = PBXGroup; children = ( A550A6622F3B72EA00136F2B /* oAI.app */, + A586FF5130122589002CFF95 /* oAITests.xctest */, ); name = Products; sourceTree = ""; @@ -92,6 +117,29 @@ productReference = A550A6622F3B72EA00136F2B /* oAI.app */; productType = "com.apple.product-type.application"; }; + A586FF5030122589002CFF95 /* oAITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A586FF5930122589002CFF95 /* Build configuration list for PBXNativeTarget "oAITests" */; + buildPhases = ( + A586FF4D30122589002CFF95 /* Sources */, + A586FF4E30122589002CFF95 /* Frameworks */, + A586FF4F30122589002CFF95 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A586FF5630122589002CFF95 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A586FF5230122589002CFF95 /* oAITests */, + ); + name = oAITests; + packageProductDependencies = ( + ); + productName = oAITests; + productReference = A586FF5130122589002CFF95 /* oAITests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -99,12 +147,16 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2620; - LastUpgradeCheck = 2620; + LastSwiftUpdateCheck = 2700; + LastUpgradeCheck = 2700; TargetAttributes = { A550A6612F3B72EA00136F2B = { CreatedOnToolsVersion = 26.2; }; + A586FF5030122589002CFF95 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = A550A6612F3B72EA00136F2B; + }; }; }; buildConfigurationList = A550A65D2F3B72EA00136F2B /* Build configuration list for PBXProject "oAI" */; @@ -131,6 +183,7 @@ projectRoot = ""; targets = ( A550A6612F3B72EA00136F2B /* oAI */, + A586FF5030122589002CFF95 /* oAITests */, ); }; /* End PBXProject section */ @@ -143,6 +196,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A586FF4F30122589002CFF95 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -153,14 +213,30 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A586FF4D30122589002CFF95 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A586FF5630122589002CFF95 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A550A6612F3B72EA00136F2B /* oAI */; + targetProxy = A586FF5530122589002CFF95 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ A550A66B2F3B72EC00136F2B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -190,7 +266,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 6RJQ2QZYPG; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -212,6 +290,7 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -222,6 +301,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -251,7 +331,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 6RJQ2QZYPG; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -266,6 +348,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -278,12 +361,12 @@ CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6RJQ2QZYPG; + DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "oAI/Info.plist"; + INFOPLIST_FILE = oAI/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."; INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."; @@ -328,12 +411,12 @@ CODE_SIGN_ENTITLEMENTS = oAI/oAI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6RJQ2QZYPG; + DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = NO; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "oAI/Info.plist"; + INFOPLIST_FILE = oAI/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "oAI can read and create calendar events when you ask it to, if you enable Calendar access in Settings."; INFOPLIST_KEY_NSContactsUsageDescription = "oAI can search your contacts when you ask it to, if you enable Contacts access in Settings."; @@ -370,6 +453,50 @@ }; name = Release; }; + A586FF5730122589002CFF95 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/oAI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/oAI"; + }; + name = Debug; + }; + A586FF5830122589002CFF95 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/oAI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/oAI"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -391,6 +518,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A586FF5930122589002CFF95 /* Build configuration list for PBXNativeTarget "oAITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A586FF5730122589002CFF95 /* Debug */, + A586FF5830122589002CFF95 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/oAI/Providers/AnthropicProvider.swift b/oAI/Providers/AnthropicProvider.swift index dcebe80..34cb0fd 100644 --- a/oAI/Providers/AnthropicProvider.swift +++ b/oAI/Providers/AnthropicProvider.swift @@ -186,6 +186,21 @@ class AnthropicProvider: AIProvider { ("claude-haiku", 0.80, 4.0), ] + /// Fuzzy pricing lookup for a model ID not found in `knownModels`: finds the + /// longest matching prefix in `fallback` (longest match wins when several + /// prefixes match, e.g. a future "claude-sonnet-mini" against both + /// "claude-sonnet" and a shorter unrelated prefix). Unmatched IDs price at zero. + static func resolveFallbackPricing( + for modelId: String, + fallback: [(prefix: String, prompt: Double, completion: Double)] = pricingFallback + ) -> ModelInfo.Pricing { + let match = fallback + .filter { modelId.hasPrefix($0.prefix) } + .max(by: { $0.prefix.count < $1.prefix.count }) + return match.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) } + ?? ModelInfo.Pricing(prompt: 0, completion: 0) + } + /// Fetch live model list from GET /v1/models, enriched with local pricing/context metadata. /// Falls back to knownModels if the request fails (no key, offline, etc.). func listModels() async throws -> [ModelInfo] { @@ -223,11 +238,7 @@ class AnthropicProvider: AIProvider { // Exact match first if let known = enrichment[id] { return known } // Fuzzy fallback: find the longest prefix that matches - let fallback = Self.pricingFallback - .filter { id.hasPrefix($0.prefix) } - .max(by: { $0.prefix.count < $1.prefix.count }) - let pricing = fallback.map { ModelInfo.Pricing(prompt: $0.prompt, completion: $0.completion) } - ?? ModelInfo.Pricing(prompt: 0, completion: 0) + let pricing = Self.resolveFallbackPricing(for: id) return ModelInfo( id: id, name: displayName, diff --git a/oAI/Services/BackupService.swift b/oAI/Services/BackupService.swift index e65b89a..534f4b1 100644 --- a/oAI/Services/BackupService.swift +++ b/oAI/Services/BackupService.swift @@ -206,37 +206,68 @@ final class BackupService { log.debug("Pushed \(payload.ids.count) favorite model(s) to iCloud") } + /// Outcome of reconciling local favorites against the iCloud copy. Pure result type -- + /// see `decideFavoritesSync` for the actual decision logic. + enum FavoritesSyncDecision: Equatable { + case applyRemote(ids: Set, updatedAt: String) + case pushLocal + case mergeAndPush(ids: Set, updatedAt: String) + case noop + } + + /// Last-write-wins reconciliation, pulled out of `syncFavoritesOnLaunch` so the branching + /// logic (5 cases: remote absent / remote newer / local newer / tied-but-different / + /// tied-and-identical) can be tested without touching iCloud Drive or SettingsService. + static func decideFavoritesSync( + localIds: Set, + localUpdatedAt: String, + remote: FavoritesPayload?, + now: Date + ) -> FavoritesSyncDecision { + guard let remote else { + return .pushLocal + } + if remote.updatedAt > localUpdatedAt { + return .applyRemote(ids: Set(remote.ids), updatedAt: remote.updatedAt) + } else if localUpdatedAt > remote.updatedAt { + return .pushLocal + } else if Set(remote.ids) != localIds { + // Tied timestamps (both empty is the common case: two machines that already had + // favorites before this sync feature existed, neither ever bumped the timestamp). + // Union rather than silently dropping one side's favorites. + let merged = localIds.union(remote.ids) + return .mergeAndPush(ids: merged, updatedAt: ISO8601DateFormatter().string(from: now)) + } + return .noop + } + /// Reconcile local favorites with the iCloud copy using last-write-wins (by timestamp). /// Call on launch (and optionally on app-become-active) to pick up changes from other machines. func syncFavoritesOnLaunch() async { let settings = SettingsService.shared let fileURL = favoritesFileURL() - guard let data = try? Data(contentsOf: fileURL), - let remote = try? JSONDecoder().decode(FavoritesPayload.self, from: data) else { - // No remote copy yet — push local state (may be empty, that's fine). - await pushFavorites() - return - } - - let localUpdatedAt = settings.favoriteModelsUpdatedAt + let remote: FavoritesPayload? = { + guard let data = try? Data(contentsOf: fileURL) else { return nil } + return try? JSONDecoder().decode(FavoritesPayload.self, from: data) + }() let localIds = settings.favoriteModelIds - if remote.updatedAt > localUpdatedAt { - settings.favoriteModelIds = Set(remote.ids) - settings.favoriteModelsUpdatedAt = remote.updatedAt - log.info("Applied \(remote.ids.count) favorite model(s) from iCloud (remote was newer)") - } else if localUpdatedAt > remote.updatedAt { + + switch Self.decideFavoritesSync(localIds: localIds, localUpdatedAt: settings.favoriteModelsUpdatedAt, remote: remote, now: Date()) { + case .pushLocal: await pushFavorites() - } else if Set(remote.ids) != localIds { - // Tied timestamps (both empty is the common case: two machines that already had - // favorites before this sync feature existed, neither ever bumped the timestamp). - // Union rather than silently dropping one side's favorites. - settings.favoriteModelIds = localIds.union(remote.ids) - settings.favoriteModelsUpdatedAt = ISO8601DateFormatter().string(from: Date()) + case .applyRemote(let ids, let updatedAt): + settings.favoriteModelIds = ids + settings.favoriteModelsUpdatedAt = updatedAt + log.info("Applied \(ids.count) favorite model(s) from iCloud (remote was newer)") + case .mergeAndPush(let ids, let updatedAt): + settings.favoriteModelIds = ids + settings.favoriteModelsUpdatedAt = updatedAt await pushFavorites() - log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote.ids.count) remote") + log.info("Merged favorites from iCloud (tied timestamps) — union of \(localIds.count) local + \(remote?.ids.count ?? 0) remote") + case .noop: + break } - // Equal timestamps and identical sets: already in sync, nothing to do. } // MARK: - Automatic Backup @@ -252,17 +283,27 @@ final class BackupService { } } - func checkAndPerformAutoBackupIfDue() async { - let frequency = SettingsService.shared.autoBackupFrequency + /// Whether an automatic backup should run now, given the chosen frequency and the last + /// known backup time. Pulled out of `checkAndPerformAutoBackupIfDue` for testability -- + /// "manual" is never due, a missing last-backup-date is always due, otherwise it's due + /// once the elapsed time reaches the frequency's interval. + static func isBackupDue(frequency: String, lastBackupDate: Date?, now: Date) -> Bool { let interval: TimeInterval switch frequency { - case "daily": interval = Self.dailyInterval - case "weekly": interval = Self.weeklyInterval - default: return // "manual" — user triggers backups by hand + case "daily": interval = dailyInterval + case "weekly": interval = weeklyInterval + default: return false // "manual" — user triggers backups by hand } + guard let last = lastBackupDate else { return true } + return now.timeIntervalSince(last) >= interval + } + + func checkAndPerformAutoBackupIfDue() async { + let frequency = SettingsService.shared.autoBackupFrequency + guard frequency == "daily" || frequency == "weekly" else { return } checkForExistingBackup() - if let last = lastBackupDate, Date().timeIntervalSince(last) < interval { + guard Self.isBackupDue(frequency: frequency, lastBackupDate: lastBackupDate, now: Date()) else { return // not due yet } diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index d6dde1f..b9827ae 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -45,7 +45,7 @@ class GitSyncService { func testConnection() async throws -> String { let url = try buildAuthenticatedURL() _ = try await runGit(["ls-remote", url]) - return "Connected to \(extractProvider())" + return "Connected to \(Self.extractProvider(from: settings.syncRepoURL))" } /// Clone repository to local path @@ -703,8 +703,7 @@ class GitSyncService { return name.components(separatedBy: invalid).joined(separator: "-") } - private func extractProvider() -> String { - let url = settings.syncRepoURL + static func extractProvider(from url: String) -> String { if url.contains("github.com") { return "GitHub" } else if url.contains("gitlab.com") { diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 1eebbe1..3af0d2e 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -1888,40 +1888,43 @@ Don't narrate future actions ("Let me...") - just use the tools. } } + /// Pure auto-save eligibility check, pulled out of `shouldAutoSave()` so the criteria + /// (enabled, configured, cloned, min message count, not-already-saved) can be tested + /// without a live ChatViewModel/GitSyncService/SettingsService. + nonisolated static func shouldAutoSave( + syncEnabled: Bool, + syncAutoSave: Bool, + syncConfigured: Bool, + isCloned: Bool, + chatMessageCount: Int, + minMessages: Int, + lastSavedConversationId: String?, + currentConversationHash: String + ) -> Bool { + guard syncEnabled && syncAutoSave else { return false } + guard syncConfigured else { return false } + guard isCloned else { return false } + guard chatMessageCount >= minMessages else { return false } + if let lastSavedId = lastSavedConversationId, lastSavedId == currentConversationHash { + return false // Already saved this exact conversation + } + return true + } + /// Check if conversation should be auto-saved based on criteria func shouldAutoSave() -> Bool { - // Check if auto-save is enabled - guard settings.syncEnabled && settings.syncAutoSave else { - return false - } - - // Check if sync is configured - guard settings.syncConfigured else { - return false - } - - // Check if repository is cloned - guard GitSyncService.shared.syncStatus.isCloned else { - return false - } - - // Check minimum message count let chatMessages = messages.filter { $0.role == .user || $0.role == .assistant } - guard chatMessages.count >= settings.syncAutoSaveMinMessages else { - return false - } - - // Check if already auto-saved this conversation - // (prevent duplicate saves) - if let lastSavedId = settings.syncLastAutoSaveConversationId { - // Create a hash of current conversation to detect if it's the same one - let currentHash = chatMessages.map { $0.content }.joined() - if lastSavedId == currentHash { - return false // Already saved this exact conversation - } - } - - return true + let currentHash = chatMessages.map { $0.content }.joined() + return Self.shouldAutoSave( + syncEnabled: settings.syncEnabled, + syncAutoSave: settings.syncAutoSave, + syncConfigured: settings.syncConfigured, + isCloned: GitSyncService.shared.syncStatus.isCloned, + chatMessageCount: chatMessages.count, + minMessages: settings.syncAutoSaveMinMessages, + lastSavedConversationId: settings.syncLastAutoSaveConversationId, + currentConversationHash: currentHash + ) } /// Auto-save the current conversation with background summarization diff --git a/oAITests/AnthropicProviderTests.swift b/oAITests/AnthropicProviderTests.swift index 5aa54ee..3a859ec 100644 --- a/oAITests/AnthropicProviderTests.swift +++ b/oAITests/AnthropicProviderTests.swift @@ -177,4 +177,31 @@ struct AnthropicProviderTests { #expect(items?["type"] as? String == "string") #expect(dict["required"] == nil) } + + // MARK: - resolveFallbackPricing + + @Test("Exact-length single prefix match returns that tier's pricing") + func fallbackPricingSingleMatch() { + let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-haiku-4-5-20251001") + #expect(pricing.prompt == 0.80) + #expect(pricing.completion == 4.0) + } + + @Test("Longest matching prefix wins when multiple prefixes could match") + func fallbackPricingLongestPrefixWins() { + let fallback: [(prefix: String, prompt: Double, completion: Double)] = [ + ("claude", 1.0, 2.0), + ("claude-sonnet", 3.0, 15.0), + ] + let pricing = AnthropicProvider.resolveFallbackPricing(for: "claude-sonnet-5", fallback: fallback) + #expect(pricing.prompt == 3.0) + #expect(pricing.completion == 15.0) + } + + @Test("An unmatched model ID prices at zero rather than guessing") + func fallbackPricingNoMatch() { + let pricing = AnthropicProvider.resolveFallbackPricing(for: "some-future-unknown-model") + #expect(pricing.prompt == 0) + #expect(pricing.completion == 0) + } } diff --git a/oAITests/BackupServiceTests.swift b/oAITests/BackupServiceTests.swift new file mode 100644 index 0000000..c3fa605 --- /dev/null +++ b/oAITests/BackupServiceTests.swift @@ -0,0 +1,105 @@ +// +// BackupServiceTests.swift +// oAITests +// +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright (C) 2026 Rune Olsen + +import Testing +import Foundation +@testable import oAI + +@Suite("BackupService.decideFavoritesSync") +struct BackupServiceFavoritesSyncTests { + + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + @Test("No remote copy yet -- push local state") + func noRemoteCopy() { + let decision = BackupService.decideFavoritesSync(localIds: ["a", "b"], localUpdatedAt: "", remote: nil, now: now) + #expect(decision == .pushLocal) + } + + @Test("Remote is newer -- apply it locally") + func remoteNewer() { + let remote = FavoritesPayload(updatedAt: "2026-02-01T00:00:00Z", ids: ["x", "y"]) + let decision = BackupService.decideFavoritesSync( + localIds: ["a"], + localUpdatedAt: "2026-01-01T00:00:00Z", + remote: remote, + now: now + ) + #expect(decision == .applyRemote(ids: ["x", "y"], updatedAt: "2026-02-01T00:00:00Z")) + } + + @Test("Local is newer -- push local state, don't touch remote") + func localNewer() { + let remote = FavoritesPayload(updatedAt: "2026-01-01T00:00:00Z", ids: ["x"]) + let decision = BackupService.decideFavoritesSync( + localIds: ["a"], + localUpdatedAt: "2026-02-01T00:00:00Z", + remote: remote, + now: now + ) + #expect(decision == .pushLocal) + } + + @Test("Tied timestamps with different sets -- merge via union and push") + func tiedTimestampsDifferentSets() { + let remote = FavoritesPayload(updatedAt: "", ids: ["b", "c"]) + let decision = BackupService.decideFavoritesSync(localIds: ["a", "b"], localUpdatedAt: "", remote: remote, now: now) + guard case .mergeAndPush(let ids, let updatedAt) = decision else { + Issue.record("Expected .mergeAndPush") + return + } + #expect(ids == ["a", "b", "c"]) + #expect(!updatedAt.isEmpty) // stamped with `now`, not left blank + } + + @Test("Tied timestamps with identical sets -- no-op, already in sync") + func tiedTimestampsIdenticalSets() { + let remote = FavoritesPayload(updatedAt: "2026-01-01T00:00:00Z", ids: ["a", "b"]) + let decision = BackupService.decideFavoritesSync( + localIds: ["a", "b"], + localUpdatedAt: "2026-01-01T00:00:00Z", + remote: remote, + now: now + ) + #expect(decision == .noop) + } +} + +@Suite("BackupService.isBackupDue") +struct BackupServiceAutoBackupTests { + + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let oneHour: TimeInterval = 3600 + + @Test("Manual frequency is never due") + func manualNeverDue() { + #expect(!BackupService.isBackupDue(frequency: "manual", lastBackupDate: nil, now: now)) + #expect(!BackupService.isBackupDue(frequency: "manual", lastBackupDate: now.addingTimeInterval(-1_000_000), now: now)) + } + + @Test("No prior backup at all is always due, regardless of frequency") + func noPriorBackupIsAlwaysDue() { + #expect(BackupService.isBackupDue(frequency: "daily", lastBackupDate: nil, now: now)) + #expect(BackupService.isBackupDue(frequency: "weekly", lastBackupDate: nil, now: now)) + } + + @Test("Daily: not due before 24 hours have passed, due at or after") + func dailyThreshold() { + let justUnder = now.addingTimeInterval(-(24 * oneHour - 1)) + let exactly = now.addingTimeInterval(-(24 * oneHour)) + #expect(!BackupService.isBackupDue(frequency: "daily", lastBackupDate: justUnder, now: now)) + #expect(BackupService.isBackupDue(frequency: "daily", lastBackupDate: exactly, now: now)) + } + + @Test("Weekly: not due before 7 days have passed, due at or after") + func weeklyThreshold() { + let justUnder = now.addingTimeInterval(-(7 * 24 * oneHour - 1)) + let exactly = now.addingTimeInterval(-(7 * 24 * oneHour)) + #expect(!BackupService.isBackupDue(frequency: "weekly", lastBackupDate: justUnder, now: now)) + #expect(BackupService.isBackupDue(frequency: "weekly", lastBackupDate: exactly, now: now)) + } +} diff --git a/oAITests/ChatViewModelPureLogicTests.swift b/oAITests/ChatViewModelPureLogicTests.swift index f577f6a..c6a163f 100644 --- a/oAITests/ChatViewModelPureLogicTests.swift +++ b/oAITests/ChatViewModelPureLogicTests.swift @@ -103,4 +103,60 @@ struct ChatViewModelPureLogicTests { let pricing = ModelInfo.Pricing(prompt: 3.0, completion: 15.0) #expect(ChatViewModel.calculateCost(usage: usage, pricing: pricing) == 0.0) } + + // MARK: - shouldAutoSave + + private func autoSaveEligible( + syncEnabled: Bool = true, + syncAutoSave: Bool = true, + syncConfigured: Bool = true, + isCloned: Bool = true, + chatMessageCount: Int = 10, + minMessages: Int = 4, + lastSavedConversationId: String? = nil, + currentConversationHash: String = "hash-a" + ) -> Bool { + ChatViewModel.shouldAutoSave( + syncEnabled: syncEnabled, + syncAutoSave: syncAutoSave, + syncConfigured: syncConfigured, + isCloned: isCloned, + chatMessageCount: chatMessageCount, + minMessages: minMessages, + lastSavedConversationId: lastSavedConversationId, + currentConversationHash: currentConversationHash + ) + } + + @Test("Eligible when every criterion is satisfied") + func autoSaveEligibleWhenAllCriteriaMet() { + #expect(autoSaveEligible()) + } + + @Test("Not eligible when sync or auto-save is disabled") + func autoSaveIneligibleWhenDisabled() { + #expect(!autoSaveEligible(syncEnabled: false)) + #expect(!autoSaveEligible(syncAutoSave: false)) + } + + @Test("Not eligible when sync isn't configured or the repo isn't cloned") + func autoSaveIneligibleWhenNotConfiguredOrCloned() { + #expect(!autoSaveEligible(syncConfigured: false)) + #expect(!autoSaveEligible(isCloned: false)) + } + + @Test("Not eligible below the minimum message count") + func autoSaveIneligibleBelowMinimumMessages() { + #expect(!autoSaveEligible(chatMessageCount: 2, minMessages: 4)) + } + + @Test("Not eligible if this exact conversation was already auto-saved") + func autoSaveIneligibleWhenAlreadySaved() { + #expect(!autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-a")) + } + + @Test("Eligible when the last saved hash differs from the current conversation") + func autoSaveEligibleWhenConversationChanged() { + #expect(autoSaveEligible(lastSavedConversationId: "hash-a", currentConversationHash: "hash-b")) + } } diff --git a/oAITests/GitSyncServiceTests.swift b/oAITests/GitSyncServiceTests.swift index e5e7b2d..67a2b4d 100644 --- a/oAITests/GitSyncServiceTests.swift +++ b/oAITests/GitSyncServiceTests.swift @@ -91,4 +91,18 @@ struct GitSyncServiceTests { let shortKey = "sk-" + String(repeating: "a", count: 31) #expect(!service.detectSecretsInText(shortKey).contains("OpenAI Key")) } + + // MARK: - extractProvider + + @Test("Recognizes github.com, gitlab.com, and gitea URLs by name") + func extractProviderRecognizesKnownHosts() { + #expect(GitSyncService.extractProvider(from: "https://github.com/user/repo.git") == "GitHub") + #expect(GitSyncService.extractProvider(from: "https://gitlab.com/user/repo.git") == "GitLab") + #expect(GitSyncService.extractProvider(from: "https://my-gitea-instance.example.com/user/repo.git") == "Gitea") + } + + @Test("Falls back to a generic label for an unrecognized host") + func extractProviderFallsBackForUnknownHost() { + #expect(GitSyncService.extractProvider(from: "https://gitlab.pm/rune/oai-swift.git") == "Git repository") + } }