Initial commit
This commit is contained in:
@@ -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?"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user