2.4.3 #9

Merged
rune merged 14 commits from 2.4.3 into main 2026-07-27 08:13:22 +02:00
9 changed files with 464 additions and 72 deletions
Showing only changes of commit 02bf73fec6 - Show all commits
+142 -6
View File
@@ -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 = "<group>";
};
A586FF5230122589002CFF95 /* oAITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = oAITests;
sourceTree = "<group>";
};
/* 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 = "<group>";
@@ -61,6 +85,7 @@
isa = PBXGroup;
children = (
A550A6622F3B72EA00136F2B /* oAI.app */,
A586FF5130122589002CFF95 /* oAITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -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 */
+16 -5
View File
@@ -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,
+68 -27
View File
@@ -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<String>, updatedAt: String)
case pushLocal
case mergeAndPush(ids: Set<String>, 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<String>,
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
}
+2 -3
View File
@@ -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") {
+34 -31
View File
@@ -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
+27
View File
@@ -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)
}
}
+105
View File
@@ -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))
}
}
@@ -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"))
}
}
+14
View File
@@ -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")
}
}