Files
KMix_Pro/KJamsCloneTests/KJamsCloneTests.swift
T
2026-11-05 16:26:24 +00:00

2143 lines
178 KiB
Swift

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:180_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)
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: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 = NSPoint(x:x,y:900-top-44)
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))
NSApp.sendEvent(event)
}
try await Task.sleep(nanoseconds:90_000_000)
}
try await click(24,336)
XCTAssertTrue(model.expandedSingerIDs.contains(singer),"Clicking the actual chevron must expand the singer")
try await click(100,153);XCTAssertEqual(model.activeSection,"Rotation")
try await click(100,116);XCTAssertEqual(model.activeSection,"Library")
XCTAssertTrue(model.expandedSingerIDs.contains(singer))
try await click(24,336)
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)
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: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);try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"/tmp/kjmix-rotation-test-"+name+".png")) }
}
func click(_ x: Double,_ top: Double) async throws {
for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] {
let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:x,y:760-top-44),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event)
}
try await Task.sleep(nanoseconds:120_000_000)
}
try await click(485,502);try capture("songs")
try await click(990,565);try capture("added")
XCTAssertEqual(model.tonightSongIDs[a],[song],"Actual Add to Tonight button must enqueue the song")
try await click(518,507);try capture("reordered")
XCTAssertEqual(model.rotationSingers.map(\.id),[b,a],"Visible down arrow must move Alex after Sam")
try await click(335,387)
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);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.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 testLinkedSyncUsesFirstDeckTogglesBothAndRestoresOriginalTempos() 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
system.sync(system.b)
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)
}
@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 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,-6.0,0.75]
let host = NSHostingView(rootView:HStack(spacing:0) { ForEach(0..<3,id:\.self) { i in DJKnob(label:["HIGH","LOW","FILTER"][i],value:Binding(get:{values[i]},set:{values[i] = $0}),range:i == 2 ? -1...1 : -12...12,reset:0,color:.cyan).frame(width:80,height:80) } }.frame(width:240,height:80))
host.frame = NSRect(x:0,y:0,width:240,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..<3 {
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])
}
}
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 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);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)
}
}