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
|
## 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 loading, offline and provider-error states.
|
||||||
- [ ] Add accessibility labels and keyboard navigation.
|
- [ ] Add accessibility labels and keyboard navigation.
|
||||||
- [ ] Test project/version recovery.
|
- [ ] Test project/version recovery.
|
||||||
|
|||||||
Reference in New Issue
Block a user