From f42d7826292b45f3145d33a922303cd4a22d00ce Mon Sep 17 00:00:00 2001 From: diyaa Date: Tue, 15 Sep 2026 12:34:48 +0200 Subject: [PATCH] Validate SongProject consistency --- .../Validation/SongProjectValidator.swift | 83 +++++++++++++++++++ .../SongProjectValidatorTests.swift | 52 ++++++++++++ docs/TASKS.md | 2 +- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 Sources/MusicAssistantCore/Services/Validation/SongProjectValidator.swift create mode 100644 Tests/MusicAssistantCoreTests/SongProjectValidatorTests.swift diff --git a/Sources/MusicAssistantCore/Services/Validation/SongProjectValidator.swift b/Sources/MusicAssistantCore/Services/Validation/SongProjectValidator.swift new file mode 100644 index 0000000..38dc3cf --- /dev/null +++ b/Sources/MusicAssistantCore/Services/Validation/SongProjectValidator.swift @@ -0,0 +1,83 @@ +import Foundation + +public protocol SongProjectValidating: Sendable { + func validate(_ project: SongProject) -> [SongProjectValidationIssue] +} + +public enum SongProjectValidationIssue: Equatable, Sendable { + case invalidDuration + case invalidBPM(Int) + case duplicateSectionID(String) + case invalidSectionTiming(String) + case invalidInstrumentTiming(String) + case unknownSectionReference(String) +} + +public struct SongProjectValidator: SongProjectValidating { + public init() {} + + public func validate(_ project: SongProject) -> [SongProjectValidationIssue] { + var issues: [SongProjectValidationIssue] = [] + let sectionIDs = Set(project.sections.map(\.id)) + + if project.duration?.seconds.map({ $0 <= 0 }) == true { + issues.append(.invalidDuration) + } + + if let bpm = project.bpm?.value, bpm <= 0 { + issues.append(.invalidBPM(bpm)) + } + + for section in project.sections { + if project.sections.filter({ $0.id == section.id }).count > 1 { + append(.duplicateSectionID(section.id), to: &issues) + } + + if invalidTiming(start: section.startTime, end: section.endTime) { + issues.append(.invalidSectionTiming(section.id)) + } + } + + for instrument in project.instruments { + for placement in instrument.placements { + if invalidTiming(start: placement.startTime, end: placement.endTime) { + issues.append(.invalidInstrumentTiming(instrument.instrumentId)) + } + + if let sectionID = placement.sectionId, !sectionIDs.contains(sectionID) { + append(.unknownSectionReference(sectionID), to: &issues) + } + } + } + + for vocalist in project.vocalists { + for sectionID in vocalist.assignedSections where !sectionIDs.contains(sectionID) { + append(.unknownSectionReference(sectionID), to: &issues) + } + } + + for point in project.emotionalArc { + if let sectionID = point.sectionID, !sectionIDs.contains(sectionID) { + append(.unknownSectionReference(sectionID), to: &issues) + } + } + + for direction in project.productionDirections { + if let sectionID = direction.sectionID, !sectionIDs.contains(sectionID) { + append(.unknownSectionReference(sectionID), to: &issues) + } + } + + return issues + } + + private func invalidTiming(start: TimeInterval?, end: TimeInterval?) -> Bool { + guard let start, let end else { return start.map { $0 < 0 } ?? end.map { $0 < 0 } ?? false } + return start < 0 || end < 0 || end <= start + } + + private func append(_ issue: SongProjectValidationIssue, to issues: inout [SongProjectValidationIssue]) { + guard !issues.contains(issue) else { return } + issues.append(issue) + } +} diff --git a/Tests/MusicAssistantCoreTests/SongProjectValidatorTests.swift b/Tests/MusicAssistantCoreTests/SongProjectValidatorTests.swift new file mode 100644 index 0000000..8eddb11 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/SongProjectValidatorTests.swift @@ -0,0 +1,52 @@ +import MusicAssistantCore +import XCTest + +final class SongProjectValidatorTests: XCTestCase { + func testValidatorReportsInvalidProjectRelationshipsAndTiming() { + let project = SongProject( + title: "Invalid Project", + idea: "Validate structure", + duration: SongDuration(seconds: 0), + bpm: ManualAutoValue(mode: .manual, value: 0), + sections: [ + SongSection(id: "verse", type: .verse, title: "Verse", startTime: 12, endTime: 8), + SongSection(id: "verse", type: .chorus, title: "Chorus") + ], + instruments: [ + InstrumentTrack( + instrumentId: "piano", + placements: [InstrumentPlacement(sectionId: "missing", startTime: -1, endTime: 4)] + ) + ], + vocalists: [Vocalist(label: "Lead", assignedSections: ["missing"])] + ) + + let issues = SongProjectValidator().validate(project) + + XCTAssertEqual( + issues, + [ + .invalidDuration, + .invalidBPM(0), + .duplicateSectionID("verse"), + .invalidSectionTiming("verse"), + .invalidInstrumentTiming("piano"), + .unknownSectionReference("missing") + ] + ) + } + + func testValidatorAcceptsConsistentProject() { + let project = SongProject( + title: "Valid Project", + idea: "Validate structure", + duration: SongDuration(seconds: 180), + bpm: ManualAutoValue(mode: .manual, value: 100), + sections: [SongSection(id: "verse", type: .verse, title: "Verse", startTime: 0, endTime: 30)], + instruments: [InstrumentTrack(instrumentId: "piano", placements: [InstrumentPlacement(sectionId: "verse", startTime: 0, endTime: 30)])], + vocalists: [Vocalist(label: "Lead", assignedSections: ["verse"])] + ) + + XCTAssertTrue(SongProjectValidator().validate(project).isEmpty) + } +} diff --git a/docs/TASKS.md b/docs/TASKS.md index 80c14cc..236a3d8 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -112,7 +112,7 @@ requirement is missing and blocks implementation, record it in ## Phase 9 --- Quality -- [ ] Add validation for contradictory/invalid project settings. +- [x] Add validation for contradictory/invalid project settings. - [ ] Add loading, offline and provider-error states. - [ ] Add accessibility labels and keyboard navigation. - [ ] Test project/version recovery.