Enforce user locks for AI updates

This commit is contained in:
diyaa
2026-09-13 21:20:57 +02:00
parent a39bdbe6fc
commit ae88c6e533
15 changed files with 507 additions and 59 deletions
@@ -188,6 +188,29 @@ final class AutoSongProjectDecisionsDirectorTests: XCTestCase {
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 {
@@ -137,6 +137,30 @@ final class ExistingLyricsRevisionDirectorTests: XCTestCase {
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 {
@@ -105,6 +105,42 @@ final class SongProjectGenerationDirectorTests: XCTestCase {
let requestCount = await aiService.recordedGenerationRequests.count
XCTAssertEqual(requestCount, 0)
}
func testGenerateCompleteProjectPreservesLockedAndManualSeedValues() async throws {
let seedProject = SongProject(
id: "locked-seed",
title: "Seed",
idea: "Original",
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
bpm: ManualAutoValue(mode: .manual, value: 96),
lyrics: Lyrics(text: "User-approved lyrics"),
userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics])
)
let aiService = RecordingGenerationAIService(
generationResult: SongProjectGenerationResult(
project: SongProject(
title: "Generated Title",
idea: "Generated idea",
genres: [GenreStyle(id: "rock", name: "Rock")],
bpm: ManualAutoValue(mode: .auto, value: 120),
lyrics: Lyrics(text: "AI lyrics")
)
)
)
let director = SongProjectGenerationDirector(aiService: aiService)
let result = try await director.generateCompleteProject(
fromIdea: "Generate around the locked choices",
seedProject: seedProject
)
XCTAssertEqual(result.project.title, "Generated Title")
XCTAssertEqual(result.project.idea, "Generated idea")
XCTAssertEqual(result.project.genres, seedProject.genres)
XCTAssertEqual(result.project.lyrics, seedProject.lyrics)
XCTAssertEqual(result.project.bpm, seedProject.bpm)
XCTAssertEqual(result.project.userLocks, seedProject.userLocks)
}
}
private actor RecordingGenerationAIService: AIService {
@@ -105,10 +105,69 @@ final class StructuredAIResponseSchemaTests: XCTestCase {
XCTAssertEqual(result.project.createdAt, createdAt)
XCTAssertEqual(result.project.updatedAt, updatedAt)
XCTAssertEqual(result.project.versions, [version])
XCTAssertEqual(result.project.title, "Updated")
XCTAssertEqual(result.project.title, "Original")
XCTAssertEqual(result.project.lyrics.text, "Updated lyrics")
XCTAssertEqual(result.changedScopes, [.genres, .lyrics])
XCTAssertEqual(result.notes, ["Proposed focused update."])
XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"])
}
func testStructuredResponsePreservesLockedScopesAndManualValues() {
let section = SongSection(id: "verse", type: .verse, title: "Manual Verse")
let manualInstrument = InstrumentTrack(
instrumentId: "oud",
selected: true,
role: "Manual lead",
autoArrangementEnabled: false,
placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")]
)
let existingProject = SongProject(
title: "Original Title",
idea: "Original Idea",
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
bpm: ManualAutoValue(mode: .manual, value: 96),
key: ManualAutoValue(mode: .auto),
structureMode: .manual,
sections: [section],
instruments: [manualInstrument],
lyrics: Lyrics(text: "Locked lyrics"),
userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics])
)
let response = StructuredSongProjectUpdateResponse(
project: SongProjectSnapshot(
title: "AI Title",
idea: "AI Idea",
genres: [GenreStyle(id: "rock", name: "Rock")],
bpm: ManualAutoValue(mode: .auto, value: 120),
key: ManualAutoValue(mode: .auto, value: "A"),
sections: [SongSection(id: "chorus", type: .chorus, title: "AI Chorus")],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
role: "AI lead",
placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI entry")]
)
],
lyrics: Lyrics(text: "AI lyrics")
),
changedScopes: [.genres, .lyrics, .instruments, .musicalParameters, .structure, .arrangement]
)
let result = response.makeUpdateResult(
updating: existingProject,
updatedAt: Date(timeIntervalSince1970: 500)
)
XCTAssertEqual(result.project.title, "Original Title")
XCTAssertEqual(result.project.idea, "Original Idea")
XCTAssertEqual(result.project.genres, existingProject.genres)
XCTAssertEqual(result.project.lyrics, existingProject.lyrics)
XCTAssertEqual(result.project.bpm, existingProject.bpm)
XCTAssertEqual(result.project.key, ManualAutoValue(mode: .auto, value: "A"))
XCTAssertEqual(result.project.sections, existingProject.sections)
XCTAssertEqual(result.project.instruments, existingProject.instruments)
XCTAssertEqual(result.project.userLocks, existingProject.userLocks)
XCTAssertEqual(result.changedScopes, [.musicalParameters])
}
}