Files
Termind/TFITests/MobileProviderDefaultsTests.swift

168 lines
8.4 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindIOS
@Suite @MainActor
struct MobileProviderDefaultsTests {
@Test(arguments: ModelAPIType.allCases)
func blankDraftsPreviewDefaultsAndPersistConcreteValues(_ type: ModelAPIType) throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let editor = MobileProviderEditorModel(ai: ai, provider: ModelProvider(name: ""))
#expect(!editor.isDirty)
editor.endpoint.apiType = type
#expect(editor.canSave)
#expect(editor.draft.name.isEmpty)
#expect(editor.endpoint.url.isEmpty)
#expect(editor.requestProvider.name == type.defaultProviderName)
#expect(try editor.requestSecret() == "")
#expect(ai.modelProviders.isEmpty)
try editor.save()
let saved = try #require(ai.modelProvider(editor.original.id))
#expect(saved.name == type.defaultProviderName)
#expect(ai.endpoint(for: saved.id).url == type.defaultURL)
let reopened = MobileProviderEditorModel(ai: ai, provider: saved)
#expect(reopened.draft.name == type.defaultProviderName)
#expect(reopened.endpoint.url == type.defaultURL)
#expect(!reopened.isDirty)
}
@Test func typeSwitchDoesNotReplaceExplicitValuesAndDefaultsUseMergedType() throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let provider = ModelProvider(name: "Original")
#expect(ai.saveProvider(provider, endpoint: .init()))
let saved = try #require(ai.modelProvider(provider.id))
let editor = MobileProviderEditorModel(ai: ai, provider: saved)
editor.draft.name = ""
editor.endpoint.url = ""
ai.setEndpoint(.init(apiType: .messages, url: ModelAPIType.automatic.defaultURL), for: provider.id)
try editor.save()
#expect(ai.modelProvider(provider.id)?.name == "Anthropic")
#expect(ai.endpoint(for: provider.id).url == ModelAPIType.messages.defaultURL)
let explicit = MobileProviderEditorModel(ai: ai, provider: try #require(ai.modelProvider(provider.id)))
explicit.endpoint.apiType = .generateContent
#expect(explicit.draft.name == "Anthropic")
#expect(explicit.endpoint.url == ModelAPIType.messages.defaultURL)
}
@Test func modelNamesAreMaterializedAfterMergingTheProviderDraft() throws {
let model = AIModel(id: "private-model", displayName: "Original")
let provider = ModelProvider(name: "Gateway", models: [model])
let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false)
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
editor.draft.models[0].displayName = " \n"
var remote = model
remote.contextLength = 1234
ai.updateModel(remote, in: provider.id)
try editor.save()
let saved = try #require(ai.modelProvider(provider.id))
#expect(saved.models[0].displayName == model.id)
#expect(saved.models[0].contextLength == 1234)
let reopened = MobileProviderEditorModel(ai: ai, provider: saved)
#expect(reopened.draft.models[0].displayName == model.id)
#expect(!reopened.isDirty)
let conflict = MobileProviderEditorModel(ai: ai, provider: saved)
conflict.draft.models[0].displayName = ""
remote = saved.models[0]
remote.displayName = "Remote Name"
ai.updateModel(remote, in: provider.id)
#expect(throws: SettingsDraftMerge.Conflict.self) { try conflict.save() }
#expect(conflict.draft.models[0].displayName == "")
#expect(ai.modelProvider(provider.id)?.models[0].displayName == "Remote Name")
}
@Test(arguments: ["", " \n"], ["", "Local Name"])
func legacyNameMaterializationDoesNotConflictWithDraftEdits(originalName: String, localName: String) throws {
let model = AIModel(id: "private-model", displayName: originalName)
let provider = ModelProvider(name: "Gateway", models: [model])
let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false)
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
if !localName.isEmpty { editor.draft.models[0].displayName = localName }
editor.draft.models[0].maxOutputTokens = 512
var remote = model
remote.contextLength = 1234
ai.updateModel(remote, in: provider.id)
#expect(ai.modelProvider(provider.id)?.models[0].displayName == model.id)
try editor.save()
let saved = try #require(ai.modelProvider(provider.id)?.models.first)
#expect(saved.displayName == (localName.isEmpty ? model.id : localName))
#expect(saved.contextLength == 1234)
#expect(saved.maxOutputTokens == 512)
}
@Test(arguments: ["private-model", "Custom Name"], ["", " \n"])
func clearingASavedNameConflictsWithARemoteRename(originalName: String, clearedName: String) throws {
let model = AIModel(id: "private-model", displayName: originalName)
let provider = ModelProvider(name: "Gateway", models: [model])
let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false)
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
editor.draft.models[0].displayName = clearedName
var remote = model
remote.displayName = "Remote Name"
ai.updateModel(remote, in: provider.id)
#expect(throws: SettingsDraftMerge.Conflict.self) { try editor.save() }
#expect(editor.draft.models[0].displayName == clearedName)
#expect(ai.modelProvider(provider.id)?.models[0] == remote)
}
@Test func vertexPreviewSelectsItsOwnCredentialAndCanSaveAfterClearingIt() throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let provider = ModelProvider(name: "Google", credentialRef: "key")
#expect(ai.saveProvider(provider, endpoint: .init(apiType: .generateContent)))
ai.setSecret("ordinary-key", for: "key")
let editor = MobileProviderEditorModel(ai: ai, provider: try #require(ai.modelProvider(provider.id)))
editor.draft.usesVertex = true
#expect(!editor.canSave)
editor.draft.vertex.projectID = "project"
editor.draft.vertex.location = ""
#expect(editor.canSave)
#expect(throws: (any Error).self) { try editor.requestSecret() }
editor.serviceAccount.action = .replace
editor.serviceAccount.text = "draft-service-account-json"
editor.draft.vertex.clientEmail = "fixture@example.invalid"
#expect(try editor.requestSecret() == "draft-service-account-json")
#expect(ai.modelProvider(provider.id)?.usesVertex == false)
try editor.save()
let saved = try #require(ai.modelProvider(provider.id))
#expect(saved.vertex.location == "global")
#expect(saved.credentialRef == "key")
#expect(!saved.vertex.credentialRef.isEmpty)
#expect(ai.modelSecret(for: saved) == "draft-service-account-json")
let reopened = MobileProviderEditorModel(ai: ai, provider: saved)
reopened.removeServiceAccount()
#expect(reopened.canSave)
#expect(throws: (any Error).self) { try reopened.requestSecret() }
try reopened.save()
#expect(ai.modelProvider(provider.id)?.vertex.credentialRef == "")
#expect(ai.secret(for: saved.vertex.credentialRef) == nil)
#expect(ai.secret(for: "key") == "ordinary-key")
}
@Test func vertexProjectConflictsRejectSaveAndInvalidImportsDoNotAlterDraft() throws {
let ai = AIConfigurationModel(persistsSecrets: false)
let provider = ModelProvider(
name: "Google", usesVertex: true,
vertex: VertexConfig(projectID: "original", credentialRef: "service"))
#expect(ai.saveProvider(provider, endpoint: .init(apiType: .generateContent)))
let saved = try #require(ai.modelProvider(provider.id))
let editor = MobileProviderEditorModel(ai: ai, provider: saved)
#expect(throws: (any Error).self) { try editor.importServiceAccount("private-invalid") }
#expect(editor.draft == saved)
editor.draft.vertex.projectID = "local"
var remote = saved
remote.vertex.projectID = "remote"
ai.upsert(remote)
#expect(throws: SettingsDraftMerge.Conflict.self) { try editor.save() }
#expect(ai.modelProvider(provider.id)?.vertex.projectID == "remote")
}
}