import MusicAssistantCore import XCTest final class ExistingLyricsRevisionDirectorTests: XCTestCase { func testReviseExistingLyricsBuildsRevisionRequestAndUpdatesProjectLyrics() async throws { let revisedAt = Date(timeIntervalSince1970: 500) let project = SongProject( id: "lyric-project", title: "Lyric Project", idea: "Make the hook stronger", lyrics: Lyrics(text: "Original lines"), createdAt: Date(timeIntervalSince1970: 100), updatedAt: Date(timeIntervalSince1970: 200) ) let aiService = RecordingLyricsAIService( revisionResult: LyricsRevisionResult( lyrics: Lyrics(text: "Revised lines"), notes: ["Improved hook phrasing."] ) ) let director = ExistingLyricsRevisionDirector(aiService: aiService) let conversation = [ AIConversationMessage(role: .user, content: "Keep it direct.") ] let result = try await director.reviseExistingLyrics( in: project, instruction: " Improve the chorus. ", mode: .improve, conversation: conversation, localeIdentifier: "en_US", revisedAt: revisedAt ) let recordedRequest = await aiService.recordedRevisionRequests.first XCTAssertEqual(recordedRequest?.context.userInstruction, "Improve the chorus.") XCTAssertEqual(recordedRequest?.context.conversation, conversation) XCTAssertEqual(recordedRequest?.context.localeIdentifier, "en_US") XCTAssertEqual(recordedRequest?.project, project) XCTAssertEqual(recordedRequest?.sourceLyrics, Lyrics(text: "Original lines")) XCTAssertEqual(recordedRequest?.mode, .improve) XCTAssertEqual(result.project.id, project.id) XCTAssertEqual(result.project.createdAt, project.createdAt) XCTAssertEqual(result.project.lyrics, Lyrics(text: "Revised lines")) XCTAssertEqual(result.project.updatedAt, revisedAt) XCTAssertEqual(result.changedScopes, [.lyrics]) XCTAssertEqual(result.notes, ["Improved hook phrasing."]) } func testReviseExistingLyricsCanUseExplicitSourceLyricsWithoutMutatingOtherProjectFields() async throws { let project = SongProject( title: "Existing Project", idea: "Complete second verse", genres: [GenreStyle(id: "pop", name: "Pop")], lyrics: Lyrics(text: "Stored project lyrics") ) let sourceLyrics = Lyrics(text: "External draft lyrics") let aiService = RecordingLyricsAIService( revisionResult: LyricsRevisionResult( lyrics: Lyrics(text: "Completed external draft") ) ) let director = ExistingLyricsRevisionDirector(aiService: aiService) let result = try await director.reviseExistingLyrics( in: project, sourceLyrics: sourceLyrics, instruction: "Complete this draft.", mode: .complete, revisedAt: Date(timeIntervalSince1970: 900) ) let recordedRequest = await aiService.recordedRevisionRequests.first XCTAssertEqual(recordedRequest?.sourceLyrics, sourceLyrics) XCTAssertEqual(result.project.title, project.title) XCTAssertEqual(result.project.idea, project.idea) XCTAssertEqual(result.project.genres, project.genres) XCTAssertEqual(result.project.lyrics.text, "Completed external draft") } func testReviseExistingLyricsRejectsBlankSourceBeforeCallingAI() async { let aiService = RecordingLyricsAIService( revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused")) ) let director = ExistingLyricsRevisionDirector(aiService: aiService) do { _ = try await director.reviseExistingLyrics( in: SongProject(title: "Blank", idea: "Blank", lyrics: Lyrics(text: " \n ")), instruction: "Improve.", mode: .improve ) XCTFail("Expected blank source lyrics to throw.") } catch let error as ExistingLyricsRevisionDirectorError { XCTAssertEqual(error, .emptySourceLyrics) } catch { XCTFail("Expected ExistingLyricsRevisionDirectorError.") } let requestCount = await aiService.recordedRevisionRequests.count XCTAssertEqual(requestCount, 0) } func testReviseExistingLyricsRejectsBlankInstructionAndWriteFromIdeaMode() async { let aiService = RecordingLyricsAIService( revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused")) ) let director = ExistingLyricsRevisionDirector(aiService: aiService) let project = SongProject(title: "Song", idea: "Idea", lyrics: Lyrics(text: "Draft")) do { _ = try await director.reviseExistingLyrics( in: project, instruction: " ", mode: .correct ) XCTFail("Expected blank instruction to throw.") } catch let error as ExistingLyricsRevisionDirectorError { XCTAssertEqual(error, .emptyInstruction) } catch { XCTFail("Expected ExistingLyricsRevisionDirectorError.") } do { _ = try await director.reviseExistingLyrics( in: project, instruction: "Write from idea.", mode: .writeFromIdea ) XCTFail("Expected unsupported mode to throw.") } catch let error as ExistingLyricsRevisionDirectorError { XCTAssertEqual(error, .unsupportedMode(.writeFromIdea)) } catch { XCTFail("Expected ExistingLyricsRevisionDirectorError.") } let requestCount = await aiService.recordedRevisionRequests.count XCTAssertEqual(requestCount, 0) } func testReviseExistingLyricsDoesNotCallAIWhenLyricsAreLocked() async throws { let aiService = RecordingLyricsAIService( revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused")) ) let director = ExistingLyricsRevisionDirector(aiService: aiService) let project = SongProject( title: "Locked Lyrics", idea: "Keep the words", lyrics: Lyrics(text: "User-approved lyrics"), userLocks: SongProjectUserLocks(scopes: [.lyrics]) ) let result = try await director.reviseExistingLyrics( in: project, instruction: "Improve the chorus.", mode: .improve ) let requestCount = await aiService.recordedRevisionRequests.count XCTAssertEqual(requestCount, 0) XCTAssertEqual(result.project, project) XCTAssertTrue(result.changedScopes.isEmpty) } } private actor RecordingLyricsAIService: AIService { private(set) var recordedRevisionRequests: [LyricsRevisionRequest] = [] private let revisionResult: LyricsRevisionResult init(revisionResult: LyricsRevisionResult) { self.revisionResult = revisionResult } 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 { recordedRevisionRequests.append(request) return revisionResult } func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult { SongProjectUpdateResult(project: request.project) } }