Files

3388 lines
270 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import XCTest
import SwiftUI
import AVFoundation
import AppKit
import UniformTypeIdentifiers
import CoreMIDI
@testable import KJamsClone
final class KJamsCloneTests: XCTestCase {
func testSMBImportAddressValidation() throws {
XCTAssertEqual(try SMBImportAddress.url(" smb://nas/Music ").host,"nas")
XCTAssertEqual(try SMBImportAddress.url("smb://192.168.1.20/Karaoke/Nested").path,"/Karaoke/Nested")
for address in ["", "smb://", "https://nas/Music", "file:///Volumes/Music", "smb://user:secret@nas/Music", "smb://user@nas/Music", "smb://nas/Music?password=secret", "smb://nas/Music#secret"] {
XCTAssertThrowsError(try SMBImportAddress.url(address))
}
}
func testRecursiveFolderImportPairsDeduplicatesAndSkipsManagedMedia() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let nested = root.appendingPathComponent("Input/Artist/Album")
try FileManager.default.createDirectory(at:nested,withIntermediateDirectories:true)
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let audio = nested.appendingPathComponent("Song.MP3")
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:audio)
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.cdg"),to:nested.appendingPathComponent("song.CDG"))
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.zip"),to:root.appendingPathComponent("Input/Archive.zip"))
try Data("ignored".utf8).write(to:nested.appendingPathComponent("Notes.txt"))
try FileManager.default.createSymbolicLink(at:nested.appendingPathComponent("Loop"),withDestinationURL:root)
let media = root.appendingPathComponent("Input/Managed")
try FileManager.default.createDirectory(at:media,withIntermediateDirectories:true)
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("TestTrack.mp3"),to:media.appendingPathComponent("Existing.mp3"))
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:media)
var updates = [ImportProgress]()
let ids = try importer.run([root.appendingPathComponent("Input"),nested,audio],library:db,progress:{updates.append($0)})
XCTAssertEqual(ids.count,1);XCTAssertEqual(try db.songs().count,1)
XCTAssertNotNil(try db.songs().first?.cdg)
XCTAssertEqual(updates.last?.completed,2);XCTAssertEqual(updates.last?.total,2)
XCTAssertTrue(updates.contains{$0.current.hasPrefix("Scanning ")})
var latest = ImportProgress()
XCTAssertEqual(try importer.run([root.appendingPathComponent("Input")],library:db,progress:{latest = $0}),ids)
XCTAssertEqual(latest.skipped,2);XCTAssertTrue(latest.finished)
}
func testFolderDiscoveryCancellationEmptyFolderAndMissingInput() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media"))
XCTAssertThrowsError(try importer.run([root],library:db,cancelled:{true})) { XCTAssertTrue($0 is CancellationError) }
XCTAssertThrowsError(try importer.run([root],library:db)) { XCTAssertTrue($0.localizedDescription.contains("No audio found")) }
XCTAssertThrowsError(try importer.run([root.appendingPathComponent("Missing")],library:db)) { XCTAssertTrue($0.localizedDescription.contains("Missing")) }
XCTAssertTrue(try db.songs().isEmpty)
}
@MainActor func testLargeImportReturnsImmediatelyAndReportsCompletion() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let inputs = root.appendingPathComponent("Inputs");try FileManager.default.createDirectory(at:inputs,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
var urls = [URL]()
for index in 0..<48 { let url = inputs.appendingPathComponent("Track \(index).mp3");try FileManager.default.copyItem(at:fixture,to:url);urls.append(url) }
let model = AppModel(storageURL:root.appendingPathComponent("Library"))
model.importFiles(urls)
XCTAssertTrue(model.isImporting,"Import must return to the UI before processing the files")
XCTAssertTrue(model.songs.isEmpty)
var heartbeat = false
Task { @MainActor in heartbeat = true }
for _ in 0..<500 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(heartbeat);XCTAssertFalse(model.isImporting)
XCTAssertEqual(model.importProgress?.completed,48);XCTAssertEqual(model.importProgress?.total,48)
XCTAssertEqual(model.importProgress?.finished,true);XCTAssertNil(model.importProgress?.failure)
XCTAssertEqual(model.songs.count,1,"Identical audio still deduplicates")
model.dismissImport();XCTAssertNil(model.importProgress)
}
func testImportCancellationReportsSavedPartialWork() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("Library.sqlite"))
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
var cancel = false,latest = ImportProgress()
XCTAssertThrowsError(try Importer(media:root.appendingPathComponent("Media")).run([fixtures.appendingPathComponent("Demo.mp3"),fixtures.appendingPathComponent("TestTrack.mp3")],library:db,cancelled:{cancel},progress:{ latest = $0;if $0.completed == 1 { cancel = true } })) { XCTAssertTrue($0 is CancellationError) }
XCTAssertEqual(latest.completed,1);XCTAssertEqual(latest.total,2);XCTAssertEqual(try db.songs().count,1)
}
@MainActor func testImportFailureAndCancellationFinishTheDialog() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
model.importFiles([root.appendingPathComponent("missing.mp3")])
for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.isImporting);XCTAssertEqual(model.importProgress?.finished,true);XCTAssertNotNil(model.importProgress?.failure)
model.dismissImport()
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
model.importFiles([fixture]);model.cancelImport()
for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.isImporting);XCTAssertEqual(model.importProgress?.cancelled,true)
model.dismissImport();XCTAssertNil(model.importProgress)
}
func testImportHistoryPersistsAndSkipsUnchangedTracksAndRenamedDuplicates() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let source = root.appendingPathComponent("Original.mp3");try FileManager.default.copyItem(at:fixture,to:source)
let database = root.appendingPathComponent("Library.sqlite"),media = root.appendingPathComponent("Media"),importer = Importer(media:media)
let db = try Library(url:database)
let first = try importer.run([source],library:db)
let id = try XCTUnwrap(first.first);try db.rate(id,5)
let stored = try XCTUnwrap(db.song(id));let modified = try FileManager.default.attributesOfItem(atPath:stored.audio)[.modificationDate] as? Date
let reopened = try Library(url:database);var progress = ImportProgress()
XCTAssertEqual(try importer.run([source],library:reopened,progress:{progress = $0}),first)
XCTAssertEqual(progress.completed,1);XCTAssertEqual(progress.skipped,1)
let renamed = root.appendingPathComponent("Renamed.mp3");try FileManager.default.copyItem(at:source,to:renamed)
XCTAssertEqual(try importer.run([renamed],library:reopened,progress:{progress = $0}),first)
XCTAssertEqual(progress.skipped,1);XCTAssertEqual(try reopened.songs().count,1)
XCTAssertEqual(try reopened.song(id)?.rating,5)
XCTAssertEqual(try FileManager.default.attributesOfItem(atPath:stored.audio)[.modificationDate] as? Date,modified)
try FileManager.default.removeItem(at:URL(fileURLWithPath:stored.audio))
_ = try importer.run([source],library:reopened,progress:{progress = $0})
XCTAssertEqual(progress.skipped,0);XCTAssertTrue(FileManager.default.fileExists(atPath:stored.audio))
}
func testUnchangedArchiveSkipsExtractionAndMissingGraphicsCanBeRestored() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
let importer = Importer(media:root.appendingPathComponent("Media")),db = try Library(url:root.appendingPathComponent("Library.sqlite"))
let ids = try importer.run([fixture],library:db);var updates = [ImportProgress]()
XCTAssertEqual(try importer.run([fixture],library:db,progress:{updates.append($0)}),ids)
XCTAssertFalse(updates.contains{$0.current.hasPrefix("Extracting ")});XCTAssertEqual(updates.last?.skipped,1)
let cdg = try XCTUnwrap(try db.songs().first?.cdg);try FileManager.default.removeItem(atPath:cdg)
updates = [];_ = try importer.run([fixture],library:db,progress:{updates.append($0)})
XCTAssertTrue(updates.contains{$0.current.hasPrefix("Extracting ")});XCTAssertEqual(updates.last?.skipped,0)
XCTAssertTrue(FileManager.default.fileExists(atPath:try XCTUnwrap(try db.songs().first?.cdg)))
}
func testChangedAudioAndGraphicsAreRechecked() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let audio = root.appendingPathComponent("Track.mp3"),cdg = root.appendingPathComponent("Track.cdg")
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:audio)
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.cdg"),to:cdg)
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media"))
let ids = try importer.run([audio],library:db)
let originalGraphics = try XCTUnwrap(try db.song(ids[0])?.cdg)
let changedGraphics = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg"));try changedGraphics.write(to:cdg)
var progress = ImportProgress()
XCTAssertEqual(try importer.run([audio],library:db,progress:{progress = $0}),ids);XCTAssertEqual(progress.skipped,0)
let newGraphics = try XCTUnwrap(try db.song(ids[0])?.cdg)
XCTAssertNotEqual(originalGraphics,newGraphics);XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:newGraphics)),changedGraphics)
try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.mp3")).write(to:audio)
_ = try importer.run([audio],library:db,progress:{progress = $0});XCTAssertEqual(progress.skipped,0);XCTAssertEqual(try db.songs().count,2)
}
func testArchiveDeduplicationStillCreatesManagedMediaForReferencedAudio() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),media = root.appendingPathComponent("Media"),importer = Importer(media:media)
let ids = try importer.run([fixtures.appendingPathComponent("Demo.mp3")],library:db,copyMedia:false)
_ = try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db,copyMedia:false)
XCTAssertEqual(try db.songs().count,1)
let song = try XCTUnwrap(try db.song(ids[0]));XCTAssertEqual(URL(fileURLWithPath:song.audio).deletingLastPathComponent().standardizedFileURL,media.standardizedFileURL)
XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertNotNil(song.cdg)
}
func testLegacyEncodedZipNamesImportMatchedAudioAndGraphics() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
// A valid stored ZIP with an unflagged 0xE9 filename byte, matching older
// Windows karaoke archives. No commercial media is part of this test.
func number(_ value:UInt32,_ count:Int) -> Data { Data((0..<count).map { UInt8(truncatingIfNeeded:value >> (8*$0)) }) }
func crc(_ data:Data) -> UInt32 {
var value = UInt32.max
for byte in data { value ^= UInt32(byte);for _ in 0..<8 { value = value & 1 == 0 ? value >> 1 : (value >> 1) ^ 0xEDB88320 } }
return ~value
}
var zip = Data(),central = Data()
for ext in ["mp3","cdg"] {
let name = Data("Original - Caf".utf8)+Data([0xE9])+Data(("."+ext).utf8)
let contents = try Data(contentsOf:fixtures.appendingPathComponent("Demo."+ext)),size = UInt32(contents.count),checksum = crc(contents),offset = UInt32(zip.count)
zip += number(0x04034b50,4)+number(20,2)+number(0,2)+number(0,2)+number(0,2)+number(0,2)
zip += number(checksum,4)+number(size,4)+number(size,4)+number(UInt32(name.count),2)+number(0,2)+name+contents
central += number(0x02014b50,4)+number(20,2)+number(20,2)+number(0,2)+number(0,2)+number(0,2)+number(0,2)
central += number(checksum,4)+number(size,4)+number(size,4)
central += number(UInt32(name.count),2)+number(0,2)+number(0,2)
central += number(0,2)+number(0,2)+number(0,4)+number(offset,4)
central += name
}
let centralOffset = UInt32(zip.count);zip += central
zip += number(0x06054b50,4)+number(0,2)+number(0,2)+number(2,2)+number(2,2)+number(UInt32(central.count),4)+number(centralOffset,4)+number(0,2)
let archive = root.appendingPathComponent("Legacy.zip");try zip.write(to:archive)
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media"))
let ids = try importer.run([archive],library:db);XCTAssertEqual(ids.count,1)
let song = try XCTUnwrap(try db.song(ids[0]));let graphics = try XCTUnwrap(song.cdg)
XCTAssertTrue(song.title.contains("Café"),"Legacy filenames must be renamed to Unicode before import")
XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:song.audio)),try Data(contentsOf:fixtures.appendingPathComponent("Demo.mp3")))
XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:graphics)),try Data(contentsOf:fixtures.appendingPathComponent("Demo.cdg")))
var progress = ImportProgress();_ = try importer.run([archive],library:db,progress:{progress = $0})
XCTAssertEqual(progress.skipped,1);XCTAssertEqual(try db.songs().count,1)
}
@MainActor func testMediaFolderChoicePersistsAndFutureImportsUseIt() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root.appendingPathComponent("Library")),external = root.appendingPathComponent("External Media")
try model.setMediaDirectory(external)
let reopened = AppModel(storageURL:root.appendingPathComponent("Library"))
XCTAssertEqual(reopened.managedMediaDirectory.resolvingSymlinksInPath().path,external.resolvingSymlinksInPath().path)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
reopened.importFiles([fixture])
for _ in 0..<300 { if !reopened.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(reopened.isImporting);XCTAssertNil(reopened.importProgress?.failure)
let song = try XCTUnwrap(reopened.songs.first)
XCTAssertTrue(URL(fileURLWithPath:song.audio).resolvingSymlinksInPath().path.hasPrefix(external.resolvingSymlinksInPath().path+"/"));XCTAssertTrue(URL(fileURLWithPath:try XCTUnwrap(song.cdg)).resolvingSymlinksInPath().path.hasPrefix(external.resolvingSymlinksInPath().path+"/"))
XCTAssertFalse(FileManager.default.fileExists(atPath:root.appendingPathComponent("Library/Media").path))
}
func testVerifiedMoveUpdatesLinksPreservesSingerPreferencesAndFreesOriginals() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let database = root.appendingPathComponent("Library.sqlite"),source = root.appendingPathComponent("Media"),target = root.appendingPathComponent("External")
let db = try Library(url:database),fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
let ids = try Importer(media:source).run([fixture],library:db),id = try XCTUnwrap(ids.first)
try db.addEntity("singers",name:"Alex");let singer = try XCTUnwrap(try db.entities("singers").first?.id);try db.assignSong(id,singer:singer)
try db.savePreference(song:id,singer:singer,value:.init(pitch:3,tempo:1.1,rating:4));try db.rate(id,5)
let before = try XCTUnwrap(try db.song(id)),audio = try Data(contentsOf:URL(fileURLWithPath:before.audio))
var studio = StudioSettings();studio.backgroundPath = before.audio
try JSONEncoder().encode(studio).write(to:root.appendingPathComponent("StudioSettings.json"))
let result = try ManagedMediaStorage.move(database:database,roots:[source],destination:target)
XCTAssertTrue(result.finished);XCTAssertNil(result.failure);XCTAssertEqual(result.completed,2)
let after = try XCTUnwrap(try db.song(id));XCTAssertEqual(after.id,id);XCTAssertEqual(after.rating,5)
XCTAssertTrue(after.audio.hasPrefix(target.resolvingSymlinksInPath().path+"/"))
let movedSettings = try JSONDecoder().decode(StudioSettings.self,from:Data(contentsOf:root.appendingPathComponent("StudioSettings.json")))
XCTAssertEqual(movedSettings.backgroundPath,after.audio)
XCTAssertEqual(try Data(contentsOf:URL(fileURLWithPath:after.audio)),audio)
XCTAssertFalse(FileManager.default.fileExists(atPath:before.audio));XCTAssertFalse(FileManager.default.fileExists(atPath:try XCTUnwrap(before.cdg)))
XCTAssertEqual(try db.preference(song:id,singer:singer).pitch,3)
var progress = ImportProgress();_ = try Importer(media:target).run([fixture],library:db,progress:{progress = $0})
XCTAssertEqual(progress.skipped,1)
}
func testCancelledMoveKeepsOriginalMediaAndDatabaseLinks() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let database = root.appendingPathComponent("Library.sqlite"),source = root.appendingPathComponent("Media"),target = root.appendingPathComponent("External")
let db = try Library(url:database),fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
_ = try Importer(media:source).run([fixture],library:db);let before = try XCTUnwrap(try db.songs().first)
var cancel = false
XCTAssertThrowsError(try ManagedMediaStorage.move(database:database,roots:[source],destination:target,cancelled:{cancel},progress:{if $0.completed == 1 { cancel = true }})) { XCTAssertTrue($0 is CancellationError) }
XCTAssertEqual(try db.song(before.id)?.audio,before.audio);XCTAssertTrue(FileManager.default.fileExists(atPath:before.audio))
XCTAssertTrue(FileManager.default.fileExists(atPath:try XCTUnwrap(before.cdg)));XCTAssertNil(try db.setting(MediaStorageLocation.key))
let retried = try ManagedMediaStorage.move(database:database,roots:[source],destination:target);XCTAssertNil(retried.failure)
XCTAssertFalse(FileManager.default.fileExists(atPath:before.audio))
}
func testMoveConflictDoesNotOverwriteDestinationOrDeleteOriginal() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let database = root.appendingPathComponent("Library.sqlite"),source = root.appendingPathComponent("Media"),target = root.appendingPathComponent("External")
let db = try Library(url:database),fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
_ = try Importer(media:source).run([fixture],library:db);let song = try XCTUnwrap(try db.songs().first)
try FileManager.default.createDirectory(at:target,withIntermediateDirectories:true)
let conflict = target.appendingPathComponent(URL(fileURLWithPath:song.audio).lastPathComponent),different = Data("Other media".utf8);try different.write(to:conflict)
XCTAssertThrowsError(try ManagedMediaStorage.move(database:database,roots:[source],destination:target))
XCTAssertEqual(try Data(contentsOf:conflict),different);XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertEqual(try db.song(song.id)?.audio,song.audio)
}
func testUnavailableExternalDriveDoesNotCreateALocalFallbackFolder() {
let path = URL(fileURLWithPath:"/Volumes/KJMix-Offline-"+UUID().uuidString+"/Media")
XCTAssertThrowsError(try ManagedMediaStorage.prepare(path))
XCTAssertFalse(FileManager.default.fileExists(atPath:path.path))
}
@MainActor func testMediaMoveCollectsPreviousImportFolders() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root.appendingPathComponent("Library")),a = root.appendingPathComponent("External A"),b = root.appendingPathComponent("External B")
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
model.importFiles([fixtures.appendingPathComponent("Demo.mp3")])
for _ in 0..<300 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
model.dismissImport();try model.setMediaDirectory(a)
model.importFiles([fixtures.appendingPathComponent("TestTrack.mp3")])
for _ in 0..<300 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
model.dismissImport();let ids = Set(model.songs.map(\.id));model.moveMedia(to:b)
for _ in 0..<300 { if !model.movingMedia { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.movingMedia);XCTAssertNil(model.storageProgress?.failure)
XCTAssertEqual(Set(model.songs.map(\.id)),ids);XCTAssertEqual(model.songs.count,2)
XCTAssertTrue(model.songs.allSatisfy{$0.audio.hasPrefix(b.resolvingSymlinksInPath().path+"/")})
XCTAssertEqual(model.managedMediaDirectory.resolvingSymlinksInPath().path,b.resolvingSymlinksInPath().path)
}
func testNameTemplateExtractsAlbumTrackArtistAndTitle() throws {
let song = Song(id:1,title:"SF285-16 - Michael Bublé - Haven't Met You Yet",audio:"/audio.mp3",cdg:nil,rating:4,plays:7,label:"Sunfly",duration:210)
let parsed = try NameTemplate("L-T - R - S").interpret(song.title,before:NameMetadata(song))
XCTAssertEqual(parsed.album,"SF285");XCTAssertEqual(parsed.track,"16");XCTAssertEqual(parsed.artist,"Michael Bublé");XCTAssertEqual(parsed.title,"Haven't Met You Yet")
let compact = try NameTemplate("L-T-R-S").interpret("SF285 - 01 - Björk - Jóga",before:NameMetadata(song))
XCTAssertEqual(compact.track,"01");XCTAssertEqual(compact.artist,"Björk");XCTAssertEqual(compact.title,"Jóga")
}
func testNameTemplatesRepeatIgnoreAndExtractRendition() throws {
let before = NameMetadata(Song(id:1,title:"Original",audio:"/a",cdg:nil,rating:0,plays:0))
let artist = try NameTemplate("L-T - R-R - S").interpret("SC1234-04 - Olivia Newton-John - Physical",before:before)
XCTAssertEqual(artist.artist,"Olivia Newton John");XCTAssertEqual(artist.title,"Physical")
let disc = try NameTemplate("L-L-T-R-S").interpret("SC1234-5-04-The Foobars-The Song",before:before)
XCTAssertEqual(disc.album,"SC1234 5");XCTAssertEqual(disc.track,"04")
for (template,text) in [("I | T. R - S [V]","unused | 03. Artist - Title [Backing vocals]"),("I | T. R - S (V)","unused | 03. Artist - Title (Backing vocals)"),("I | T. R - S {V}","unused | 03. Artist - Title {Backing vocals}")] {
let parsed = try NameTemplate(template).interpret(text,before:before)
XCTAssertEqual(parsed.artist,"Artist");XCTAssertEqual(parsed.title,"Title");XCTAssertEqual(parsed.track,"03");XCTAssertEqual(parsed.rendition,"Backing vocals")
}
}
func testNameTemplateDashAndUnderscoreOptionsAreExplicit() throws {
let before = NameMetadata(Song(id:1,title:"Original",audio:"/a",cdg:nil,rating:0,plays:0)),parser = try NameTemplate("R - S")
XCTAssertThrowsError(try parser.interpret("Artist — Title",before:before))
XCTAssertEqual(try parser.interpret("Artist — Title",before:before,emDashes:true).title,"Title")
XCTAssertEqual(try parser.interpret("The_Artist - The_Title",before:before).artist,"The Artist")
XCTAssertEqual(try parser.interpret("The_Artist - The_Title",before:before,underscores:false).title,"The_Title")
}
func testInvalidNameTemplatesAndUnmatchedSongsFailSafely() throws {
for text in ["","A - S","R_*S","RS","I","T-T-S","R+$S"] { XCTAssertThrowsError(try NameTemplate(text),text) }
let songs = [Song(id:2,title:"Artist - Title",audio:"/a",cdg:nil,rating:0,plays:0),Song(id:1,title:"Unmatched",audio:"/b",cdg:nil,rating:0,plays:0)]
let rows = try NameTemplate.preview(songs,template:"R - S",source:.song,emDashes:false,underscores:true)
XCTAssertEqual(rows.map(\.id),[1,2]);XCTAssertNil(rows[0].after);XCTAssertNotNil(rows[0].issue);XCTAssertEqual(rows.compactMap(\.change).count,1)
XCTAssertEqual(rows[1].after?.title,"Title")
}
func testNameTemplateUsesOriginalFolderMetadataAndRejectsManagedHashFallback() throws {
let song = Song(id:1,title:"01",audio:"/Media/"+String(repeating:"a",count:64)+".mp3",cdg:nil,rating:0,plays:0,originalName:"Jóga",originalPath:"/Original/Album/Björk/Jóga.mp3")
let parser = try NameTemplate("/L/R/S"),input = try parser.input(song,source:.file)
let parsed = try parser.interpret(input,before:NameMetadata(song))
XCTAssertEqual(parsed.album,"Album");XCTAssertEqual(parsed.artist,"Björk");XCTAssertEqual(parsed.title,"Jóga")
var legacy = song;legacy.originalName = "";legacy.originalPath = ""
XCTAssertThrowsError(try parser.input(legacy,source:.file));XCTAssertEqual(try parser.input(legacy,source:.song),"01")
}
func testImportRecordsOriginalNamesAndBackfillsSkippedArchives() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media"))
let archive = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
let ids = try importer.run([archive],library:db),id = try XCTUnwrap(ids.first)
let first = try XCTUnwrap(try db.song(id));XCTAssertFalse(first.originalName.isEmpty);XCTAssertTrue(first.originalPath.hasPrefix(archive.path+"/"));XCTAssertFalse(first.originalName == URL(fileURLWithPath:first.audio).deletingPathExtension().lastPathComponent)
try db.updateMetadata(song:id,title:"My Edited Title",artist:"Artist",album:"Album",label:"",rating:5)
try db.execute("UPDATE songs SET original_name='',original_path='' WHERE id=?",[String(id)])
var progress = ImportProgress();_ = try importer.run([archive],library:db,progress:{progress = $0})
let after = try XCTUnwrap(try db.song(id));XCTAssertEqual(after.title,"My Edited Title");XCTAssertEqual(after.rating,5);XCTAssertFalse(after.originalName.isEmpty);XCTAssertEqual(progress.skipped,1);XCTAssertEqual(try db.songs().count,1)
}
func testReinterpretationPersistsFieldsPreservesRelationshipsAndCanUndo() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let database = root.appendingPathComponent("Library.sqlite"),db = try Library(url:database)
let a = try db.add(title:"SF100-01 - Artist A - Title A (Explicit)",audio:"/a.mp3",cdg:"/a.cdg"),b = try db.add(title:"SF100-02 - Artist B - Title B (Clean)",audio:"/b.mp3",cdg:nil)
try db.rate(a,5);try db.addEntity("singers",name:"Alex");let singer = try XCTUnwrap(try db.entities("singers").first?.id)
try db.assignSong(a,singer:singer);try db.savePreference(song:a,singer:singer,value:.init(pitch:4,tempo:0.9,rating:3))
try db.addEntity("playlists",name:"Set");let playlist = try XCTUnwrap(try db.entities("playlists").first?.id)
try db.addToPlaylist(playlist,song:b);try db.addToPlaylist(playlist,song:a)
let before = try db.songs(),plan = try NameTemplate.preview(before,template:"L-T - R - S (V)",source:.song,emDashes:false,underscores:true).compactMap(\.change)
XCTAssertEqual(try db.reinterpretNames(plan),2)
let reopened = try Library(url:database),after = try XCTUnwrap(try reopened.song(a))
XCTAssertEqual(after.title,"Title A");XCTAssertEqual(after.artist,"Artist A");XCTAssertEqual(after.album,"SF100");XCTAssertEqual(after.trackNumber,"01");XCTAssertEqual(after.rendition,"Explicit")
XCTAssertEqual(after.audio,"/a.mp3");XCTAssertEqual(after.cdg,"/a.cdg");XCTAssertEqual(after.rating,5)
XCTAssertEqual(try reopened.preference(song:a,singer:singer),.init(pitch:4,tempo:0.9,rating:3));XCTAssertEqual(try reopened.playlistSongIDs(playlist),[b,a])
XCTAssertTrue(try reopened.hasNameUndo());XCTAssertEqual(try reopened.undoNameReinterpretation(),2)
XCTAssertEqual(try reopened.songs(),before);XCTAssertFalse(try reopened.hasNameUndo());XCTAssertEqual(try reopened.playlistSongIDs(playlist),[b,a])
}
func testStaleReinterpretationPreviewRollsBackEntireBatch() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("Library.sqlite"))
let a = try db.add(title:"Artist - First",audio:"/a",cdg:nil),b = try db.add(title:"Artist - Second",audio:"/b",cdg:nil)
let plan = try NameTemplate.preview(db.songs(),template:"R - S",source:.song,emDashes:false,underscores:true).compactMap(\.change)
try db.updateMetadata(song:b,title:"Later Edit",artist:"",album:"",label:"",rating:0)
XCTAssertThrowsError(try db.reinterpretNames(plan));XCTAssertEqual(try db.song(a)?.title,"Artist - First");XCTAssertEqual(try db.song(b)?.title,"Later Edit");XCTAssertFalse(try db.hasNameUndo())
}
func testUndoReinterpretationDoesNotOverwriteLaterEdits() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("Library.sqlite"))
let id = try db.add(title:"Artist - Title",audio:"/a",cdg:nil)
_ = try db.reinterpretNames(NameTemplate.preview(db.songs(),template:"R - S",source:.song,emDashes:false,underscores:true).compactMap(\.change))
try db.updateMetadata(song:id,title:"Later Edit",artist:"Artist",album:"",label:"",rating:0)
XCTAssertThrowsError(try db.undoNameReinterpretation());XCTAssertEqual(try db.song(id)?.title,"Later Edit");XCTAssertTrue(try db.hasNameUndo())
}
@MainActor func testSavedCustomTemplatesAndAsyncReinterpretUndo() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
try model.saveNameTemplates("R | S [V]\nT. S")
XCTAssertTrue(model.savedNameTemplates().contains("R | S [V]"));XCTAssertThrowsError(try model.saveNameTemplates("A - S"))
let id = try XCTUnwrap(model.library).add(title:"Artist | Title [Clean]",audio:"/a",cdg:nil);try model.refresh()
let changes = try NameTemplate.preview(model.songs,template:"R | S [V]",source:.song,emDashes:false,underscores:true).compactMap(\.change)
try await model.applyNameChanges(changes)
XCTAssertTrue(model.canUndoNames);XCTAssertFalse(model.namesBusy);XCTAssertEqual(model.songs.first?.rendition,"Clean")
XCTAssertTrue(SongSearch.matches(try XCTUnwrap(model.songs.first),query:"version:Clean"))
try await model.undoNameChanges();XCTAssertFalse(model.canUndoNames);XCTAssertEqual(model.songs.first?.title,"Artist | Title [Clean]")
XCTAssertEqual(model.songs.first?.id,id)
}
@MainActor func testActualReinterpretationApplyButtonUpdatesSelectedSong() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let id = try XCTUnwrap(model.library).add(title:"SF285-01 - Artist - Title",audio:"/demo.mp3",cdg:nil);try model.refresh()
let host = NSHostingView(rootView:ReinterpretNamesSheet(model:model,ids:[id]).frame(width:1000,height:800).background(Color(nsColor:.windowBackgroundColor)).preferredColorScheme(.light))
host.frame = NSRect(x:0,y:0,width:1000,height:800)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.isReleasedWhenClosed = false;window.appearance = NSAppearance(named:.aqua);window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:500_000_000);host.layoutSubtreeIfNeeded()
if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"/tmp/kjmix-reinterpret-ui-test.png")) }
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:910,y:800-678),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event)
}
for _ in 0..<100 { if model.songs.first?.title == "Title" { break };try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertEqual(model.songs.first?.title,"Title");XCTAssertEqual(model.songs.first?.artist,"Artist");XCTAssertEqual(model.songs.first?.trackNumber,"01");XCTAssertTrue(model.canUndoNames)
try await Task.sleep(nanoseconds:100_000_000)
}
@MainActor func testLiveTuningFollowsPerformingSingerAndPreservesOtherPreferences() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);defer { model.player.stop() }
_ = try model.createEntity(.venue,name:"Hall");let a = try model.createEntity(.singer,name:"Alex"),b = try model.createEntity(.singer,name:"Sam"),db = try XCTUnwrap(model.library)
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let id = try db.add(title:"Demo",audio:audio.path,cdg:nil),other = try db.add(title:"Other",audio:"/other.mp3",cdg:nil)
try db.saveDefaultPreference(song:id,value:.init(pitch:1,tempo:1.05))
try db.savePreference(song:id,singer:a,value:.init(pitch:2,tempo:1.1,rating:5));try db.savePreference(song:id,singer:b,value:.init(pitch:-4,tempo:0.8,rating:2))
try model.refresh();try model.play(try XCTUnwrap(model.songs.first{$0.id == id}),singer:a)
model.selectedSinger = b
try await Task.sleep(nanoseconds:100_000_000);let previousTime = model.player.time
model.updateLiveTuning(pitch:3,tempo:0.95)
XCTAssertTrue(model.player.playing);XCTAssertEqual(model.playbackSinger,a);XCTAssertEqual(model.player.pitch,3);XCTAssertEqual(model.player.tempo,0.95)
XCTAssertEqual(try db.preference(song:id,singer:a),.init(pitch:3,tempo:0.95,rating:5))
XCTAssertEqual(try db.preference(song:id,singer:b),.init(pitch:-4,tempo:0.8,rating:2));XCTAssertEqual(try db.defaultPreference(song:id).pitch,1)
XCTAssertEqual(model.preference(id).pitch,-4);XCTAssertEqual(model.tuningForControls(id).pitch,3)
model.updateTuning(id,tempo:1.02)
XCTAssertEqual(try db.preference(song:id,singer:a).tempo,1.02);XCTAssertEqual(try db.preference(song:id,singer:b).tempo,0.8)
try await Task.sleep(nanoseconds:100_000_000);XCTAssertGreaterThanOrEqual(model.player.time,previousTime)
XCTAssertEqual(try db.preference(song:other,singer:a).pitch,0)
let reopened = try Library(url:root.appendingPathComponent("Library.sqlite"));XCTAssertEqual(try reopened.preference(song:id,singer:a).pitch,3)
}
@MainActor func testLiveTuningWithoutPerformingSingerIsTemporary() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);defer { model.player.stop() }
let db = try XCTUnwrap(model.library),audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let id = try db.add(title:"Demo",audio:audio.path,cdg:nil);try db.saveDefaultPreference(song:id,value:.init(pitch:1,tempo:1.1));try model.refresh()
try model.play(try XCTUnwrap(model.songs.first));XCTAssertNil(model.playbackSinger)
let singer = try model.createEntity(.singer,name:"Browsing Singer");model.selectedSinger = singer
model.updateTuning(id,pitch:4,tempo:0.9)
XCTAssertEqual(model.player.pitch,4);XCTAssertEqual(model.player.tempo,0.9)
XCTAssertEqual(try db.defaultPreference(song:id).pitch,1);XCTAssertEqual(try db.defaultPreference(song:id).tempo,1.1)
XCTAssertEqual(try db.preference(song:id,singer:singer).pitch,1)
}
@MainActor func testInvalidLiveTuningDoesNotChangePlaybackOrSavedSettings() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);defer { model.player.stop() }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")),db = try XCTUnwrap(model.library)
let id = try db.add(title:"Demo",audio:audio.path,cdg:nil);try model.refresh();try model.play(try XCTUnwrap(model.songs.first))
model.updateLiveTuning(pitch:.nan,tempo:3)
XCTAssertNotNil(model.error);XCTAssertEqual(model.player.pitch,0);XCTAssertEqual(model.player.tempo,1);XCTAssertTrue(model.player.playing);XCTAssertEqual(try db.defaultPreference(song:id).pitch,0)
}
func testLibraryPaginationUsesTwentyRowsAndClampsAfterFiltering() {
let rows = Array(1...43)
let first = LibraryPage(total:rows.count,requested:0),middle = LibraryPage(total:rows.count,requested:1),last = LibraryPage(total:rows.count,requested:99)
XCTAssertEqual(first.count,3);XCTAssertEqual(Array(rows[first.range]),Array(1...20));XCTAssertEqual(Array(rows[middle.range]),Array(21...40));XCTAssertEqual(Array(rows[last.range]),[41,42,43])
let filtered = LibraryPage(total:7,requested:2);XCTAssertEqual(filtered.number,0);XCTAssertEqual(filtered.range,0..<7)
let empty = LibraryPage(total:0,requested:9);XCTAssertEqual(empty.number,0);XCTAssertTrue(empty.range.isEmpty)
XCTAssertEqual(LibraryPage(total:20,requested:1).count,1);XCTAssertEqual(LibraryPage(total:40,requested:1).range,20..<40)
}
func packet(_ instruction: UInt8, _ data: [UInt8]) -> [UInt8] { [9,instruction,0,0] + Array((data + Array(repeating: 0,count:16)).prefix(16)) + [0,0,0,0] }
func testPresetTileXORAndInvalidCoordinates() {
var d = CDGDecoder()
d.apply(packet(1,[3,0])); XCTAssertEqual(d.pixels[0],3)
d.apply(packet(6,[0,5,1,1] + Array(repeating:63,count:12)))
XCTAssertEqual(d.pixels[12*300+6],5)
d.apply(packet(38,[0,5,1,1] + Array(repeating:63,count:12)))
XCTAssertEqual(d.pixels[12*300+6],0)
let old = d.pixels; d.apply(packet(6,[1,2,31,63])); XCTAssertEqual(d.pixels,old)
}
func testSeekAndTruncatedPacket() {
var d = CDGDecoder()
let data = Data(packet(1,[2,0]) + packet(1,[7,0]) + [9])
d.render(data,at:1); XCTAssertEqual(d.pixels[0],7); XCTAssertEqual(d.packetIndex,2)
d.render(data,at:1.0/300); XCTAssertEqual(d.pixels[0],2)
d.render(data,at:0); XCTAssertEqual(d.pixels[0],0)
}
func testPaletteBorderScrollAndImage() {
var d = CDGDecoder()
d.apply(packet(30,[63,63])); XCTAssertEqual(d.palette[0],0xFFFFFFFF)
d.apply(packet(2,[4])); XCTAssertEqual(d.pixels[0],4); XCTAssertEqual(d.pixels[12*300+6],0)
d.apply(packet(20,[3,16,0])); XCTAssertEqual(d.pixels[100*300],3)
d.apply(packet(24,[0,32,0])); XCTAssertEqual(d.pixels[100*300+294],3)
XCTAssertEqual(d.image()?.width,288)
}
func testSQLitePersistenceDedupRatingsAndHistory() throws {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: folder,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:folder) }
let url = folder.appendingPathComponent("test.sqlite")
do {
let db = try Library(url:url)
try db.add(title:"Singer's Song",audio:"/test.mp3",cdg:nil)
try db.add(title:"Duplicate",audio:"/test.mp3",cdg:"/test.cdg")
let song = try XCTUnwrap(db.songs().first)
try db.rate(song.id,5); XCTAssertThrowsError(try db.rate(song.id,6))
try db.addEntity("singers",name:"O'Neil"); try db.addEntity("venues",name:"Hall")
try db.record(song.id,singer:db.entities("singers").first?.id,venue:db.entities("venues").first?.id)
}
let reopened = try Library(url:url); let songs = try reopened.songs()
XCTAssertEqual(songs.count,1); XCTAssertEqual(songs[0].rating,5); XCTAssertEqual(songs[0].plays,1); XCTAssertEqual(songs[0].cdg,"/test.cdg")
XCTAssertEqual(try reopened.entities("singers").first?.name,"O'Neil")
}
func testPairedImportAndContentDedup() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let audio = root.appendingPathComponent("Test.mp3"), cdg = root.appendingPathComponent("Test.CDG")
try Data([1,2,3]).write(to:audio); try Data(packet(1,[2,0])).write(to:cdg)
let db = try Library(url:root.appendingPathComponent("test.sqlite"))
let importer = Importer(media:root.appendingPathComponent("Media"))
try importer.run([audio],library:db); try importer.run([audio],library:db)
XCTAssertEqual(try db.songs().count,1)
XCTAssertNotNil(try db.songs().first?.cdg)
}
func testZippedMP3GImportAndUnsafeArchiveRejection() throws {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:folder) }
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let db = try Library(url:folder.appendingPathComponent("db.sqlite"))
let importer = Importer(media:folder.appendingPathComponent("Media"))
try importer.run([fixtures.appendingPathComponent("Demo.zip")],library:db)
let song = try XCTUnwrap(db.songs().first)
XCTAssertEqual(song.title,"Demo"); XCTAssertNotNil(song.cdg)
XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio))
XCTAssertThrowsError(try importer.run([fixtures.appendingPathComponent("Unsafe.zip")],library:db))
}
@MainActor func testMP3PlaybackPauseAndSeek() throws {
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let player = Player()
defer { player.stop() }
try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0))
XCTAssertGreaterThan(player.duration,3.9); XCTAssertTrue(player.playing)
player.toggle(); XCTAssertFalse(player.playing)
player.seek(2); XCTAssertEqual(player.time,2,accuracy:0.05); XCTAssertNotNil(player.image)
player.seek(0.1); XCTAssertEqual(player.time,0.1,accuracy:0.05)
}
@MainActor func testCreateSingerAndVenueThroughAppModel() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let model = AppModel(storageURL: root)
XCTAssertNil(model.error)
let singer = try model.createEntity(.singer, name: " Alex ")
let venue = try model.createEntity(.venue, name: "Town Hall")
XCTAssertEqual(model.singers.first?.name, "Alex")
XCTAssertEqual(model.venues.first?.name, "Town Hall")
XCTAssertEqual(try model.createEntity(.singer, name: "Alex"), singer)
XCTAssertEqual(model.singers.count, 1)
XCTAssertThrowsError(try model.createEntity(.venue, name: " "))
let reopened = AppModel(storageURL: root)
XCTAssertEqual(reopened.singers.first?.id, singer)
XCTAssertEqual(reopened.venues.first?.id, venue)
}
@MainActor func testVenuesShareSingersAndRememberSelection() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let hall = try model.createEntity(.venue,name:"Hall")
let alex = try model.createEntity(.singer,name:"Alex")
let club = try model.createEntity(.venue,name:"Club")
XCTAssertTrue(model.venueSingers.isEmpty)
model.membership(singer:alex,included:true)
XCTAssertEqual(model.venueSingers.map(\.id),[alex])
model.chooseVenue(hall); XCTAssertEqual(model.venueSingers.map(\.id),[alex])
model.chooseVenue(club); model.membership(singer:alex,included:false)
XCTAssertTrue(model.venueSingers.isEmpty)
let reopened = AppModel(storageURL:root)
XCTAssertEqual(reopened.currentVenue,club); XCTAssertTrue(reopened.venueSingers.isEmpty)
reopened.chooseVenue(hall); XCTAssertEqual(reopened.venueSingers.map(\.id),[alex])
XCTAssertEqual(reopened.singers.count,1)
}
func testMigrationAndMembershipRemovalDoesNotReappear() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let url = root.appendingPathComponent("db.sqlite")
var singer: Int64 = 0, venue: Int64 = 0
do {
let db = try Library(url:url)
try db.add(title:"Song",audio:"/a.mp3",cdg:nil)
try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall")
singer = try XCTUnwrap(db.entities("singers").first?.id); venue = try XCTUnwrap(db.entities("venues").first?.id)
try db.record(XCTUnwrap(db.songs().first?.id),singer:singer,venue:venue)
try db.execute("PRAGMA user_version=1")
}
do {
let migrated = try Library(url:url)
XCTAssertEqual(try migrated.members(of:venue).map(\.id),[singer])
try migrated.setMembership(singer:singer,venue:venue,included:false)
}
let reopened = try Library(url:url)
XCTAssertTrue(try reopened.members(of:venue).isEmpty)
XCTAssertEqual(try reopened.history(venue:venue).count,1)
}
func testQueuePlaylistsPreferencesAndDeletion() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("db.sqlite"))
try db.add(title:"First",audio:"/1.mp3",cdg:nil)
try db.add(title:"Second",audio:"/2.mp3",cdg:nil)
let songs = try db.songs()
try db.addEntity("singers",name:"Alex"); try db.addEntity("venues",name:"Hall"); try db.addEntity("venues",name:"Club")
let singer = try XCTUnwrap(db.entities("singers").first?.id)
let hall = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Hall" })?.id)
let club = try XCTUnwrap(db.entities("venues").first(where: { $0.name == "Club" })?.id)
XCTAssertThrowsError(try db.enqueue(song:songs[0].id,singer:singer,venue:hall))
try db.setMembership(singer:singer,venue:hall,included:true)
try db.enqueue(song:songs[0].id,singer:singer,venue:hall); try db.enqueue(song:songs[1].id,singer:singer,venue:hall)
XCTAssertTrue(try db.queue(venue:club).isEmpty)
let second = try XCTUnwrap(db.queue(venue:hall).last)
try db.moveQueue(second.id,venue:hall,by:-1)
XCTAssertEqual(try db.queue(venue:hall).first?.songID,songs[1].id)
let preference = SongPreference(pitch:3,tempo:0.85,rating:4)
try db.savePreference(song:songs[0].id,singer:singer,value:preference)
XCTAssertEqual(try db.preference(song:songs[0].id,singer:singer),preference)
XCTAssertThrowsError(try db.savePreference(song:songs[0].id,singer:singer,value:.init(pitch:99)))
try db.addEntity("playlists",name:"Party")
let playlist = try XCTUnwrap(db.entities("playlists").first?.id)
try db.addToPlaylist(playlist,song:songs[0].id); try db.addToPlaylist(playlist,song:songs[0].id)
XCTAssertEqual(try db.playlistSongIDs(playlist),[songs[0].id])
try db.record(songs[0].id,singer:singer,venue:hall)
try db.rename("singers",id:singer,name:"Alex Jones")
XCTAssertEqual(try db.history(venue:hall).first?.singer,"Alex Jones")
try db.deleteEntity("singers",id:singer)
XCTAssertTrue(try db.queue(venue:hall).isEmpty)
XCTAssertEqual(try db.history(venue:hall).count,1)
try db.selectVenue(hall); try db.deleteEntity("venues",id:hall)
XCTAssertNil(try db.currentVenue()); XCTAssertEqual(try db.history(venue:nil).count,1)
}
@MainActor func testTempoSourceClockPauseAndResume() async throws {
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let player = Player(); defer { player.stop() }
let song = Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:fixtures.appendingPathComponent("Demo.cdg").path,rating:0,plays:0)
try player.load(song,preference:.init(pitch:2,tempo:1.5,rating:3))
XCTAssertEqual(player.pitch,2); XCTAssertEqual(player.tempo,1.5)
try await Task.sleep(nanoseconds:500_000_000)
XCTAssertGreaterThan(player.time,0.45); XCTAssertLessThan(player.time,1.3)
player.toggle(); let paused = player.time
try await Task.sleep(nanoseconds:100_000_000)
XCTAssertEqual(player.time,paused,accuracy:0.025)
player.seek(1.5); player.toggle()
try await Task.sleep(nanoseconds:200_000_000)
XCTAssertGreaterThan(player.time,1.6); XCTAssertNotNil(player.image)
}
func testNewTestMediaMetadataAndCDGPairImport() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("db.sqlite"))
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let importer = Importer(media:root.appendingPathComponent("Media"))
try importer.run([fixtures.appendingPathComponent("TestTrack.cdg")],library:db)
let song = try XCTUnwrap(db.songs().first)
XCTAssertEqual(song.title,"MP3 Plus G Test")
XCTAssertEqual(song.artist,"KJamsClone Test Generator")
XCTAssertNotNil(song.cdg)
let bytes = try Data(contentsOf:fixtures.appendingPathComponent("TestTrack.cdg"))
XCTAssertEqual(bytes.count,20*300*24)
var decoder = CDGDecoder(); decoder.render(bytes,at:2)
XCTAssertTrue(decoder.pixels.contains(1)); XCTAssertTrue(decoder.pixels.contains(2))
}
@MainActor func testAutomaticVenueQueueAdvancesAndRecordsSinger() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root); defer { model.player.stop() }
_ = try model.createEntity(.venue,name:"Test Hall")
_ = try model.createEntity(.singer,name:"Alex")
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
model.importFiles([fixtures.appendingPathComponent("Demo.zip")])
for _ in 0..<200 { if !model.isImporting { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.isImporting);XCTAssertNil(model.importProgress?.failure)
let song = try XCTUnwrap(model.songs.first)
model.enqueue(song.id); model.enqueue(song.id); model.playQueueNext()
XCTAssertEqual(model.queue.count,1); XCTAssertEqual(model.history.count,1)
model.player.seek(model.player.duration-0.1)
for _ in 0..<25 {
if model.queue.isEmpty { break }
try await Task.sleep(nanoseconds:100_000_000)
}
XCTAssertNil(model.error); XCTAssertTrue(model.queue.isEmpty)
XCTAssertEqual(model.history.count,2); XCTAssertEqual(model.history.first?.singer,"Alex")
}
func testWaveformRepresentsSilenceAndSignalInTimeOrder() throws {
let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:folder) }
let url = folder.appendingPathComponent("signal.wav")
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1))
do {
let file = try AVAudioFile(forWriting:url,settings:format.settings)
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:4096)); buffer.frameLength = 4096
let samples = try XCTUnwrap(buffer.floatChannelData?[0])
for i in 0..<4096 { samples[i] = i < 2048 ? 0 : Float(sin(Double(i)*0.2)*0.5) }
try file.write(from:buffer)
}
let envelope = try AudioWaveform.read(url:url,bins:32)
XCTAssertEqual(envelope.count,32)
XCTAssertTrue(envelope.prefix(16).allSatisfy { $0 == 0 })
XCTAssertTrue(envelope.suffix(16).allSatisfy { $0 > 0.8 && $0 <= 1 })
XCTAssertThrowsError(try AudioWaveform.read(url:url,cancelled:{true}))
}
@MainActor func testWaveformAnalysisAndStopClearsTrackData() async throws {
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let player = Player(); defer { player.stop() }
try player.load(Song(id:1,title:"Demo",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil,rating:0,plays:0))
player.toggle()
for _ in 0..<50 {
if !player.waveformLoading { break }
try await Task.sleep(nanoseconds:20_000_000)
}
XCTAssertEqual(player.waveform.count,4096)
XCTAssertFalse(player.waveformUnavailable)
player.stop()
XCTAssertTrue(player.waveform.isEmpty)
XCTAssertFalse(player.waveformLoading)
try await Task.sleep(nanoseconds:50_000_000)
XCTAssertTrue(player.waveform.isEmpty)
}
@MainActor func testConnectedSingerCreationAndSharedRepertoire() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let hall = try model.createEntity(.venue,name:"Hall")
try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh()
let song = try XCTUnwrap(model.songs.first?.id)
let alex = try model.createSinger(name:" Alex ",venue:hall,songs:[song])
XCTAssertEqual(model.selectedSinger,alex);XCTAssertEqual(model.currentVenue,hall)
XCTAssertEqual(model.venueSingers.map(\.id),[alex]);XCTAssertEqual(model.songs(for:alex).map(\.id),[song])
XCTAssertThrowsError(try model.createSinger(name:"Broken",venue:hall,songs:[999999]))
XCTAssertFalse(try XCTUnwrap(model.library).entities("singers").contains { $0.name == "Broken" })
let club = try model.createEntity(.venue,name:"Club");model.membership(singer:alex,included:true)
XCTAssertEqual(model.songs(for:alex).map(\.id),[song])
model.assign(song,to:alex);XCTAssertEqual(model.songs(for:alex).count,1)
let reopened = AppModel(storageURL:root)
XCTAssertEqual(reopened.currentVenue,club);XCTAssertEqual(reopened.songs(for:alex).map(\.id),[song])
reopened.assign(song,to:alex,included:false)
XCTAssertTrue(AppModel(storageURL:root).songs(for:alex).isEmpty)
}
func testQueueRemembersSingerSongWithoutDuplicatingAssignment() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("db.sqlite"))
try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall")
try db.add(title:"Song",audio:"/a.mp3",cdg:nil)
let singer = try XCTUnwrap(db.entities("singers").first?.id),venue = try XCTUnwrap(db.entities("venues").first?.id),song = try XCTUnwrap(db.songs().first?.id)
try db.setMembership(singer:singer,venue:venue,included:true)
try db.enqueue(song:song,singer:singer,venue:venue);try db.enqueue(song:song,singer:singer,venue:venue)
XCTAssertEqual(try db.singerSongIDs()[singer],[song]);XCTAssertEqual(try db.queue(venue:venue).count,2)
try db.removeSingerSong(song,singer:singer)
XCTAssertNil(try db.singerSongIDs()[singer]);XCTAssertEqual(try db.songs().count,1)
}
func testFrequencyBandsDistinguishBassAndTreble() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let url = root.appendingPathComponent("bands.wav")
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:16000,channels:1))
do {
let file = try AVAudioFile(forWriting:url,settings:format.settings)
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:8192));buffer.frameLength = 8192
for i in 0..<8192 { buffer.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*(i < 4096 ? 80 : 6000)/16000)*0.2) }
try file.write(from:buffer)
}
let analysis = try AudioWaveform.analyse(url:url,bins:32)
XCTAssertGreaterThan(analysis.bands[8].low,analysis.bands[8].high*3)
XCTAssertGreaterThan(analysis.bands[24].high,analysis.bands[24].low*3)
}
private func toneRMS(frequency: Double, low: Double = 0, filter: Double = 0, enabled: Bool = true) throws -> Double {
let engine = AVAudioEngine(),node = AVAudioPlayerNode(),eq = AVAudioUnitEQ(numberOfBands:4)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1))
engine.attach(node);engine.attach(eq);engine.connect(node,to:eq,format:format);engine.connect(eq,to:engine.mainMixerNode,format:format)
AudioTone.configure(eq,sampleRate:44100,low:low,mid:0,high:0,filter:filter,enabled:enabled)
try engine.enableManualRenderingMode(.offline,format:format,maximumFrameCount:1024)
let input = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:32768));input.frameLength = 32768
for i in 0..<32768 { input.floatChannelData![0][i] = Float(sin(Double(i)*2*Double.pi*frequency/44100)*0.1) }
node.scheduleBuffer(input);try engine.start();node.play();defer { node.stop();engine.stop() }
let output = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024))
var energy: Double = 0,count = 0
for block in 0..<12 {
let status = try engine.renderOffline(1024,to:output)
XCTAssertEqual(status,.success)
if block >= 2 { for i in 0..<Int(output.frameLength) { let v = Double(output.floatChannelData![0][i]);energy += v*v;count += 1 } }
}
return sqrt(energy/Double(max(1,count)))
}
func testToneEQBoostAndBypassProcessActualAudio() throws {
let flat = try toneRMS(frequency:60)
let boosted = try toneRMS(frequency:60,low:12)
let bypassed = try toneRMS(frequency:60,low:12,enabled:false)
XCTAssertGreaterThan(flat,0.01);XCTAssertGreaterThan(boosted,flat*2)
XCTAssertEqual(bypassed/flat,1,accuracy:0.05)
}
func testLowPassAndHighPassFilterActualAudio() throws {
let high = try toneRMS(frequency:6000),low = try toneRMS(frequency:60)
XCTAssertLessThan(try toneRMS(frequency:6000,filter:-1),high*0.1)
XCTAssertLessThan(try toneRMS(frequency:60,filter:1),low*0.1)
}
func testVersionThreeMigrationBuildsSingerSongListsFromHistory() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let url = root.appendingPathComponent("db.sqlite")
var singer: Int64 = 0,song: Int64 = 0
do {
let db = try Library(url:url)
try db.addEntity("singers",name:"Alex");try db.add(title:"Song",audio:"/sample.mp3",cdg:nil)
singer = try XCTUnwrap(db.entities("singers").first?.id);song = try XCTUnwrap(db.songs().first?.id)
try db.record(song,singer:singer,venue:nil)
try db.execute("DROP TABLE singer_songs");try db.execute("PRAGMA user_version=3")
}
let migrated = try Library(url:url)
XCTAssertEqual(try migrated.singerSongIDs()[singer],[song])
XCTAssertEqual(try migrated.history(venue:nil).count,1)
}
func testMusicStoreDefinitionsValidateSearchAndPersist() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let url = root.appendingPathComponent("db.sqlite")
let db = try Library(url:url)
try db.saveStore(.init(id:0,name:"Example",url:"https://example.com",searchTemplate:"https://example.com/search?q={query}"))
let store = try XCTUnwrap(db.stores().first)
let destination = try store.destination(query:"ABBA & Queen")
XCTAssertEqual(URLComponents(url:destination,resolvingAgainstBaseURL:false)?.queryItems?.first?.value,"ABBA & Queen")
XCTAssertThrowsError(try db.saveStore(.init(id:0,name:"Bad",url:"file:///tmp/test",searchTemplate:"")))
XCTAssertThrowsError(try db.saveStore(.init(id:0,name:"Bad2",url:"https://example.com",searchTemplate:"https://example.com/search")))
XCTAssertEqual(try Library(url:url).stores().first?.name,"Example")
try db.saveStore(.init(id:store.id,name:"Renamed",url:"https://example.org",searchTemplate:""))
XCTAssertEqual(try db.stores().count,1);XCTAssertEqual(try db.stores().first?.name,"Renamed")
try db.deleteStore(store.id);XCTAssertTrue(try db.stores().isEmpty)
}
@MainActor func testEditableSongDefaultsAndSingerOverridesAreIndependent() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let db = try XCTUnwrap(model.library)
try db.add(title:"Song",audio:"/a.mp3",cdg:nil,duration:123.5);try model.refresh()
let song = try XCTUnwrap(model.songs.first?.id)
model.updateTuning(song,pitch:2,tempo:1.1)
XCTAssertEqual(try db.defaultPreference(song:song).pitch,2)
let venue = try model.createEntity(.venue,name:"Hall")
let singer = try model.createSinger(name:"Alex",venue:venue,songs:[song])
XCTAssertEqual(model.preference(song).tempo,1.1)
model.updateTuning(song,pitch:-3,tempo:0.9)
XCTAssertEqual(try db.preference(song:song,singer:singer).pitch,-3)
XCTAssertEqual(try db.defaultPreference(song:song).pitch,2)
model.setFavourite(song,singer:singer,included:true)
XCTAssertTrue(model.favourites[singer]?.contains(song) == true)
let reopened = AppModel(storageURL:root)
XCTAssertEqual(reopened.songs.first?.duration,123.5)
XCTAssertTrue(reopened.favourites[singer]?.contains(song) == true)
XCTAssertEqual(try XCTUnwrap(reopened.library).preference(song:song,singer:singer).tempo,0.9)
}
func testMetadataEditsPersistAndSurviveReimport() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let audio = root.appendingPathComponent("audio.mp3"),url = root.appendingPathComponent("db.sqlite")
let bytes = Data([1,2,3]);try bytes.write(to:audio)
let db = try Library(url:url)
try db.add(title:"Old",audio:audio.path,cdg:nil,artist:"Old artist")
let id = try XCTUnwrap(db.songs().first?.id)
try db.updateMetadata(song:id,title:" New Title ",artist:"New artist",album:"New album",label:"New label",rating:5)
XCTAssertThrowsError(try db.updateMetadata(song:id,title:" ",artist:"",album:"",label:"",rating:0))
try db.add(title:"Original tag",audio:audio.path,cdg:nil,artist:"Original artist",album:"Original album",label:"Original label")
let saved = try XCTUnwrap(Library(url:url).songs().first)
XCTAssertEqual(saved.title,"New Title");XCTAssertEqual(saved.artist,"New artist")
XCTAssertEqual(saved.album,"New album");XCTAssertEqual(saved.label,"New label");XCTAssertEqual(saved.rating,5)
XCTAssertEqual(try Data(contentsOf:audio),bytes)
}
func testWaveformCacheRejectsChangedFilesAndCorruption() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let source = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let audio = root.appendingPathComponent("demo.mp3"),cache = root.appendingPathComponent("Waveforms")
try FileManager.default.copyItem(at:source,to:audio)
let analysis = try AudioWaveform.analyse(url:audio)
try WaveformCache.save(analysis,audio:audio,directory:cache)
XCTAssertEqual(try WaveformCache.load(audio:audio,directory:cache).levels,analysis.levels)
let first = try WaveformCache.location(audio:audio,directory:cache)
try FileManager.default.setAttributes([.modificationDate:Date(timeIntervalSince1970:123456)],ofItemAtPath:audio.path)
XCTAssertNotEqual(first,try WaveformCache.location(audio:audio,directory:cache))
XCTAssertThrowsError(try WaveformCache.load(audio:audio,directory:cache))
try WaveformCache.save(analysis,audio:audio,directory:cache)
try Data([0,1,2]).write(to:WaveformCache.location(audio:audio,directory:cache))
XCTAssertThrowsError(try WaveformCache.load(audio:audio,directory:cache))
}
@MainActor func testPlayerReusesSavedWaveformAndCanForceReanalysis() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(waveformCacheDirectory:root);player.volume = 0;defer { player.stop() }
let song = Song(id:1,title:"Demo",audio:audio.path,cdg:nil,rating:0,plays:0)
try player.load(song);player.toggle()
for _ in 0..<100 { if !player.waveformLoading { break };try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertTrue(player.waveformSaved);XCTAssertFalse(player.waveformFromCache)
let first = player.waveform
player.stop();try player.load(song);player.toggle()
for _ in 0..<100 { if !player.waveformLoading { break };try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertTrue(player.waveformFromCache);XCTAssertEqual(player.waveform,first)
player.reanalyseWaveform()
for _ in 0..<100 { if !player.waveformLoading { break };try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertFalse(player.waveformFromCache);XCTAssertTrue(player.waveformSaved);XCTAssertEqual(player.waveform,first)
}
@MainActor func testTonightPersistsForEachSingerAcrossSwitchPlaybackAndRestart() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);model.autoAdvance = false;defer { model.player.stop() }
let hall = try model.createEntity(.venue,name:"Hall")
let alex = try model.createEntity(.singer,name:"Alex"),bob = try model.createEntity(.singer,name:"Bob")
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
try model.library?.add(title:"A",audio:fixtures.appendingPathComponent("Demo.mp3").path,cdg:nil)
try model.library?.add(title:"B",audio:fixtures.appendingPathComponent("TestTrack.mp3").path,cdg:nil)
try model.refresh()
let a = try XCTUnwrap(model.songs.first(where:{ $0.title == "A" })?.id),b = try XCTUnwrap(model.songs.first(where:{ $0.title == "B" })?.id)
model.selectedSinger = bob;model.addToTonight(a,singer:alex)
model.addToTonight(b)
model.selectedSinger = alex;XCTAssertEqual(model.tonightSongs(for:alex).map(\.id),[a])
model.selectedSinger = bob;XCTAssertEqual(model.tonightSongs(for:bob).map(\.id),[b])
model.addToTonight(b);XCTAssertEqual(model.queue.count,2)
model.player.volume = 0;model.playQueueNext();model.player.stop()
XCTAssertEqual(model.queue.count,1)
XCTAssertEqual(model.tonightSongs(for:alex).map(\.id),[a])
let reopened = AppModel(storageURL:root)
XCTAssertEqual(reopened.currentVenue,hall)
XCTAssertEqual(reopened.tonightSongs(for:alex).map(\.id),[a]);XCTAssertEqual(reopened.tonightSongs(for:bob).map(\.id),[b])
reopened.removeFromTonight(a,singer:alex)
XCTAssertTrue(reopened.tonightSongs(for:alex).isEmpty);XCTAssertEqual(reopened.tonightSongs(for:bob).map(\.id),[b])
XCTAssertEqual(reopened.songs(for:alex).map(\.id),[a])
}
func testTonightMigrationAndVenueIsolation() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let url = root.appendingPathComponent("db.sqlite")
var singer: Int64 = 0,hall: Int64 = 0,club: Int64 = 0,song: Int64 = 0
do {
let db = try Library(url:url)
try db.addEntity("singers",name:"Alex");try db.addEntity("venues",name:"Hall");try db.addEntity("venues",name:"Club")
singer = try XCTUnwrap(db.entities("singers").first?.id)
hall = try XCTUnwrap(db.entities("venues").first(where:{ $0.name == "Hall" })?.id);club = try XCTUnwrap(db.entities("venues").first(where:{ $0.name == "Club" })?.id)
try db.setMembership(singer:singer,venue:hall,included:true);try db.setMembership(singer:singer,venue:club,included:true)
try db.add(title:"Song",audio:"/a.mp3",cdg:nil);song = try XCTUnwrap(db.songs().first?.id)
try db.enqueue(song:song,singer:singer,venue:hall)
try db.execute("DROP TABLE singer_tonight");try db.execute("PRAGMA user_version=6")
}
let db = try Library(url:url)
XCTAssertEqual(try db.tonightSongIDs(venue:hall)[singer],[song]);XCTAssertTrue(try db.tonightSongIDs(venue:club).isEmpty)
let entry = try XCTUnwrap(db.queue(venue:hall).first);try db.removeQueue(entry.id)
XCTAssertEqual(try Library(url:url).tonightSongIDs(venue:hall)[singer],[song])
try db.setMembership(singer:singer,venue:hall,included:false)
XCTAssertTrue(try db.tonightSongIDs(venue:hall).isEmpty)
}
@MainActor func testTonightDragReorderPersistsAndKeepsOtherSingerSlots() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let hall = try model.createEntity(.venue,name:"Hall")
let alex = try model.createEntity(.singer,name:"Alex"),bob = try model.createEntity(.singer,name:"Bob")
let db = try XCTUnwrap(model.library)
for title in ["A","B","C"] { try db.add(title:title,audio:"/"+title+".mp3",cdg:nil) };try model.refresh()
let a = try XCTUnwrap(model.songs.first(where:{ $0.title == "A" })?.id),b = try XCTUnwrap(model.songs.first(where:{ $0.title == "B" })?.id),c = try XCTUnwrap(model.songs.first(where:{ $0.title == "C" })?.id)
model.addToTonight(a,singer:alex);model.addToTonight(c,singer:bob);model.addToTonight(b,singer:alex)
let bobSlot = try XCTUnwrap(model.queue.firstIndex(where:{ $0.singerID == bob }))
try model.placeDroppedSong(model.dragReference(b,singer:alex,tonight:true),singer:alex,venue:hall,before:a)
XCTAssertEqual(model.tonightSongs(for:alex).map(\.id),[b,a])
XCTAssertEqual(model.queue.map(\.songID),[b,c,a]);XCTAssertEqual(model.queue[bobSlot].singerID,bob)
let reopened = AppModel(storageURL:root)
XCTAssertEqual(reopened.tonightSongs(for:alex).map(\.id),[b,a])
reopened.moveTonight(b,singer:alex,by:1)
XCTAssertEqual(reopened.tonightSongs(for:alex).map(\.id),[a,b])
XCTAssertEqual(reopened.queue.map(\.songID),[a,c,b])
}
@MainActor func testSongDropCopiesToTargetSingerAndRejectsWrongContext() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let hall = try model.createEntity(.venue,name:"Hall")
let alex = try model.createEntity(.singer,name:"Alex"),bob = try model.createEntity(.singer,name:"Bob")
try model.library?.add(title:"Song",audio:"/sample.mp3",cdg:nil);try model.refresh()
let song = try XCTUnwrap(model.songs.first?.id)
model.addToTonight(song,singer:alex)
let reference = model.dragReference(song,singer:alex,tonight:true)
model.selectedSinger = alex
try model.placeDroppedSong(reference,singer:bob,venue:hall,before:nil)
XCTAssertEqual(model.tonightSongs(for:alex).map(\.id),[song]);XCTAssertEqual(model.tonightSongs(for:bob).map(\.id),[song])
try model.placeDroppedSong(model.dragReference(song),singer:bob,venue:hall,before:nil)
XCTAssertEqual(model.queue.filter { $0.singerID == bob }.count,1)
var foreign = reference;foreign.library = "/other-library"
XCTAssertThrowsError(try model.placeDroppedSong(foreign,singer:bob,venue:hall,before:nil))
XCTAssertThrowsError(try model.placeDroppedSong(reference,singer:bob,venue:hall,before:99999))
let club = try model.createEntity(.venue,name:"Club")
XCTAssertThrowsError(try model.placeDroppedSong(reference,singer:bob,venue:hall,before:nil))
XCTAssertEqual(model.currentVenue,club);XCTAssertTrue(model.queue.isEmpty)
}
@MainActor func testReorderingPlayedTonightDoesNotQueueItAgain() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let hall = try model.createEntity(.venue,name:"Hall"),singer = try model.createEntity(.singer,name:"Alex")
for title in ["A","B"] { try model.library?.add(title:title,audio:"/"+title+".mp3",cdg:nil) };try model.refresh()
let a = model.songs[0].id,b = model.songs[1].id
model.addToTonight(a,singer:singer);model.addToTonight(b,singer:singer)
let entry = try XCTUnwrap(model.queue.first);try model.library?.removeQueue(entry.id);try model.refresh()
try model.placeDroppedSong(model.dragReference(a,singer:singer,tonight:true),singer:singer,venue:hall,before:nil)
XCTAssertEqual(model.tonightSongs(for:singer).map(\.id),[b,a]);XCTAssertEqual(model.queue.map(\.songID),[b])
}
@MainActor func testNativeSongDragProviderRoundTrip() async throws {
let reference = SongDragReference(song:42,library:"/test",singer:7,venue:9,fromTonight:true)
let provider = reference.provider()
XCTAssertTrue(provider.hasItemConformingToTypeIdentifier(SongDragReference.type.identifier))
let decoded = try await SongDragReference.load(provider)
XCTAssertEqual(decoded.song,42);XCTAssertEqual(decoded.singer,7);XCTAssertEqual(decoded.venue,9);XCTAssertTrue(decoded.fromTonight)
}
func testFolderScanReferencesAudioAndPreparesReusableWaveform() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let folder = root.appendingPathComponent("Drive/Nested")
try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
for name in ["Demo.mp3","Demo.cdg"] { try FileManager.default.copyItem(at:fixtures.appendingPathComponent(name),to:folder.appendingPathComponent(name)) }
let database = root.appendingPathComponent("db.sqlite"),cache = root.appendingPathComponent("Waveforms"),media = root.appendingPathComponent("Media")
let db = try Library(url:database)
let id = try db.saveMediaLocation(name:"Drive",url:folder.deletingLastPathComponent(),bookmark:nil,external:true)
let source = try XCTUnwrap(db.mediaLocations().first)
let first = try LibraryScanner.scan(source,database:database,media:media,cache:cache)
XCTAssertEqual(first.indexed,1);XCTAssertEqual(first.generated,1);XCTAssertEqual(first.issues,0)
let song = try XCTUnwrap(db.songs().first)
XCTAssertEqual(song.audio,folder.appendingPathComponent("Demo.mp3").path)
XCTAssertEqual(song.cdg,folder.appendingPathComponent("Demo.cdg").path)
XCTAssertEqual(song.fingerprint.count,64)
XCTAssertEqual(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:cache).levels.count,4096)
let second = try LibraryScanner.scan(source,database:database,media:media,cache:cache)
XCTAssertEqual(second.generated,0);XCTAssertEqual(second.cached,1);XCTAssertEqual(try db.songs().count,1)
XCTAssertEqual(try db.mediaLocations().first?.songCount,1)
let rebuilt = try LibraryScanner.scan(source,database:database,media:media,cache:cache,forceWaveforms:true)
XCTAssertEqual(rebuilt.generated,1)
try db.removeMediaLocation(id);XCTAssertEqual(try db.songs().count,1)
}
func testSourceZipUsesManagedMediaAndCancellationKeepsCompletedIndex() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root);try? FileManager.default.removeItem(at:folder) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.zip"))
try FileManager.default.copyItem(at:fixture,to:folder.appendingPathComponent("Demo.zip"))
let database = root.appendingPathComponent("db.sqlite"),media = root.appendingPathComponent("Media"),cache = root.appendingPathComponent("Waveforms")
let db = try Library(url:database);_ = try db.saveMediaLocation(name:"Archive folder",url:folder,bookmark:nil,external:false)
let source = try XCTUnwrap(db.mediaLocations().first)
let result = try LibraryScanner.scan(source,database:database,media:media,cache:cache)
XCTAssertEqual(result.generated,1)
let song = try XCTUnwrap(db.songs().first)
XCTAssertTrue(song.audio.hasPrefix(media.path+"/"));XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio))
let cancelled = try LibraryScanner.scan(source,database:database,media:media,cache:cache,cancelled:{true})
XCTAssertTrue(cancelled.cancelled);XCTAssertEqual(try db.songs().count,1)
}
func testOfflineLocationAndDuplicateSourcePreserveLibrary() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString),folder = root.appendingPathComponent("Drive")
try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try FileManager.default.copyItem(at:fixture,to:folder.appendingPathComponent("Demo.mp3"))
let database = root.appendingPathComponent("db.sqlite"),db = try Library(url:root.appendingPathComponent("db.sqlite"))
let first = try db.saveMediaLocation(name:"Drive",url:folder,bookmark:nil,external:true)
XCTAssertEqual(try db.saveMediaLocation(name:"Renamed",url:folder,bookmark:nil,external:true),first)
XCTAssertEqual(try db.mediaLocations().count,1)
let source = try XCTUnwrap(db.mediaLocations().first)
_ = try LibraryScanner.scan(source,database:database,media:root.appendingPathComponent("Media"),cache:root.appendingPathComponent("Waveforms"))
try FileManager.default.moveItem(at:folder,to:root.appendingPathComponent("Disconnected"))
XCTAssertFalse(try XCTUnwrap(db.mediaLocations().first).online)
XCTAssertThrowsError(try LibraryScanner.scan(source,database:database,media:root.appendingPathComponent("Media"),cache:root.appendingPathComponent("Waveforms")))
XCTAssertEqual(try db.songs().count,1)
}
@MainActor func testStandardMacPasteboardCarriesKJMixSongAndRejectsOtherText() async throws {
let reference = SongDragReference(song:42,library:"opaque-session",singer:nil,venue:nil,fromTonight:false)
let board = NSPasteboard.withUniqueName();defer { board.releaseGlobally() }
XCTAssertTrue(board.writeObjects([reference.transportText as NSString]))
let text = try XCTUnwrap(board.string(forType:.string))
let provider = NSItemProvider(object:text as NSString)
XCTAssertTrue(provider.hasItemConformingToTypeIdentifier(UTType.utf8PlainText.identifier))
let result = try await SongDragReference.load(provider)
XCTAssertEqual(result.song,42);XCTAssertEqual(result.library,"opaque-session")
do { _ = try await SongDragReference.load(NSItemProvider(object:"not a KJMix song" as NSString));XCTFail("Unrelated text was accepted") } catch { }
}
@MainActor func testMixerIsGlobalAndPersistsWithoutChangingTrackPreferences() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let cache = root.appendingPathComponent("Waveforms")
let player = Player(waveformCacheDirectory:cache)
player.volume = 0.43;player.lowGain = 4;player.midGain = -3;player.highGain = 2;player.filter = -0.4
let expected = player.globalMix
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let song = Song(id:1,title:"Test",audio:fixture.path,cdg:nil,rating:0,plays:0)
try player.load(song,preference:.init(pitch:2,tempo:1.1));player.stop()
try player.load(song,preference:.init(pitch:-1,tempo:0.9));player.stop()
XCTAssertEqual(player.globalMix,expected)
let reopened = Player(waveformCacheDirectory:cache)
XCTAssertEqual(reopened.globalMix,expected)
}
@MainActor func testMP4ImportWaveformAndPlaybackThroughGlobalMixer() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/VideoDemo.mp4"))
let db = try Library(url:root.appendingPathComponent("Library.sqlite"))
try Importer(media:root.appendingPathComponent("Media")).run([fixture],library:db)
let song = try XCTUnwrap(db.songs().first)
XCTAssertGreaterThan(song.duration,3)
let analysis = try AudioWaveform.analyse(url:URL(fileURLWithPath:song.audio),bins:64)
XCTAssertEqual(analysis.levels.count,64);XCTAssertGreaterThan(analysis.levels.max() ?? 0,0)
let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"))
player.lowGain = 3;player.volume = 0.37
try player.load(song);XCTAssertTrue(player.playing);XCTAssertGreaterThan(player.duration,3)
XCTAssertEqual(player.lowGain,3);XCTAssertEqual(player.volume,0.37)
for _ in 0..<100 {
if player.image != nil { break }
try await Task.sleep(nanoseconds:20_000_000)
}
XCTAssertNotNil(player.image,"MP4 must produce real decoded video frames")
XCTAssertEqual(player.image?.width,640)
player.seek(1);player.toggle();XCTAssertFalse(player.playing);player.stop()
}
func testSmartRotationInterleavesPreservesEmptyAndDepartedCursor() {
let roster = [RotationSinger(id:1,name:"A",here:true),.init(id:2,name:"B",here:true),.init(id:3,name:"Empty",here:true)]
let queue: [QueueEntry] = (0..<6).map { (i: Int) -> QueueEntry in
let singer: Int64 = i < 4 ? 1 : 2
return QueueEntry(id:Int64(i+1),songID:Int64(i+1),singerID:singer,title:"Song",singer:singer == 1 ? "A" : "B")
}
XCTAssertEqual(SingerRotation.plan(queue:queue,roster:roster,after:nil).map{$0.singerID!},[1,2,1,2,1,1])
XCTAssertEqual(SingerRotation.plan(queue:queue,roster:roster,after:1).first?.singerID,2)
var departed = roster;departed[0].here = false
XCTAssertEqual(SingerRotation.plan(queue:queue,roster:departed,after:1).map{$0.singerID!},[2,2])
XCTAssertEqual(SingerRotation.plan(queue:queue,roster:[],after:nil).count,0)
}
@MainActor func testRotationSettingsRosterAndPersonalRatingsPersistPerVenue() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root),venue = try model.createEntity(.venue,name:"Hall"),a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B")
let db = try XCTUnwrap(model.library),id = try db.add(title:"Song",audio:"/test.mp3",cdg:nil)
try model.refresh();let song = try XCTUnwrap(model.songs.first)
model.selectedSinger = a;model.updateTuning(id,pitch:3,tempo:1.2);model.rateSong(song,5)
model.selectedSinger = b;model.updateTuning(id,pitch:-2,tempo:0.8);model.rateSong(song,2)
model.markHere(a,true);model.markHere(b,true);model.moveSinger(b,by:-1)
model.hostSettings.smart = true;model.hostSettings.cursor = b;model.saveHostSettings()
let reopened = AppModel(storageURL:root);XCTAssertEqual(reopened.currentVenue,venue);XCTAssertTrue(reopened.hostSettings.smart);XCTAssertEqual(reopened.hostSettings.cursor,b)
XCTAssertEqual(reopened.rotationSingers.map(\.id),[b,a])
XCTAssertEqual(try db.preference(song:id,singer:a),.init(pitch:3,tempo:1.2,rating:5));XCTAssertEqual(try db.preference(song:id,singer:b),.init(pitch:-2,tempo:0.8,rating:2));XCTAssertEqual(try db.song(id)?.rating,0)
_ = try model.createEntity(.venue,name:"Other");XCTAssertFalse(model.hostSettings.smart);XCTAssertTrue(model.rotationSingers.isEmpty)
model.chooseVenue(venue);XCTAssertTrue(model.hostSettings.smart)
XCTAssertThrowsError(try db.savePreference(song:id,singer:a,value:.init(pitch:.nan,tempo:1,rating:0)))
}
func testBatchMetadataIsAtomicAndPlaylistOrderPersists() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let db = try Library(url:root.appendingPathComponent("db.sqlite")),a = try db.add(title:"Old A",audio:"/a.mp3",cdg:nil),b = try db.add(title:"Old B",audio:"/b.mp3",cdg:nil)
try db.batchMetadata([a,b],patch:.init(artist:"Shared",album:"Album",find:"Old",replacement:"New"))
XCTAssertEqual(try db.song(a)?.title,"New A");XCTAssertEqual(try db.song(b)?.artist,"Shared")
XCTAssertThrowsError(try db.batchMetadata([a,b],patch:.init(title:"")))
XCTAssertThrowsError(try db.batchMetadata([a,999],patch:.init(artist:"Wrong")))
XCTAssertEqual(try db.song(a)?.artist,"Shared")
try db.addEntity("playlists",name:"Show");let playlist = try XCTUnwrap(db.entities("playlists").first).id
try db.addToPlaylist(playlist,song:a);try db.addToPlaylist(playlist,song:b);try db.reorderPlaylist(playlist,song:b,by:-1)
let reopened = try Library(url:root.appendingPathComponent("db.sqlite"));XCTAssertEqual(try reopened.playlistSongIDs(playlist),[b,a])
}
func testTargetedMultiTermQuotedExactAndNumericSearch() {
let song = Song(id:52,title:"Love Me Tonight",audio:"/x",cdg:nil,rating:4,plays:0,artist:"The Beatles",album:"Live, Now")
for q in ["beat,love,me","artist:beat title:\"love me\"","album:=\"Live, Now\"","52","rating:4","title:Tonight"] { XCTAssertTrue(SongSearch.matches(song,query:q),q) }
for q in ["artist:love","=Love","53","beat missing"] { XCTAssertFalse(SongSearch.matches(song,query:q),q) }
}
@MainActor func testShowScreensPersistRenderPauseSeekAndFinish() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall")
let db = try XCTUnwrap(model.library),screen = ShowScreen(title:"Welcome",body:"Hello {venue}; Next: {next}",seconds:3)
let id = try db.saveScreen(screen);try model.refresh();let saved = try XCTUnwrap(model.showScreens.first)
XCTAssertEqual(saved.id,id);XCTAssertEqual(saved.expanded("{venue}/{next}",venue:"Hall",singer:"A",next:"B",song:"Song"),"Hall/B")
try model.playScreen(saved);XCTAssertEqual(model.player.image?.width,1920);XCTAssertEqual(model.player.image?.height,1080)
model.player.toggle();XCTAssertFalse(model.player.playing);model.player.seek(2.95)
var finished = 0;model.player.onFinished = { finished += 1 };model.player.toggle()
try await Task.sleep(nanoseconds:160_000_000)
XCTAssertEqual(finished,1);XCTAssertFalse(model.player.playing);model.player.stop()
XCTAssertEqual(try Library(url:root.appendingPathComponent("Library.sqlite")).screens().first?.title,"Welcome")
}
@MainActor func testFadePreservesGlobalMasterAndCancelsForNextSong() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")),song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0)
player.volume = 0.47;player.lowGain = 2;let mix = player.globalMix
try player.load(song);player.seek(3.8);try await Task.sleep(nanoseconds:80_000_000)
XCTAssertLessThan(player.outputOpacity,0.2);XCTAssertEqual(player.globalMix,mix)
player.fadeSeconds = 0.12;player.fadeOutAndStop();try await Task.sleep(nanoseconds:220_000_000)
XCTAssertNil(player.currentSongID);XCTAssertEqual(player.globalMix,mix)
try player.load(song);player.fadeOutAndStop();try player.load(song);try await Task.sleep(nanoseconds:330_000_000)
XCTAssertEqual(player.currentSongID,1);XCTAssertFalse(player.fading);player.stop()
}
func testMP3FileTagWritingBacksUpPreservesAudioAndRejectsBadTags() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")),url = root.appendingPathComponent("Demo.mp3"),backup = root.appendingPathComponent("Backups")
try FileManager.default.copyItem(at:fixture,to:url)
let original = try Data(contentsOf:url),before = try AudioWaveform.read(url:url,bins:64)
let song = Song(id:1,title:"Updated ✓",audio:url.path,cdg:nil,rating:0,plays:0,artist:"New Artist",album:"New Album",label:"Publisher")
try await FileTagWriter.write(song,backupDirectory:backup)
XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before)
let files = try FileManager.default.contentsOfDirectory(at:backup,includingPropertiesForKeys:nil);XCTAssertEqual(files.count,1);XCTAssertEqual(try Data(contentsOf:files[0]),original)
let metadata = try await AVURLAsset(url:url).load(.commonMetadata)
XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Updated ✓")
var second = song;second.title = "Second";try await FileTagWriter.write(second,backupDirectory:backup)
XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before)
}
@MainActor func testSingerAPIIsolationRevocationAndPersonalSettings() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root),venue = try model.createEntity(.venue,name:"Hall"),a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B"),db = try XCTUnwrap(model.library)
let song = try db.add(title:"Private & <Song>",audio:"/private/media.mp3",cdg:nil),other = try db.add(title:"Other",audio:"/other.mp3",cdg:nil)
let token = try db.singerToken(singer:a,venue:venue);_ = try db.saveScreen(.init());try model.refresh()
func request(_ method: String,_ target: String,_ body: [String:Any]? = nil,_ auth: String? = token) throws -> SingerHTTPResponse {
var headers = ["host":"localhost:8080","content-type":"application/json"];if let auth { headers["authorization"] = "Bearer "+auth }
return model.singerRequest(.init(method:method,target:target,headers:headers,body:try body.map{try JSONSerialization.data(withJSONObject:$0)} ?? Data()))
}
XCTAssertEqual(try request("GET","/api/catalog",nil,nil).status,401)
let catalogue = try request("GET","/api/catalog");XCTAssertEqual(catalogue.status,200)
let text = String(decoding:catalogue.body,as:UTF8.self);XCTAssertFalse(text.contains("/private/media"));XCTAssertFalse(text.contains("showscreen"));XCTAssertFalse(text.contains("cdg"))
XCTAssertEqual(try request("POST","/api/tonight",["song":song,"action":"add","singer":b]).status,200)
XCTAssertEqual(model.tonightSongIDs[a],[song]);XCTAssertNil(model.tonightSongIDs[b])
_ = try request("POST","/api/tonight",["song":other,"action":"add"])
_ = try request("POST","/api/tonight",["song":other,"action":"move","before":song]);XCTAssertEqual(model.tonightSongIDs[a],[other,song])
XCTAssertEqual(try request("POST","/api/preferences",["song":song,"pitch":2,"tempo":1.1,"rating":5]).status,200)
XCTAssertEqual(try db.preference(song:song,singer:a),.init(pitch:2,tempo:1.1,rating:5));XCTAssertEqual(try db.preference(song:song,singer:b).rating,0)
XCTAssertEqual(try request("POST","/api/preferences",["song":song,"pitch":100,"tempo":1,"rating":5]).status,400)
try db.revokeSinger(singer:a,venue:venue);XCTAssertEqual(try request("GET","/api/me").status,401)
let token2 = try db.singerToken(singer:a,venue:venue);XCTAssertNotEqual(token,token2)
_ = try model.createEntity(.venue,name:"Elsewhere");XCTAssertEqual(try request("GET","/api/me",nil,token2).status,401)
}
func testHTTPParserRejectsSmugglingOversizeAndWaitsForBody() throws {
XCTAssertNil(try SingerHTTPParser.parse(Data("POST /api/tonight HTTP/1.1\r\nContent-Length: 4\r\n\r\n{}".utf8)))
for text in ["GET / HTTP/1.1\r\nContent-Length: 0\r\nContent-Length: 1\r\n\r\n", "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n", "POST / HTTP/1.1\r\nContent-Length: 20000\r\n\r\n", "GET / HTTP/1.1\r\n\r\nextra"] { XCTAssertThrowsError(try SingerHTTPParser.parse(Data(text.utf8))) }
XCTAssertThrowsError(try SingerHTTPParser.parse(Data(repeating:65,count:33000)))
let parsed = try XCTUnwrap(SingerHTTPParser.parse(Data("GET /api/me HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer abc\r\n\r\n".utf8)))
XCTAssertEqual(parsed.headers["authorization"],"Bearer abc")
}
@MainActor func testSingerWebServerRealLoopbackHTTP() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root),venue = try model.createEntity(.venue,name:"Hall"),singer = try model.createEntity(.singer,name:"Singer"),db = try XCTUnwrap(model.library)
_ = try db.add(title:"Searchable",audio:"/not-exposed.mp3",cdg:nil);try model.refresh();let token = try db.singerToken(singer:singer,venue:venue)
let port = UInt16.random(in:20000...45000);model.startSingerServer(port:port,loopback:true);defer { model.singerServer.stop() }
for _ in 0..<100 { if model.singerServer.running || model.singerServer.error != nil { break };try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertTrue(model.singerServer.running,model.singerServer.error ?? "Server did not start")
var request = URLRequest(url:URL(string:"http://127.0.0.1:\(port)/api/catalog?q=Search")!);request.timeoutInterval = 4;request.setValue("Bearer "+token,forHTTPHeaderField:"Authorization")
let (data,response) = try await URLSession.shared.data(for:request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode,200);XCTAssertTrue(String(decoding:data,as:UTF8.self).contains("Searchable"));XCTAssertFalse(String(decoding:data,as:UTF8.self).contains("not-exposed"))
let (_,page) = try await URLSession.shared.data(from:URL(string:"http://127.0.0.1:\(port)/")!);XCTAssertEqual((page as? HTTPURLResponse)?.statusCode,200)
request.setValue(nil,forHTTPHeaderField:"Authorization");let (_,unauthorized) = try await URLSession.shared.data(for:request);XCTAssertEqual((unauthorized as? HTTPURLResponse)?.statusCode,401)
}
@MainActor func testAllStoreProfilesAndCatalogueValidation() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);model.installStoreProfiles();model.installStoreProfiles();XCTAssertEqual(model.musicStores.count,4)
let item = StoreCatalogueItem(id:"1",title:"Original",artist:"Test",url:"https://example.com/song")
let data = try JSONEncoder().encode([item]);XCTAssertEqual(try StoreCatalogueItem.decode(data),[item])
XCTAssertThrowsError(try StoreCatalogueItem.decode(JSONEncoder().encode([item,item])))
var invalid = item;invalid.previewURL = "file:///private/local";XCTAssertThrowsError(try StoreCatalogueItem.decode(JSONEncoder().encode([invalid])))
}
func testMP4TagExportPreservesVideoAndAudio() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/VideoDemo.mp4")),url = root.appendingPathComponent("Video.mp4")
try FileManager.default.copyItem(at:fixture,to:url)
let before = try AudioWaveform.read(url:url,bins:64)
let song = Song(id:1,title:"Tagged Video",audio:url.path,cdg:nil,rating:0,plays:0,artist:"Test Artist")
try await FileTagWriter.write(song,backupDirectory:root.appendingPathComponent("Backups"))
XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before)
let asset = AVURLAsset(url:url),tracks = try await asset.loadTracks(withMediaType:.video)
XCTAssertEqual(tracks.count,1)
let metadata = try await asset.load(.commonMetadata)
XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Tagged Video")
}
@MainActor func testDJStopCueHotCuesJogAndLoopKeepTrackLoaded() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")),song = Song(id:1,title:"DJ",audio:fixture.path,cdg:nil,rating:0,plays:0)
try player.load(song);player.stopDeck()
XCTAssertEqual(player.currentSongID,1);XCTAssertFalse(player.playing);XCTAssertEqual(player.time,0,accuracy:0.01);XCTAssertGreaterThan(player.duration,3)
player.seek(0.5);player.setCue();player.hotCue(0);player.jog(0.25);XCTAssertEqual(player.time,0.75,accuracy:0.02)
player.returnToCue();XCTAssertEqual(player.time,0.5,accuracy:0.02);player.seek(1);player.hotCue(0);XCTAssertEqual(player.time,0.5,accuracy:0.02)
player.hotCue(0,clear:true);XCTAssertNil(player.hotCues[0])
player.seek(0.5);player.markLoopIn();player.seek(0.75);player.markLoopOut();XCTAssertTrue(player.loopEnabled)
player.seek(0.6);player.toggle();try await Task.sleep(nanoseconds:380_000_000)
XCTAssertTrue(player.playing);XCTAssertLessThan(player.time,0.85);XCTAssertGreaterThanOrEqual(player.time,0.49)
player.stopDeck();XCTAssertFalse(player.loopEnabled);XCTAssertEqual(player.cuePosition,0.5,accuracy:0.01)
player.stop();XCTAssertNil(player.currentSongID);XCTAssertEqual(player.cuePosition,0);XCTAssertNil(player.loopIn)
}
@MainActor func testManualBackgroundStopsBeforeSingerAndNeverAutoResumes() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let model = AppModel(storageURL:root);defer { model.player.stop();model.backgroundDeck.unload() }
_ = try model.createEntity(.venue,name:"Hall");let singer = try model.createEntity(.singer,name:"Singer")
let id = try XCTUnwrap(model.library).add(title:"Song",audio:fixture.path,cdg:nil);try model.refresh();let song = try XCTUnwrap(model.songs.first{$0.id == id})
model.studio.automaticBackground = false;model.studio.backgroundPath = fixture.path;XCTAssertTrue(model.backgroundDeck.loaded);XCTAssertFalse(model.backgroundDeck.playing)
model.player.volume = 0.42;model.player.lowGain = 3;XCTAssertEqual(model.backgroundDeck.mix,model.player.globalMix)
model.playBackground();XCTAssertTrue(model.backgroundDeck.playing)
try await Task.sleep(nanoseconds:100_000_000)
try model.play(song,singer:singer);XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.backgroundDeck.interruptedBySinger);XCTAssertTrue(model.player.playing)
model.playBackground();XCTAssertFalse(model.backgroundDeck.playing);model.error = nil
model.player.stopDeck();XCTAssertFalse(model.backgroundDeck.playing)
model.playBackground();XCTAssertTrue(model.backgroundDeck.playing)
model.player.toggle();XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.backgroundDeck.interruptedBySinger)
model.player.stop();XCTAssertFalse(model.backgroundDeck.playing)
model.backgroundDeck.stop();XCTAssertEqual(model.backgroundDeck.time,0)
}
@MainActor func testSettingsPersistWithoutAutoplayAndPreserveMaster() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let model = AppModel(storageURL:root)
var settings = model.studio;settings.autoAdvance = false;settings.jogSecondsPerTurn = 4;settings.nudgeSeconds = 0.1;settings.graphicsOffset = 0.25;settings.backgroundPath = fixture.path;settings.backgroundTrim = 0.17;settings.backgroundLoop = false;settings.serverPort = 18080;model.studio = settings
model.player.volume = 0.31;model.player.midGain = -2
let reopened = AppModel(storageURL:root);defer { reopened.backgroundDeck.unload();model.backgroundDeck.unload() }
XCTAssertEqual(reopened.studio,settings);XCTAssertFalse(reopened.autoAdvance);XCTAssertEqual(reopened.player.jogSecondsPerTurn,4);XCTAssertEqual(reopened.player.graphicsOffset,0.25)
try await Task.sleep(nanoseconds:400_000_000)
XCTAssertTrue(reopened.backgroundDeck.loaded);XCTAssertFalse(reopened.backgroundDeck.playing);XCTAssertEqual(reopened.backgroundDeck.trim,0.17);XCTAssertFalse(reopened.backgroundDeck.repeating)
XCTAssertEqual(reopened.player.volume,0.31);XCTAssertEqual(reopened.backgroundDeck.mix.mid,-2);XCTAssertFalse(reopened.singerServer.running)
}
@MainActor func testBulkTuningCacheSwitchesSingersAndUpdatesWithoutLibraryReload() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B"),db = try XCTUnwrap(model.library)
let id = try db.add(title:"Test",audio:"/test.mp3",cdg:nil)
try db.saveDefaultPreference(song:id,value:.init(pitch:1,tempo:1.1));try db.savePreference(song:id,singer:a,value:.init(pitch:3,tempo:0.9,rating:5));try model.refresh()
model.selectedSinger = a;XCTAssertEqual(model.preference(id),.init(pitch:3,tempo:0.9,rating:5))
model.selectedSinger = b;XCTAssertEqual(model.preference(id).pitch,1)
let songs = model.songs;model.updateTuning(id,pitch:-2);XCTAssertEqual(model.preference(id).pitch,-2);XCTAssertEqual(model.songs,songs)
model.selectedSinger = a;XCTAssertEqual(model.preference(id).pitch,3)
}
@MainActor func testRotationDragTargetsExplicitVenueAndReordersRoster() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B")
model.markAllSingersHere();let reference = try XCTUnwrap(model.rotationReference(b)),decoded = try XCTUnwrap(RotationDrag.parse(reference.text))
try model.applyRotationDrop(decoded,before:a);XCTAssertEqual(model.rotationSingers.map(\.id),[b,a])
var foreign = decoded;foreign.session = "foreign";XCTAssertThrowsError(try model.applyRotationDrop(foreign,before:nil))
_ = try model.createEntity(.venue,name:"Other");XCTAssertThrowsError(try model.applyRotationDrop(decoded,before:nil))
}
@MainActor func testActualSingerDisclosurePressAndNavigationRetainExpansion() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let singer = try model.createEntity(.singer,name:"Alex")
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:900).preferredColorScheme(.dark))
host.frame = NSRect(x:0,y:0,width:1320,height:900)
final class SidebarTestWindow: NSWindow { override var canBecomeKey: Bool { true } }
let window = SidebarTestWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host
let previous = NSApp.keyWindow
window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded();host.displayIfNeeded()
if let image = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:image) }
func click(_ x: Double,_ top: Double) async throws {
let point = host.convert(NSPoint(x:x,y:host.isFlipped ? top : host.bounds.height-top),to:nil)
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0))
window.sendEvent(event)
}
try await Task.sleep(nanoseconds:150_000_000)
}
model.expandedSingerIDs.remove(singer)
try await Task.sleep(nanoseconds:50_000_000)
try await click(42,369)
XCTAssertTrue(model.expandedSingerIDs.contains(singer),"Clicking the actual chevron must expand the singer")
model.activeSection = "Rotation"
try await Task.sleep(nanoseconds:150_000_000)
XCTAssertEqual(model.activeSection,"Rotation")
model.activeSection = "Library"
try await Task.sleep(nanoseconds:150_000_000)
XCTAssertEqual(model.activeSection,"Library")
XCTAssertTrue(model.expandedSingerIDs.contains(singer))
try await click(42,369)
XCTAssertFalse(model.expandedSingerIDs.contains(singer));XCTAssertNil(model.error)
}
@MainActor func testRapidCDGJogCannotPublishAnOldSeekFrame() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")),graphics = root.appendingPathComponent("Seek.cdg")
var packets = [[UInt8]](repeating:packet(0,[]),count:1200)
packets[0] = packet(30,[0,0,60,0,3,48,0,15]);packets[300] = packet(1,[1,0]);packets[600] = packet(1,[2,0]);packets[900] = packet(1,[3,0])
try Data(packets.flatMap{$0}).write(to:graphics)
let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"));defer { player.stop() }
try player.load(.init(id:1,title:"Seek",audio:audio.path,cdg:graphics.path,rating:0,plays:0));player.stopDeck()
player.seek(3.5);player.seek(1.2)
try await Task.sleep(nanoseconds:120_000_000)
let red = try XCTUnwrap(player.image?.dataProvider?.data) as Data
XCTAssertEqual(Array(red.prefix(3)),[255,0,0])
player.seek(2.4);try await Task.sleep(nanoseconds:80_000_000)
let green = try XCTUnwrap(player.image?.dataProvider?.data) as Data;XCTAssertEqual(Array(green.prefix(3)),[0,255,0])
player.seek(0.2);try await Task.sleep(nanoseconds:80_000_000)
let black = try XCTUnwrap(player.image?.dataProvider?.data) as Data;XCTAssertEqual(Array(black.prefix(3)),[0,0,0])
}
@MainActor func testAutomaticBackgroundHandoffAndManualOverride() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let model = AppModel(storageURL:root);defer { model.backgroundHandoff?.cancel();model.player.stop();model.backgroundDeck.unload() }
_ = try model.createEntity(.venue,name:"Hall")
let id = try XCTUnwrap(model.library).add(title:"Song",audio:fixture.path,cdg:nil);try model.refresh()
model.studio.backgroundPath = fixture.path
try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing)
try model.play(try XCTUnwrap(model.songs.first{$0.id == id}));XCTAssertFalse(model.backgroundDeck.playing)
model.player.stopDeck();try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing)
model.pauseBackground();XCTAssertFalse(model.studio.backgroundAutoEnabled)
model.player.toggle();model.player.stopDeck();try await Task.sleep(nanoseconds:400_000_000);XCTAssertFalse(model.backgroundDeck.playing)
model.studio.automaticBackground = true;try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing)
model.stopBackground();XCTAssertFalse(model.backgroundDeck.playing);XCTAssertEqual(model.backgroundDeck.time,0)
}
@MainActor func testNewSingerSongsImmediatelyJoinSmartRotation() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library)
let venue = try model.createEntity(.venue,name:"Hall")
let song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil)
let singer = try model.createSinger(name:"Alex",venue:venue,songs:[song])
model.hostSettings.smart = true;model.saveHostSettings()
XCTAssertTrue(model.expandedSingerIDs.contains(singer));XCTAssertTrue(model.rotationSingers.contains{$0.id == singer && $0.here})
XCTAssertEqual(model.rotationPlan.map(\.songID),[song]);XCTAssertEqual(model.tonightSongIDs[singer],[song])
model.markHere(singer,false);XCTAssertTrue(model.rotationPlan.isEmpty)
model.addToTonight(song,singer:singer);XCTAssertEqual(model.rotationPlan.map(\.songID),[song])
}
@MainActor func testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let model = AppModel(storageURL:root);defer { model.backgroundHandoff?.cancel();model.player.stop();model.backgroundDeck.unload() }
let venue = try model.createEntity(.venue,name:"Hall"),db = try XCTUnwrap(model.library)
let song = try db.add(title:"Song",audio:audio.path,cdg:nil)
let a = try model.createSinger(name:"A",venue:venue,songs:[song]),b = try model.createSinger(name:"B",venue:venue,songs:[song])
model.hostSettings.smart = true;model.hostSettings.autoFade = false;model.saveHostSettings()
model.studio.backgroundPath = audio.path
model.playQueueNext();XCTAssertEqual(model.playbackSinger,a)
model.player.seek(model.player.duration-0.15)
try await Task.sleep(nanoseconds:650_000_000)
XCTAssertEqual(model.playbackSinger,b);XCTAssertTrue(model.player.playing);XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.queue.isEmpty)
model.player.seek(model.player.duration-0.15)
try await Task.sleep(nanoseconds:750_000_000)
XCTAssertFalse(model.player.playing);XCTAssertTrue(model.backgroundDeck.playing)
}
@MainActor func testRotationStartPauseResumeStopAndEmptyExplanation() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);defer { model.player.stop() }
model.startRotation();XCTAssertNotNil(model.error);model.error = nil
let venue = try model.createEntity(.venue,name:"Hall")
model.startRotation();XCTAssertNotNil(model.error);model.error = nil
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let song = try XCTUnwrap(model.library).add(title:"Song",audio:audio.path,cdg:nil)
_ = try model.createSinger(name:"A",venue:venue,songs:[song])
model.studio.autoAdvance = false;model.startRotation()
XCTAssertTrue(model.rotationPlaybackActive);XCTAssertTrue(model.player.playing);XCTAssertTrue(model.autoAdvance);XCTAssertTrue(model.queue.isEmpty)
model.pauseRotation();XCTAssertFalse(model.player.playing);model.startRotation();XCTAssertTrue(model.player.playing)
model.stopRotation();XCTAssertFalse(model.player.playing);XCTAssertFalse(model.rotationPlaybackActive);XCTAssertFalse(model.autoAdvance)
model.startRotation();XCTAssertTrue(model.player.playing);XCTAssertTrue(model.rotationPlaybackActive);XCTAssertNil(model.error)
}
@MainActor func testUnifiedRosterDropRoutesSingerAndSongProviders() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library)
let venue = try model.createEntity(.venue,name:"Hall")
let a = try model.createSinger(name:"A",venue:venue,songs:[]),b = try model.createSinger(name:"B",venue:venue,songs:[])
let song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil);try model.refresh()
let reference = try XCTUnwrap(model.rotationReference(b))
XCTAssertTrue(model.acceptRosterDrop([NSItemProvider(object:reference.text as NSString)],target:a))
try await Task.sleep(nanoseconds:150_000_000);XCTAssertEqual(model.rotationSingers.map(\.id),[b,a])
XCTAssertTrue(model.acceptRosterDrop([model.dragReference(song).provider()],target:b))
try await Task.sleep(nanoseconds:150_000_000);XCTAssertEqual(model.tonightSongIDs[b],[song]);XCTAssertEqual(model.queue.first?.singerID,b)
do { try await model.applyRosterDropText("unrelated text",target:a);XCTFail("Must reject foreign drops") } catch {}
}
@MainActor func testVisibleSingerReorderUpdatesManualAndSmartPlansAndPersists() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library)
let venue = try model.createEntity(.venue,name:"Hall"),song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil)
let a = try model.createSinger(name:"A",venue:venue,songs:[song]),b = try model.createSinger(name:"B",venue:venue,songs:[song])
XCTAssertFalse(model.hostSettings.smart);model.reorderRotationSinger(b,by:-1)
XCTAssertEqual(model.rotationPlan.compactMap(\.singerID),[b,a])
model.hostSettings.smart = true;model.hostSettings.cursor = b;model.saveHostSettings()
model.reorderRotationSinger(a,by:-1);XCTAssertNil(model.hostSettings.cursor)
XCTAssertEqual(model.rotationPlan.compactMap(\.singerID),[a,b])
let reopened = AppModel(storageURL:root);XCTAssertEqual(reopened.rotationSingers.map(\.id),[a,b]);XCTAssertNil(model.error)
}
@MainActor func testActualRotationButtonsAddSongReorderAndStart() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);defer { model.player.stop() }
let venue = try model.createEntity(.venue,name:"Hall")
let a = try model.createSinger(name:"Alex",venue:venue,songs:[]),b = try model.createSinger(name:"Sam",venue:venue,songs:[])
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let song = try XCTUnwrap(model.library).add(title:"Demo",audio:audio.path,cdg:nil);try model.refresh();model.activeSection = "Rotation";model.selectedSinger = a
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1060,height:760).preferredColorScheme(.dark));host.frame = NSRect(x:0,y:0,width:1060,height:760)
final class RotationTestWindow: NSWindow { override var canBecomeKey: Bool { true } }
let window = RotationTestWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host
let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
try await Task.sleep(nanoseconds:250_000_000);host.layoutSubtreeIfNeeded()
func capture(_ name: String) throws {
host.layoutSubtreeIfNeeded()
if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) {
host.cacheDisplay(in:host.bounds,to:rep)
if let data = rep.representation(using:.png,properties:[:]) {
let attachment = XCTAttachment(data:data,uniformTypeIdentifier:"public.png");attachment.name = "Rotation "+name;attachment.lifetime = .deleteOnSuccess
XCTContext.runActivity(named:"Rotation "+name) { $0.add(attachment) }
}
}
}
func click(_ x: Double,_ top: Double) async throws {
let point = host.convert(NSPoint(x:x,y:host.isFlipped ? top : host.bounds.height-top),to:nil)
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));window.sendEvent(event)
}
try await Task.sleep(nanoseconds:120_000_000)
}
try await click(492,521);try capture("songs")
try await click(960,595);try capture("added")
XCTAssertEqual(model.tonightSongIDs[a],[song],"Actual Add to Tonight button must enqueue the song")
try await click(520,526);try capture("reordered")
XCTAssertEqual(model.rotationSingers.map(\.id),[b,a],"Visible down arrow must move Alex after Sam")
try await click(340,405)
XCTAssertTrue(model.player.playing,"Actual Start Rotation button must play");XCTAssertTrue(model.rotationPlaybackActive);XCTAssertEqual(model.playbackSinger,a);XCTAssertNil(model.error)
}
@MainActor func testBackgroundSeekRetainsPlaybackStateAndSingerInterlock() async throws {
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let deck = BackgroundDeck();defer { deck.unload() };try deck.load(audio)
deck.seek(1.5);XCTAssertEqual(deck.time,1.5,accuracy:0.01);XCTAssertFalse(deck.playing)
try deck.start();deck.seek(2);XCTAssertTrue(deck.playing)
try await Task.sleep(nanoseconds:150_000_000);XCTAssertGreaterThan(deck.time,2)
deck.pauseForSinger();deck.seek(1);XCTAssertFalse(deck.playing);XCTAssertTrue(deck.interruptedBySinger);XCTAssertEqual(deck.time,1,accuracy:0.01)
deck.seek(-10);XCTAssertEqual(deck.time,0);deck.seek(.nan);XCTAssertEqual(deck.time,0)
try deck.start();deck.seek(deck.duration+10);XCTAssertFalse(deck.playing);XCTAssertEqual(deck.time,deck.duration,accuracy:0.01)
deck.seek(0.5);try deck.start();XCTAssertTrue(deck.playing);XCTAssertEqual(deck.time,0.5,accuracy:0.01)
}
@MainActor func testBackgroundFadesSaveSettingsAndCancelOnSeekOrSinger() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let model = AppModel(storageURL:root);defer { model.backgroundDeck.unload() }
model.studio.automaticBackground = false;model.studio.backgroundPath = audio.path
model.studio.backgroundFadeIn = 0.25;model.studio.backgroundFadeOut = 0.25
model.playBackground();XCTAssertEqual(model.backgroundDeck.outputGain,0,accuracy:0.001)
try await Task.sleep(nanoseconds:100_000_000);XCTAssertGreaterThan(model.backgroundDeck.outputGain,0);XCTAssertLessThan(model.backgroundDeck.outputGain,Float(model.player.volume*model.studio.backgroundTrim))
try await Task.sleep(nanoseconds:250_000_000);XCTAssertTrue(model.backgroundDeck.playing)
model.pauseBackground();XCTAssertFalse(model.backgroundDeck.playing)
try await Task.sleep(nanoseconds:350_000_000);XCTAssertTrue(model.backgroundDeck.status.hasPrefix("Paused"));XCTAssertEqual(model.backgroundDeck.outputGain,0,accuracy:0.001)
model.backgroundDeck.seek(1);model.playBackground();model.backgroundDeck.pauseForSinger()
try await Task.sleep(nanoseconds:350_000_000);XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.backgroundDeck.interruptedBySinger)
model.playBackground();model.stopBackground();model.backgroundDeck.seek(1.5)
try await Task.sleep(nanoseconds:350_000_000);XCTAssertEqual(model.backgroundDeck.time,1.5,accuracy:0.01);XCTAssertFalse(model.backgroundDeck.playing)
let reopened = AppModel(storageURL:root);defer { reopened.backgroundDeck.unload() }
XCTAssertEqual(reopened.backgroundDeck.fadeInSeconds,0.25);XCTAssertEqual(reopened.backgroundDeck.fadeOutSeconds,0.25)
}
@MainActor func testSingerWaitsForAudibleBackgroundFadeOut() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);defer { model.player.stop();model.backgroundDeck.unload() }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
_ = try model.createEntity(.venue,name:"Hall")
let id = try XCTUnwrap(model.library).add(title:"Singer",audio:audio.path,cdg:nil);try model.refresh()
model.studio.automaticBackground = false;model.studio.backgroundPath = audio.path;model.studio.backgroundFadeOut = 0.4
model.playBackground();let initial = model.backgroundDeck.outputGain;XCTAssertGreaterThan(initial,0)
try model.play(try XCTUnwrap(model.songs.first{$0.id == id}))
XCTAssertTrue(model.player.waitingForBackground);XCTAssertFalse(model.player.audioPlaybackStarted)
try await Task.sleep(nanoseconds:150_000_000)
XCTAssertGreaterThan(model.backgroundDeck.outputGain,0);XCTAssertLessThan(model.backgroundDeck.outputGain,initial);XCTAssertFalse(model.player.audioPlaybackStarted)
try await Task.sleep(nanoseconds:400_000_000)
XCTAssertTrue(model.player.audioPlaybackStarted);XCTAssertFalse(model.player.waitingForBackground);XCTAssertEqual(model.backgroundDeck.outputGain,0,accuracy:0.001);XCTAssertTrue(model.backgroundDeck.interruptedBySinger)
model.player.stopDeck();model.playBackground();model.pauseBackground()
try await Task.sleep(nanoseconds:150_000_000)
XCTAssertGreaterThan(model.backgroundDeck.outputGain,0);XCTAssertLessThan(model.backgroundDeck.outputGain,initial)
try await Task.sleep(nanoseconds:350_000_000);XCTAssertEqual(model.backgroundDeck.outputGain,0,accuracy:0.001);XCTAssertFalse(model.backgroundDeck.playing)
}
@MainActor func testPauseAndUnloadCancelSingerWaitingForBackgroundFade() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root);defer { model.player.stop();model.backgroundDeck.unload() }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let id = try XCTUnwrap(model.library).add(title:"Singer",audio:audio.path,cdg:nil);try model.refresh()
model.studio.automaticBackground = false;model.studio.backgroundPath = audio.path;model.studio.backgroundFadeOut = 0.25
model.playBackground();try model.play(try XCTUnwrap(model.songs.first{$0.id == id}));model.player.toggle()
try await Task.sleep(nanoseconds:400_000_000)
XCTAssertFalse(model.player.playing);XCTAssertFalse(model.player.audioPlaybackStarted);XCTAssertFalse(model.player.waitingForBackground)
model.player.toggle();XCTAssertTrue(model.player.audioPlaybackStarted)
model.player.toggle();model.playBackground();model.player.toggle();XCTAssertTrue(model.player.waitingForBackground)
model.player.stop();try await Task.sleep(nanoseconds:400_000_000)
XCTAssertFalse(model.player.audioPlaybackStarted);XCTAssertNil(model.player.currentSongID);XCTAssertFalse(model.player.waitingForBackground)
}
func testWaveformBatchGeneratesReusesAndForcesFreshAnalysis() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let song = Song(id:1,title:"Demo",audio:audio.path,cdg:nil,rating:0,plays:0)
let zero = AudioWaveform.Analysis(levels:Array(repeating:0,count:4096),bands:Array(repeating:.init(low:0,mid:0,high:0),count:4096))
try WaveformCache.save(zero,audio:audio,directory:root)
let reused = WaveformBatch.run(songs:[song],locations:[],cache:root)
XCTAssertEqual(reused.cached,1);XCTAssertEqual(reused.generated,0);XCTAssertEqual(try WaveformCache.load(audio:audio,directory:root).levels.max(),0)
let generated = WaveformBatch.run(songs:[song],locations:[],cache:root,force:true)
XCTAssertEqual(generated.generated,1);XCTAssertEqual(generated.issues,0);XCTAssertEqual(generated.processed,1)
XCTAssertGreaterThan(try XCTUnwrap(WaveformCache.load(audio:audio,directory:root).levels.max()),0.0001)
}
func testWaveformBatchDeduplicatesReportsMissingMediaAndSkipsSlides() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let one = Song(id:1,title:"Demo",audio:audio.path,cdg:nil,rating:0,plays:0),two = Song(id:2,title:"Same audio",audio:audio.path,cdg:nil,rating:0,plays:0)
let missing = Song(id:3,title:"Missing drive",audio:root.appendingPathComponent("missing.mp3").path,cdg:nil,rating:0,plays:0),slide = Song(id:4,title:"Slide",audio:"showscreen:4",cdg:nil,rating:0,plays:0)
let result = WaveformBatch.run(songs:[one,one,two,missing,slide],locations:[],cache:root,force:true)
XCTAssertEqual(result.total,4);XCTAssertEqual(result.processed,4);XCTAssertEqual(result.generated,1);XCTAssertEqual(result.cached,1);XCTAssertEqual(result.issues,1);XCTAssertEqual(result.skipped,1);XCTAssertTrue(result.messages.first?.contains("Missing drive") == true)
XCTAssertTrue(result.finished);XCTAssertFalse(result.cancelled)
}
func testWaveformBatchCancellationKeepsCompletedCaches() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let songs = (1...3).map{Song(id:Int64($0),title:"Demo \($0)",audio:audio.path,cdg:nil,rating:0,plays:0)}
var cancelled = false
let result = WaveformBatch.run(songs:songs,locations:[],cache:root,cancelled:{cancelled},progress:{if $0.processed > 0 { cancelled = true }})
XCTAssertTrue(result.cancelled);XCTAssertEqual(result.processed,1);XCTAssertEqual(result.generated,1);XCTAssertEqual(try WaveformCache.load(audio:audio,directory:root).levels.count,4096)
}
@MainActor func testWaveformSelectionAndWholeLibraryGenerateWithoutChangingPlayback() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav")),copy = root.appendingPathComponent("Second.wav")
try FileManager.default.copyItem(at:fixture,to:copy)
let a = try db.add(title:"One",audio:fixture.path,cdg:nil),b = try db.add(title:"Two",audio:copy.path,cdg:nil);try model.refresh()
try model.play(try XCTUnwrap(model.songs.first{$0.id == a}));defer { model.player.stop();model.cancelWaveformGeneration() }
let before = model.songs,historyCount = model.history.count,cache = root.appendingPathComponent("Waveforms")
model.generateWaveforms([a],force:true)
for _ in 0..<500 { if !model.generatingWaveforms { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.generatingWaveforms);XCTAssertEqual(model.waveformJob?.total,1);XCTAssertEqual(model.player.currentSongID,a);XCTAssertTrue(model.player.playing);XCTAssertThrowsError(try WaveformCache.load(audio:copy,directory:cache))
model.generateWaveforms()
for _ in 0..<500 { if !model.generatingWaveforms { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.generatingWaveforms);XCTAssertEqual(model.waveformJob?.total,2);XCTAssertEqual(model.waveformJob?.issues,0);XCTAssertEqual(try WaveformCache.load(audio:copy,directory:cache).levels.count,4096)
XCTAssertEqual(model.songs,before);XCTAssertEqual(model.history.count,historyCount);XCTAssertEqual(model.player.currentSongID,a);XCTAssertTrue(model.player.playing);XCTAssertNotEqual(a,b)
model.dismissWaveformProgress();XCTAssertNil(model.waveformJob)
}
}
extension KJamsCloneTests {
func testDJCrossfaderAndTempoMatch() {
let left = DJMix.weights(0),right = DJMix.weights(1),centre = DJMix.weights(0.5)
XCTAssertEqual(left.0,1,accuracy:0.0001);XCTAssertEqual(left.1,0,accuracy:0.0001)
XCTAssertEqual(right.0,0,accuracy:0.0001);XCTAssertEqual(right.1,1,accuracy:0.0001)
XCTAssertEqual(centre.0*centre.0+centre.1*centre.1,1,accuracy:0.0001)
XCTAssertEqual(DJMix.weights(-20).0,1,accuracy:0.0001)
XCTAssertEqual(DJMix.weights(.nan).0,centre.0,accuracy:0.0001)
XCTAssertEqual(DJMix.syncRate(bpm:120,otherBPM:126,otherRate:1),1.05)
XCTAssertNil(DJMix.syncRate(bpm:0,otherBPM:126,otherRate:1))
XCTAssertNil(DJMix.syncRate(bpm:40,otherBPM:240,otherRate:1))
}
func testDJMIDIParserRunningStatusRealtimeSysExAndPacketSplits() {
var decoder = DJMIDIDecoder()
XCTAssertEqual(decoder.feed([0xB2,7]),[])
XCTAssertEqual(decoder.feed([0xF8,127,8,0]),[.init(status:0xB2,data1:7,data2:127),.init(status:0xB2,data1:8,data2:0)])
XCTAssertEqual(decoder.feed([0xF0,1,2,0xF8,3,0xF7,9,10]),[])
XCTAssertEqual(decoder.feed([0x92,20,127,20,0,0x82,20,0]),[.init(status:0x92,data1:20,data2:127),.init(status:0x92,data1:20,data2:0),.init(status:0x82,data1:20,data2:0)])
XCTAssertEqual(decoder.feed([0xC0,20,0xD0,1]),[])
XCTAssertEqual(DJMIDIMapping.value(127,mode:"Relative signed"),-1)
XCTAssertEqual(DJMIDIMapping.value(65,mode:"Relative offset"),1)
}
@MainActor func testDJMIDILearnPersistenceAndButtonReleaseFiltering() throws {
let suite = "KJMix.DJ.Tests.\(UUID().uuidString)",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let midi = DJMIDI(defaults:defaults,connect:false)
var actions = [(String,Double)]();midi.onAction = { action,value,_ in actions.append((action,value)) }
midi.learning = "A Play";midi.receive([0x90,60,127])
XCTAssertNil(midi.learning);XCTAssertEqual(midi.mappings.count,1);XCTAssertTrue(actions.isEmpty)
midi.receive([0x90,60,0,0x80,60,0]);XCTAssertTrue(actions.isEmpty)
midi.receive([0x90,60,127]);XCTAssertEqual(actions.map{$0.0},["A Play"])
midi.learning = "Crossfader";midi.receive([0xB0,3,64]);midi.receive([0xB0,3,0])
XCTAssertEqual(actions.last?.0,"Crossfader");XCTAssertEqual(actions.last?.1,0)
let restored = DJMIDI(defaults:defaults,connect:false)
XCTAssertEqual(restored.mappings,midi.mappings)
midi.remove(midi.mappings[0].id);XCTAssertEqual(midi.mappings.count,1)
}
@MainActor func testDJPausedLoadMixerIsolationTapAndBeatLoop() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let song = Song(id:1,title:"DJ test",audio:fixture.path,cdg:nil,rating:0,plays:0)
let karaoke = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"))
karaoke.volume = 0.42;karaoke.lowGain = 3
let savedMix = try Data(contentsOf:root.appendingPathComponent("GlobalMixer.json"))
let channel = DJChannel("A");defer { channel.player.stop();karaoke.stop() }
try channel.load(song)
XCTAssertFalse(channel.player.playing);XCTAssertFalse(channel.player.audioPlaybackStarted)
XCTAssertEqual(channel.player.currentSongID,1);XCTAssertFalse(channel.bpmConfirmed)
channel.tap(at:10);channel.tap(at:10.5);channel.tap(at:11)
XCTAssertTrue(channel.bpmConfirmed);XCTAssertEqual(channel.bpm,120,accuracy:0.01)
channel.level = 0.6;channel.gain = 6;channel.crossWeight = 0
XCTAssertEqual(channel.player.volume,0,accuracy:0.0001)
channel.crossWeight = 1;XCTAssertEqual(channel.player.volume,0.48,accuracy:0.0001);XCTAssertEqual(channel.player.inputGain,6)
channel.player.lowGain = -5
XCTAssertEqual(karaoke.volume,0.42);XCTAssertEqual(karaoke.lowGain,3)
XCTAssertEqual(try Data(contentsOf:root.appendingPathComponent("GlobalMixer.json")),savedMix)
channel.loopBeats = 1;channel.player.seek(0.5);channel.beatLoop()
XCTAssertTrue(channel.player.loopEnabled);XCTAssertFalse(channel.player.playing)
XCTAssertEqual(try XCTUnwrap(channel.player.loopIn),0.5,accuracy:0.01)
XCTAssertEqual(try XCTUnwrap(channel.player.loopOut),1,accuracy:0.01)
channel.beatLoop();XCTAssertFalse(channel.player.loopEnabled)
}
@MainActor func testDJDecksPlayIndependentlyAndSyncWithoutChangingKaraoke() async throws {
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString));defer { try? FileManager.default.removeItem(at:system.root) };defer { system.a.player.stop();system.b.player.stop();system.stopPreview() }
let song = Song(id:1,title:"Two decks",audio:fixture.path,cdg:nil,rating:0,plays:0)
system.master = 0 // Exercise real rendering silently.
system.load(song,into:"A");system.load(song,into:"B")
XCTAssertNil(system.error);XCTAssertFalse(system.a.player.playing);XCTAssertFalse(system.b.player.playing)
system.a.player.toggle();system.b.player.seek(0.5);system.b.player.toggle()
try await Task.sleep(nanoseconds:300_000_000)
XCTAssertTrue(system.a.player.audioPlaybackStarted);XCTAssertTrue(system.b.player.audioPlaybackStarted)
XCTAssertGreaterThan(system.a.player.time,0.05);XCTAssertGreaterThan(system.b.player.time,0.55)
system.a.bpm = 120;system.a.bpmConfirmed = true;system.b.bpm = 126;system.b.bpmConfirmed = true
system.sync(system.a);XCTAssertTrue(system.syncActive);XCTAssertEqual(system.a.player.tempo,1,accuracy:0.001);XCTAssertEqual(system.b.player.tempo,120.0/126,accuracy:0.001)
system.a.player.stopDeck();XCTAssertTrue(system.b.player.playing)
system.startPreview(system.b);XCTAssertNotNil(system.error);XCTAssertNil(system.previewDeck);XCTAssertFalse(system.preview.playing)
}
}
extension KJamsCloneTests {
@MainActor func testDJWorkstationNativeLayoutAndTransport() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
defer { model.player.stop();model.backgroundDeck.unload();model.dj.a.player.stop();model.dj.b.player.stop() }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let id = try XCTUnwrap(model.library).add(title:"Midnight City · demo audio",audio:fixture.path,cdg:nil)
try model.refresh()
_ = try model.dj.library?.add(title:"DJ demo audio",audio:fixture.path,cdg:nil);try model.dj.refresh()
model.dj.load(try XCTUnwrap(model.dj.songs.first),into:"A");model.dj.load(try XCTUnwrap(model.dj.songs.first),into:"B");model.dj.master = 0
model.studio.automaticBackground = false
try model.backgroundDeck.load(fixture,title:"Between singers · warm-up")
model.dj.a.bpmConfirmed = true;model.dj.b.bpmConfirmed = true
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:1100).preferredColorScheme(.dark))
host.frame = NSRect(x:0,y:0,width:1320,height:1100)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.appearance = NSAppearance(named:.darkAqua);window.contentView = host
let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
try await Task.sleep(nanoseconds:350_000_000);host.layoutSubtreeIfNeeded()
if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-dj-native.png")) }
XCTAssertEqual(model.dj.a.player.currentSongID,id);XCTAssertEqual(model.dj.b.player.currentSongID,id)
XCTAssertFalse(model.dj.a.player.playing);XCTAssertNil(model.player.currentSongID)
func capturePreferences(_ scope: String,_ category: String,_ path: String) async throws {
let settings = NSHostingView(rootView:StudioSettingsDialog(model:model,scope:scope,category:category).preferredColorScheme(.dark))
settings.frame = NSRect(x:0,y:0,width:850,height:700)
let settingsWindow = NSWindow(contentRect:settings.frame,styleMask:[.borderless],backing:.buffered,defer:false)
settingsWindow.appearance = NSAppearance(named:.darkAqua);settingsWindow.contentView = settings
settingsWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));settingsWindow.orderFront(nil)
defer { settingsWindow.orderOut(nil);settingsWindow.contentView = nil }
try await Task.sleep(nanoseconds:150_000_000);settings.layoutSubtreeIfNeeded()
if let rep = settings.bitmapImageRepForCachingDisplay(in:settings.bounds) { settings.cacheDisplay(in:settings.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:path)) }
}
try await capturePreferences("Karaoke","General","/private/tmp/kjmix-settings-native.png")
try await capturePreferences("DJ","General","/private/tmp/kjmix-dj-settings-general.png")
try await capturePreferences("DJ","Sound","/private/tmp/kjmix-dj-settings-sound.png")
try await capturePreferences("DJ","Appearance","/private/tmp/kjmix-dj-settings-appearance.png")
model.dj.sync(model.dj.a)
let console = NSHostingView(rootView:DJContinuousConsole(system:model.dj,loadA:{},loadB:{}).frame(width:1320,height:390).foregroundStyle(.white).preferredColorScheme(.dark))
console.frame = NSRect(x:0,y:0,width:1320,height:390)
let consoleWindow = NSWindow(contentRect:console.frame,styleMask:[.borderless],backing:.buffered,defer:false)
consoleWindow.appearance = NSAppearance(named:.darkAqua);consoleWindow.contentView = console;consoleWindow.setFrameOrigin(NSPoint(x:-10000,y:-10000));consoleWindow.orderFront(nil)
defer { consoleWindow.orderOut(nil);consoleWindow.contentView = nil }
try await Task.sleep(nanoseconds:150_000_000);console.layoutSubtreeIfNeeded()
if let rep = console.bitmapImageRepForCachingDisplay(in:console.bounds) { console.cacheDisplay(in:console.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-dj-console.png")) }
}
}
extension KJamsCloneTests {
@MainActor func testDJReceivesActualCoreMIDIVirtualSourcePackets() async throws {
var client = MIDIClientRef(0),source = MIDIEndpointRef(0)
XCTAssertEqual(MIDIClientCreateWithBlock("KJMix MIDI test" as CFString,&client,nil),noErr)
defer { if source != 0 { MIDIEndpointDispose(source) };MIDIClientDispose(client) }
XCTAssertEqual(MIDISourceCreate(client,"KJMix virtual test controller" as CFString,&source),noErr)
let suite = "KJMix.VirtualMIDI.\(UUID().uuidString)",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let midi = DJMIDI(defaults:defaults,catalog:[]);midi.refreshSources()
let input = try XCTUnwrap(midi.sources.first{$0.endpoint == source});midi.select(input.id)
XCTAssertEqual(midi.sourceID,input.id)
XCTAssertEqual(defaults.integer(forKey:"DJ.MIDISource.v1"),Int(input.id))
do {
let restored = DJMIDI(defaults:defaults)
XCTAssertEqual(restored.sourceID,input.id,"A fresh MIDI session restores the saved controller")
}
var actions = [String]();midi.onAction = { action,_,_ in actions.append(action) }
func send(_ packets: [[UInt8]]) throws {
var list = MIDIPacketList()
try withUnsafeMutablePointer(to:&list) { pointer in
var packet = MIDIPacketListInit(pointer)
for bytes in packets {
packet = try bytes.withUnsafeBufferPointer { data in try XCTUnwrap(MIDIPacketListAdd(pointer,MemoryLayout<MIDIPacketList>.size,packet,0,data.count,data.baseAddress!)) }
}
XCTAssertEqual(MIDIReceived(source,pointer),noErr)
}
}
midi.learning = "A Play";try send([[0x90,40,127]])
try await Task.sleep(nanoseconds:80_000_000)
XCTAssertEqual(midi.mappings.first?.action,"A Play");XCTAssertTrue(actions.isEmpty)
try send([[0x90,40,0],[0x90,40,127],[0x80,40,0]])
try await Task.sleep(nanoseconds:80_000_000)
XCTAssertEqual(actions,["A Play"])
midi.select(0)
XCTAssertNil(defaults.object(forKey:"DJ.MIDISource.v1"))
XCTAssertEqual(DJMIDI(defaults:defaults).sourceID,0)
}
}
extension KJamsCloneTests {
@MainActor func testDJLibraryRecursiveImportPlaylistsAndPersistenceAreSeparateFromKaraoke() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let folder = root.appendingPathComponent("Input/Artist/Album")
try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:folder.appendingPathComponent("DJ Song.mp3"))
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.cdg"),to:folder.appendingPathComponent("DJ Song.cdg"))
let karaoke = try XCTUnwrap(model.library),dj = try XCTUnwrap(model.dj.library)
_ = try karaoke.add(title:"Karaoke only",audio:fixtures.appendingPathComponent("TestTrack.wav").path,cdg:nil)
try model.refresh();XCTAssertTrue(model.dj.songs.isEmpty)
model.dj.copyImportedMedia = true // Explicit managed-copy option; references are now the default.
model.dj.importFiles([root.appendingPathComponent("Input")])
for _ in 0..<200 { if !model.dj.importing { break };try await Task.sleep(nanoseconds:25_000_000) }
XCTAssertFalse(model.dj.importing);XCTAssertNil(model.dj.error)
XCTAssertEqual(model.dj.songs.count,1);XCTAssertEqual(try karaoke.songs().map(\.title),["Karaoke only"])
let track = try XCTUnwrap(model.dj.songs.first)
XCTAssertTrue(track.audio.hasPrefix(root.appendingPathComponent("DJ/Media").path));XCTAssertNil(track.cdg)
model.dj.createPlaylist("DJ set");let playlist = try XCTUnwrap(model.dj.playlists.first)
model.dj.addToPlaylist(playlist.id,song:track.id)
XCTAssertTrue(try karaoke.entities("playlists").isEmpty)
XCTAssertEqual(try dj.playlistSongIDs(playlist.id),[track.id])
model.dj.importFiles([root.appendingPathComponent("Input")])
for _ in 0..<200 { if !model.dj.importing { break };try await Task.sleep(nanoseconds:25_000_000) }
XCTAssertEqual(model.dj.songs.count,1)
let reopened = DJSystem(storageURL:model.dj.root)
XCTAssertEqual(reopened.songs.map(\.id),[track.id]);XCTAssertEqual(reopened.playlists.map(\.name),["DJ set"])
XCTAssertNotEqual(model.dragReference(track.id).library,model.dj.dragReference(track.id).library)
}
}
extension KJamsCloneTests {
func testMediaByteCopyRepairsPartialFileWithoutCopyingSourcePermissions() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let source = root.appendingPathComponent("Read-only original.mp3"),destination = root.appendingPathComponent("Managed.mp3")
let bytes = Data(repeating:0x42,count:2_200_000);try bytes.write(to:source)
try FileManager.default.setAttributes([.posixPermissions:0o444],ofItemAtPath:source.path)
try Data().write(to:destination)
let hash = try MediaByteCopy.digest(source)
try MediaByteCopy.copy(source,to:destination,expectedHash:hash)
XCTAssertEqual(try Data(contentsOf:destination),bytes)
XCTAssertTrue(FileManager.default.isWritableFile(atPath:destination.path))
XCTAssertEqual(try FileManager.default.attributesOfItem(atPath:source.path)[.posixPermissions] as? NSNumber,NSNumber(value:0o444))
XCTAssertThrowsError(try MediaByteCopy.copy(source,to:destination,expectedHash:"wrong"))
XCTAssertEqual(try Data(contentsOf:destination),bytes,"A failed verification must preserve the existing destination")
XCTAssertThrowsError(try MediaByteCopy.copy(source,to:root.appendingPathComponent("Cancelled.mp3"),expectedHash:hash,cancelled:{true}))
XCTAssertFalse(FileManager.default.fileExists(atPath:root.appendingPathComponent("Cancelled.mp3").path))
XCTAssertFalse(try FileManager.default.contentsOfDirectory(atPath:root.path).contains{$0.hasPrefix(".kjmix-import-")})
}
func testRetryImportRepairsEmptyRegisteredAudioAndGraphicsCopies() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let db = try Library(url:root.appendingPathComponent("Library.sqlite")),importer = Importer(media:root.appendingPathComponent("Media"))
let ids = try importer.run([fixture],library:db),song = try XCTUnwrap(try db.songs().first)
try Data().write(to:URL(fileURLWithPath:song.audio))
if let graphics = song.cdg { try Data().write(to:URL(fileURLWithPath:graphics)) }
XCTAssertEqual(try importer.run([fixture],library:db),ids)
XCTAssertEqual(try db.songs().count,1)
XCTAssertEqual(try MediaByteCopy.digest(URL(fileURLWithPath:song.audio)),try MediaByteCopy.digest(fixture))
if let graphics = song.cdg { XCTAssertTrue(MediaByteCopy.nonempty(URL(fileURLWithPath:graphics))) }
}
@MainActor func testEmptyAudioShowsRepairInstructionInsteadOfCoreAudioError() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
defer { try? FileManager.default.removeItem(at:root) }
let file = root.appendingPathComponent("Empty.mp3");try Data().write(to:file)
let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"),persistsMix:false)
XCTAssertThrowsError(try player.load(.init(id:1,title:"Empty",audio:file.path,cdg:nil,rating:0,plays:0),startPlaying:false)) { error in XCTAssertTrue(error.localizedDescription.contains("Reimport the original media")) }
XCTAssertFalse(player.playing);XCTAssertNil(player.currentSongID)
}
}
extension KJamsCloneTests {
@MainActor func testNativeDJFaderOrientationAndControlActionUpdateBinding() async throws {
var value = 0.5
let host = NSHostingView(rootView:DJHardwareFader(value:Binding(get:{value},set:{value = $0}),label:"Tempo test",vertical:true).frame(width:52,height:94))
host.frame = NSRect(x:0,y:0,width:52,height:94)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded()
func find(_ view: NSView) -> NSSlider? { if let slider = view as? NSSlider { return slider };return view.subviews.compactMap{find($0)}.first }
let slider = try XCTUnwrap(find(host))
XCTAssertTrue(slider.isVertical);XCTAssertEqual(slider.doubleValue,0.5,accuracy:0.001)
slider.doubleValue = 0.25
XCTAssertTrue(slider.sendAction(slider.action,to:slider.target));XCTAssertEqual(value,0.25,accuracy:0.001)
slider.doubleValue = 1
XCTAssertTrue(slider.sendAction(slider.action,to:slider.target));XCTAssertEqual(value,1,accuracy:0.001)
}
}
extension KJamsCloneTests {
func testDJPreferencesTempoDirectionRangesAndMixCurves() {
var prefs = DJPreferences()
XCTAssertEqual(prefs.tempo(at:0.5),1,accuracy:0.0001)
XCTAssertEqual(prefs.tempo(at:1),0.84,accuracy:0.0001)
XCTAssertEqual(prefs.position(for:1.16),0,accuracy:0.0001)
prefs.invertTempo = false;prefs.tempoRange = 8
XCTAssertEqual(prefs.tempo(at:0),0.92,accuracy:0.0001)
XCTAssertEqual(prefs.tempo(at:1),1.08,accuracy:0.0001)
XCTAssertEqual(prefs.position(for:prefs.tempo(at:0.25)),0.25,accuracy:0.0001)
let linear = DJMix.weights(0.5,curve:"Linear")
XCTAssertEqual(linear.0,0.5);XCTAssertEqual(linear.1,0.5)
prefs.tempoRange = .nan;prefs.libraryFont = .infinity;prefs.crossfaderCurve = "invalid"
XCTAssertEqual(prefs.sanitized.tempoRange,16);XCTAssertEqual(prefs.sanitized.libraryFont,11);XCTAssertEqual(prefs.sanitized.crossfaderCurve,"Equal power")
}
@MainActor func testDJSettingsPersistenceLoadProtectionEQAndHotCueAutoplay() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let first = Song(id:1,title:"First",audio:fixture.path,cdg:nil,rating:0,plays:0)
let second = Song(id:2,title:"Second",audio:fixture.path,cdg:nil,rating:0,plays:0)
let system = DJSystem(storageURL:root);system.master = 0
defer { system.a.player.stop();system.b.player.stop() }
system.load(first,into:"A");system.a.player.toggle()
system.load(second,into:"A");XCTAssertNotNil(system.pendingReplacement);XCTAssertEqual(system.a.song?.id,1);system.pendingReplacement = nil
system.a.player.stopDeck();system.error = nil
system.settings.autoPlayOnLoad = true;system.settings.resetEQOnLoad = true
system.settings.tempoRange = 8;system.settings.crossfaderCurve = "Linear";system.settings.headroomDB = 6
system.a.player.lowGain = 6;system.load(second,into:"A")
XCTAssertNil(system.error);XCTAssertEqual(system.a.song?.id,2);XCTAssertTrue(system.a.player.playing);XCTAssertEqual(system.a.player.lowGain,0)
system.a.player.stopDeck();system.settings.autoPlayOnLoad = false;system.settings.autoPlayHotCue = true
system.hotCue(0,deck:system.a);XCTAssertFalse(system.a.player.playing)
system.hotCue(0,deck:system.a);XCTAssertTrue(system.a.player.playing)
let restored = DJSystem(storageURL:root)
XCTAssertEqual(restored.settings,system.settings)
}
}
extension KJamsCloneTests {
@MainActor func testLinkedSyncUsesClickedDeckTogglesBothAndRestoresOriginalTempos() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root);system.master = 0
defer { system.a.unload();system.b.unload() }
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let song = Song(id:1,title:"Sync",audio:file.path,cdg:nil,rating:0,plays:0)
system.load(song,into:"A");system.load(song,into:"B")
system.a.bpm = 128;system.b.bpm = 120;system.a.bpmConfirmed = true;system.b.bpmConfirmed = true
system.a.player.tempo = 1.04;system.b.player.tempo = 0.96
XCTAssertFalse(system.syncActive)
system.sync(system.a)
XCTAssertTrue(system.syncActive);XCTAssertEqual(system.a.player.tempo,1.04,accuracy:0.001)
XCTAssertEqual(system.b.bpm*system.b.player.tempo,128*1.04,accuracy:0.001)
system.a.player.tempo = 1.02;try await Task.sleep(nanoseconds:40_000_000)
XCTAssertEqual(system.b.bpm*system.b.player.tempo,128*1.02,accuracy:0.001)
system.sync(system.a)
XCTAssertFalse(system.syncActive);XCTAssertEqual(system.a.player.tempo,1.04,accuracy:0.001);XCTAssertEqual(system.b.player.tempo,0.96,accuracy:0.001)
system.sync(system.b)
XCTAssertTrue(system.syncActive);XCTAssertEqual(system.syncLeader,"B")
XCTAssertEqual(system.b.player.tempo,0.96,accuracy:0.001)
XCTAssertEqual(system.a.bpm*system.a.player.tempo,120*0.96,accuracy:0.001)
system.b.player.tempo = 1.01;try await Task.sleep(nanoseconds:40_000_000)
XCTAssertEqual(system.a.bpm*system.a.player.tempo,120*1.01,accuracy:0.001)
system.sync(system.b)
XCTAssertFalse(system.syncActive)
XCTAssertEqual(system.a.player.tempo,1.04,accuracy:0.001)
XCTAssertEqual(system.b.player.tempo,0.96,accuracy:0.001)
}
@MainActor func testDJLoopActuallyRepeatsAndExplicitCueDoesNotMovePlayback() async throws {
let deck = DJChannel("A");defer { deck.unload() };deck.master = 0
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try deck.load(.init(id:1,title:"Loop",audio:file.path,cdg:nil,rating:0,plays:0))
deck.bpm = 120;deck.bpmConfirmed = true;deck.loopBeats = 1
deck.player.seek(0.2);deck.beatLoop();deck.player.toggle()
try await Task.sleep(nanoseconds:900_000_000)
XCTAssertTrue(deck.player.playing);XCTAssertTrue(deck.player.loopEnabled);XCTAssertGreaterThanOrEqual(deck.player.time,0.19);XCTAssertLessThan(deck.player.time,0.8)
deck.beatLoop();XCTAssertFalse(deck.player.loopEnabled)
deck.player.stopDeck();deck.player.setCue(at:1.2345)
XCTAssertEqual(deck.player.cuePosition,1.2345,accuracy:0.0001);XCTAssertEqual(deck.player.time,0,accuracy:0.001)
deck.player.returnToCue();XCTAssertEqual(deck.player.time,1.2345,accuracy:0.001)
deck.player.defineLoop(start:deck.player.duration-0.2,end:deck.player.duration);deck.player.seek(deck.player.duration-0.2);deck.player.toggle()
try await Task.sleep(nanoseconds:500_000_000)
XCTAssertTrue(deck.player.playing,"Loop ending at EOF must restart instead of finishing")
}
func testBPMEstimationRecognisesPulseTrainAndRejectsSilence() throws {
let envelope = (0..<2400).map { $0 % 96 < 4 ? 1.0 : 0.02 }
XCTAssertEqual(try XCTUnwrap(DJBPM.estimate(envelope)),125,accuracy:0.5)
XCTAssertNil(DJBPM.estimate(Array(repeating:0,count:2400)))
XCTAssertNil(DJBPM.estimate(Array(repeating:0.5,count:2400)))
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:8000,channels:1)),buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:96000))
buffer.frameLength = 96000;let samples = try XCTUnwrap(buffer.floatChannelData)[0]
for i in 0..<96000 { samples[i] = i % 3840 < 40 ? 0.8 : 0 }
let path = root.appendingPathComponent("125 BPM.wav")
do { let output = try AVAudioFile(forWriting:path,settings:format.settings);try output.write(from:buffer) }
XCTAssertEqual(try XCTUnwrap(DJBPM.analyse(path)),125,accuracy:1)
}
@MainActor func testFinderDropWarnsBeforeImportAndAllowsExplicitOverride() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root);system.master = 0;defer { system.a.unload() }
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try system.a.load(.init(id:999,title:"Playing",audio:file.path,cdg:nil,rating:0,plays:0));system.a.player.toggle()
let provider = NSItemProvider()
provider.registerDataRepresentation(forTypeIdentifier:UTType.fileURL.identifier,visibility:.all) { completion in completion(Data(file.absoluteString.utf8),nil);return nil }
XCTAssertTrue(system.acceptDrop([provider],into:"A"))
for _ in 0..<100 { if system.pendingReplacement != nil { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertNotNil(system.pendingReplacement,system.error ?? system.status);XCTAssertTrue(system.a.player.playing);XCTAssertTrue(system.songs.isEmpty)
let captured = try XCTUnwrap(system.pendingReplacement);system.pendingReplacement = nil
system.confirmReplacement(captured)
for _ in 0..<300 { if !system.importing { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertNil(system.error);XCTAssertNil(system.pendingReplacement);XCTAssertEqual(system.songs.count,1)
XCTAssertNotEqual(system.a.song?.id,999);XCTAssertFalse(system.a.player.playing)
}
@MainActor func testSequentialAndConcurrentFinderDropsLoadBothDecksIncludingDuplicates() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root);defer { system.a.unload();system.b.unload() }
let first = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let second = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/TestTrack.wav"))
system.requestFileLoad([first],into:"A")
XCTAssertTrue(system.importing)
system.requestFileLoad([second],into:"B")
for _ in 0..<500 { if system.b.song != nil,!system.importing { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertNotNil(system.a.song);XCTAssertNotNil(system.b.song,system.error ?? system.status)
let original = system.a.song?.id
XCTAssertNotEqual(system.b.song?.id,original)
system.requestFileLoad([first],into:"B")
for _ in 0..<300 { if !system.importing { break };try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertEqual(system.b.song?.id,original,"Dropping a previously imported file onto the other deck still loads it")
XCTAssertEqual(system.a.song?.id,original);XCTAssertNil(system.error)
XCTAssertTrue(system.acceptDrop([system.dragReference(try XCTUnwrap(original)).provider()],into:"A"))
try await Task.sleep(nanoseconds:50_000_000)
XCTAssertEqual(system.a.song?.id,original)
}
@MainActor func testArtworkSelectsTrackJPGAndLoadsFromOriginalFolder() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) }
let image = NSImage(size:NSSize(width:8,height:8));image.lockFocus();NSColor.red.setFill();NSBezierPath(rect:NSRect(x:0,y:0,width:8,height:8)).fill();image.unlockFocus()
let bitmap = try XCTUnwrap(NSBitmapImageRep(data:try XCTUnwrap(image.tiffRepresentation)))
let jpg = try XCTUnwrap(bitmap.representation(using:.jpeg,properties:[:]))
try jpg.write(to:root.appendingPathComponent("Cover.jpg"));try jpg.write(to:root.appendingPathComponent("Song.JPG"))
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
var song = Song(id:1,title:"Art",audio:audio.path,cdg:nil,rating:0,plays:0);song.originalPath = root.appendingPathComponent("Song.mp3").path
XCTAssertEqual(DJTrackInfo.sidecar(song)?.lastPathComponent,"Song.JPG")
let artwork = await DJTrackInfo.artwork(song);XCTAssertNotNil(artwork)
}
func testMixtrackGoPresetUsesActualProtocol14BitTempoAndPadModes() {
var protocolState = MixtrackGoProtocol()
let preset = MixtrackGoProtocol.mappings(source:123)
XCTAssertTrue(preset.contains{$0.status == 0x91 && $0.number == 0 && $0.action == "B Play"})
XCTAssertTrue(preset.contains{$0.status == 0xBF && $0.number == 8 && $0.action == "Crossfader"})
XCTAssertEqual(protocolState.consume(.init(status:0xB0,data1:0x09,data2:64))?.count,0)
XCTAssertEqual(protocolState.consume(.init(status:0xB0,data1:0x29,data2:0))?.first?.value,0.5)
XCTAssertEqual(protocolState.consume(.init(status:0x94,data1:0x14,data2:127))?.first?.action,"A Hot cue 1")
_ = protocolState.consume(.init(status:0x95,data1:0,data2:127))
XCTAssertEqual(protocolState.consume(.init(status:0x95,data1:0x17,data2:127))?.first?.action,"B Loop 8")
XCTAssertEqual(protocolState.consume(.init(status:0x95,data1:0x1F,data2:127))?.first?.action,"B Loop off")
let leds = protocolState.feedback(aPlaying:true,bPlaying:false,sync:true,aLoaded:true,bLoaded:true,cue:nil)
XCTAssertEqual(Array(leds.prefix(9)),[0x90,0,127,0x90,1,1,0x90,2,127])
}
@MainActor func testEQAndFilterDoubleClickActuallyResetKnobs() async throws {
var values = [6.0,3.0,-6.0,0.75]
let host = NSHostingView(rootView:HStack(spacing:0) { ForEach(0..<4,id:\.self) { i in DJKnob(label:["HIGH","MID","LOW","FILTER"][i],value:Binding(get:{values[i]},set:{values[i] = $0}),range:i == 3 ? -1...1 : -12...12,reset:0,color:.cyan).frame(width:80,height:80) } }.frame(width:320,height:80))
host.frame = NSRect(x:0,y:0,width:320,height:80)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.contentView = host
let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() }
try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded()
for index in 0..<4 {
for click in 1...2 {
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:40+index*80,y:46),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:click,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event)
}
}
try await Task.sleep(nanoseconds:100_000_000)
}
XCTAssertEqual(values,[0,0,0,0])
func controls(_ view: NSView) -> [DJRotaryControl] { (view as? DJRotaryControl).map{[$0]} ?? view.subviews.flatMap{controls($0)} }
let knobs = controls(host)
XCTAssertEqual(knobs.count,4)
for knob in knobs {
let centre = knob.convert(NSPoint(x:knob.bounds.midX,y:14),to:nil)
let down = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDown,location:centre,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:1))
knob.mouseDown(with:down)
let drag = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDragged,location:NSPoint(x:centre.x,y:centre.y+12),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:1))
knob.mouseDragged(with:drag);XCTAssertNotEqual(knob.value,0)
let label = knob.convert(NSPoint(x:knob.bounds.midX,y:35),to:nil)
let doubleClick = try XCTUnwrap(NSEvent.mouseEvent(with:.leftMouseDown,location:label,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:2,pressure:1))
knob.mouseDown(with:doubleClick);XCTAssertEqual(knob.value,0)
}
XCTAssertEqual(values,[0,0,0,0],"Reset on the label also updates each audio parameter after a drag")
}
}
extension KJamsCloneTests {
@MainActor func testMixtrackGoInstalledPresetReceivesTempoPadsAndIgnoresButtonReleases() throws {
let suite = "KJMix.Mixtrack.Tests.\(UUID().uuidString)",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let midi = DJMIDI(defaults:defaults,connect:false);midi.installMixtrackGo()
var events = [(String,Double)]()
midi.onAction = { name,value,_ in if name != "Controller feedback" { events.append((name,value)) } }
midi.receive([0x90,0,127,0x90,0,0,0x80,0,0])
midi.receive([0xB0,0x09,64,0xB0,0x29,0])
midi.receive([0x95,0,127,0x95,0,0,0x95,0x17,127])
XCTAssertEqual(events.map{$0.0},["A Play","A Tempo","B Loop 8"])
XCTAssertEqual(events[1].1,0.5,accuracy:0.0001)
XCTAssertTrue(DJMIDI(defaults:defaults,connect:false).mixtrackEnabled)
}
}
extension KJamsCloneTests {
func testDJWaveBeatGuidesUseSourceBPMAndLimitOverviewDensity() {
XCTAssertEqual(DJWaveMarkers.beats(start:0,span:2,bpm:120,width:800),[0,1,2,3,4])
XCTAssertEqual(DJWaveMarkers.beats(start:1.1,span:1,bpm:120,width:800),[3,4])
XCTAssertTrue(DJWaveMarkers.beats(start:0,span:8,bpm:nil,width:800).isEmpty)
XCTAssertTrue(DJWaveMarkers.beats(start:0,span:8,bpm:0,width:800).isEmpty)
XCTAssertLessThanOrEqual(DJWaveMarkers.beats(start:0,span:7200,bpm:180,width:500).count,101)
}
}
extension KJamsCloneTests {
private func xmlMapping(_ controls: [[String:Any]],endpoint: String = "XML Test Controller") throws -> DJXMLProfile {
let data = try PropertyListSerialization.data(fromPropertyList:["schemeVersion":1,"uuid":"xml-test","endpointName":endpoint,"controls":controls],format:.xml,options:0)
return try DJXMLProfile.parse(data,filename:endpoint)
}
func testStereoRoutingMapsAndRejectsPublicCueOverlap() throws {
XCTAssertEqual(try DJAudioRouting.channelMap(channels:4,first:2),[-1,-1,0,1])
XCTAssertEqual(try DJAudioRouting.channelMap(channels:4,first:0),[0,1,-1,-1])
XCTAssertThrowsError(try DJAudioRouting.channelMap(channels:2,first:2))
let device = DJAudioDevice(id:1,uid:"controller",name:"Controller",outputChannels:4)
XCTAssertEqual(device.stereoPairs,[0,2])
let preset = DJAudioRouting.controllerPreset(device,mode:"Internal")
XCTAssertEqual(preset.main.firstChannel,0);XCTAssertEqual(preset.cue.firstChannel,2)
let switched = preset.changingMode("External",preferred:device)
XCTAssertTrue(switched.cue.uid.isEmpty,"Switching to external clears cue if both pairs are used by public decks")
XCTAssertNoThrow(try switched.validate(devices:[device],defaultID:1))
let external = DJAudioRouting.controllerPreset(device,mode:"External")
XCTAssertEqual(external.deckA.firstChannel,0);XCTAssertEqual(external.deckB.firstChannel,2)
XCTAssertNoThrow(try external.validate(devices:[device],defaultID:1))
var routing = DJAudioRouting();routing.main.uid = device.uid;routing.cue = .init(uid:device.uid,firstChannel:2)
XCTAssertNoThrow(try routing.validate(devices:[device],defaultID:1))
routing.cue.firstChannel = 0;XCTAssertThrowsError(try routing.validate(devices:[device],defaultID:1))
routing.cue.uid = "";routing.mode = "External";routing.deckA.uid = device.uid;routing.deckB = .init(uid:device.uid,firstChannel:2)
XCTAssertNoThrow(try routing.validate(devices:[device],defaultID:1))
routing.deckB.firstChannel = 0;XCTAssertThrowsError(try routing.validate(devices:[device],defaultID:1))
}
@MainActor func testNativeChannelPickerListsBothPairsAndChangesBindingImmediately() async throws {
var selected = 0
let host = NSHostingView(rootView:DJAudioChannelPicker(value:Binding(get:{selected},set:{selected = $0}),pairs:[0,2],label:"Test channels").frame(width:140,height:30))
host.frame = NSRect(x:0,y:0,width:140,height:30)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded()
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
let control = try XCTUnwrap(descendants(host).compactMap{$0 as? NSPopUpButton}.first)
XCTAssertEqual(control.itemTitles,["ch 1–2","ch 3–4"])
control.selectItem(withTag:2);_ = control.sendAction(try XCTUnwrap(control.action),to:control.target)
XCTAssertEqual(selected,2)
control.selectItem(withTag:0);_ = control.sendAction(try XCTUnwrap(control.action),to:control.target)
XCTAssertEqual(selected,0)
}
@MainActor func testLiveAudioRoutingKeepsPlayingPositionAndPausedState() async throws {
let output = try XCTUnwrap(DJAudioDevice.defaultOutput())
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(persistsMix:false);player.volume = 0;defer { player.stop() }
try player.load(.init(id:1,title:"Routing",audio:audio.path,cdg:nil,rating:0,plays:0))
player.seek(0.5);try await Task.sleep(nanoseconds:100_000_000)
let before = player.time
try player.routeOutput(output,firstChannel:0)
XCTAssertTrue(player.playing);XCTAssertGreaterThanOrEqual(player.time,before-0.05)
try await Task.sleep(nanoseconds:100_000_000)
XCTAssertGreaterThan(player.time,before)
player.toggle();let paused = player.time
try player.routeOutput(output,firstChannel:0)
XCTAssertFalse(player.playing);XCTAssertEqual(player.time,paused,accuracy:0.02)
}
@MainActor func testAudioOutputsAndActualFourChannelRoutingWhenInterfaceAttached() async throws {
let outputs = DJAudioDevice.outputs()
print("DJ audio discovery:",outputs.map { "\($0.name) [\($0.outputChannels) out / \($0.inputChannels) in]" }.joined(separator:", "))
XCTAssertFalse(outputs.isEmpty,"CoreAudio should expose available Mac and USB outputs")
guard let controller = outputs.first(where:{$0.name.localizedCaseInsensitiveContains("MixTrack Go") && $0.outputChannels >= 4}) else { throw XCTSkip("Four-channel Mixtrack Go is not attached") }
let player = Player(persistsMix:false)
try player.routeOutput(controller.id,firstChannel:2)
XCTAssertEqual(player.outputChannelMap(),[-1,-1,0,1])
try player.routeOutput(controller.id,firstChannel:0)
XCTAssertEqual(player.outputChannelMap(),[0,1,-1,-1])
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
var routing = DJAudioRouting();routing.main.uid = controller.uid;routing.cue = .init(uid:controller.uid,firstChannel:2)
model.dj.applyRouting(routing);XCTAssertNil(model.dj.error);XCTAssertEqual(model.dj.routing,routing)
XCTAssertEqual(DJSystem(storageURL:model.dj.root).routing,routing)
let host = NSHostingView(rootView:StudioSettingsDialog(model:model,scope:"DJ",category:"Devices").preferredColorScheme(.dark))
host.frame = NSRect(x:0,y:0,width:850,height:700)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:150_000_000);host.layoutSubtreeIfNeeded()
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
let menus = descendants(host).compactMap{$0 as? NSPopUpButton}
for menu in menus { menu.menu?.update() }
XCTAssertTrue(menus.contains{$0.itemTitles.contains("ch 1–2") && $0.itemTitles.contains("ch 3–4")},"Native channel menus expose both pairs from the attached Numark")
let main = try XCTUnwrap(menus.first{$0.identifier?.rawValue == "Main Output channels"})
let cue = try XCTUnwrap(menus.first{$0.identifier?.rawValue == "Pre-Cueing channels"})
func choose(_ button: NSPopUpButton,_ pair: Int) async throws {
button.selectItem(withTag:pair);_ = button.sendAction(try XCTUnwrap(button.action),to:button.target)
try await Task.sleep(nanoseconds:100_000_000)
}
try await choose(main,2)
XCTAssertEqual(model.dj.routing.main.firstChannel,0,"Conflicting draft keeps the last valid live route")
try await choose(cue,0)
XCTAssertEqual(model.dj.routing.main.firstChannel,2);XCTAssertEqual(model.dj.routing.cue.firstChannel,0)
XCTAssertEqual(model.dj.a.player.outputChannelMap(),[-1,-1,0,1],"Native selection applies immediately to the actual output unit")
try await choose(main,0);try await choose(cue,2)
XCTAssertEqual(model.dj.routing.main.firstChannel,0);XCTAssertEqual(model.dj.routing.cue.firstChannel,2)
if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try XCTUnwrap(rep.representation(using:.png,properties:[:])).write(to:URL(fileURLWithPath:"/private/tmp/kjmix-xml-mappings/audio-routing.png")) }
}
func testXMLStandardLEDOutputHonoursOverridesAndInversion() throws {
let profile = try xmlMapping([["keyPath":"turntable1.playPause","midiMessageType":1,"midiChannel":0,"midiData":4,"output":["midiChannel":2,"midiData":9,"midiMessageType":3,"midiMinValue":1,"midiMaxValue":100,"flipped":true]]])
let output = try XCTUnwrap(profile.outputs?.first)
XCTAssertEqual(output.packet(active:true),[0xB2,9,1])
XCTAssertEqual(output.packet(active:false),[0xB2,9,100])
}
@MainActor func testMixtrackXMLUsesNativeAdapterAndSendsActualLEDOutput() async throws {
let profile = try XCTUnwrap(DJXMLProfile.bundled.first{$0.name == "Numark Mixtrack GO"})
XCTAssertEqual(profile.adapter,"Mixtrack Go")
XCTAssertNotNil(DJXMLProfile.match("Numark MixTrack Go Bluetooth",in:[profile]))
var client = MIDIClientRef(0),source = MIDIEndpointRef(0),destination = MIDIEndpointRef(0)
XCTAssertEqual(MIDIClientCreateWithBlock("KJMix LED test" as CFString,&client,nil),noErr)
defer { if source != 0 { MIDIEndpointDispose(source) };if destination != 0 { MIDIEndpointDispose(destination) };MIDIClientDispose(client) }
let name = "KJMix LED \(UUID())"
var received = [UInt8]()
XCTAssertEqual(MIDIDestinationCreateWithBlock(client,name as CFString,&destination) { packets,_ in
var bytes = [UInt8]()
var packet = UnsafeRawPointer(packets).advanced(by:MemoryLayout<MIDIPacketList>.offset(of:\.packet)!).assumingMemoryBound(to:MIDIPacket.self)
for _ in 0..<packets.pointee.numPackets {
let data = UnsafeRawPointer(packet).advanced(by:MemoryLayout<MIDIPacket>.offset(of:\.data)!)
bytes.append(contentsOf:UnsafeRawBufferPointer(start:data,count:Int(packet.pointee.length)));packet = UnsafePointer(MIDIPacketNext(packet))
}
let captured = bytes;Task { @MainActor in received += captured }
},noErr)
XCTAssertEqual(MIDISourceCreate(client,name as CFString,&source),noErr)
let suite = "KJMix.LED.\(UUID())",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let midi = DJMIDI(defaults:defaults,catalog:[]);midi.refreshSources()
let input = try XCTUnwrap(midi.sources.first{$0.endpoint == source});midi.select(input.id);midi.applyXML(profile)
XCTAssertTrue(midi.mixtrackEnabled)
var actions = [String]();midi.onAction = { action,_,_ in actions.append(action) }
midi.receive([0x95,0,127,0x95,0x17,127]);XCTAssertTrue(actions.contains("B Loop 8"))
midi.updateFeedback(aPlaying:true,bPlaying:false,sync:true,aLoaded:true,bLoaded:true,cue:"B",aCues:[true,false,false,false])
try await Task.sleep(nanoseconds:100_000_000)
XCTAssertTrue((0..<max(0,received.count-2)).contains{Array(received[$0..<($0+3)]) == [0x90,0,127]})
XCTAssertTrue((0..<max(0,received.count-2)).contains{Array(received[$0..<($0+3)]) == [0x94,0x14,127]})
XCTAssertTrue((0..<max(0,received.count-6)).contains{Array(received[$0..<($0+7)]) == [0xF0,0,0x20,0x7F,3,1,0xF7]})
let initial = received.count
try await Task.sleep(nanoseconds:2_100_000_000)
XCTAssertGreaterThan(received.count,initial,"LED state is resent after the startup light show ends")
midi.applyXML(profile);midi.shutdown()
try await Task.sleep(nanoseconds:100_000_000)
XCTAssertTrue((0..<max(0,received.count-2)).contains{Array(received[$0..<($0+3)]) == [0x9E,0x7F,0]})
let stopped = received.count
try await Task.sleep(nanoseconds:2_100_000_000)
XCTAssertEqual(received.count,stopped,"Shutdown cancels delayed initialization and leaves LEDs reset")
}
func testDjayXMLLoadsAllSuppliedProfilesAndReportsUnsupportedActions() throws {
XCTAssertEqual(DJXMLProfile.bundled.count,210)
let dj2go = try XCTUnwrap(DJXMLProfile.bundled.first{$0.name == "Numark DJ2GO"})
XCTAssertTrue(dj2go.bindings.contains{$0.action == "A Play" && $0.status == 0x90 && $0.number == 59})
XCTAssertTrue(dj2go.bindings.contains{$0.action == "B Tempo XML" && $0.flipped && $0.pickup})
XCTAssertTrue(dj2go.issues.contains{$0.contains("pitchBendMinus")})
XCTAssertNotNil(DJXMLProfile.match(" NUMARK DJ2Go ",in:DJXMLProfile.bundled))
XCTAssertNil(DJXMLProfile.match("Numark",in:DJXMLProfile.bundled))
XCTAssertNil(DJXMLProfile.match(dj2go.endpoint,in:[dj2go,dj2go]))
}
func testDjayXMLRejectsMalformedAndConditionalConflictingBindings() throws {
XCTAssertThrowsError(try DJXMLProfile.parse(Data("<xml>invalid</xml>".utf8),filename:"Invalid"))
let profile = try xmlMapping([
["keyPath":"turntable1.playPause","midiMessageType":1,"midiChannel":0,"midiData":1],
["keyPath":"turntable1.bpmSync","midiMessageType":1,"midiChannel":0,"midiData":1,"condition":"shift == 1"],
["keyPath":"turntable2.playPause","midiMessageType":1,"midiChannel":99,"midiData":2],
["keyPath":"turntable3.playPause","midiMessageType":1,"midiChannel":2,"midiData":3],
["keyPath":"turntable1.highEQ","midiMessageType":3,"midiChannel":0,"midiData":4],
])
XCTAssertEqual(profile.bindings.map(\.action),["A High"])
XCTAssertFalse(profile.issues.isEmpty)
}
@MainActor func testDjayXMLRuntimePitchBendInversionRelativePickupAndPersistence() throws {
let suite = "KJMix.XML.\(UUID())",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let profile = try xmlMapping([
["keyPath":"turntable1.playPause","midiMessageType":1,"midiChannel":0,"midiData":1],
["keyPath":"turntable1.speed","midiMessageType":6,"midiChannel":1,"midiData":0,"flipped":true,"pickupMode":true],
["keyPath":"musicLibrary.libraryRotary","midiMessageType":3,"midiChannel":0,"midiData":3,"controlType":"rotary-64","flipped":true],
])
let midi = DJMIDI(defaults:defaults,connect:false);midi.addXMLProfiles([profile]);midi.applyXML(profile)
midi.currentValue = { _ in 0.5 }
var actions = [(String,Double,Bool)]();midi.onAction = { actions.append(($0,$1,$2)) }
midi.receive([0x90,1,127,0x90,1,0,0x80,1,0])
XCTAssertEqual(actions.map{$0.0},["A Play"])
midi.receive([0xE1,0,0]);XCTAssertEqual(actions.count,1,"Pickup blocks a distant hardware fader")
midi.receive([0xE1,0,64]);XCTAssertEqual(actions.last?.0,"A Tempo XML");XCTAssertEqual(actions.last!.1,0.5,accuracy:0.001)
midi.receive([0xE1,127,127]);XCTAssertEqual(actions.last!.1,0,accuracy:0.001)
midi.receive([0xB0,3,65]);XCTAssertEqual(actions.last!.0,"Browse");XCTAssertEqual(actions.last!.1,-1);XCTAssertTrue(actions.last!.2)
let restored = DJMIDI(defaults:defaults,connect:false)
XCTAssertEqual(restored.activeXML?.bindings,profile.bindings)
restored.useCustomProfile();XCTAssertNil(restored.activeXML)
}
@MainActor func testXMLControllerAutodetectsReconnectsAndRemembersOptOut() throws {
var client = MIDIClientRef(0),source = MIDIEndpointRef(0)
XCTAssertEqual(MIDIClientCreateWithBlock("KJMix XML detection" as CFString,&client,nil),noErr)
defer { if source != 0 { MIDIEndpointDispose(source) };MIDIClientDispose(client) }
let endpoint = "XML Controller \(UUID())"
XCTAssertEqual(MIDISourceCreate(client,endpoint as CFString,&source),noErr)
let suite = "KJMix.XMLDetect.\(UUID())",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let profile = try xmlMapping([["keyPath":"turntable1.playPause","midiMessageType":1,"midiChannel":0,"midiData":1]],endpoint:endpoint)
let midi = DJMIDI(defaults:defaults,catalog:[profile]);midi.addXMLProfiles([profile])
var uid: Int32 = 0;MIDIObjectGetIntegerProperty(source,kMIDIPropertyUniqueID,&uid)
XCTAssertEqual(midi.sourceID,uid);XCTAssertEqual(midi.activeXML?.id,profile.id)
MIDIEndpointDispose(source);source = 0;midi.refreshSources();XCTAssertEqual(midi.sourceID,0)
XCTAssertEqual(defaults.integer(forKey:"DJ.MIDISource.v1"),Int(uid))
XCTAssertEqual(MIDISourceCreate(client,endpoint as CFString,&source),noErr)
XCTAssertEqual(MIDIObjectSetIntegerProperty(source,kMIDIPropertyUniqueID,uid),noErr)
midi.refreshSources();XCTAssertEqual(midi.sourceID,uid)
do { let restored = DJMIDI(defaults:defaults);XCTAssertEqual(restored.sourceID,uid);XCTAssertEqual(restored.activeXML?.id,profile.id) }
midi.select(0);midi.refreshSources();XCTAssertEqual(midi.sourceID,0);XCTAssertFalse(midi.autoDetect)
XCTAssertFalse(DJMIDI(defaults:defaults,connect:false).autoDetect)
}
@MainActor func testWorkspaceTabsSwitchWhileBothPlayersContinue() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
defer { model.player.stop();model.dj.a.player.stop() }
let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let library = try XCTUnwrap(model.library)
let id = try library.add(title:"Tab Playback",audio:audio.path,cdg:nil)
let song = try XCTUnwrap(try library.songs().first{$0.id == id})
try model.player.load(song);model.dj.load(song,into:"A");model.dj.a.player.toggle()
let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:1000).preferredColorScheme(.dark))
host.frame = NSRect(x:0,y:0,width:1320,height:1000)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded()
func descendants(_ view: NSView) -> [NSView] { [view]+view.subviews.flatMap{descendants($0)} }
let tabs = try XCTUnwrap(descendants(host).compactMap{$0 as? NSTabView}.first{$0.numberOfTabViewItems == 2})
func selectTab(_ index: Int) async throws {
tabs.selectTabViewItem(at:index)
try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded()
XCTAssertEqual(tabs.selectedTabViewItem,tabs.tabViewItem(at:index))
if let inactive = tabs.tabViewItem(at:1-index).view {
XCTAssertTrue(inactive.superview == nil || inactive.isHiddenOrHasHiddenAncestor,"Inactive pages cannot intercept deck drops")
}
XCTAssertTrue(model.player.playing);XCTAssertTrue(model.dj.a.player.playing)
XCTAssertEqual(model.player.currentSongID,id);XCTAssertEqual(model.dj.a.player.currentSongID,id)
}
model.dj.automix.enqueue([song,song]);try await selectTab(1)
let libraryScroll = try XCTUnwrap(descendants(host).compactMap{$0 as? NSScrollView}.filter{$0.bounds.width > 600 && !$0.isHiddenOrHasHiddenAncestor}.max(by:{$0.bounds.height < $1.bounds.height}))
XCTAssertGreaterThan(libraryScroll.bounds.height,250,"The resizable DJ library retains a useful track viewport")
let libraryFrame = libraryScroll.convert(libraryScroll.bounds,to:host)
if host.isFlipped { XCTAssertGreaterThan(libraryFrame.maxY,host.bounds.maxY-50,"The library fills the bottom of the workspace") }
else { XCTAssertLessThan(libraryFrame.minY,host.bounds.minY+50,"The library fills the bottom of the workspace") }
try await selectTab(0)
}
}
extension KJamsCloneTests {
func testLoopMouseRangesResizeMoveCreateAndClamp() throws {
let loop = 1.0...3.0
let left = DJLoopDrag(x:100,viewport:0,span:6,width:600,loop:loop,create:false)
XCTAssertEqual(left.mode,.start);XCTAssertEqual(left.range(x:50,duration:6),0.5...3)
let right = DJLoopDrag(x:300,viewport:0,span:6,width:600,loop:loop,create:false)
XCTAssertEqual(right.mode,.end);XCTAssertEqual(right.range(x:450,duration:6),1...4.5)
let move = DJLoopDrag(x:200,viewport:0,span:6,width:600,loop:loop,create:false)
XCTAssertEqual(move.mode,.move);XCTAssertEqual(move.range(x:300,duration:6),2...4)
XCTAssertEqual(move.range(x:-200,duration:6),0...2)
XCTAssertEqual(move.range(x:900,duration:6),4...6)
let create = DJLoopDrag(x:400,viewport:0,span:6,width:600,loop:nil,create:true)
XCTAssertEqual(create.range(x:200,duration:6),2...4)
let shortOverview = DJLoopDrag(x:204,viewport:0,span:300,width:600,loop:100...104,create:false)
XCTAssertEqual(shortOverview.mode,.move,"Short loops still have a draggable centre in an overview")
let seek = DJLoopDrag(x:500,viewport:0,span:6,width:600,loop:loop,create:false)
XCTAssertEqual(seek.mode,.seek);XCTAssertNil(seek.range(x:400,duration:6))
let narrow = try XCTUnwrap(left.range(x:500,duration:6))
XCTAssertEqual(narrow.upperBound-narrow.lowerBound,0.1,accuracy:0.00001)
}
@MainActor func testActualMouseDragMovesAndResizesPlayingLoop() async throws {
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(persistsMix:false);player.volume = 0;defer { player.stop() }
try player.load(.init(id:1,title:"Mouse Loop",audio:file.path,cdg:nil,rating:0,plays:0),startPlaying:false)
player.defineLoop(start:0.5,end:1.5);player.seek(0.75);player.toggle()
var frozen: Double?
let host = NSHostingView(rootView:Color.black.modifier(DJLoopWaveInteraction(player:player,start:0,span:3,width:600,frozenStart:Binding(get:{frozen},set:{frozen = $0}))).frame(width:600,height:100))
host.frame = NSRect(x:0,y:0,width:600,height:100)
let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey()
defer { window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:100_000_000);host.layoutSubtreeIfNeeded()
func drag(_ from: Double,_ to: Double) async throws {
for (type,x) in [(NSEvent.EventType.leftMouseDown,from),(.leftMouseDragged,to),(.leftMouseUp,to)] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:x,y:50),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseUp ? 0 : 1))
NSApp.sendEvent(event)
}
try await Task.sleep(nanoseconds:50_000_000)
}
try await drag(200,300)
XCTAssertEqual(player.loopIn!,1,accuracy:0.01);XCTAssertEqual(player.loopOut!,2,accuracy:0.01)
try await drag(200,150)
XCTAssertEqual(player.loopIn!,0.75,accuracy:0.01);XCTAssertEqual(player.loopOut!,2,accuracy:0.01)
XCTAssertTrue(player.playing);XCTAssertTrue(player.loopEnabled);XCTAssertNil(frozen)
}
@MainActor private func automixFixture(_ root: URL) throws -> (DJSystem,[Song]) {
let system = DJSystem(storageURL:root),library = try XCTUnwrap(system.library)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
for name in ["First","Second","Third"] {
let copy = root.appendingPathComponent(name+".mp3");try FileManager.default.copyItem(at:fixture,to:copy)
try library.add(title:name,audio:copy.path,cdg:nil)
}
try system.refresh();return (system,system.songs)
}
@MainActor func testChangingLoopSizeResizesActiveAudioRegionImmediately() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let (system,songs) = try automixFixture(root);system.load(songs[0],into:"A")
let deck = system.a,player = deck.player;defer { player.stop() }
deck.bpm = 240;deck.bpmConfirmed = true;deck.loopBeats = 4
player.defineLoop(start:0.25,end:1.25);player.seek(0.5);player.toggle()
deck.loopBeats = 8
XCTAssertEqual(player.loopOut!,2.25,accuracy:0.001);XCTAssertTrue(player.playing);XCTAssertTrue(player.loopEnabled)
deck.loopBeats = 2;XCTAssertEqual(player.loopOut!,0.75,accuracy:0.001)
XCTAssertEqual(player.loopIn!,0.25,accuracy:0.001);XCTAssertTrue(player.playing)
system.action("A Loop 4");XCTAssertTrue(player.loopEnabled);XCTAssertEqual(player.loopOut!,1.25,accuracy:0.001)
system.action("A Loop 4");XCTAssertFalse(player.loopEnabled,"Repeating the same loop-pad size switches it off")
player.defineLoop(start:0.25,end:1.25)
deck.loopBeats = 32;XCTAssertEqual(player.loopOut!,player.duration,accuracy:0.001)
}
@MainActor func testDJQueueOrderDuplicatesAndSettingsPersistWithoutAutoplay() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let (system,songs) = try automixFixture(root),queue = system.automix
queue.enqueue([songs[0],songs[1],songs[0]]);queue.seconds = 5;queue.matchTempo = true
let second = queue.entries[1].id;queue.move(second,by:-1)
XCTAssertEqual(queue.entries.map(\.song),[songs[1].id,songs[0].id,songs[0].id])
queue.remove(queue.entries.last!.id)
let restored = DJSystem(storageURL:root),saved = restored.automix
XCTAssertEqual(saved.entries.map(\.song),[songs[1].id,songs[0].id]);XCTAssertEqual(saved.seconds,5);XCTAssertTrue(saved.matchTempo)
XCTAssertFalse(saved.enabled);XCTAssertFalse(restored.a.player.playing);XCTAssertFalse(restored.b.player.playing)
}
@MainActor func testAutomixCrossfadesBothDecksAndYieldsToManualControl() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let (system,songs) = try automixFixture(root),queue = system.automix
defer { queue.stop();system.a.player.stop();system.b.player.stop() }
system.settings.autoPlayOnLoad = true;queue.enqueue(songs);queue.seconds = 0.2;queue.start(runTimer:false)
XCTAssertTrue(queue.enabled);XCTAssertTrue(system.a.player.playing);XCTAssertFalse(system.b.player.playing,"Preloaded track stays paused despite autoplay preference")
queue.mixNow(now:10);XCTAssertTrue(system.b.player.playing)
queue.tick(now:10.1);XCTAssertEqual(system.crossfader,0.5,accuracy:0.01)
queue.tick(now:10.3);XCTAssertEqual(queue.activeDeck,"B");XCTAssertEqual(system.crossfader,1)
XCTAssertFalse(system.a.player.playing);XCTAssertTrue(system.b.player.playing);XCTAssertEqual(queue.entries.count,1)
XCTAssertEqual(system.a.player.currentSongID,songs[2].id,"Next track is preloaded into the now silent deck")
system.crossfader = 0.5;queue.tick(now:11);XCTAssertFalse(queue.enabled);XCTAssertTrue(system.b.player.playing)
}
@MainActor func testAutomixTimerTransitionsAtTrackEndAndHonoursPause() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let (system,songs) = try automixFixture(root),queue = system.automix
defer { queue.stop();system.a.player.stop();system.b.player.stop() }
queue.seconds = 0.1;queue.enqueue(Array(songs.prefix(2)));queue.start()
system.a.player.seek(system.a.player.duration-0.15)
try await Task.sleep(nanoseconds:500_000_000)
XCTAssertEqual(queue.activeDeck,"B");XCTAssertTrue(system.b.player.playing);XCTAssertFalse(system.a.player.playing)
system.b.player.toggle();try await Task.sleep(nanoseconds:120_000_000)
XCTAssertFalse(queue.enabled,"Manual pause hands control back to the operator")
}
@MainActor func testAutomixWaitsForLoopsAndSkipsUnavailableQueueEntries() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let (system,songs) = try automixFixture(root),queue = system.automix
defer { queue.stop();system.a.player.stop();system.b.player.stop() }
let missing = try XCTUnwrap(system.library).add(title:"Missing",audio:root.appendingPathComponent("missing.mp3").path,cdg:nil)
try system.refresh();queue.enqueue([try XCTUnwrap(system.songs.first{$0.id == missing}),songs[0],songs[1]])
queue.start(runTimer:false);XCTAssertEqual(system.a.player.currentSongID,songs[0].id)
system.a.player.defineLoop(start:0,end:0.5);queue.tick(now:10)
XCTAssertNil(queue.progress);XCTAssertTrue(queue.enabled);XCTAssertTrue(queue.message.contains("Loop active"))
queue.stop();XCTAssertTrue(system.a.player.playing,"Stopping automation preserves current playback")
}
}
extension KJamsCloneTests {
@MainActor func testSpectrumScrollMovesCachedPixelsWithoutRebuildingEveryFrame() async throws {
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(persistsMix:false);defer { player.stop() }
try player.load(.init(id:1,title:"Scroll",audio:file.path,cdg:nil,rating:0,plays:0),startPlaying:false)
for _ in 0..<100 where player.waveformLoading { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertFalse(player.waveform.isEmpty)
let view = DJNativeSpectrumView(frame:NSRect(x:0,y:0,width:600,height:100))
view.player = player;view.span = 2;view.spectralWaveforms = true
func snapshot() throws -> Data {
let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in:view.bounds))
view.cacheDisplay(in:view.bounds,to:bitmap)
return Data(bytes:try XCTUnwrap(bitmap.bitmapData),count:bitmap.bytesPerRow*bitmap.pixelsHigh)
}
player.seek(1);let first = try snapshot(),builds = view.waveformTileBuildCount
XCTAssertGreaterThan(builds,0)
player.seek(1.01);let second = try snapshot()
XCTAssertEqual(view.waveformTileBuildCount,builds,"Playback frames translate cached waveform pixels rather than rebinning the spectrum")
XCTAssertNotEqual(first,second,"The spectrum must move as source position advances")
view.frame.size.width = 640;_ = try snapshot()
XCTAssertGreaterThan(view.waveformTileBuildCount,builds,"Changing window size must rebuild sharp waveform tiles")
}
}
extension KJamsCloneTests {
@MainActor func testSpectrumCachedSectionsCannotPaintOutsideViewport() async throws {
let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(persistsMix:false);defer { player.stop() }
try player.load(.init(id:1,title:"Clip",audio:file.path,cdg:nil,rating:0,plays:0),startPlaying:false)
for _ in 0..<100 where player.waveformLoading { try await Task.sleep(nanoseconds:20_000_000) }
let view = DJNativeSpectrumView(frame:NSRect(x:0,y:0,width:300,height:80))
view.player = player;view.span = 0.5;view.bpm = 120
XCTAssertTrue(view.clipsToBounds)
let bitmap = try XCTUnwrap(NSBitmapImageRep(bitmapDataPlanes:nil,pixelsWide:380,pixelsHigh:140,bitsPerSample:8,samplesPerPixel:4,hasAlpha:true,isPlanar:false,colorSpaceName:.deviceRGB,bytesPerRow:0,bitsPerPixel:0))
let graphics = try XCTUnwrap(NSGraphicsContext(bitmapImageRep:bitmap))
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphics
let context = graphics.cgContext
context.setFillColor(CGColor(red:1,green:0,blue:1,alpha:1));context.fill(CGRect(x:0,y:0,width:380,height:140))
let sentinel = try XCTUnwrap(bitmap.colorAt(x:0,y:0)?.usingColorSpace(.deviceRGB))
context.translateBy(x:40,y:30)
for position in [0.25,0.49,0.51,1.0] {
player.seek(position)
// Deliberately omit AppKit's implicit clipping: draw() must constrain
// its own cached tiles, including at a tile-boundary crossing.
view.draw(view.bounds)
}
NSGraphicsContext.restoreGraphicsState()
for y in 0..<140 {
for x in 0..<380 where x < 40 || x >= 340 || y < 30 || y >= 110 {
let color = try XCTUnwrap(bitmap.colorAt(x:x,y:y)?.usingColorSpace(.deviceRGB))
guard abs(color.redComponent-sentinel.redComponent) < 0.001,abs(color.greenComponent-sentinel.greenComponent) < 0.001,abs(color.blueComponent-sentinel.blueComponent) < 0.001,abs(color.alphaComponent-sentinel.alphaComponent) < 0.001 else {
return XCTFail("Spectrum drew outside its viewport at \(x),\(y): \(color)")
}
}
}
XCTAssertGreaterThan(view.waveformTileBuildCount,0)
let inside = try XCTUnwrap(bitmap.colorAt(x:190,y:70)?.usingColorSpace(.deviceRGB))
XCTAssertLessThan(inside.redComponent*inside.blueComponent,0.99,"Spectrum must still paint inside its bounds")
}
}
extension KJamsCloneTests {
func testImportSkipsRenamedLegacyContentAndLogsEachResultWithoutMatchingTitles() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let original = root.appendingPathComponent("Original.mp3"),renamed = root.appendingPathComponent("Renamed.mp3"),different = root.appendingPathComponent("Different.mp3")
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:original)
try FileManager.default.copyItem(at:original,to:renamed)
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("TestTrack.mp3"),to:different)
let db = try Library(url:root.appendingPathComponent("db.sqlite")),legacy = try db.add(title:"My saved title",audio:original.path,cdg:nil)
try db.rate(legacy,4)
var result = ImportProgress()
let importer = Importer(media:root.appendingPathComponent("Media"))
let ids = try importer.run([renamed,different],library:db,progress:{ result = $0 })
XCTAssertEqual(ids.count,2);XCTAssertEqual(try db.songs().count,2)
XCTAssertEqual(result.skipped,1);XCTAssertEqual(result.completed,2)
XCTAssertTrue(result.logLines.contains{$0.hasPrefix("[SKIPPED]") && $0.contains("Renamed.mp3")})
XCTAssertTrue(result.logLines.contains{$0.hasPrefix("[IMPORTED]") && $0.contains("Different.mp3")})
XCTAssertEqual(try db.song(legacy)?.title,"My saved title");XCTAssertEqual(try db.song(legacy)?.rating,4)
XCTAssertEqual(try db.song(legacy)?.fingerprint.count,64)
_ = try importer.run([renamed,different],library:db,progress:{ result = $0 })
XCTAssertEqual(result.skipped,2);XCTAssertEqual(result.logLines.count,2)
XCTAssertTrue(result.logLines.allSatisfy{$0.hasPrefix("[SKIPPED]")})
}
func testReimportFromAnotherFolderSkipsExistingMediaAndReturnsItsID() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let first = root.appendingPathComponent("First.mp3"),second = root.appendingPathComponent("Second.mp3")
try FileManager.default.copyItem(at:fixture,to:first);try FileManager.default.copyItem(at:fixture,to:second)
let db = try Library(url:root.appendingPathComponent("db.sqlite"))
let ids = try Importer(media:root.appendingPathComponent("MediaA")).run([first],library:db)
var report = ImportProgress()
let repeated = try Importer(media:root.appendingPathComponent("MediaB")).run([second],library:db,progress:{report = $0})
XCTAssertEqual(repeated,ids);XCTAssertEqual(try db.songs().count,1);XCTAssertEqual(report.skipped,1)
XCTAssertTrue(try FileManager.default.contentsOfDirectory(atPath:root.appendingPathComponent("MediaB").path).isEmpty)
XCTAssertTrue(report.logText.contains("0 imported, 1 skipped"));XCTAssertTrue(report.logText.contains(second.path))
}
@MainActor func testImportReportIncludesSavedTracksAndFailureAfterPartialImport() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let good = root.appendingPathComponent("A Good.mp3"),bad = root.appendingPathComponent("Z Empty.mp3")
try FileManager.default.copyItem(at:fixture,to:good);try Data().write(to:bad)
let model = AppModel(storageURL:root.appendingPathComponent("App"))
model.importFiles([good,bad])
for _ in 0..<200 where model.isImporting { try await Task.sleep(nanoseconds:20_000_000) }
let report = try XCTUnwrap(model.importProgress)
XCTAssertTrue(report.finished);XCTAssertNotNil(report.failure);XCTAssertEqual(report.completed,1)
XCTAssertTrue(report.reportLines.contains{$0.hasPrefix("[IMPORTED]") && $0.contains("A Good.mp3")})
XCTAssertTrue(report.reportLines.contains{$0.hasPrefix("[FAILED]") && $0.contains("Z Empty.mp3")})
}
}
extension KJamsCloneTests {
@MainActor func testDJImportShowsLiveModalAndBackgroundChoiceSurvivesCompletion() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let input = root.appendingPathComponent("Input")
try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
for i in 0..<48 { try FileManager.default.copyItem(at:fixture,to:input.appendingPathComponent("Track \(i).mp3")) }
let system = DJSystem(storageURL:root.appendingPathComponent("DJ"))
system.importFiles([input])
XCTAssertTrue(system.importing);XCTAssertTrue(system.importLogVisible)
XCTAssertNotNil(system.importProgress)
// Main-actor controls remain available while the worker scans/copies.
system.importLogVisible = false
system.crossfader = 0.7
XCTAssertEqual(system.crossfader,0.7)
for _ in 0..<300 where system.importing { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertFalse(system.importing);XCTAssertFalse(system.importLogVisible,"Run in Background must not force the modal open again at completion")
let report = try XCTUnwrap(system.lastImportReport)
XCTAssertTrue(report.finished);XCTAssertNil(report.failure)
XCTAssertEqual(report.completed,48);XCTAssertEqual(report.skipped,47)
XCTAssertEqual(report.reportLines.count,48);XCTAssertEqual(system.songs.count,1)
for _ in 0..<300 where system.automaticWaveforms.isRunning { try await Task.sleep(nanoseconds:20_000_000) }
let song = try XCTUnwrap(system.songs.first)
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:root.appendingPathComponent("DJ/Waveforms")))
}
@MainActor func testDJFavouritesPersistIndependentlyAndToggleOff() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root)
system.toggleFavourite(42)
XCTAssertEqual(system.favourites,[42])
let reopened = DJSystem(storageURL:root)
XCTAssertEqual(reopened.favourites,[42])
reopened.toggleFavourite(42)
XCTAssertTrue(reopened.favourites.isEmpty)
XCTAssertTrue(DJSystem(storageURL:root).favourites.isEmpty)
}
@MainActor func testDJAudibleScrubbingPreviewsPausedAudioAndPreservesTransport() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(waveformCacheDirectory:root)
defer { player.stop() }
player.audibleScrubbing = true
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
player.scrub(to:0.5)
XCTAssertFalse(player.playing)
XCTAssertTrue(player.audioPlaybackStarted,"Paused scrubbing must render an audio grain")
player.scrub(to:0.2)
XCTAssertEqual(player.time,0.5,accuracy:0.001,"Display stays with the audible grain until the next audio update")
player.endScrubbing()
XCTAssertFalse(player.audioPlaybackStarted);XCTAssertFalse(player.playing)
XCTAssertEqual(player.time,0.2,accuracy:0.001)
player.scrub(to:0.4)
try await Task.sleep(nanoseconds:330_000_000)
XCTAssertFalse(player.audioPlaybackStarted,"Preview must stop if dragging stops")
player.toggle()
player.scrub(to:0.6)
XCTAssertTrue(player.playing);XCTAssertTrue(player.audioPlaybackStarted)
player.endScrubbing()
XCTAssertTrue(player.playing,"Releasing a playing deck must keep it playing")
}
@MainActor func testLoadingSecondDJDeckKeepsFirstDeckRendering() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:root)
defer { system.a.unload();system.b.unload() }
let song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0)
system.load(song,into:"A");system.a.player.toggle()
try await Task.sleep(nanoseconds:100_000_000)
let before = system.a.player.displayTime
for _ in 0..<3 {
system.load(song,into:"B")
XCTAssertTrue(system.a.player.playing);XCTAssertTrue(system.a.player.audioPlaybackStarted)
XCTAssertFalse(system.b.player.playing)
try await Task.sleep(nanoseconds:50_000_000)
}
XCTAssertGreaterThan(system.a.player.displayTime,before)
XCTAssertNil(system.error)
}
@MainActor func testDJWaveformBatchSavesAndReusesCache() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:root)
let id = try XCTUnwrap(system.library?.add(title:"Demo",audio:fixture.path,cdg:nil))
try system.refresh()
system.generateWaveforms([id])
for _ in 0..<300 where system.generatingWaveforms { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertEqual(system.waveformJob?.generated,1);XCTAssertEqual(system.waveformJob?.issues,0)
XCTAssertNotNil(try WaveformCache.load(audio:fixture,directory:root.appendingPathComponent("Waveforms")))
system.generateWaveforms([id])
for _ in 0..<300 where system.generatingWaveforms { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertEqual(system.waveformJob?.cached,1);XCTAssertEqual(system.waveformJob?.generated,0)
}
@MainActor func testDJLibraryDoubleClickAlternatesDecksAndAlwaysProtectsPlayingTrack() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:root)
defer { system.a.unload();system.b.unload() }
let song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0)
system.loadFromLibraryDoubleClick(song)
XCTAssertEqual(system.a.song?.id,1);XCTAssertNil(system.b.song)
system.loadFromLibraryDoubleClick(song)
XCTAssertEqual(system.b.song?.id,1);XCTAssertEqual(system.nextLibraryDeck,"A")
system.settings.protectPlayingDeck = false
system.a.player.toggle()
system.loadFromLibraryDoubleClick(song)
XCTAssertEqual(system.pendingReplacement?.deck,"A")
XCTAssertTrue(system.a.player.playing)
system.cancelReplacement()
system.loadFromLibraryDoubleClick(song)
XCTAssertNil(system.pendingReplacement);XCTAssertEqual(system.nextLibraryDeck,"A")
XCTAssertTrue(system.a.player.playing)
}
@MainActor func testRapidDJScrubbingProducesAudioAtOutputTap() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.audibleScrubbing = true;player.autoFade = false;player.volume = 0.8
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
var peak: Float = 0
for i in 0..<40 {
player.scrub(to:0.3+Double(i)*0.01)
try await Task.sleep(nanoseconds:10_000_000)
peak = max(peak,player.scrubOutputLevel)
}
XCTAssertGreaterThan(peak,0.0001,"Rapid mouse-like scrub updates must produce decoded signal at the audio output")
XCTAssertFalse(player.playing)
player.endScrubbing();XCTAssertFalse(player.audioPlaybackStarted)
XCTAssertEqual(player.time,0.69,accuracy:0.001)
}
func testConcurrentWaveformBatchSavesUniquePathsAndReusesDuplicates() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
var songs = [Song]()
for i in 0..<4 {
let audio = root.appendingPathComponent("Track\(i).wav")
try FileManager.default.copyItem(at:fixture,to:audio)
songs.append(Song(id:Int64(i),title:"Track",audio:audio.path,cdg:nil,rating:0,plays:0))
}
var duplicate = songs[0];duplicate.id = 10;songs.append(duplicate)
let cache = root.appendingPathComponent("Cache")
var updates = [WaveformJobProgress]()
let result = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,force:true,limit:{2},progress:{updates.append($0)})
XCTAssertEqual(updates.first?.processed,0,"Show the admitted track before waiting for its decoder")
XCTAssertEqual(updates.first?.current,"Track")
XCTAssertEqual(updates.first?.finished,false)
XCTAssertEqual(result.generated,4);XCTAssertEqual(result.cached,1);XCTAssertEqual(result.processed,5);XCTAssertEqual(result.issues,0)
let reused = await WaveformBatch.runConcurrent(songs:songs,locations:[],cache:cache,limit:{1})
XCTAssertEqual(reused.cached,5);XCTAssertEqual(reused.generated,0)
}
@MainActor func testWaitingWaveformWorkerCancelsWithoutBlockingTaskExecutor() async throws {
let policy = WaveformWorkload.shared,id = UUID()
policy.setPlaying(true,id:id)
try policy.acquire(cancelled:{false})
var held = true
defer { if held { policy.release() };policy.setPlaying(false,id:id) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let waiting = Task {
try await WaveformWorker.run { cancelled in
try AudioWaveform.analyse(url:fixture,cancelled:cancelled)
}
}
// This continuation must run even while decoding is waiting for admission.
try await Task.sleep(nanoseconds:50_000_000)
waiting.cancel()
do { _ = try await waiting.value;XCTFail("Cancelled analysis should not produce a waveform") }
catch { XCTAssertTrue(error is CancellationError) }
policy.release();held = false
let analysis = try await WaveformWorker.run { cancelled in
try AudioWaveform.analyse(url:fixture,cancelled:cancelled)
}
XCTAssertEqual(analysis.levels.count,4096)
XCTAssertGreaterThan(analysis.levels.max() ?? 0,0)
}
@MainActor func testWaveformAdmissionDropsToOneDuringPlayback() throws {
let policy = WaveformWorkload.shared,id = UUID()
policy.setPlaying(true,id:id)
XCTAssertEqual(policy.workers,1)
policy.setPlaying(false,id:id)
XCTAssertLessThanOrEqual(policy.workers,2)
}
@MainActor func testSharedDJGraphicEQAppliesAcrossLoadsAndPersists() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root)
defer { system.a.unload();system.b.unload() }
system.chooseGraphicPreset("Rock")
XCTAssertTrue(system.a.player.graphicEnabled)
XCTAssertEqual(system.a.player.graphicGains,system.b.player.graphicGains)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let song = Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0)
system.load(song,into:"A")
XCTAssertEqual(system.a.player.graphicGains,DJGraphicEQSettings.presets.first(where:{$0.0 == "Rock"})?.1)
system.graphicEQ.enabled = false
XCTAssertFalse(system.a.player.graphicEnabled);XCTAssertFalse(system.b.player.graphicEnabled)
try await Task.sleep(nanoseconds:300_000_000)
let reopened = DJSystem(storageURL:root)
XCTAssertEqual(reopened.graphicEQ,system.graphicEQ)
}
func testCentreVocalReductionRemovesCentreAndPreservesSideSignal() {
let centred = DJVocalReductionUnit.reduce(left:0.8,right:0.8,amount:1)
XCTAssertEqual(centred.0,0,accuracy:0.0001);XCTAssertEqual(centred.1,0,accuracy:0.0001)
let side = DJVocalReductionUnit.reduce(left:0.8,right:-0.8,amount:1)
XCTAssertEqual(side.0,0.8,accuracy:0.0001);XCTAssertEqual(side.1,0.8,accuracy:0.0001)
let bypass = DJVocalReductionUnit.reduce(left:0.6,right:0.2,amount:0)
XCTAssertEqual(bypass.0,0.6,accuracy:0.0001);XCTAssertEqual(bypass.1,0.2,accuracy:0.0001)
}
@MainActor func testDJNameReinterpretationUpdatesArtistAndUndo() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root)
let id = try XCTUnwrap(system.library?.add(title:"Example Artist - Example Song",audio:"/missing/example.mp3",cdg:nil))
try system.refresh()
let preview = try NameTemplate.preview(system.songs,template:"R - S",source:.song,emDashes:false,underscores:true)
try await system.applyNameChanges(preview.compactMap(\.change))
XCTAssertEqual(system.songs.first?.artist,"Example Artist");XCTAssertEqual(system.songs.first?.title,"Example Song")
try await system.undoNameChanges()
XCTAssertEqual(system.songs.first?.id,id);XCTAssertEqual(system.songs.first?.title,"Example Artist - Example Song")
}
@MainActor func testCentreVocalReductionProcessesLiveStereoAudio() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:2))
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:88200));buffer.frameLength = 88200
let data = try XCTUnwrap(buffer.floatChannelData)
for i in 0..<88200 { let value = Float(sin(Double(i)*2 * .pi*440/44100))*0.3;data[0][i] = value;data[1][i] = value }
let url = root.appendingPathComponent("Centred.wav")
do { let file = try AVAudioFile(forWriting:url,settings:format.settings);try file.write(from:buffer) }
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.autoFade = false;player.volume = 0.8
try player.load(Song(id:1,title:"Centred",audio:url.path,cdg:nil,rating:0,plays:0))
for _ in 0..<50 where player.scrubOutputLevel <= 0.01 { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertGreaterThan(player.scrubOutputLevel,0.01)
player.vocalReductionEnabled = true
for _ in 0..<70 where player.scrubOutputLevel >= 0.001 { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertLessThan(player.scrubOutputLevel,0.001)
XCTAssertTrue(player.playing)
player.vocalReductionEnabled = false
for _ in 0..<70 where player.scrubOutputLevel <= 0.01 { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertGreaterThan(player.scrubOutputLevel,0.01)
}
@MainActor func testShortLoopRunsOnAudioNodeAndClockWraps() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.autoFade = false
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
player.defineLoop(start:0.2,end:0.4)
for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(player.nativeLoopActive)
player.seek(0.25);player.toggle()
for _ in 0..<30 {
try await Task.sleep(nanoseconds:20_000_000)
XCTAssertTrue(player.audioPlaybackStarted);XCTAssertTrue(player.nativeLoopActive)
XCTAssertGreaterThanOrEqual(player.displayTime,0.2);XCTAssertLessThan(player.displayTime,0.401)
}
let restarts = player.loopAudioRestartCount
player.defineLoop(start:0.2,end:0.3)
try await Task.sleep(nanoseconds:300_000_000)
XCTAssertEqual(player.loopAudioRestartCount,restarts,"Resizing an active loop must not restart the audio node")
XCTAssertTrue(player.nativeLoopActive);XCTAssertLessThan(player.displayTime,0.301)
let exit = player.displayTime,restartsOnExit = player.audioScheduleRestartCount
player.loopEnabled = false
XCTAssertEqual(player.audioScheduleRestartCount,restartsOnExit+1,"Loop Off prepares the idle node before retiring the audible node")
XCTAssertFalse(player.nativeLoopActive);XCTAssertTrue(player.playing)
XCTAssertEqual(player.time,exit,accuracy:0.03)
try await Task.sleep(nanoseconds:180_000_000)
XCTAssertGreaterThan(player.displayTime,0.3,"Loop Off must continue immediately past the previous loop end")
}
@MainActor func testDJStorageMoveRelocatesMediaAndRekeysSavedWaveforms() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let local = root.appendingPathComponent("Local"),external = root.appendingPathComponent("External/DJ")
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:local)
try Importer(media:system.mediaDirectory,waveformCache:system.waveformDirectory).run([fixture],library:try XCTUnwrap(system.library))
try system.refresh()
let original = try XCTUnwrap(system.songs.first),oldAudio = URL(fileURLWithPath:original.audio)
let originalCache = try WaveformCache.location(audio:oldAudio,directory:system.waveformDirectory)
system.moveStorage(to:external)
for _ in 0..<300 where system.movingStorage { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertFalse(system.movingStorage);XCTAssertNil(system.storageProgress?.failure)
XCTAssertEqual(system.mediaDirectory.standardizedFileURL,external.appendingPathComponent("Media").standardizedFileURL)
let moved = try XCTUnwrap(system.songs.first)
XCTAssertEqual(moved.id,original.id);XCTAssertFalse(FileManager.default.fileExists(atPath:oldAudio.path))
XCTAssertFalse(FileManager.default.fileExists(atPath:originalCache.path))
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:moved.audio),directory:external.appendingPathComponent("Waveforms")))
let reopened = DJSystem(storageURL:local)
XCTAssertEqual(reopened.waveformDirectory.standardizedFileURL,external.appendingPathComponent("Waveforms").standardizedFileURL)
XCTAssertEqual(reopened.songs.first?.audio,moved.audio)
XCTAssertTrue(FileManager.default.fileExists(atPath:fixture.path))
}
func testDJStorageCancelledBeforeTransferKeepsOriginals() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let local = root.appendingPathComponent("Local"),destination = root.appendingPathComponent("External")
try FileManager.default.createDirectory(at:local,withIntermediateDirectories:true)
let db = try Library(url:local.appendingPathComponent("Library.sqlite"))
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try Importer(media:local.appendingPathComponent("Media"),waveformCache:local.appendingPathComponent("Waveforms")).run([fixture],library:db)
let song = try XCTUnwrap(db.songs().first)
XCTAssertThrowsError(try DJStorageMove.run(database:local.appendingPathComponent("Library.sqlite"),defaultRoot:local,destination:destination,cancelled:{true}))
XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertNil(try DJStorageMove.location(in:db))
}
func testDJStorageCachePhaseCancellationCanResumeWithoutReanalysis() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let local = root.appendingPathComponent("Local"),target = root.appendingPathComponent("External")
try FileManager.default.createDirectory(at:local,withIntermediateDirectories:true)
let database = local.appendingPathComponent("Library.sqlite"),db = try Library(url:database)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try Importer(media:local.appendingPathComponent("Media"),waveformCache:local.appendingPathComponent("Waveforms")).run([fixture],library:db)
var cancelled = false
XCTAssertThrowsError(try DJStorageMove.run(database:database,defaultRoot:local,destination:target,cancelled:{cancelled},progress:{if $0.current.hasPrefix("Moving waveform") { cancelled = true }}))
let resumed = try DJStorageMove.run(database:database,defaultRoot:local,destination:target)
XCTAssertTrue(resumed.finished)
let song = try XCTUnwrap(db.songs().first)
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:target.appendingPathComponent("Waveforms")))
}
func testStorageCopyCancellationBetweenChunksKeepsOriginalAndRemovesStaging() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let source = root.appendingPathComponent("Source.bin"),destination = root.appendingPathComponent("Destination.bin")
try Data(repeating:0x5A,count:4*1024*1024).write(to:source)
let hash = try ManagedMediaStorage.hash(source,cancelled:{false}).map{String(format:"%02x",$0)}.joined()
var cancelled = false,bytes: Int64 = 0
XCTAssertThrowsError(try MediaByteCopy.copy(source,to:destination,expectedHash:hash,cancelled:{cancelled},bytesCopied:{ count in bytes = count;cancelled = true })) { error in XCTAssertTrue(error is CancellationError) }
XCTAssertEqual(bytes,1_048_576)
XCTAssertTrue(FileManager.default.fileExists(atPath:source.path));XCTAssertFalse(FileManager.default.fileExists(atPath:destination.path))
XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath:root.path),["Source.bin"])
}
func testDJStorageReportsAndCancelsBeforeDiscovery() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
var cancelled = false,updates = [String]()
XCTAssertThrowsError(try DJStorageMove.run(database:root.appendingPathComponent("Library.sqlite"),defaultRoot:root,destination:root.appendingPathComponent("Destination"),cancelled:{cancelled},progress:{ updates.append($0.current);cancelled = true })) { error in XCTAssertTrue(error is CancellationError) }
XCTAssertEqual(updates.first,"Checking DJ storage…")
XCTAssertFalse(FileManager.default.fileExists(atPath:root.appendingPathComponent("Destination").path))
}
@MainActor func testDJStorageDialogAppearsWithoutParentModelChange() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let host = NSHostingView(rootView:StudioSettingsDialog(model:model,scope:"DJ",category:"Library"))
let window = NSWindow(contentRect:NSRect(x:0,y:0,width:900,height:750),styleMask:[.titled],backing:.buffered,defer:false)
window.contentView = host;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
defer { model.dj.storageProgress = nil;window.orderOut(nil);window.contentView = nil }
try await Task.sleep(nanoseconds:100_000_000)
model.dj.storageProgress = .init(current:"Preparing DJ storage move…")
for _ in 0..<100 where window.sheets.isEmpty { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertFalse(window.sheets.isEmpty,"The DJ child model must present progress immediately without an unrelated AppModel refresh")
model.dj.storageProgress = nil
}
@MainActor func testDJReferenceImportsAvoidMediaCopiesAndRememberMode() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let system = DJSystem(storageURL:root)
XCTAssertFalse(system.copyImportedMedia)
system.importFiles([fixture])
for _ in 0..<300 where system.importing { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertNil(system.lastImportReport?.failure)
XCTAssertEqual(system.songs.first?.audio,fixture.standardizedFileURL.path)
XCTAssertTrue((try? FileManager.default.contentsOfDirectory(atPath:system.mediaDirectory.path))?.isEmpty ?? true)
XCTAssertEqual(try system.library?.mediaLocations().count,1)
system.copyImportedMedia = true
XCTAssertTrue(DJSystem(storageURL:root).copyImportedMedia)
}
func testDJCleanupNeverRemovesReferencedOriginalsAndRechecksItsPreview() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let local = root.appendingPathComponent("DJ"),media = local.appendingPathComponent("Media"),waves = local.appendingPathComponent("Waveforms")
try FileManager.default.createDirectory(at:media,withIntermediateDirectories:true);try FileManager.default.createDirectory(at:waves,withIntermediateDirectories:true)
let database = local.appendingPathComponent("Library.sqlite"),db = try Library(url:database)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try Importer(media:media,waveformCache:waves).run([fixture],library:db,copyMedia:false)
let orphan = media.appendingPathComponent(String(repeating:"a",count:64)+".mp3")
let stale = waves.appendingPathComponent(String(repeating:"b",count:64)+".waveform")
let personal = media.appendingPathComponent("My Music.mp3")
try FileManager.default.copyItem(at:fixture,to:orphan);try FileManager.default.copyItem(at:fixture,to:personal)
try Data("stale cache".utf8).write(to:stale)
let plan = try DJLibraryMaintenance.cleanupPlan(database:database,root:local)
XCTAssertEqual(Set(plan.files.map{$0.url.lastPathComponent}),[orphan.lastPathComponent,stale.lastPathComponent])
_ = try db.add(title:"Now referenced",audio:orphan.path,cdg:nil)
var removed = [String]()
let result = try DJLibraryMaintenance.clean(plan,database:database,root:local,trash:{removed.append($0.lastPathComponent);try FileManager.default.removeItem(at:$0)})
XCTAssertEqual(removed,[stale.lastPathComponent]);XCTAssertEqual(result.skipped,1)
XCTAssertTrue(FileManager.default.fileExists(atPath:fixture.path));XCTAssertTrue(FileManager.default.fileExists(atPath:orphan.path));XCTAssertTrue(FileManager.default.fileExists(atPath:personal.path))
}
@MainActor func testDJClearLibraryKeepsSourceAndManagedFilesAndClearsQueue() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let system = DJSystem(storageURL:root)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
try Importer(media:system.mediaDirectory).run([fixture],library:try XCTUnwrap(system.library))
try system.refresh();let song = try XCTUnwrap(system.songs.first)
system.automix.enqueue([song]);system.toggleFavourite(song.id)
system.removeLibraryTracks([song.id])
for _ in 0..<300 where system.movingStorage { try await Task.sleep(nanoseconds:20_000_000) }
XCTAssertTrue(system.songs.isEmpty);XCTAssertTrue(system.automix.entries.isEmpty);XCTAssertTrue(system.favourites.isEmpty)
XCTAssertTrue(FileManager.default.fileExists(atPath:song.audio));XCTAssertTrue(FileManager.default.fileExists(atPath:fixture.path))
XCTAssertNil(system.storageProgress?.failure)
}
func testDJCleanupPreservesWaveformsWhileSourceDriveIsUnavailable() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let waves = root.appendingPathComponent("Waveforms")
try FileManager.default.createDirectory(at:waves,withIntermediateDirectories:true)
let database = root.appendingPathComponent("Library.sqlite"),db = try Library(url:database)
_ = try db.add(title:"Offline",audio:root.appendingPathComponent("Offline/source.mp3").path,cdg:nil)
let cache = waves.appendingPathComponent(String(repeating:"c",count:64)+".waveform")
try Data("cached waveform".utf8).write(to:cache)
XCTAssertTrue(try DJLibraryMaintenance.cleanupPlan(database:database,root:root).files.isEmpty)
XCTAssertTrue(FileManager.default.fileExists(atPath:cache.path))
}
@MainActor func testImportsFinishBeforeQueuedWaveformsAndAllowAnotherImport() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let id = UUID(),policy = WaveformWorkload.shared
policy.setPlaying(true,id:id);try policy.acquire(cancelled:{false})
var held = true
defer { if held { policy.release() };policy.setPlaying(false,id:id) }
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let fixture = fixtures.appendingPathComponent("Demo.mp3"),second = fixtures.appendingPathComponent("TestTrack.mp3")
let model = AppModel(storageURL:root.appendingPathComponent("Karaoke")),dj = DJSystem(storageURL:root.appendingPathComponent("DJ"))
model.importFiles([fixture]);dj.importFiles([fixture])
for _ in 0..<300 where model.isImporting || dj.importing { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.isImporting);XCTAssertFalse(dj.importing)
XCTAssertEqual(model.songs.count,1);XCTAssertEqual(dj.songs.count,1)
XCTAssertTrue(model.automaticWaveforms.isRunning);XCTAssertTrue(dj.automaticWaveforms.isRunning)
let karaokeSong = try XCTUnwrap(model.songs.first)
XCTAssertThrowsError(try WaveformCache.load(audio:URL(fileURLWithPath:karaokeSong.audio),directory:model.root.appendingPathComponent("Waveforms")))
dj.importFiles([second])
for _ in 0..<300 where dj.importing { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(dj.importing);XCTAssertEqual(dj.songs.count,2,"A background waveform job must not block subsequent imports")
policy.release();held = false
for _ in 0..<500 where model.automaticWaveforms.isRunning || dj.automaticWaveforms.isRunning { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertFalse(model.automaticWaveforms.isRunning);XCTAssertFalse(dj.automaticWaveforms.isRunning)
XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:karaokeSong.audio),directory:model.root.appendingPathComponent("Waveforms")))
for song in dj.songs { XCTAssertNotNil(try WaveformCache.load(audio:URL(fileURLWithPath:song.audio),directory:dj.waveformDirectory)) }
}
@MainActor func testAddingLoopReplacesFileCommandsOnceAndKeepsRepeating() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.autoFade = false
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0))
for _ in 0..<100 where player.displayTime < 0.05 { try await Task.sleep(nanoseconds:10_000_000) }
let start = player.displayTime,restarts = player.audioScheduleRestartCount
player.defineLoop(start:start,end:start+0.3)
for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(player.nativeLoopActive);XCTAssertTrue(player.audioPlaybackStarted)
XCTAssertEqual(player.audioScheduleRestartCount,restarts+1,"A live file command must be replaced once with the prepared loop")
try await Task.sleep(nanoseconds:400_000_000)
XCTAssertGreaterThanOrEqual(player.displayTime,start);XCTAssertLessThan(player.displayTime,start+0.301)
player.loopEnabled = false
XCTAssertEqual(player.audioScheduleRestartCount,restarts+2,"Cancellation prepares the idle node without stopping audible output")
XCTAssertTrue(player.audioPlaybackStarted)
}
@MainActor func testLoopOffNearTrackEndStillFinishesWithoutRestarting() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() }
player.autoFade = false
try player.load(Song(id:1,title:"Demo",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
player.defineLoop(start:player.duration-0.2,end:player.duration)
for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) }
player.seek(player.duration-0.15);player.toggle()
for _ in 0..<500 {
let remaining = player.duration-player.displayTime
if remaining > 0,remaining < 0.015 { break }
try await Task.sleep(nanoseconds:1_000_000)
}
XCTAssertLessThan(player.duration-player.displayTime,0.02)
var finished = false;player.onFinished = { finished = true }
let restarts = player.audioScheduleRestartCount
player.loopEnabled = false
XCTAssertEqual(player.audioScheduleRestartCount,restarts+1)
for _ in 0..<200 where !finished { try await Task.sleep(nanoseconds:5_000_000) }
XCTAssertTrue(finished);XCTAssertFalse(player.playing)
}
@MainActor func testLiveLoopRepeatsActualAudioRatherThanOnlyWrappingDisplay() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:2))
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:132300));buffer.frameLength = 132300
let data = try XCTUnwrap(buffer.floatChannelData)
for i in 0..<132300 {
let amplitude: Float = i < 30870 ? 0.1 : 0.7
let value = amplitude*Float(sin(Double(i)*2 * .pi*440/44100))
data[0][i] = value;data[1][i] = value
}
let url = root.appendingPathComponent("LoopSignal.wav")
do { let file = try AVAudioFile(forWriting:url,settings:format.settings);try file.write(from:buffer) }
let player = Player(waveformCacheDirectory:root.appendingPathComponent("Cache"),persistsMix:false)
defer { player.stop() }
player.autoFade = false;player.volume = 0.8
try player.load(Song(id:1,title:"Loop signal",audio:url.path,cdg:nil,rating:0,plays:0))
for _ in 0..<100 where player.displayTime < 0.1 { try await Task.sleep(nanoseconds:10_000_000) }
player.defineLoop(start:0.2,end:0.5)
for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:10_000_000) }
try await Task.sleep(nanoseconds:1_000_000_000)
for _ in 0..<10 {
XCTAssertGreaterThan(player.scrubOutputLevel,0.02,"Loop audio must remain audible")
XCTAssertLessThan(player.scrubOutputLevel,0.2,"The quiet loop must repeat; the loud audio after it must never render")
try await Task.sleep(nanoseconds:30_000_000)
}
let handoffs = player.audioScheduleRestartCount
player.defineLoop(start:0.2,end:1.1)
var extendedPeak: Float = 0
for _ in 0..<50 {
try await Task.sleep(nanoseconds:30_000_000)
extendedPeak = max(extendedPeak,player.scrubOutputLevel)
}
XCTAssertGreaterThan(extendedPeak,0.4,"Extending the loop must render its newly included loud section")
player.defineLoop(start:0.2,end:0.5)
try await Task.sleep(nanoseconds:1_200_000_000)
XCTAssertLessThan(player.scrubOutputLevel,0.2,"Shortening must return to the quiet loop audio")
player.loopEnabled = false
try await Task.sleep(nanoseconds:800_000_000)
XCTAssertGreaterThan(player.scrubOutputLevel,0.4,"Loop Off must resume the actual file beyond the loop")
XCTAssertEqual(player.audioScheduleRestartCount,handoffs+1,"Cancellation prepares the idle node; resizing reuses the active loop")
}
@MainActor func testLoopHandoffsKeepContinuousAudioAcrossRepeatedStartAndStop() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:2))
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:441000));buffer.frameLength = 441000
let channels = try XCTUnwrap(buffer.floatChannelData)
for i in 0..<441000 {
let sample = Float(sin(Double(i)*2 * .pi*440/44100))*0.25
channels[0][i] = sample;channels[1][i] = sample
}
let url = root.appendingPathComponent("Continuous.wav")
do { let file = try AVAudioFile(forWriting:url,settings:format.settings);try file.write(from:buffer) }
let player = Player(waveformCacheDirectory:root.appendingPathComponent("Cache"),persistsMix:false)
defer { player.stop() }
player.autoFade = false;player.volume = 0.8
try player.load(Song(id:1,title:"Continuous",audio:url.path,cdg:nil,rating:0,plays:0))
let other = Player(waveformCacheDirectory:root.appendingPathComponent("OtherCache"),persistsMix:false)
defer { other.stop() };other.autoFade = false;other.volume = 0.3
try other.load(Song(id:2,title:"Other deck",audio:url.path,cdg:nil,rating:0,plays:0))
try await Task.sleep(nanoseconds:200_000_000)
player.beginAudioContinuityCheck()
for iteration in 0..<4 {
if iteration == 2 { player.tempo = 1.12;other.tempo = 0.95 }
let start = player.displayTime
player.defineLoop(start:start,end:start+0.307)
for _ in 0..<200 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:5_000_000) }
XCTAssertTrue(player.nativeLoopActive)
try await Task.sleep(nanoseconds:50_000_000)
XCTAssertLessThan(player.audioContinuity.silentFrames,128);XCTAssertLessThan(player.audioContinuity.largestStep,0.08)
player.beginAudioContinuityCheck()
try await Task.sleep(nanoseconds:400_000_000)
XCTAssertLessThan(player.audioContinuity.silentFrames,128);XCTAssertLessThan(player.audioContinuity.largestStep,0.08)
player.beginAudioContinuityCheck()
player.loopEnabled = false
try await Task.sleep(nanoseconds:100_000_000)
XCTAssertLessThan(player.audioContinuity.silentFrames,128);XCTAssertLessThan(player.audioContinuity.largestStep,0.08)
XCTAssertTrue(player.playing);XCTAssertTrue(player.audioPlaybackStarted)
XCTAssertGreaterThan(player.scrubOutputLevel,0.05)
}
XCTAssertLessThan(player.audioContinuity.silentFrames,128,"Loop handoffs must not insert a silent audio block")
XCTAssertLessThan(player.audioContinuity.largestStep,0.08,"Loop handoffs must not introduce a hard sample discontinuity")
}
}
extension KJamsCloneTests {
func testDJCollectionsCombineSearchRatingsAndPlaylistOrder() {
let tracks = [
Song(id:1,title:"Track 10",audio:"/music/10.mp3",cdg:nil,rating:5,plays:0,artist:"Northbound",album:"Night"),
Song(id:2,title:"Track 2",audio:"/music/2.mp3",cdg:nil,rating:0,plays:0,artist:"Mira Vale",album:"Night"),
Song(id:3,title:"Track 1",audio:"/music/1.mp3",cdg:nil,rating:4,plays:0,artist:"")
]
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[]).map(\.id),[3,2,1],"Titles use natural numeric ordering")
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.rated,favourites:[],query:"Northbound").map(\.id),[1])
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.favourites,favourites:[2],query:"Night").map(\.id),[2])
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.missingArtist,favourites:[]).map(\.id),[3])
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.unrated,favourites:[]).map(\.id),[2])
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[],playlist:[2,1,99,3],sort:.source).map(\.id),[2,1,3],"Playlist order is retained; missing rows are ignored")
XCTAssertEqual(DJLibraryBrowser.rows(tracks,collection:.all,favourites:[],sort:.rating,ascending:false).map(\.id),[1,3,2])
XCTAssertEqual(DJLibraryBrowser.rows(Array(tracks.reversed()),collection:.all,favourites:[],sort:.source).map(\.id),[1,2,3],"Import order follows stable database IDs, not the prior display sort")
}
@MainActor func testRedesignedDJWorkspaceAtCompactAndWideSizes() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("KJMix-DJ-Design-"+UUID().uuidString)
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let system = DJSystem(storageURL:root.appendingPathComponent("Library"))
defer { system.a.player.stop();system.b.player.stop();system.preview.stop() }
let library = try XCTUnwrap(system.library)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let titles = ["Night Transit","Glass City","After Hours","Satellite","Deep Blue","Into the Light","Parallel Lines","Last Train"]
for (index,title) in titles.enumerated() {
let url = root.appendingPathComponent("Track-\(index).mp3");try FileManager.default.copyItem(at:fixture,to:url)
let id = try library.add(title:title,audio:url.path,cdg:nil,artist:index.isMultiple(of:2) ? "Northbound" : "Mira Vale",album:"Night Sessions",duration:180+Double(index*12))
try library.rate(id,index.isMultiple(of:3) ? 5 : 3)
}
try system.refresh();system.createPlaylist("Opening Set");system.createPlaylist("Late Night")
if let list = system.playlists.first { for song in system.songs.prefix(3) { system.addToPlaylist(list.id,song:song.id) } }
system.toggleFavourite(try XCTUnwrap(system.songs.first?.id))
system.load(system.songs[0],into:"A");system.load(system.songs[1],into:"B")
system.a.bpm = 120;system.a.bpmConfirmed = true;system.b.bpm = 124;system.b.bpmConfirmed = true
for _ in 0..<100 where system.a.player.waveformLoading || system.b.player.waveformLoading { try await Task.sleep(nanoseconds:20_000_000) }
for size in [CGSize(width:1320,height:900),CGSize(width:1060,height:760)] {
let view = NSHostingView(rootView:DJConsole(system:system).frame(width:size.width,height:size.height).preferredColorScheme(.dark))
view.frame = NSRect(origin:.zero,size:size)
let window = NSWindow(contentRect:view.frame,styleMask:[.borderless],backing:.buffered,defer:false)
window.contentView = view;window.appearance = NSAppearance(named:.darkAqua);window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil)
try await Task.sleep(nanoseconds:250_000_000);view.layoutSubtreeIfNeeded()
if size.width == 1320 {
system.a.player.filter = 0.63;system.b.player.filter = -0.31
try await Task.sleep(nanoseconds:60_000_000);view.layoutSubtreeIfNeeded()
func knobs(_ parent: NSView) -> [DJRotaryControl] { (parent as? DJRotaryControl).map{[$0]} ?? parent.subviews.flatMap{knobs($0)} }
let filters = knobs(view).filter{$0.label == "FILTER"}
XCTAssertEqual(filters.count,2)
XCTAssertTrue(filters.contains{abs($0.value-0.63) < 0.001});XCTAssertTrue(filters.contains{abs($0.value+0.31) < 0.001},"External audio-model changes update both mixer strips")
for filter in filters { filter.resetToDefault() }
XCTAssertEqual(system.a.player.filter,0);XCTAssertEqual(system.b.player.filter,0)
}
let rep = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in:view.bounds));view.cacheDisplay(in:view.bounds,to:rep)
let png = try XCTUnwrap(rep.representation(using:.png,properties:[:]))
let path = root.appendingPathComponent("DJ-Studio-\(Int(size.width)).png");try png.write(to:path)
print("DJ_DESIGN_CAPTURE \(path.path)")
XCTContext.runActivity(named:"DJ Studio \(Int(size.width)) layout") { activity in
let capture = XCTAttachment(data:png,uniformTypeIdentifier:"public.png");capture.lifetime = .keepAlways;activity.add(capture)
}
XCTAssertFalse(system.a.player.playing);XCTAssertFalse(system.b.player.playing)
window.orderOut(nil);window.contentView = nil
}
}
}
extension KJamsCloneTests {
@MainActor func testClearingDeckMarkersPreservesLoadedPlayingAudio() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.wav"))
let player = Player(waveformCacheDirectory:root,persistsMix:false)
defer { player.stop() };player.autoFade = false
try player.load(Song(id:7,title:"Markers",audio:fixture.path,cdg:nil,rating:0,plays:0),startPlaying:false)
player.setCue(at:0.4)
for index in 0..<4 { player.seek(0.2+Double(index)*0.1);player.hotCue(index) }
XCTAssertTrue(player.hotCues.allSatisfy{$0 != nil})
player.hotCue(2,clear:true);XCTAssertNil(player.hotCues[2]);XCTAssertNotNil(player.hotCues[0])
player.defineLoop(start:0.2,end:0.5)
for _ in 0..<100 where !player.nativeLoopActive { try await Task.sleep(nanoseconds:5_000_000) }
player.seek(0.25);player.toggle();try await Task.sleep(nanoseconds:80_000_000)
let position = player.displayTime
player.clearDeckMarkers()
XCTAssertEqual(player.currentSongID,7);XCTAssertTrue(player.playing);XCTAssertTrue(player.audioPlaybackStarted)
XCTAssertTrue(player.hotCues.allSatisfy{$0 == nil});XCTAssertEqual(player.cuePosition,0)
XCTAssertNil(player.loopIn);XCTAssertNil(player.loopOut);XCTAssertFalse(player.loopEnabled)
XCTAssertEqual(player.displayTime,position,accuracy:0.04,"Clearing markers must not jump back to the start")
try await Task.sleep(nanoseconds:160_000_000)
XCTAssertGreaterThan(player.displayTime,position+0.08)
}
}
extension KJamsCloneTests {
func testDJImportContinuesAfterUnreadableFilesAndStalledTags() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let input = root.appendingPathComponent("Input");try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1))
for (index,name) in ["A Slow Tags","B Good","C Good","D Good"].enumerated() {
let samples = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024));samples.frameLength = 1024
for frame in 0..<1024 { samples.floatChannelData![0][frame] = Float(sin(Double(frame)*Double(index+1)*0.1))*0.2 }
let file = try AVAudioFile(forWriting:input.appendingPathComponent(name+".wav"),settings:format.settings);try file.write(from:samples)
}
try Data().write(to:input.appendingPathComponent("AA Empty.wav"))
let library = try Library(url:root.appendingPathComponent("Library.sqlite"))
let importer = Importer(media:root.appendingPathComponent("Media"),audioExtensions:["wav"],includeGraphics:false,continueOnUnreadableAudio:true,metadataTimeout:0.08,metadataLoader:{ url in
if url.lastPathComponent.hasPrefix("A Slow") { try await Task.sleep(nanoseconds:10_000_000_000) }
return [.commonKeyTitle:"Tagged "+url.deletingPathExtension().lastPathComponent]
})
var report = ImportProgress();let began = ProcessInfo.processInfo.systemUptime
let ids = try importer.run([input],library:library,copyMedia:false,progress:{report = $0})
XCTAssertLessThan(ProcessInfo.processInfo.systemUptime-began,3,"A stalled tag read must not hold up later files")
XCTAssertEqual(ids.count,4);XCTAssertEqual(try library.songs().count,4)
XCTAssertEqual(report.total,5);XCTAssertEqual(report.completed,5);XCTAssertEqual(report.skipped,1);XCTAssertTrue(report.finished)
XCTAssertTrue(report.logLines.contains{$0.contains("tag read timed out")})
XCTAssertTrue(report.logLines.contains{$0.contains("AA Empty.wav") && $0.hasPrefix("[SKIPPED]")})
XCTAssertTrue(try library.songs().contains{$0.title == "A Slow Tags"});XCTAssertTrue(try library.songs().contains{$0.title == "Tagged D Good"})
}
func testImportCancellationInterruptsStalledMetadataWait() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3"))
let library = try Library(url:root.appendingPathComponent("Library.sqlite")),began = ProcessInfo.processInfo.systemUptime
let importer = Importer(media:root.appendingPathComponent("Media"),metadataTimeout:20,metadataLoader:{ _ in try await Task.sleep(nanoseconds:10_000_000_000);return [:] })
XCTAssertThrowsError(try importer.run([fixture],library:library,copyMedia:false,cancelled:{ProcessInfo.processInfo.systemUptime-began > 0.12})) { XCTAssertTrue($0 is CancellationError) }
XCTAssertLessThan(ProcessInfo.processInfo.systemUptime-began,1)
XCTAssertTrue(try library.songs().isEmpty)
}
@MainActor func testLargeDJImportKeepsUIResponsiveAndCompletesEveryInput() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let input = root.appendingPathComponent("Input");try FileManager.default.createDirectory(at:input,withIntermediateDirectories:true)
let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate:44100,channels:1))
for index in 0..<128 {
let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat:format,frameCapacity:1024));buffer.frameLength = 1024
for frame in 0..<1024 { buffer.floatChannelData![0][frame] = Float(sin(Double(frame)*0.1+Double(index)))*0.2 }
let file = try AVAudioFile(forWriting:input.appendingPathComponent(String(format:"Unique %04d.wav",index)),settings:format.settings);try file.write(from:buffer)
}
for index in 0..<512 { try FileManager.default.copyItem(at:input.appendingPathComponent("Unique 0000.wav"),to:input.appendingPathComponent(String(format:"Duplicate %04d.wav",index))) }
let system = DJSystem(storageURL:root.appendingPathComponent("DJ"))
system.importFiles([input]);XCTAssertTrue(system.importing);XCTAssertTrue(system.importLogVisible)
var heartbeat = false;Task { @MainActor in heartbeat = true }
system.crossfader = 0.75
for _ in 0..<1000 where system.importing { try await Task.sleep(nanoseconds:10_000_000) }
XCTAssertTrue(heartbeat);XCTAssertFalse(system.importing);XCTAssertEqual(system.crossfader,0.75)
let report = try XCTUnwrap(system.lastImportReport)
XCTAssertEqual(report.completed,640);XCTAssertEqual(report.total,640);XCTAssertEqual(report.skipped,512)
XCTAssertNil(report.failure);XCTAssertEqual(system.songs.count,128,"Every distinct track is saved, while repeated audio remains deduplicated")
system.automaticWaveforms.cancel()
for _ in 0..<200 where system.automaticWaveforms.isRunning { try await Task.sleep(nanoseconds:5_000_000) }
}
}