Initial commit

This commit is contained in:
diyaa
2026-09-13 20:21:10 +02:00
commit 18d7d56345
42 changed files with 5459 additions and 0 deletions
@@ -0,0 +1,96 @@
import MusicAssistantCore
import XCTest
final class AIServiceTests: XCTestCase {
func testProviderIndependentServiceGeneratesStructuredProjectResult() async throws {
let service = MockAIService()
let request = SongProjectGenerationRequest(
context: AIRequestContext(
userInstruction: "Write a hopeful cinematic pop song.",
conversation: [
AIConversationMessage(role: .user, content: "Use piano and violin.")
],
localeIdentifier: "en_US"
),
discussionMode: .discuss
)
let result = try await service.generateSongProject(from: request)
XCTAssertEqual(result.project.title, "Generated Song")
XCTAssertEqual(result.project.idea, request.context.userInstruction)
XCTAssertEqual(result.project.conversationMode, .discuss)
XCTAssertEqual(result.notes, ["Generated by a provider-independent mock."])
XCTAssertEqual(result.followUpQuestions, ["Should the chorus be bigger?"])
}
func testProviderIndependentServiceRevisesLyricsWithStructuredResult() async throws {
let service = MockAIService()
let project = SongProject(title: "Lyric Project", idea: "Improve words")
let sourceLyrics = Lyrics(text: "rough draft")
let request = LyricsRevisionRequest(
context: AIRequestContext(userInstruction: "Make it stronger."),
project: project,
sourceLyrics: sourceLyrics,
mode: .improve
)
let result = try await service.reviseLyrics(from: request)
XCTAssertEqual(result.lyrics.text, "rough draft\nMake it stronger.")
XCTAssertEqual(result.notes, ["improve"])
}
func testProviderIndependentServiceProposesScopedProjectUpdates() async throws {
let service = MockAIService()
let project = SongProject(title: "Scoped Project", idea: "Add tempo")
let request = SongProjectUpdateRequest(
context: AIRequestContext(userInstruction: "Set tempo manually."),
project: project,
allowedScopes: [.musicalParameters]
)
let result = try await service.proposeProjectUpdate(from: request)
XCTAssertEqual(result.project.bpm, ManualAutoValue(mode: .manual, value: 96))
XCTAssertEqual(result.changedScopes, [.musicalParameters])
XCTAssertEqual(result.notes, ["Updated only requested scopes."])
}
}
private struct MockAIService: AIService {
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
SongProjectGenerationResult(
project: SongProject(
title: "Generated Song",
idea: request.context.userInstruction,
conversationMode: request.discussionMode
),
notes: ["Generated by a provider-independent mock."],
followUpQuestions: ["Should the chorus be bigger?"]
)
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
LyricsRevisionResult(
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
notes: [request.mode.rawValue]
)
}
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
var project = request.project
var changedScopes: [SongProjectUpdateScope] = []
if request.allowedScopes.contains(.musicalParameters) {
project.setBPMManually(96)
changedScopes.append(.musicalParameters)
}
return SongProjectUpdateResult(
project: project,
changedScopes: changedScopes,
notes: ["Updated only requested scopes."]
)
}
}
@@ -0,0 +1,21 @@
import MusicAssistantCore
import XCTest
final class AppConfigurationTests: XCTestCase {
func testDevelopmentConfigurationReadsKnownEnvironmentKeys() {
let configuration = AppConfiguration.development(environment: [
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key",
"MUSIC_ASSISTANT_PRIVATE_AI_RULES": " private product rules "
])
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
XCTAssertEqual(configuration.privateApplicationRules?.content, "private product rules")
}
func testDevelopmentConfigurationAllowsMissingSecrets() {
let configuration = AppConfiguration.development(environment: [:])
XCTAssertNil(configuration.openAIAPIKey)
XCTAssertNil(configuration.privateApplicationRules)
}
}
@@ -0,0 +1,102 @@
@testable import MusicAssistantCore
import XCTest
final class ApplicationRuleInjectionTests: XCTestCase {
func testInjectingServiceAddsPrivateRulesToEveryAIRequestType() async throws {
let baseService = RecordingAIService()
let service = ApplicationRuleInjectingAIService(
baseService: baseService,
ruleProvider: StaticApplicationRuleProvider(
rules: PrivateApplicationRules(content: "Use private product rules.")
)
)
let project = SongProject(title: "Rule Test", idea: "Test rules")
_ = try await service.generateSongProject(
from: SongProjectGenerationRequest(
context: AIRequestContext(userInstruction: "Generate.")
)
)
_ = try await service.reviseLyrics(
from: LyricsRevisionRequest(
context: AIRequestContext(userInstruction: "Improve."),
project: project,
sourceLyrics: Lyrics(text: "Draft"),
mode: .improve
)
)
_ = try await service.proposeProjectUpdate(
from: SongProjectUpdateRequest(
context: AIRequestContext(userInstruction: "Update."),
project: project,
allowedScopes: [.lyrics]
)
)
let recordedRuleContents = await baseService.recordedRuleContents()
XCTAssertEqual(
recordedRuleContents,
[
"Use private product rules.",
"Use private product rules.",
"Use private product rules."
]
)
}
func testInjectingServiceDoesNotAddPrivateRulesToVisibleConversation() async throws {
let baseService = RecordingAIService()
let service = ApplicationRuleInjectingAIService(
baseService: baseService,
ruleProvider: StaticApplicationRuleProvider(
rules: PrivateApplicationRules(content: "Hidden owner rules")
)
)
let visibleMessage = AIConversationMessage(role: .user, content: "Visible user text.")
_ = try await service.generateSongProject(
from: SongProjectGenerationRequest(
context: AIRequestContext(
userInstruction: "Generate.",
conversation: [visibleMessage]
)
)
)
let recordedConversation = await baseService.recordedConversations().first
XCTAssertEqual(recordedConversation, [visibleMessage])
}
func testBlankPrivateRulesAreIgnored() {
XCTAssertNil(PrivateApplicationRules(content: " \n "))
}
}
private actor RecordingAIService: AIService {
private var contexts: [AIRequestContext] = []
func recordedRuleContents() -> [String?] {
contexts.map { $0.privateApplicationRules?.content }
}
func recordedConversations() -> [[AIConversationMessage]] {
contexts.map(\.conversation)
}
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
contexts.append(request.context)
return SongProjectGenerationResult(
project: SongProject(title: "Generated", idea: request.context.userInstruction)
)
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
contexts.append(request.context)
return LyricsRevisionResult(lyrics: request.sourceLyrics)
}
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
contexts.append(request.context)
return SongProjectUpdateResult(project: request.project)
}
}
@@ -0,0 +1,112 @@
import Foundation
import MusicAssistantCore
import XCTest
final class LocalSongProjectStoreTests: XCTestCase {
private var temporaryDirectoryURL: URL!
override func setUpWithError() throws {
temporaryDirectoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("music-assistant-store-tests")
.appendingPathComponent(UUID().uuidString)
}
override func tearDownWithError() throws {
if let temporaryDirectoryURL {
try? FileManager.default.removeItem(at: temporaryDirectoryURL)
}
temporaryDirectoryURL = nil
}
func testCreatesOpensSavesAndListsSongProjectsLocally() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let createdAt = Date(timeIntervalSince1970: 100)
let updatedAt = Date(timeIntervalSince1970: 200)
var project = SongProject(
id: "local-project",
title: "Local Project",
idea: "Persist this song locally",
genres: [GenreStyle(id: "pop", name: "Pop")],
createdAt: createdAt,
updatedAt: updatedAt
)
try await store.create(project)
let createdProjectIDs = try await store.loadProjectIDs()
let openedProject = try await store.open(id: project.id)
XCTAssertEqual(createdProjectIDs, ["local-project"])
XCTAssertEqual(openedProject, project)
project.title = "Updated Local Project"
project.setBPMManually(100)
try await store.save(project)
let updatedProject = try await store.open(id: project.id)
XCTAssertEqual(updatedProject, project)
}
func testLoadsProjectListSortedByMostRecentUpdate() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let older = SongProject(
id: "older-project",
title: "Older",
idea: "Created first",
createdAt: Date(timeIntervalSince1970: 100),
updatedAt: Date(timeIntervalSince1970: 150)
)
let newer = SongProject(
id: "newer-project",
title: "Newer",
idea: "Updated later",
createdAt: Date(timeIntervalSince1970: 200),
updatedAt: Date(timeIntervalSince1970: 300)
)
try await store.create(older)
try await store.create(newer)
let projectList = try await store.loadProjectList()
let projectIDs = try await store.loadProjectIDs()
XCTAssertEqual(
projectList,
[
SongProjectListItem(project: newer),
SongProjectListItem(project: older)
]
)
XCTAssertEqual(projectIDs, ["newer-project", "older-project"])
}
func testCreateDoesNotOverwriteExistingProject() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let project = SongProject(
id: "duplicate-project",
title: "Duplicate",
idea: "Do not overwrite"
)
try await store.create(project)
do {
try await store.create(project)
XCTFail("Expected duplicate project creation to fail.")
} catch let error as LocalSongProjectStoreError {
XCTAssertEqual(error, .projectAlreadyExists(project.id))
}
}
func testOpenMissingProjectThrowsNotFound() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
do {
_ = try await store.open(id: "missing")
XCTFail("Expected missing project open to fail.")
} catch let error as LocalSongProjectStoreError {
XCTAssertEqual(error, .projectNotFound("missing"))
}
}
}
@@ -0,0 +1,213 @@
import Foundation
import MusicAssistantCore
import XCTest
final class OpenAIClientTests: XCTestCase {
func testGenerateSongProjectUsesConfiguredEndpointAndHeaders() async throws {
let transport = RecordingOpenAITransport(
statusCode: 200,
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Generated"))
)
let configuration = OpenAIClientConfiguration(
apiKey: "test-api-key",
endpointURL: URL(string: "https://api.example.test/v1/configured")!,
model: "configured-model",
organizationID: "org-test",
projectID: "project-test"
)
let client = OpenAIClient(
configuration: configuration,
adapter: MockOpenAIClientAdapter(),
transport: transport
)
let result = try await client.generateSongProject(
from: SongProjectGenerationRequest(
context: AIRequestContext(userInstruction: "Generate this."),
discussionMode: .auto
)
)
let request = await transport.recordedRequests.first
XCTAssertEqual(result.project.title, "Generated")
XCTAssertEqual(request?.url, configuration.endpointURL)
XCTAssertEqual(request?.httpMethod, "POST")
XCTAssertEqual(request?.value(forHTTPHeaderField: "Authorization"), "Bearer test-api-key")
XCTAssertEqual(request?.value(forHTTPHeaderField: "OpenAI-Organization"), "org-test")
XCTAssertEqual(request?.value(forHTTPHeaderField: "OpenAI-Project"), "project-test")
XCTAssertEqual(request?.value(forHTTPHeaderField: "X-Adapter"), "mock")
XCTAssertEqual(request?.value(forHTTPHeaderField: "Content-Type"), "application/json")
let requestBodyString = await transport.requestBodyString
XCTAssertEqual(requestBodyString, "generate|configured-model|Generate this.")
}
func testClientUsesAdapterURLOverrideWhenProvided() async throws {
let overrideURL = URL(string: "https://api.example.test/v1/override")!
let transport = RecordingOpenAITransport(
statusCode: 200,
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Updated"))
)
let client = OpenAIClient(
configuration: OpenAIClientConfiguration(
apiKey: "test-api-key",
endpointURL: URL(string: "https://api.example.test/v1/configured")!
),
adapter: MockOpenAIClientAdapter(overrideURL: overrideURL),
transport: transport
)
_ = try await client.proposeProjectUpdate(
from: SongProjectUpdateRequest(
context: AIRequestContext(userInstruction: "Update it."),
project: SongProject(title: "Original", idea: "Original idea"),
allowedScopes: [.lyrics]
)
)
let recordedURL = await transport.recordedRequests.first?.url
XCTAssertEqual(recordedURL, overrideURL)
}
func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws {
let transport = RecordingOpenAITransport(
statusCode: 200,
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Never sent"))
)
let client = OpenAIClient(
configuration: OpenAIClientConfiguration(
apiKey: " ",
endpointURL: URL(string: "https://api.example.test/v1/configured")!
),
adapter: MockOpenAIClientAdapter(),
transport: transport
)
do {
_ = try await client.reviseLyrics(
from: LyricsRevisionRequest(
context: AIRequestContext(userInstruction: "Improve."),
project: SongProject(title: "Song", idea: "Idea"),
sourceLyrics: Lyrics(text: "Draft"),
mode: .improve
)
)
XCTFail("Expected missing API key to throw.")
} catch let error as OpenAIClientError {
XCTAssertEqual(error, .missingAPIKey)
}
let recordedRequestCount = await transport.recordedRequests.count
XCTAssertEqual(recordedRequestCount, 0)
}
func testClientReportsUnacceptableStatusWithoutLeakingResponseBody() async throws {
let transport = RecordingOpenAITransport(
statusCode: 429,
responseData: Data("secret server detail".utf8)
)
let client = OpenAIClient(
configuration: OpenAIClientConfiguration(
apiKey: "test-api-key",
endpointURL: URL(string: "https://api.example.test/v1/configured")!
),
adapter: MockOpenAIClientAdapter(),
transport: transport
)
do {
_ = try await client.generateSongProject(
from: SongProjectGenerationRequest(
context: AIRequestContext(userInstruction: "Generate.")
)
)
XCTFail("Expected status error to throw.")
} catch let error as OpenAIClientError {
XCTAssertEqual(error, .unacceptableStatusCode(429))
}
}
}
private actor RecordingOpenAITransport: OpenAIHTTPTransport {
private(set) var recordedRequests: [URLRequest] = []
private let statusCode: Int
private let responseData: Data
init(statusCode: Int, responseData: Data) {
self.statusCode = statusCode
self.responseData = responseData
}
var requestBodyString: String? {
guard let body = recordedRequests.first?.httpBody else { return nil }
return String(data: body, encoding: .utf8)
}
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
recordedRequests.append(request)
let response = HTTPURLResponse(
url: request.url!,
statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
return (responseData, response)
}
}
private struct MockOpenAIClientAdapter: OpenAIClientAdapter {
var overrideURL: URL?
func makeProjectGenerationRequest(
_ request: SongProjectGenerationRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest {
OpenAIClientRequest(
url: overrideURL,
body: Data("generate|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8),
additionalHeaders: ["X-Adapter": "mock"]
)
}
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult {
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
return SongProjectGenerationResult(
project: SongProject(title: response.text, idea: response.text)
)
}
func makeLyricsRevisionRequest(
_ request: LyricsRevisionRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest {
OpenAIClientRequest(
url: overrideURL,
body: Data("lyrics|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
)
}
func decodeLyricsRevisionResult(from data: Data) throws -> LyricsRevisionResult {
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
return LyricsRevisionResult(lyrics: Lyrics(text: response.text))
}
func makeProjectUpdateRequest(
_ request: SongProjectUpdateRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest {
OpenAIClientRequest(
url: overrideURL,
body: Data("update|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
)
}
func decodeProjectUpdateResult(from data: Data) throws -> SongProjectUpdateResult {
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
return SongProjectUpdateResult(
project: SongProject(title: response.text, idea: response.text)
)
}
}
private struct MockOpenAIResponse: Codable {
var text: String
}
@@ -0,0 +1,11 @@
import MusicAssistantCore
import XCTest
final class ProductBoundarySummaryTests: XCTestCase {
func testInitialBoundaryKeepsSunoGenerationOutsideTheApp() {
let summary = ProductBoundarySummary.initial.description
XCTAssertTrue(summary.contains("Suno"))
XCTAssertTrue(summary.contains("without starting generation"))
}
}
@@ -0,0 +1,104 @@
import Foundation
import MusicAssistantCore
import XCTest
final class ProjectAutosaveServiceTests: XCTestCase {
private var temporaryDirectoryURL: URL!
override func setUpWithError() throws {
temporaryDirectoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("music-assistant-autosave-tests")
.appendingPathComponent(UUID().uuidString)
}
override func tearDownWithError() throws {
if let temporaryDirectoryURL {
try? FileManager.default.removeItem(at: temporaryDirectoryURL)
}
temporaryDirectoryURL = nil
}
func testAutosavePersistsCurrentProjectWithoutCreatingExcessiveVersions() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let autosaver = ProjectAutosaveService(
store: store,
policy: ProjectAutosavePolicy(minimumVersionInterval: 300)
)
var project = SongProject(
id: "autosaved-project",
title: "Draft",
idea: "Initial idea",
updatedAt: Date(timeIntervalSince1970: 50)
)
try await store.create(project)
let firstAutosave = try await autosaver.autosave(
project,
at: Date(timeIntervalSince1970: 100)
)
XCTAssertTrue(firstAutosave.didCreateVersion)
XCTAssertEqual(firstAutosave.project.versions.count, 1)
XCTAssertEqual(firstAutosave.project.versions.first?.snapshot.title, "Draft")
XCTAssertEqual(firstAutosave.project.updatedAt, Date(timeIntervalSince1970: 100))
project = firstAutosave.project
project.title = "Draft with small edit"
let rapidAutosave = try await autosaver.autosave(
project,
at: Date(timeIntervalSince1970: 120)
)
XCTAssertFalse(rapidAutosave.didCreateVersion)
XCTAssertEqual(rapidAutosave.project.versions.count, 1)
XCTAssertEqual(rapidAutosave.project.title, "Draft with small edit")
XCTAssertEqual(rapidAutosave.project.updatedAt, Date(timeIntervalSince1970: 120))
let storedRapidAutosave = try await store.open(id: project.id)
XCTAssertEqual(storedRapidAutosave, rapidAutosave.project)
project = rapidAutosave.project
project.idea = "Later autosaved idea"
let laterAutosave = try await autosaver.autosave(
project,
at: Date(timeIntervalSince1970: 420)
)
XCTAssertTrue(laterAutosave.didCreateVersion)
XCTAssertEqual(laterAutosave.project.versions.count, 2)
XCTAssertEqual(laterAutosave.project.versions.last?.snapshot.idea, "Later autosaved idea")
XCTAssertEqual(laterAutosave.project.updatedAt, Date(timeIntervalSince1970: 420))
}
func testAutosaveSkipsVersionWhenCurrentSnapshotMatchesLatestVersion() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let autosaver = ProjectAutosaveService(
store: store,
policy: ProjectAutosavePolicy(minimumVersionInterval: 300)
)
var project = SongProject(
id: "unchanged-autosave-project",
title: "Stable",
idea: "No meaningful changes"
)
_ = project.createVersion(
id: "version-1",
label: "Stable checkpoint",
createdAt: Date(timeIntervalSince1970: 100)
)
try await store.create(project)
let autosave = try await autosaver.autosave(
project,
at: Date(timeIntervalSince1970: 1_000)
)
XCTAssertFalse(autosave.didCreateVersion)
XCTAssertEqual(autosave.project.versions.count, 1)
XCTAssertEqual(autosave.project.updatedAt, Date(timeIntervalSince1970: 1_000))
}
}
@@ -0,0 +1,561 @@
import Foundation
import MusicAssistantCore
import XCTest
final class SongProjectModelTests: XCTestCase {
func testSongProjectStoresCoreDomainCollections() {
let section = SongSection(
id: "section-verse-1",
type: .verse,
title: "Verse 1",
startTime: 12,
endTime: 42,
lyrics: "Draft verse",
emotion: "hopeful",
energy: 6,
vocalDirection: "soft lead",
productionDirection: "light percussion"
)
let placement = InstrumentPlacement(
sectionId: section.id,
startTime: section.startTime,
endTime: section.endTime,
direction: "enter after the first line"
)
let instrument = InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic oud",
playingStyle: "picked",
role: "main melodic hook",
autoArrangementEnabled: false,
placements: [placement]
)
let vocalist = Vocalist(
id: "lead",
label: "Lead vocalist",
voiceType: "warm tenor",
genderSelection: "male",
performanceStyle: "sung",
assignedSections: [section.id]
)
let project = SongProject(
id: "project-1",
title: "Desert Lights",
idea: "A hopeful Arabic pop song",
duration: SongDuration(seconds: 180),
conversationMode: .discuss,
languages: [LanguageSelection(id: "arabic", name: "Arabic")],
dialects: [DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic")],
arabicPronunciationSettings: ArabicPronunciationSettings(isEnabled: true),
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
moods: [MoodTag(id: "hopeful", name: "Hopeful")],
emotionalArc: [EmotionalArcPoint(id: "arc-1", sectionID: section.id, emotion: "hopeful")],
bpm: ManualAutoValue(mode: .manual, value: 96),
key: ManualAutoValue(mode: .auto),
scale: ManualAutoValue(mode: .auto),
maqam: ManualAutoValue(mode: .manual, value: "Hijaz"),
sections: [section],
instruments: [instrument],
vocalists: [vocalist],
lyrics: Lyrics(text: "Draft lyrics", sectionTexts: [section.id: "Draft verse"]),
productionDirections: [ProductionDirection(id: "prod-1", sectionID: section.id, text: "add lift")],
sunoOutput: SunoOutput(lyricsText: "Draft lyrics", stylePrompt: "Arabic pop with oud")
)
XCTAssertEqual(project.sections.first?.type, .verse)
XCTAssertEqual(project.instruments.first?.instrumentId, "oud")
XCTAssertEqual(project.instruments.first?.placements.first?.sectionId, section.id)
XCTAssertEqual(project.vocalists.first?.assignedSections, [section.id])
XCTAssertEqual(project.bpm?.mode, .manual)
XCTAssertEqual(project.maqam?.value, "Hijaz")
}
func testSongProjectSnapshotCopiesVersionableState() {
let project = SongProject(
id: "project-2",
title: "Night Drive",
idea: "Electronic track",
genres: [GenreStyle(id: "synthwave", name: "Synthwave")],
instruments: [InstrumentTrack(instrumentId: "synthesizer")]
)
let snapshot = SongProjectSnapshot(project: project)
let version = SongVersion(label: "Initial direction", snapshot: snapshot)
XCTAssertEqual(version.snapshot.title, project.title)
XCTAssertEqual(version.snapshot.idea, project.idea)
XCTAssertEqual(version.snapshot.genres, project.genres)
XCTAssertEqual(version.snapshot.instruments, project.instruments)
}
func testVersionHistoryStoresIndependentSnapshots() {
var project = SongProject(
id: "versioned-project",
title: "First Title",
idea: "Original idea",
genres: [GenreStyle(id: "folk", name: "Folk")]
)
let createdAt = Date(timeIntervalSince1970: 500)
let version = project.createVersion(
id: "version-1",
label: "Initial snapshot",
createdAt: createdAt
)
project.title = "Changed Title"
project.idea = "Changed idea"
project.selectGenreStyle(GenreStyle(id: "electronic", name: "Electronic"))
XCTAssertEqual(project.versions.count, 1)
XCTAssertEqual(project.version(id: "version-1"), version)
XCTAssertEqual(project.versions.first?.label, "Initial snapshot")
XCTAssertEqual(project.versions.first?.createdAt, createdAt)
XCTAssertEqual(project.versions.first?.snapshot.title, "First Title")
XCTAssertEqual(project.versions.first?.snapshot.idea, "Original idea")
XCTAssertEqual(project.versions.first?.snapshot.genres, [GenreStyle(id: "folk", name: "Folk")])
XCTAssertEqual(project.title, "Changed Title")
XCTAssertEqual(project.genres.map(\.id), ["folk", "electronic"])
}
func testRestoresPreviousVersionWithoutDestroyingLaterVersions() {
let createdAt = Date(timeIntervalSince1970: 100)
let restoredAt = Date(timeIntervalSince1970: 900)
var project = SongProject(
id: "restorable-project",
title: "First Title",
idea: "Original idea",
duration: SongDuration(seconds: 180),
genres: [GenreStyle(id: "folk", name: "Folk")],
instruments: [InstrumentTrack(instrumentId: "piano")],
lyrics: Lyrics(text: "Original lyrics"),
createdAt: createdAt,
updatedAt: Date(timeIntervalSince1970: 200)
)
_ = project.createVersion(
id: "version-1",
label: "Original direction",
createdAt: Date(timeIntervalSince1970: 300)
)
project.title = "Second Title"
project.idea = "Second idea"
project.duration = SongDuration(seconds: 210)
project.selectGenreStyle(GenreStyle(id: "electronic", name: "Electronic"))
project.selectInstrument(InstrumentTrack(instrumentId: "oud"))
project.lyrics = Lyrics(text: "Second lyrics")
_ = project.createVersion(
id: "version-2",
label: "Later direction",
createdAt: Date(timeIntervalSince1970: 400)
)
project.title = "Current Title"
project.idea = "Current idea"
project.duration = SongDuration(seconds: 240)
project.deselectGenreStyle(id: "folk")
project.lyrics = Lyrics(text: "Current lyrics")
let versionsBeforeRestore = project.versions
XCTAssertTrue(project.restoreVersion(id: "version-1", restoredAt: restoredAt))
XCTAssertEqual(project.title, "First Title")
XCTAssertEqual(project.idea, "Original idea")
XCTAssertEqual(project.duration, SongDuration(seconds: 180))
XCTAssertEqual(project.genres, [GenreStyle(id: "folk", name: "Folk")])
XCTAssertEqual(project.instruments, [InstrumentTrack(instrumentId: "piano")])
XCTAssertEqual(project.lyrics, Lyrics(text: "Original lyrics"))
XCTAssertEqual(project.createdAt, createdAt)
XCTAssertEqual(project.updatedAt, restoredAt)
XCTAssertEqual(project.versions, versionsBeforeRestore)
XCTAssertEqual(project.version(id: "version-2")?.snapshot.title, "Second Title")
let restoredProject = project
XCTAssertFalse(project.restoreVersion(id: "missing-version", restoredAt: Date(timeIntervalSince1970: 1_000)))
XCTAssertEqual(project, restoredProject)
}
func testGenreStylesAreModeledAsMultiSelect() {
let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop")
let synthwave = GenreStyle(id: "synthwave", name: "Synthwave")
var project = SongProject(
id: "project-3",
title: "Hybrid",
idea: "Blend traditional melody with electronic production"
)
project.selectGenreStyle(arabicPop)
project.selectGenreStyle(synthwave)
project.selectGenreStyle(arabicPop)
XCTAssertEqual(project.genres, [arabicPop, synthwave])
XCTAssertTrue(project.isGenreStyleSelected(arabicPop))
XCTAssertEqual(project.selectedGenreStyleIDs, ["arabic-pop", "synthwave"])
project.deselectGenreStyle(id: arabicPop.id)
XCTAssertEqual(project.genres, [synthwave])
XCTAssertFalse(project.isGenreStyleSelected(arabicPop))
}
func testSongSectionsKeepExplicitOrderAndOptionalTimestamps() {
var project = SongProject(
id: "project-4",
title: "Section Flow",
idea: "Build a classic song structure"
)
let intro = SongSection(id: "intro", type: .intro, title: "Intro")
let verse = SongSection(
id: "verse",
type: .verse,
title: "Verse",
startTime: 15,
endTime: 45
)
let chorus = SongSection(id: "chorus", type: .chorus, title: "Chorus")
project.appendSection(verse)
project.appendSection(chorus)
project.appendSection(intro)
project.moveSection(id: intro.id, toOrder: 0)
XCTAssertEqual(project.orderedSections.map(\.id), ["intro", "verse", "chorus"])
XCTAssertNil(project.orderedSections.first?.startTime)
XCTAssertNil(project.orderedSections.first?.endTime)
XCTAssertEqual(project.orderedSections[1].startTime, 15)
XCTAssertEqual(project.orderedSections[1].endTime, 45)
XCTAssertEqual(project.orderedSections.map(\.order), [0, 1, 2])
}
func testInstrumentsTrackVariantsRolesSelectionAndPlacements() {
var project = SongProject(
id: "project-5",
title: "Instrument Plan",
idea: "Feature oud and violin with clear entries"
)
let oud = InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic oud",
playingStyle: "tremolo",
role: "main hook",
autoArrangementEnabled: false
)
let introPlacement = InstrumentPlacement(
sectionId: "intro",
startTime: 0,
endTime: 12,
direction: "solo intro"
)
let chorusPlacement = InstrumentPlacement(
sectionId: "chorus",
direction: "double the vocal melody"
)
project.selectInstrument(oud)
project.addInstrumentPlacement(introPlacement, to: oud.instrumentId)
project.addInstrumentPlacement(chorusPlacement, to: "violin")
project.configureInstrument(
id: "violin",
variant: "solo violin",
playingStyle: "legato",
role: "countermelody",
autoArrangementEnabled: true
)
XCTAssertEqual(project.selectedInstrumentIDs, ["oud", "violin"])
XCTAssertEqual(project.instrumentTrack(for: "oud")?.variant, "Arabic oud")
XCTAssertEqual(project.instrumentTrack(for: "oud")?.role, "main hook")
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement])
XCTAssertEqual(project.instrumentTrack(for: "violin")?.variant, "solo violin")
XCTAssertEqual(project.instrumentTrack(for: "violin")?.playingStyle, "legato")
XCTAssertEqual(project.instrumentTrack(for: "violin")?.role, "countermelody")
XCTAssertEqual(project.instrumentTrack(for: "violin")?.placements, [chorusPlacement])
project.deselectInstrument(id: "oud")
XCTAssertFalse(project.isInstrumentSelected(id: "oud"))
XCTAssertEqual(project.selectedInstrumentIDs, ["violin"])
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement])
}
func testMultipleVocalistsCanBeAssignedToSections() {
var project = SongProject(
id: "project-6",
title: "Vocal Arrangement",
idea: "Lead and harmony share selected sections"
)
let lead = Vocalist(
id: "lead",
label: "Lead",
voiceType: "tenor",
genderSelection: "male",
performanceStyle: "sung",
assignedSections: ["verse", "verse"]
)
let harmony = Vocalist(
id: "harmony",
label: "Harmony",
voiceType: "alto",
genderSelection: "female",
performanceStyle: "harmony"
)
project.upsertVocalist(lead)
project.upsertVocalist(harmony)
project.assignSection("chorus", toVocalistID: "lead")
project.assignSection("chorus", toVocalistID: "lead")
project.assignSection("chorus", toVocalistID: "harmony")
project.assignSection("bridge", toVocalistID: "harmony")
XCTAssertEqual(project.vocalistIDs, ["lead", "harmony"])
XCTAssertEqual(project.vocalist(for: "lead")?.assignedSections, ["verse", "chorus"])
XCTAssertEqual(project.vocalist(for: "harmony")?.assignedSections, ["chorus", "bridge"])
XCTAssertEqual(project.vocalistsAssigned(toSectionID: "chorus").map(\.id), ["lead", "harmony"])
project.unassignSection("chorus", fromVocalistID: "lead")
XCTAssertEqual(project.vocalist(for: "lead")?.assignedSections, ["verse"])
XCTAssertEqual(project.vocalistsAssigned(toSectionID: "chorus").map(\.id), ["harmony"])
project.removeVocalist(id: "harmony")
XCTAssertNil(project.vocalist(for: "harmony"))
}
func testLanguagesDialectsAndArabicPronunciationSettingsAreModeled() {
var project = SongProject(
id: "project-7",
title: "Multilingual Song",
idea: "Arabic and English hook"
)
let arabic = LanguageSelection(id: "arabic", name: "Arabic")
let english = LanguageSelection(id: "english", name: "English")
let levantine = DialectSelection(id: "levantine", name: "Levantine", languageID: arabic.id)
let gulf = DialectSelection(id: "gulf", name: "Gulf", languageID: arabic.id)
project.upsertLanguage(arabic)
project.upsertLanguage(english)
project.upsertLanguage(LanguageSelection(id: "english", name: "English"))
project.upsertDialect(levantine)
project.upsertDialect(gulf)
project.arabicPronunciationSettings = ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted,
tanweenPolicy: .preserveUserInput,
userNotes: "Preserve dialect phrasing",
preservedSpellings: ["ya habibi"]
)
XCTAssertEqual(project.languageIDs, ["arabic", "english"])
XCTAssertEqual(project.dialectIDs, ["levantine", "gulf"])
XCTAssertEqual(project.language(for: arabic.id), arabic)
XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [levantine, gulf])
XCTAssertTrue(project.arabicPronunciationSettings.isEnabled)
XCTAssertEqual(project.arabicPronunciationSettings.diacritizationPolicy, .pronunciationTargeted)
XCTAssertEqual(project.arabicPronunciationSettings.tanweenPolicy, .preserveUserInput)
project.removeDialect(id: gulf.id)
XCTAssertNil(project.dialect(for: gulf.id))
XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [levantine])
project.removeLanguage(id: arabic.id)
XCTAssertNil(project.language(for: arabic.id))
XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [])
XCTAssertEqual(project.languageIDs, ["english"])
}
func testMusicalParametersSupportManualAndAutoModes() {
var project = SongProject(
id: "project-8",
title: "Musical Parameters",
idea: "Control tempo and harmony"
)
project.setBPMManually(104)
project.setKeyManually("D")
project.setScaleAutomatic()
project.setMaqamManually("Bayati")
XCTAssertEqual(project.bpm, ManualAutoValue(mode: .manual, value: 104))
XCTAssertEqual(project.key, ManualAutoValue(mode: .manual, value: "D"))
XCTAssertEqual(project.scale, ManualAutoValue(mode: .auto))
XCTAssertEqual(project.maqam, ManualAutoValue(mode: .manual, value: "Bayati"))
project.setBPMAutomatic()
project.setKeyAutomatic()
project.setScaleManually("minor")
project.setMaqamAutomatic()
XCTAssertEqual(project.bpm, ManualAutoValue(mode: .auto))
XCTAssertEqual(project.key, ManualAutoValue(mode: .auto))
XCTAssertEqual(project.scale, ManualAutoValue(mode: .manual, value: "minor"))
XCTAssertEqual(project.maqam, ManualAutoValue(mode: .auto))
}
func testEmotionalArcAndProductionDirectionsAreModeled() {
var project = SongProject(
id: "project-9",
title: "Arc",
idea: "Build from intimate to explosive"
)
let verseArc = EmotionalArcPoint(
id: "arc-verse",
sectionID: "verse",
emotion: "intimate",
energy: 3,
direction: "restrained"
)
let chorusArc = EmotionalArcPoint(
id: "arc-chorus",
sectionID: "chorus",
emotion: "euphoric",
energy: 9,
direction: "wide and bright"
)
let verseProduction = ProductionDirection(
id: "prod-verse",
sectionID: "verse",
text: "keep drums minimal"
)
let chorusProduction = ProductionDirection(
id: "prod-chorus",
sectionID: "chorus",
text: "add layered harmonies and bigger percussion"
)
project.upsertEmotionalArcPoint(verseArc)
project.upsertEmotionalArcPoint(chorusArc)
project.upsertEmotionalArcPoint(
EmotionalArcPoint(
id: "arc-verse",
sectionID: "verse",
emotion: "focused",
energy: 4,
direction: "slightly warmer"
)
)
project.upsertProductionDirection(verseProduction)
project.upsertProductionDirection(chorusProduction)
XCTAssertEqual(project.emotionalArc.count, 2)
XCTAssertEqual(project.emotionalArcPoint(for: "arc-verse")?.emotion, "focused")
XCTAssertEqual(project.emotionalArcPoints(forSectionID: "chorus"), [chorusArc])
XCTAssertEqual(project.productionDirections(forSectionID: "verse"), [verseProduction])
XCTAssertEqual(project.productionDirection(for: "prod-chorus"), chorusProduction)
project.removeEmotionalArcPoint(id: "arc-chorus")
project.removeProductionDirection(id: "prod-verse")
XCTAssertNil(project.emotionalArcPoint(for: "arc-chorus"))
XCTAssertNil(project.productionDirection(for: "prod-verse"))
}
func testSongProjectRoundTripsThroughJSONSerialization() throws {
let createdAt = Date(timeIntervalSince1970: 1_700_000_000)
let updatedAt = Date(timeIntervalSince1970: 1_700_000_120)
let generatedAt = Date(timeIntervalSince1970: 1_700_000_240)
let section = SongSection(
id: "verse",
type: .verse,
title: "Verse",
order: 0,
startTime: 8,
endTime: 38,
lyrics: "Draft verse",
emotion: "reflective",
energy: 4,
vocalDirection: "close vocal",
productionDirection: "subtle pad"
)
let instrument = InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic oud",
playingStyle: "picked",
role: "lead motif",
autoArrangementEnabled: false,
placements: [
InstrumentPlacement(
sectionId: section.id,
startTime: section.startTime,
endTime: section.endTime,
direction: "answer the vocal"
)
]
)
var project = SongProject(
id: "serialized-project",
title: "Serializable Song",
idea: "A multilingual cinematic song",
duration: SongDuration(seconds: 210, description: "about three and a half minutes"),
conversationMode: .discuss,
languages: [LanguageSelection(id: "arabic", name: "Arabic")],
dialects: [DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic")],
arabicPronunciationSettings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted,
tanweenPolicy: .preserveUserInput,
userNotes: "Keep dialect phrasing natural",
preservedSpellings: ["habibi"]
),
genres: [GenreStyle(id: "cinematic-pop", name: "Cinematic Pop", aliases: ["orchestral pop"])],
moods: [MoodTag(id: "yearning", name: "Yearning")],
emotionalArc: [
EmotionalArcPoint(
id: "arc-verse",
sectionID: section.id,
emotion: "reflective",
energy: 4,
direction: "slowly opening"
)
],
bpm: ManualAutoValue(mode: .manual, value: 92),
key: ManualAutoValue(mode: .manual, value: "D"),
scale: ManualAutoValue(mode: .auto),
maqam: ManualAutoValue(mode: .manual, value: "Hijaz"),
sections: [section],
instruments: [instrument],
vocalists: [
Vocalist(
id: "lead",
label: "Lead",
voiceType: "tenor",
genderSelection: "male",
performanceStyle: "sung",
assignedSections: [section.id]
)
],
lyrics: Lyrics(text: "Full lyric draft", sectionTexts: [section.id: "Draft verse"]),
productionDirections: [
ProductionDirection(id: "prod-verse", sectionID: section.id, text: "keep percussion sparse")
],
sunoOutput: SunoOutput(
lyricsText: "Full lyric draft",
stylePrompt: "Cinematic pop with oud",
additionalFields: ["style": "cinematic"],
generatedAt: generatedAt
),
createdAt: createdAt,
updatedAt: updatedAt
)
project.versions = [
SongVersion(
id: "version-1",
label: "Initial approved direction",
snapshot: SongProjectSnapshot(project: project),
createdAt: updatedAt
)
]
let encoder = JSONEncoder()
let data = try encoder.encode(project)
let decodedProject = try JSONDecoder().decode(SongProject.self, from: data)
XCTAssertEqual(decodedProject, project)
}
}
@@ -0,0 +1,114 @@
import MusicAssistantCore
import XCTest
final class StructuredAIResponseSchemaTests: XCTestCase {
func testStructuredSongProjectUpdateResponseDecodesFromJSON() throws {
let json = """
{
"project": {
"title": "Updated Song",
"idea": "A brighter chorus",
"conversationMode": "auto",
"languages": [{"id": "english", "name": "English"}],
"dialects": [],
"arabicPronunciationSettings": {
"isEnabled": false,
"diacritizationPolicy": "unspecified",
"tanweenPolicy": "unspecified",
"preservedSpellings": []
},
"genres": [{"id": "pop", "name": "Pop", "aliases": []}],
"moods": [{"id": "hopeful", "name": "Hopeful"}],
"emotionalArc": [],
"sections": [
{
"id": "chorus",
"type": "chorus",
"title": "Chorus",
"order": 0,
"lyrics": "Lift the hook higher"
}
],
"instruments": [
{
"instrumentId": "piano",
"selected": true,
"autoArrangementEnabled": true,
"placements": []
}
],
"vocalists": [],
"lyrics": {
"text": "Lift the hook higher",
"sectionTexts": {"chorus": "Lift the hook higher"}
},
"productionDirections": [],
"sunoOutput": {
"lyricsText": "Lift the hook higher",
"stylePrompt": "Hopeful pop with piano",
"additionalFields": {},
"generatedAt": "2026-09-13T18:00:00Z"
}
},
"changedScopes": ["lyrics", "structure", "sunoOutput"],
"notes": ["Updated requested creative fields."],
"followUpQuestions": ["Should the final chorus repeat?"]
}
""".data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let response = try decoder.decode(StructuredSongProjectUpdateResponse.self, from: json)
XCTAssertEqual(StructuredSongProjectUpdateResponse.schemaName, "song_project_update_response")
XCTAssertEqual(response.project.title, "Updated Song")
XCTAssertEqual(response.project.sections.first?.type, .chorus)
XCTAssertEqual(response.project.instruments.first?.instrumentId, "piano")
XCTAssertEqual(response.changedScopes, [.lyrics, .structure, .sunoOutput])
XCTAssertEqual(response.notes, ["Updated requested creative fields."])
XCTAssertEqual(response.followUpQuestions, ["Should the final chorus repeat?"])
}
func testStructuredSongProjectUpdateResponseCreatesUpdateResultWhilePreservingProjectIdentityAndVersions() {
let createdAt = Date(timeIntervalSince1970: 100)
let updatedAt = Date(timeIntervalSince1970: 200)
var existingProject = SongProject(
id: "project-id",
title: "Original",
idea: "Original idea",
createdAt: createdAt,
updatedAt: createdAt
)
let version = existingProject.createVersion(
id: "version-id",
label: "Before AI update",
createdAt: createdAt
)
let response = StructuredSongProjectUpdateResponse(
project: SongProjectSnapshot(
title: "Updated",
idea: "Updated idea",
genres: [GenreStyle(id: "cinematic", name: "Cinematic")],
lyrics: Lyrics(text: "Updated lyrics")
),
changedScopes: [.genres, .lyrics],
notes: ["Proposed focused update."],
followUpQuestions: ["Keep it minimal?"]
)
let result = response.makeUpdateResult(
updating: existingProject,
updatedAt: updatedAt
)
XCTAssertEqual(result.project.id, "project-id")
XCTAssertEqual(result.project.createdAt, createdAt)
XCTAssertEqual(result.project.updatedAt, updatedAt)
XCTAssertEqual(result.project.versions, [version])
XCTAssertEqual(result.project.title, "Updated")
XCTAssertEqual(result.project.lyrics.text, "Updated lyrics")
XCTAssertEqual(result.changedScopes, [.genres, .lyrics])
XCTAssertEqual(result.notes, ["Proposed focused update."])
XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"])
}
}