Validate SongProject consistency
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user