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 testCatalogSelectionCanBeToggledWithoutReplacingExistingArrangement() { let oudPlacement = InstrumentPlacement(sectionId: "intro", direction: "Opening melody") var project = SongProject( id: "catalog-selection-project", title: "Catalog Selection", idea: "Select and deselect instruments", instruments: [ InstrumentTrack( instrumentId: "oud", selected: false, variant: "Arabic oud", playingStyle: "tremolo", role: "main hook", autoArrangementEnabled: false, placements: [oudPlacement] ) ] ) project.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud") XCTAssertTrue(project.isInstrumentSelected(id: "oud")) XCTAssertEqual(project.instrumentTrack(for: "oud")?.variant, "Arabic oud") XCTAssertEqual(project.instrumentTrack(for: "oud")?.role, "main hook") XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [oudPlacement]) project.setInstrumentSelected(id: "oud", isSelected: false) project.setInstrumentSelected(id: "violin", isSelected: true, variant: "Violin") XCTAssertFalse(project.isInstrumentSelected(id: "oud")) XCTAssertTrue(project.isInstrumentSelected(id: "violin")) XCTAssertEqual(project.instrumentTrack(for: "violin")?.variant, "Violin") } 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 testApplyingReviewedArabicLyricsUpdatesMatchingSunoLyrics() { var project = SongProject( id: "project-arabic-review", title: "Arabic Review", idea: "Review pronunciation", lyrics: Lyrics(text: "Original lyrics"), sunoOutput: SunoOutput(lyricsText: "Original lyrics", stylePrompt: "Arabic pop") ) project.applyReviewedArabicLyrics("Reviewed lyrics") XCTAssertEqual(project.lyrics.text, "Reviewed lyrics") XCTAssertEqual(project.sunoOutput?.lyricsText, "Reviewed lyrics") } func testApplyingReviewedArabicLyricsPreservesEditedSunoLyrics() { var project = SongProject( id: "project-arabic-review-manual-output", title: "Arabic Review", idea: "Review pronunciation", lyrics: Lyrics(text: "Original lyrics"), sunoOutput: SunoOutput(lyricsText: "Manually edited output", stylePrompt: "Arabic pop") ) project.applyReviewedArabicLyrics("Reviewed lyrics") XCTAssertEqual(project.lyrics.text, "Reviewed lyrics") XCTAssertEqual(project.sunoOutput?.lyricsText, "Manually edited output") } 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) } }