import XCTest import SwiftUI import AVFoundation import AppKit import UniformTypeIdentifiers @testable import KJamsClone final class KJamsCloneTests: XCTestCase { 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")]) 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:180_000_000) XCTAssertEqual(player.currentSongID,1);XCTAssertFalse(player.fading);player.stop() } func testMP3FileTagWritingBacksUpPreservesAudioAndRejectsBadTags() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) } let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")),url = root.appendingPathComponent("Demo.mp3"),backup = root.appendingPathComponent("Backups") try FileManager.default.copyItem(at:fixture,to:url) let original = try Data(contentsOf:url),before = try AudioWaveform.read(url:url,bins:64) let song = Song(id:1,title:"Updated ✓",audio:url.path,cdg:nil,rating:0,plays:0,artist:"New Artist",album:"New Album",label:"Publisher") try await FileTagWriter.write(song,backupDirectory:backup) XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) let files = try FileManager.default.contentsOfDirectory(at:backup,includingPropertiesForKeys:nil);XCTAssertEqual(files.count,1);XCTAssertEqual(try Data(contentsOf:files[0]),original) let metadata = try await AVURLAsset(url:url).load(.commonMetadata) XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Updated ✓") var second = song;second.title = "Second";try await FileTagWriter.write(second,backupDirectory:backup) XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) } @MainActor func testSingerAPIIsolationRevocationAndPersonalSettings() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root),venue = try model.createEntity(.venue,name:"Hall"),a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B"),db = try XCTUnwrap(model.library) let song = try db.add(title:"Private & ",audio:"/private/media.mp3",cdg:nil),other = try db.add(title:"Other",audio:"/other.mp3",cdg:nil) let token = try db.singerToken(singer:a,venue:venue);_ = try db.saveScreen(.init());try model.refresh() func request(_ method: String,_ target: String,_ body: [String:Any]? = nil,_ auth: String? = token) throws -> SingerHTTPResponse { var headers = ["host":"localhost:8080","content-type":"application/json"];if let auth { headers["authorization"] = "Bearer "+auth } return model.singerRequest(.init(method:method,target:target,headers:headers,body:try body.map{try JSONSerialization.data(withJSONObject:$0)} ?? Data())) } XCTAssertEqual(try request("GET","/api/catalog",nil,nil).status,401) let catalogue = try request("GET","/api/catalog");XCTAssertEqual(catalogue.status,200) let text = String(decoding:catalogue.body,as:UTF8.self);XCTAssertFalse(text.contains("/private/media"));XCTAssertFalse(text.contains("showscreen"));XCTAssertFalse(text.contains("cdg")) XCTAssertEqual(try request("POST","/api/tonight",["song":song,"action":"add","singer":b]).status,200) XCTAssertEqual(model.tonightSongIDs[a],[song]);XCTAssertNil(model.tonightSongIDs[b]) _ = try request("POST","/api/tonight",["song":other,"action":"add"]) _ = try request("POST","/api/tonight",["song":other,"action":"move","before":song]);XCTAssertEqual(model.tonightSongIDs[a],[other,song]) XCTAssertEqual(try request("POST","/api/preferences",["song":song,"pitch":2,"tempo":1.1,"rating":5]).status,200) XCTAssertEqual(try db.preference(song:song,singer:a),.init(pitch:2,tempo:1.1,rating:5));XCTAssertEqual(try db.preference(song:song,singer:b).rating,0) XCTAssertEqual(try request("POST","/api/preferences",["song":song,"pitch":100,"tempo":1,"rating":5]).status,400) try db.revokeSinger(singer:a,venue:venue);XCTAssertEqual(try request("GET","/api/me").status,401) let token2 = try db.singerToken(singer:a,venue:venue);XCTAssertNotEqual(token,token2) _ = try model.createEntity(.venue,name:"Elsewhere");XCTAssertEqual(try request("GET","/api/me",nil,token2).status,401) } func testHTTPParserRejectsSmugglingOversizeAndWaitsForBody() throws { XCTAssertNil(try SingerHTTPParser.parse(Data("POST /api/tonight HTTP/1.1\r\nContent-Length: 4\r\n\r\n{}".utf8))) for text in ["GET / HTTP/1.1\r\nContent-Length: 0\r\nContent-Length: 1\r\n\r\n", "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n", "POST / HTTP/1.1\r\nContent-Length: 20000\r\n\r\n", "GET / HTTP/1.1\r\n\r\nextra"] { XCTAssertThrowsError(try SingerHTTPParser.parse(Data(text.utf8))) } XCTAssertThrowsError(try SingerHTTPParser.parse(Data(repeating:65,count:33000))) let parsed = try XCTUnwrap(SingerHTTPParser.parse(Data("GET /api/me HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer abc\r\n\r\n".utf8))) XCTAssertEqual(parsed.headers["authorization"],"Bearer abc") } @MainActor func testSingerWebServerRealLoopbackHTTP() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root),venue = try model.createEntity(.venue,name:"Hall"),singer = try model.createEntity(.singer,name:"Singer"),db = try XCTUnwrap(model.library) _ = try db.add(title:"Searchable",audio:"/not-exposed.mp3",cdg:nil);try model.refresh();let token = try db.singerToken(singer:singer,venue:venue) let port = UInt16.random(in:20000...45000);model.startSingerServer(port:port,loopback:true);defer { model.singerServer.stop() } for _ in 0..<100 { if model.singerServer.running || model.singerServer.error != nil { break };try await Task.sleep(nanoseconds:20_000_000) } XCTAssertTrue(model.singerServer.running,model.singerServer.error ?? "Server did not start") var request = URLRequest(url:URL(string:"http://127.0.0.1:\(port)/api/catalog?q=Search")!);request.timeoutInterval = 4;request.setValue("Bearer "+token,forHTTPHeaderField:"Authorization") let (data,response) = try await URLSession.shared.data(for:request) XCTAssertEqual((response as? HTTPURLResponse)?.statusCode,200);XCTAssertTrue(String(decoding:data,as:UTF8.self).contains("Searchable"));XCTAssertFalse(String(decoding:data,as:UTF8.self).contains("not-exposed")) let (_,page) = try await URLSession.shared.data(from:URL(string:"http://127.0.0.1:\(port)/")!);XCTAssertEqual((page as? HTTPURLResponse)?.statusCode,200) request.setValue(nil,forHTTPHeaderField:"Authorization");let (_,unauthorized) = try await URLSession.shared.data(for:request);XCTAssertEqual((unauthorized as? HTTPURLResponse)?.statusCode,401) } @MainActor func testAllStoreProfilesAndCatalogueValidation() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root);model.installStoreProfiles();model.installStoreProfiles();XCTAssertEqual(model.musicStores.count,4) let item = StoreCatalogueItem(id:"1",title:"Original",artist:"Test",url:"https://example.com/song") let data = try JSONEncoder().encode([item]);XCTAssertEqual(try StoreCatalogueItem.decode(data),[item]) XCTAssertThrowsError(try StoreCatalogueItem.decode(JSONEncoder().encode([item,item]))) var invalid = item;invalid.previewURL = "file:///private/local";XCTAssertThrowsError(try StoreCatalogueItem.decode(JSONEncoder().encode([invalid]))) } func testMP4TagExportPreservesVideoAndAudio() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) } let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/VideoDemo.mp4")),url = root.appendingPathComponent("Video.mp4") try FileManager.default.copyItem(at:fixture,to:url) let before = try AudioWaveform.read(url:url,bins:64) let song = Song(id:1,title:"Tagged Video",audio:url.path,cdg:nil,rating:0,plays:0,artist:"Test Artist") try await FileTagWriter.write(song,backupDirectory:root.appendingPathComponent("Backups")) XCTAssertEqual(try AudioWaveform.read(url:url,bins:64),before) let asset = AVURLAsset(url:url),tracks = try await asset.loadTracks(withMediaType:.video) XCTAssertEqual(tracks.count,1) let metadata = try await asset.load(.commonMetadata) XCTAssertEqual(metadata.first{$0.commonKey == .commonKeyTitle}?.stringValue,"Tagged Video") } @MainActor func testDJStopCueHotCuesJogAndLoopKeepTrackLoaded() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms")),song = Song(id:1,title:"DJ",audio:fixture.path,cdg:nil,rating:0,plays:0) try player.load(song);player.stopDeck() XCTAssertEqual(player.currentSongID,1);XCTAssertFalse(player.playing);XCTAssertEqual(player.time,0,accuracy:0.01);XCTAssertGreaterThan(player.duration,3) player.seek(0.5);player.setCue();player.hotCue(0);player.jog(0.25);XCTAssertEqual(player.time,0.75,accuracy:0.02) player.returnToCue();XCTAssertEqual(player.time,0.5,accuracy:0.02);player.seek(1);player.hotCue(0);XCTAssertEqual(player.time,0.5,accuracy:0.02) player.hotCue(0,clear:true);XCTAssertNil(player.hotCues[0]) player.seek(0.5);player.markLoopIn();player.seek(0.75);player.markLoopOut();XCTAssertTrue(player.loopEnabled) player.seek(0.6);player.toggle();try await Task.sleep(nanoseconds:380_000_000) XCTAssertTrue(player.playing);XCTAssertLessThan(player.time,0.85);XCTAssertGreaterThanOrEqual(player.time,0.49) player.stopDeck();XCTAssertFalse(player.loopEnabled);XCTAssertEqual(player.cuePosition,0.5,accuracy:0.01) player.stop();XCTAssertNil(player.currentSongID);XCTAssertEqual(player.cuePosition,0);XCTAssertNil(player.loopIn) } @MainActor func testManualBackgroundStopsBeforeSingerAndNeverAutoResumes() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) let model = AppModel(storageURL:root);defer { model.player.stop();model.backgroundDeck.unload() } _ = try model.createEntity(.venue,name:"Hall");let singer = try model.createEntity(.singer,name:"Singer") let id = try XCTUnwrap(model.library).add(title:"Song",audio:fixture.path,cdg:nil);try model.refresh();let song = try XCTUnwrap(model.songs.first{$0.id == id}) model.studio.automaticBackground = false;model.studio.backgroundPath = fixture.path;XCTAssertTrue(model.backgroundDeck.loaded);XCTAssertFalse(model.backgroundDeck.playing) model.player.volume = 0.42;model.player.lowGain = 3;XCTAssertEqual(model.backgroundDeck.mix,model.player.globalMix) model.playBackground();XCTAssertTrue(model.backgroundDeck.playing) try await Task.sleep(nanoseconds:100_000_000) try model.play(song,singer:singer);XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.backgroundDeck.interruptedBySinger);XCTAssertTrue(model.player.playing) model.playBackground();XCTAssertFalse(model.backgroundDeck.playing);model.error = nil model.player.stopDeck();XCTAssertFalse(model.backgroundDeck.playing) model.playBackground();XCTAssertTrue(model.backgroundDeck.playing) model.player.toggle();XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.backgroundDeck.interruptedBySinger) model.player.stop();XCTAssertFalse(model.backgroundDeck.playing) model.backgroundDeck.stop();XCTAssertEqual(model.backgroundDeck.time,0) } @MainActor func testSettingsPersistWithoutAutoplayAndPreserveMaster() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) let model = AppModel(storageURL:root) var settings = model.studio;settings.autoAdvance = false;settings.jogSecondsPerTurn = 4;settings.nudgeSeconds = 0.1;settings.graphicsOffset = 0.25;settings.backgroundPath = fixture.path;settings.backgroundTrim = 0.17;settings.backgroundLoop = false;settings.serverPort = 18080;model.studio = settings model.player.volume = 0.31;model.player.midGain = -2 let reopened = AppModel(storageURL:root);defer { reopened.backgroundDeck.unload();model.backgroundDeck.unload() } XCTAssertEqual(reopened.studio,settings);XCTAssertFalse(reopened.autoAdvance);XCTAssertEqual(reopened.player.jogSecondsPerTurn,4);XCTAssertEqual(reopened.player.graphicsOffset,0.25) try await Task.sleep(nanoseconds:400_000_000) XCTAssertTrue(reopened.backgroundDeck.loaded);XCTAssertFalse(reopened.backgroundDeck.playing);XCTAssertEqual(reopened.backgroundDeck.trim,0.17);XCTAssertFalse(reopened.backgroundDeck.repeating) XCTAssertEqual(reopened.player.volume,0.31);XCTAssertEqual(reopened.backgroundDeck.mix.mid,-2);XCTAssertFalse(reopened.singerServer.running) } @MainActor func testBulkTuningCacheSwitchesSingersAndUpdatesWithoutLibraryReload() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B"),db = try XCTUnwrap(model.library) let id = try db.add(title:"Test",audio:"/test.mp3",cdg:nil) try db.saveDefaultPreference(song:id,value:.init(pitch:1,tempo:1.1));try db.savePreference(song:id,singer:a,value:.init(pitch:3,tempo:0.9,rating:5));try model.refresh() model.selectedSinger = a;XCTAssertEqual(model.preference(id),.init(pitch:3,tempo:0.9,rating:5)) model.selectedSinger = b;XCTAssertEqual(model.preference(id).pitch,1) let songs = model.songs;model.updateTuning(id,pitch:-2);XCTAssertEqual(model.preference(id).pitch,-2);XCTAssertEqual(model.songs,songs) model.selectedSinger = a;XCTAssertEqual(model.preference(id).pitch,3) } @MainActor func testRotationDragTargetsExplicitVenueAndReordersRoster() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let a = try model.createEntity(.singer,name:"A"),b = try model.createEntity(.singer,name:"B") model.markAllSingersHere();let reference = try XCTUnwrap(model.rotationReference(b)),decoded = try XCTUnwrap(RotationDrag.parse(reference.text)) try model.applyRotationDrop(decoded,before:a);XCTAssertEqual(model.rotationSingers.map(\.id),[b,a]) var foreign = decoded;foreign.session = "foreign";XCTAssertThrowsError(try model.applyRotationDrop(foreign,before:nil)) _ = try model.createEntity(.venue,name:"Other");XCTAssertThrowsError(try model.applyRotationDrop(decoded,before:nil)) } @MainActor func testActualSingerDisclosurePressAndNavigationRetainExpansion() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString);defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root);_ = try model.createEntity(.venue,name:"Hall");let singer = try model.createEntity(.singer,name:"Alex") let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1320,height:900).preferredColorScheme(.dark)) host.frame = NSRect(x:0,y:0,width:1320,height:900) let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host let previous = NSApp.keyWindow window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey() defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() } try await Task.sleep(nanoseconds:200_000_000);host.layoutSubtreeIfNeeded();host.displayIfNeeded() if let image = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:image) } func click(_ x: Double,_ top: Double) async throws { let point = NSPoint(x:x,y:900-top) for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:point,modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0)) NSApp.sendEvent(event) } try await Task.sleep(nanoseconds:90_000_000) } try await click(24,336) XCTAssertTrue(model.expandedSingerIDs.contains(singer),"Clicking the actual chevron must expand the singer") try await click(100,153);XCTAssertEqual(model.activeSection,"Rotation") try await click(100,116);XCTAssertEqual(model.activeSection,"Library") XCTAssertTrue(model.expandedSingerIDs.contains(singer)) try await click(24,336) XCTAssertFalse(model.expandedSingerIDs.contains(singer));XCTAssertNil(model.error) } @MainActor func testRapidCDGJogCannotPublishAnOldSeekFrame() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true);defer { try? FileManager.default.removeItem(at:root) } let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")),graphics = root.appendingPathComponent("Seek.cdg") var packets = [[UInt8]](repeating:packet(0,[]),count:1200) packets[0] = packet(30,[0,0,60,0,3,48,0,15]);packets[300] = packet(1,[1,0]);packets[600] = packet(1,[2,0]);packets[900] = packet(1,[3,0]) try Data(packets.flatMap{$0}).write(to:graphics) let player = Player(waveformCacheDirectory:root.appendingPathComponent("Waveforms"));defer { player.stop() } try player.load(.init(id:1,title:"Seek",audio:audio.path,cdg:graphics.path,rating:0,plays:0));player.stopDeck() player.seek(3.5);player.seek(1.2) try await Task.sleep(nanoseconds:120_000_000) let red = try XCTUnwrap(player.image?.dataProvider?.data) as Data XCTAssertEqual(Array(red.prefix(3)),[255,0,0]) player.seek(2.4);try await Task.sleep(nanoseconds:80_000_000) let green = try XCTUnwrap(player.image?.dataProvider?.data) as Data;XCTAssertEqual(Array(green.prefix(3)),[0,255,0]) player.seek(0.2);try await Task.sleep(nanoseconds:80_000_000) let black = try XCTUnwrap(player.image?.dataProvider?.data) as Data;XCTAssertEqual(Array(black.prefix(3)),[0,0,0]) } @MainActor func testAutomaticBackgroundHandoffAndManualOverride() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at:root) } let fixture = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) let model = AppModel(storageURL:root);defer { model.backgroundHandoff?.cancel();model.player.stop();model.backgroundDeck.unload() } _ = try model.createEntity(.venue,name:"Hall") let id = try XCTUnwrap(model.library).add(title:"Song",audio:fixture.path,cdg:nil);try model.refresh() model.studio.backgroundPath = fixture.path try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing) try model.play(try XCTUnwrap(model.songs.first{$0.id == id}));XCTAssertFalse(model.backgroundDeck.playing) model.player.stopDeck();try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing) model.pauseBackground();XCTAssertFalse(model.studio.backgroundAutoEnabled) model.player.toggle();model.player.stopDeck();try await Task.sleep(nanoseconds:400_000_000);XCTAssertFalse(model.backgroundDeck.playing) model.studio.automaticBackground = true;try await Task.sleep(nanoseconds:400_000_000);XCTAssertTrue(model.backgroundDeck.playing) model.stopBackground();XCTAssertFalse(model.backgroundDeck.playing);XCTAssertEqual(model.backgroundDeck.time,0) } @MainActor func testNewSingerSongsImmediatelyJoinSmartRotation() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library) let venue = try model.createEntity(.venue,name:"Hall") let song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil) let singer = try model.createSinger(name:"Alex",venue:venue,songs:[song]) model.hostSettings.smart = true;model.saveHostSettings() XCTAssertTrue(model.expandedSingerIDs.contains(singer));XCTAssertTrue(model.rotationSingers.contains{$0.id == singer && $0.here}) XCTAssertEqual(model.rotationPlan.map(\.songID),[song]);XCTAssertEqual(model.tonightSongIDs[singer],[song]) model.markHere(singer,false);XCTAssertTrue(model.rotationPlan.isEmpty) model.addToTonight(song,singer:singer);XCTAssertEqual(model.rotationPlan.map(\.songID),[song]) } @MainActor func testNaturalFinishAdvancesSingerBeforeBackgroundThenResumesWhenEmpty() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at:root) } let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) let model = AppModel(storageURL:root);defer { model.backgroundHandoff?.cancel();model.player.stop();model.backgroundDeck.unload() } let venue = try model.createEntity(.venue,name:"Hall"),db = try XCTUnwrap(model.library) let song = try db.add(title:"Song",audio:audio.path,cdg:nil) let a = try model.createSinger(name:"A",venue:venue,songs:[song]),b = try model.createSinger(name:"B",venue:venue,songs:[song]) model.hostSettings.smart = true;model.hostSettings.autoFade = false;model.saveHostSettings() model.studio.backgroundPath = audio.path model.playQueueNext();XCTAssertEqual(model.playbackSinger,a) model.player.seek(model.player.duration-0.15) try await Task.sleep(nanoseconds:650_000_000) XCTAssertEqual(model.playbackSinger,b);XCTAssertTrue(model.player.playing);XCTAssertFalse(model.backgroundDeck.playing);XCTAssertTrue(model.queue.isEmpty) model.player.seek(model.player.duration-0.15) try await Task.sleep(nanoseconds:750_000_000) XCTAssertFalse(model.player.playing);XCTAssertTrue(model.backgroundDeck.playing) } @MainActor func testRotationStartPauseResumeStopAndEmptyExplanation() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root);defer { model.player.stop() } model.startRotation();XCTAssertNotNil(model.error);model.error = nil let venue = try model.createEntity(.venue,name:"Hall") model.startRotation();XCTAssertNotNil(model.error);model.error = nil let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) let song = try XCTUnwrap(model.library).add(title:"Song",audio:audio.path,cdg:nil) _ = try model.createSinger(name:"A",venue:venue,songs:[song]) model.studio.autoAdvance = false;model.startRotation() XCTAssertTrue(model.rotationPlaybackActive);XCTAssertTrue(model.player.playing);XCTAssertTrue(model.autoAdvance);XCTAssertTrue(model.queue.isEmpty) model.pauseRotation();XCTAssertFalse(model.player.playing);model.startRotation();XCTAssertTrue(model.player.playing) model.stopRotation();XCTAssertFalse(model.player.playing);XCTAssertFalse(model.rotationPlaybackActive);XCTAssertFalse(model.autoAdvance) model.startRotation();XCTAssertTrue(model.player.playing);XCTAssertTrue(model.rotationPlaybackActive);XCTAssertNil(model.error) } @MainActor func testUnifiedRosterDropRoutesSingerAndSongProviders() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library) let venue = try model.createEntity(.venue,name:"Hall") let a = try model.createSinger(name:"A",venue:venue,songs:[]),b = try model.createSinger(name:"B",venue:venue,songs:[]) let song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil);try model.refresh() let reference = try XCTUnwrap(model.rotationReference(b)) XCTAssertTrue(model.acceptRosterDrop([NSItemProvider(object:reference.text as NSString)],target:a)) try await Task.sleep(nanoseconds:150_000_000);XCTAssertEqual(model.rotationSingers.map(\.id),[b,a]) XCTAssertTrue(model.acceptRosterDrop([model.dragReference(song).provider()],target:b)) try await Task.sleep(nanoseconds:150_000_000);XCTAssertEqual(model.tonightSongIDs[b],[song]);XCTAssertEqual(model.queue.first?.singerID,b) do { try await model.applyRosterDropText("unrelated text",target:a);XCTFail("Must reject foreign drops") } catch {} } @MainActor func testVisibleSingerReorderUpdatesManualAndSmartPlansAndPersists() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root),db = try XCTUnwrap(model.library) let venue = try model.createEntity(.venue,name:"Hall"),song = try db.add(title:"Song",audio:"/demo.mp3",cdg:nil) let a = try model.createSinger(name:"A",venue:venue,songs:[song]),b = try model.createSinger(name:"B",venue:venue,songs:[song]) XCTAssertFalse(model.hostSettings.smart);model.reorderRotationSinger(b,by:-1) XCTAssertEqual(model.rotationPlan.compactMap(\.singerID),[b,a]) model.hostSettings.smart = true;model.hostSettings.cursor = b;model.saveHostSettings() model.reorderRotationSinger(a,by:-1);XCTAssertNil(model.hostSettings.cursor) XCTAssertEqual(model.rotationPlan.compactMap(\.singerID),[a,b]) let reopened = AppModel(storageURL:root);XCTAssertEqual(reopened.rotationSingers.map(\.id),[a,b]);XCTAssertNil(model.error) } @MainActor func testActualRotationButtonsAddSongReorderAndStart() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at:root) } let model = AppModel(storageURL:root);defer { model.player.stop() } let venue = try model.createEntity(.venue,name:"Hall") let a = try model.createSinger(name:"Alex",venue:venue,songs:[]),b = try model.createSinger(name:"Sam",venue:venue,songs:[]) let audio = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) let song = try XCTUnwrap(model.library).add(title:"Demo",audio:audio.path,cdg:nil);try model.refresh();model.activeSection = "Rotation";model.selectedSinger = a let host = NSHostingView(rootView:LibraryView(model:model).frame(width:1060,height:760).preferredColorScheme(.dark));host.frame = NSRect(x:0,y:0,width:1060,height:760) let window = NSWindow(contentRect:host.frame,styleMask:[.borderless],backing:.buffered,defer:false);window.appearance = NSAppearance(named:.darkAqua);window.contentView = host let previous = NSApp.keyWindow;window.setFrameOrigin(NSPoint(x:-10000,y:-10000));window.orderFront(nil);window.makeKey() defer { window.orderOut(nil);window.contentView = nil;previous?.makeKey() } try await Task.sleep(nanoseconds:250_000_000);host.layoutSubtreeIfNeeded() func capture(_ name: String) throws { host.layoutSubtreeIfNeeded();if let rep = host.bitmapImageRepForCachingDisplay(in:host.bounds) { host.cacheDisplay(in:host.bounds,to:rep);try rep.representation(using:.png,properties:[:])!.write(to:URL(fileURLWithPath:"/tmp/kjmix-rotation-test-"+name+".png")) } } func click(_ x: Double,_ top: Double) async throws { for type in [NSEvent.EventType.leftMouseDown,.leftMouseUp] { let event = try XCTUnwrap(NSEvent.mouseEvent(with:type,location:NSPoint(x:x,y:760-top),modifierFlags:[],timestamp:ProcessInfo.processInfo.systemUptime,windowNumber:window.windowNumber,context:nil,eventNumber:0,clickCount:1,pressure:type == .leftMouseDown ? 1 : 0));NSApp.sendEvent(event) } try await Task.sleep(nanoseconds:120_000_000) } try await click(485,453);try capture("songs") try await click(990,516);try capture("added") XCTAssertEqual(model.tonightSongIDs[a],[song],"Actual Add to Tonight button must enqueue the song") try await click(518,458);try capture("reordered") XCTAssertEqual(model.rotationSingers.map(\.id),[b,a],"Visible down arrow must move Alex after Sam") try await click(335,338) 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) } }