From 02bf73fec6f5802315842dea1112b38ed9130a66 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Thu, 23 Jul 2026 14:19:53 +0200 Subject: [PATCH] Actually commit the oAITests target wiring in project.pbxproj The oAITests PBXNativeTarget (PBXContainerItemProxy, PBXTargetDependency, XCBuildConfiguration with TEST_HOST/BUNDLE_LOADER, the "recommended settings" changes accepted when the target was created -- DEAD_CODE_STRIPPING, CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED, STRING_CATALOG_GENERATE_SYMBOLS, DEVELOPMENT_TEAM moved to project-level inheritance) was somehow never actually staged in the very first "Add real oAITests target" commit (8c7fb59) despite every xcodebuild test run since then depending on it being present on disk. Every subsequent commit this session only staged specific file paths (never oAI.xcodeproj again), so the gap went unnoticed until a full `git status` review here. Without this, anyone else pulling the branch (or a truly clean checkout on this machine) would have all the .swift test files but no target to compile them into -- xcodebuild test would fail to find oAITests at all. Confirmed the diff is exactly the expected target-wiring content, nothing unrelated or corrupted, before committing. --- oAI.xcodeproj/project.pbxproj | 148 ++++++++++++++++++++- oAI/Providers/AnthropicProvider.swift | 21 ++- oAI/Services/BackupService.swift | 95 +++++++++---- oAI/Services/GitSyncService.swift | 5 +- oAI/ViewModels/ChatViewModel.swift | 65 ++++----- oAITests/AnthropicProviderTests.swift | 27 ++++ oAITests/BackupServiceTests.swift | 105 +++++++++++++++ oAITests/ChatViewModelPureLogicTests.swift | 56 ++++++++ oAITests/GitSyncServiceTests.swift | 14 ++ 9 files changed, 464 insertions(+), 72 deletions(-) create mode 100644 oAITests/BackupServiceTests.swift 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") + } }