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.
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user