Files
music-assistant/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift
T

474 lines
19 KiB
Swift

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 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)
}
}