broken changes

This commit is contained in:
Mark Gregory
2026-11-05 12:51:29 +00:00
parent 528e10b43a
commit abee603250
11 changed files with 1076 additions and 55 deletions
+179 -6
View File
@@ -3,6 +3,7 @@ import SwiftUI
import AVFoundation
import AppKit
import UniformTypeIdentifiers
import CoreMIDI
@testable import KJamsClone
final class KJamsCloneTests: XCTestCase {
@@ -1342,7 +1343,7 @@ final class KJamsCloneTests: XCTestCase {
@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))
let host = NSHostingView(rootView:LibraryView(model:model,showDJ:false).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
@@ -1487,7 +1488,7 @@ final class KJamsCloneTests: XCTestCase {
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 host = NSHostingView(rootView:LibraryView(model:model,showDJ:false).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() }
@@ -1501,12 +1502,12 @@ final class KJamsCloneTests: XCTestCase {
}
try await Task.sleep(nanoseconds:120_000_000)
}
try await click(485,453);try capture("songs")
try await click(990,516);try capture("added")
try await click(485,502);try capture("songs")
try await click(990,565);try capture("added")
XCTAssertEqual(model.tonightSongIDs[a],[song],"Actual Add to Tonight button must enqueue the song")
try await click(518,458);try capture("reordered")
try await click(518,507);try capture("reordered")
XCTAssertEqual(model.rotationSingers.map(\.id),[b,a],"Visible down arrow must move Alex after Sam")
try await click(335,338)
try await click(335,387)
XCTAssertTrue(model.player.playing,"Actual Start Rotation button must play");XCTAssertTrue(model.rotationPlaybackActive);XCTAssertEqual(model.playbackSinger,a);XCTAssertNil(model.error)
}
@@ -1626,3 +1627,175 @@ final class KJamsCloneTests: XCTestCase {
}
}
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);XCTAssertEqual(system.a.player.tempo,1.05,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)
}
}
extension KJamsCloneTests {
@MainActor func testDJReceivesActualCoreMIDIVirtualSourcePackets() async throws {
var client = MIDIClientRef(0),source = MIDIEndpointRef(0)
XCTAssertEqual(MIDIClientCreateWithBlock("KJMix MIDI test" as CFString,&client,nil),noErr)
defer { if source != 0 { MIDIEndpointDispose(source) };MIDIClientDispose(client) }
XCTAssertEqual(MIDISourceCreate(client,"KJMix virtual test controller" as CFString,&source),noErr)
let suite = "KJMix.VirtualMIDI.\(UUID().uuidString)",defaults = try XCTUnwrap(UserDefaults(suiteName:suite))
defer { defaults.removePersistentDomain(forName:suite) }
let midi = DJMIDI(defaults:defaults);midi.refreshSources()
let input = try XCTUnwrap(midi.sources.first{$0.endpoint == source});midi.select(input.id)
XCTAssertEqual(midi.sourceID,input.id)
var actions = [String]();midi.onAction = { action,_,_ in actions.append(action) }
func send(_ packets: [[UInt8]]) throws {
var list = MIDIPacketList()
try withUnsafeMutablePointer(to:&list) { pointer in
var packet = MIDIPacketListInit(pointer)
for bytes in packets {
packet = try bytes.withUnsafeBufferPointer { data in try XCTUnwrap(MIDIPacketListAdd(pointer,MemoryLayout<MIDIPacketList>.size,packet,0,data.count,data.baseAddress!)) }
}
XCTAssertEqual(MIDIReceived(source,pointer),noErr)
}
}
midi.learning = "A Play";try send([[0x90,40,127]])
try await Task.sleep(nanoseconds:80_000_000)
XCTAssertEqual(midi.mappings.first?.action,"A Play");XCTAssertTrue(actions.isEmpty)
try send([[0x90,40,0],[0x90,40,127],[0x80,40,0]])
try await Task.sleep(nanoseconds:80_000_000)
XCTAssertEqual(actions,["A Play"])
midi.select(0)
}
}
extension KJamsCloneTests {
@MainActor func testDJLibraryRecursiveImportPlaylistsAndPersistenceAreSeparateFromKaraoke() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at:root) }
let model = AppModel(storageURL:root)
let fixtures = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures"))
let folder = root.appendingPathComponent("Input/Artist/Album")
try FileManager.default.createDirectory(at:folder,withIntermediateDirectories:true)
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.mp3"),to:folder.appendingPathComponent("DJ Song.mp3"))
try FileManager.default.copyItem(at:fixtures.appendingPathComponent("Demo.cdg"),to:folder.appendingPathComponent("DJ Song.cdg"))
let karaoke = try XCTUnwrap(model.library),dj = try XCTUnwrap(model.dj.library)
_ = try karaoke.add(title:"Karaoke only",audio:fixtures.appendingPathComponent("TestTrack.wav").path,cdg:nil)
try model.refresh();XCTAssertTrue(model.dj.songs.isEmpty)
model.dj.importFiles([root.appendingPathComponent("Input")])
for _ in 0..<200 { if !model.dj.importing { break };try await Task.sleep(nanoseconds:25_000_000) }
XCTAssertFalse(model.dj.importing);XCTAssertNil(model.dj.error)
XCTAssertEqual(model.dj.songs.count,1);XCTAssertEqual(try karaoke.songs().map(\.title),["Karaoke only"])
let track = try XCTUnwrap(model.dj.songs.first)
XCTAssertTrue(track.audio.hasPrefix(root.appendingPathComponent("DJ/Media").path));XCTAssertNil(track.cdg)
model.dj.createPlaylist("DJ set");let playlist = try XCTUnwrap(model.dj.playlists.first)
model.dj.addToPlaylist(playlist.id,song:track.id)
XCTAssertTrue(try karaoke.entities("playlists").isEmpty)
XCTAssertEqual(try dj.playlistSongIDs(playlist.id),[track.id])
model.dj.importFiles([root.appendingPathComponent("Input")])
for _ in 0..<200 { if !model.dj.importing { break };try await Task.sleep(nanoseconds:25_000_000) }
XCTAssertEqual(model.dj.songs.count,1)
let reopened = DJSystem(storageURL:model.dj.root)
XCTAssertEqual(reopened.songs.map(\.id),[track.id]);XCTAssertEqual(reopened.playlists.map(\.name),["DJ set"])
XCTAssertNotEqual(model.dragReference(track.id).library,model.dj.dragReference(track.id).library)
}
}