import MusicAssistantCore import XCTest final class AutoSongProjectDecisionsDirectorTests: XCTestCase { func testAutomaticDecisionsRequestEnabledScopesAndPreserveManualChoices() async throws { let currentProject = SongProject( id: "auto-project", title: "Current Project", idea: "Build a cinematic Arabic pop song", bpm: ManualAutoValue(mode: .auto), key: ManualAutoValue(mode: .manual, value: "D"), scale: ManualAutoValue(mode: .auto), maqam: ManualAutoValue(mode: .manual, value: "Hijaz"), structureMode: .manual, sections: [ SongSection(id: "verse", type: .verse, title: "Manual Verse", productionDirection: "Dry vocal") ], instruments: [ InstrumentTrack( instrumentId: "piano", selected: true, role: "Manual piano role", autoArrangementEnabled: false, placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")] ), InstrumentTrack( instrumentId: "violin", selected: true, autoArrangementEnabled: true ) ], productionDirections: [ProductionDirection(id: "manual-production", sectionID: "verse", text: "Keep it dry")], productionMode: .manual, createdAt: Date(timeIntervalSince1970: 10), updatedAt: Date(timeIntervalSince1970: 20) ) let proposedProject = SongProject( title: "Proposed Project", idea: "Different", bpm: ManualAutoValue(mode: .manual, value: 102), key: ManualAutoValue(mode: .auto, value: "A"), scale: ManualAutoValue(mode: .manual, value: "minor"), maqam: ManualAutoValue(mode: .auto, value: "Bayati"), sections: [ SongSection(id: "chorus", type: .chorus, title: "AI Chorus", productionDirection: "Wide synths") ], instruments: [ InstrumentTrack( instrumentId: "piano", selected: true, role: "AI piano role", placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI piano entry")] ), InstrumentTrack( instrumentId: "violin", selected: true, playingStyle: "legato", role: "AI countermelody", placements: [InstrumentPlacement(sectionId: "chorus", direction: "Enter at chorus")] ) ], productionDirections: [ProductionDirection(id: "ai-production", sectionID: "chorus", text: "Wide synths")] ) let aiService = RecordingAutoDecisionsAIService( result: SongProjectUpdateResult( project: proposedProject, changedScopes: [.structure, .arrangement, .musicalParameters, .productionDirections], notes: ["Applied automatic decisions."] ) ) let director = AutoSongProjectDecisionsDirector(aiService: aiService) let result = try await director.applyAutomaticDecisions( to: currentProject, instruction: " Complete automatic values. ", localeIdentifier: "en_US", updatedAt: Date(timeIntervalSince1970: 100) ) let request = await aiService.recordedRequests.first XCTAssertEqual(request?.context.userInstruction, "Complete automatic values.") XCTAssertEqual(request?.context.localeIdentifier, "en_US") XCTAssertEqual( request?.allowedScopes ?? [], [SongProjectUpdateScope.arrangement, .musicalParameters] ) XCTAssertEqual(result.project.sections, currentProject.sections) XCTAssertEqual(result.project.productionDirections, currentProject.productionDirections) XCTAssertEqual(result.project.bpm, ManualAutoValue(mode: .auto, value: 102)) XCTAssertEqual(result.project.key, currentProject.key) XCTAssertEqual(result.project.scale, ManualAutoValue(mode: .auto, value: "minor")) XCTAssertEqual(result.project.maqam, currentProject.maqam) XCTAssertEqual(result.project.instrumentTrack(for: "piano"), currentProject.instrumentTrack(for: "piano")) XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.role, "AI countermelody") XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.playingStyle, "legato") XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.placements, [InstrumentPlacement(sectionId: "chorus", direction: "Enter at chorus")]) XCTAssertEqual(result.project.updatedAt, Date(timeIntervalSince1970: 100)) XCTAssertEqual( result.changedScopes, [SongProjectUpdateScope.arrangement, .musicalParameters] ) XCTAssertEqual(result.notes, ["Applied automatic decisions."]) } func testAutomaticDecisionsApplyStructureAndProductionWhenTheirModesAreAutomatic() async throws { let currentProject = SongProject( title: "Auto Project", idea: "Let AI plan it", structureMode: .auto, sections: [SongSection(id: "intro", type: .intro, title: "Current Intro")], productionMode: .auto ) let proposedProject = SongProject( title: "Proposed", idea: "Proposed", sections: [SongSection(id: "verse", type: .verse, title: "AI Verse", productionDirection: "Add texture")], productionDirections: [ProductionDirection(id: "production", sectionID: "verse", text: "Add texture")] ) let aiService = RecordingAutoDecisionsAIService( result: SongProjectUpdateResult(project: proposedProject) ) let director = AutoSongProjectDecisionsDirector(aiService: aiService) let result = try await director.applyAutomaticDecisions(to: currentProject, updatedAt: Date(timeIntervalSince1970: 200)) let request = await aiService.recordedRequests.first XCTAssertEqual( request?.allowedScopes ?? [], [.structure, .musicalParameters, .productionDirections] ) XCTAssertEqual(result.project.sections, proposedProject.sections) XCTAssertEqual(result.project.productionDirections, proposedProject.productionDirections) XCTAssertEqual(result.changedScopes, [.structure, .productionDirections]) } func testAutomaticDecisionsDoNotCallAIWhenEveryDecisionIsManual() async throws { let project = SongProject( title: "Manual Project", idea: "Keep all choices", bpm: ManualAutoValue(mode: .manual, value: 96), key: ManualAutoValue(mode: .manual, value: "C"), scale: ManualAutoValue(mode: .manual, value: "major"), maqam: ManualAutoValue(mode: .manual, value: "Rast"), structureMode: .manual, instruments: [InstrumentTrack(instrumentId: "oud", selected: true, autoArrangementEnabled: false)], productionMode: .manual ) let aiService = RecordingAutoDecisionsAIService( result: SongProjectUpdateResult(project: project) ) let director = AutoSongProjectDecisionsDirector(aiService: aiService) let result = try await director.applyAutomaticDecisions(to: project) let requestCount = await aiService.recordedRequests.count XCTAssertEqual(requestCount, 0) XCTAssertEqual(result.project, project) XCTAssertTrue(result.changedScopes.isEmpty) XCTAssertEqual(result.notes, ["No automatic decisions are enabled."]) } func testAutomaticStructureDoesNotReplaceManualProductionDirections() async throws { let project = SongProject( title: "Mixed Modes", idea: "Change the structure only", bpm: ManualAutoValue(mode: .manual, value: 100), key: ManualAutoValue(mode: .manual, value: "C"), scale: ManualAutoValue(mode: .manual, value: "minor"), maqam: ManualAutoValue(mode: .manual, value: "Rast"), structureMode: .auto, sections: [SongSection(id: "verse", type: .verse, title: "Current Verse", productionDirection: "Keep dry")], productionDirections: [ProductionDirection(id: "manual-production", sectionID: "verse", text: "Keep dry")], productionMode: .manual ) let proposedProject = SongProject( title: "Proposed", idea: "Proposed", sections: [SongSection(id: "verse", type: .chorus, title: "AI Chorus", productionDirection: "Add wide synths")], productionDirections: [ProductionDirection(id: "ai-production", sectionID: "verse", text: "Add wide synths")] ) let aiService = RecordingAutoDecisionsAIService(result: SongProjectUpdateResult(project: proposedProject)) let director = AutoSongProjectDecisionsDirector(aiService: aiService) let result = try await director.applyAutomaticDecisions(to: project) XCTAssertEqual(result.project.sections.first?.type, .chorus) XCTAssertEqual(result.project.sections.first?.productionDirection, "Keep dry") XCTAssertEqual(result.project.productionDirections, project.productionDirections) XCTAssertEqual(result.changedScopes, [SongProjectUpdateScope.structure]) } func testAutomaticDecisionsDoNotCallAIForLockedAutomaticScopes() async throws { let project = SongProject( title: "Locked Auto Project", idea: "Do not update the tempo", bpm: ManualAutoValue(mode: .auto), key: ManualAutoValue(mode: .manual, value: "C"), scale: ManualAutoValue(mode: .manual, value: "minor"), maqam: ManualAutoValue(mode: .manual, value: "Rast"), structureMode: .manual, productionMode: .manual, userLocks: SongProjectUserLocks(scopes: [.musicalParameters]) ) let aiService = RecordingAutoDecisionsAIService(result: SongProjectUpdateResult(project: project)) let director = AutoSongProjectDecisionsDirector(aiService: aiService) let result = try await director.applyAutomaticDecisions(to: project) let requestCount = await aiService.recordedRequests.count XCTAssertEqual(requestCount, 0) XCTAssertEqual(result.project, project) XCTAssertTrue(result.changedScopes.isEmpty) } } private actor RecordingAutoDecisionsAIService: AIService { private(set) var recordedRequests: [SongProjectUpdateRequest] = [] private let result: SongProjectUpdateResult init(result: SongProjectUpdateResult) { self.result = result } func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult { SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused")) } func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult { LyricsRevisionResult(lyrics: request.sourceLyrics) } func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult { recordedRequests.append(request) return result } }