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