From a5faa0b28e62973a3ad87201d7053d5989ec880d Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 07:55:47 +0300 Subject: [PATCH 01/80] refactor: apply low-risk audit cleanup --- JammLab.xcodeproj/project.pbxproj | 4 --- JammLab/JammLabApp.swift | 16 ++++++++++ JammLab/Services/AudioAnalyzer.swift | 23 +------------- .../AudioPlayerViewModel+Project.swift | 30 ++++++++----------- 4 files changed, 29 insertions(+), 44 deletions(-) diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 4a43286..422acaf 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -97,9 +97,7 @@ 9F9201022C80000100112233 /* JammLabStemHelper in Embed Stem Helper */ = {isa = PBXBuildFile; fileRef = 9F8E00022C40000100112233 /* JammLabStemHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 9F9301012C90000100112233 /* StemBackendResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9300012C90000100112233 /* StemBackendResolver.swift */; }; 9F9301022C90000100112233 /* StemBackendResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9300012C90000100112233 /* StemBackendResolver.swift */; }; - 9F9301032C90000100112233 /* StemBackendResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9300012C90000100112233 /* StemBackendResolver.swift */; }; 9F9401012CA0000100112233 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9400012CA0000100112233 /* AppSettingsStore.swift */; }; - 9F9401022CA0000100112233 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9400012CA0000100112233 /* AppSettingsStore.swift */; }; 9F9401032CA0000100112233 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9400022CA0000100112233 /* SettingsView.swift */; }; 9F9501012CB0000100112233 /* AbletonNumberField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9500012CB0000100112233 /* AbletonNumberField.swift */; }; 9F9601012CC0000100112233 /* JammValueSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9600012CC0000100112233 /* JammValueSlider.swift */; }; @@ -914,8 +912,6 @@ 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, - 9F9401022CA0000100112233 /* AppSettingsStore.swift in Sources */, - 9F9301032C90000100112233 /* StemBackendResolver.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/JammLab/JammLabApp.swift b/JammLab/JammLabApp.swift index a7b5cbf..9c902da 100644 --- a/JammLab/JammLabApp.swift +++ b/JammLab/JammLabApp.swift @@ -68,6 +68,14 @@ struct JammLabCommands: Commands { @ObservedObject var recentProjectsStore: RecentProjectsStore var body: some Commands { + fileCommands + editCommands + windowCommands + toolsCommands + helpCommands + } + + private var fileCommands: some Commands { CommandGroup(replacing: .newItem) { Button("New Project") { viewModel.newProject() @@ -134,7 +142,9 @@ struct JammLabCommands: Commands { } .disabled(!viewModel.canExportNotation) } + } + private var editCommands: some Commands { CommandGroup(replacing: .undoRedo) { Button("Undo") { viewModel.undoLastEdit() @@ -148,7 +158,9 @@ struct JammLabCommands: Commands { .keyboardShortcut("z", modifiers: [.command, .shift]) .disabled(!viewModel.canRedo) } + } + private var windowCommands: some Commands { CommandGroup(after: .toolbar) { Button("Show Notation Window") { openWindow(id: AppWindowID.notation) @@ -160,13 +172,17 @@ struct JammLabCommands: Commands { } .disabled(!viewModel.canToggleVideoWindow) } + } + private var toolsCommands: some Commands { CommandMenu("Tools") { Button("Tuner") { openWindow(id: "tuner") } } + } + private var helpCommands: some Commands { CommandGroup(replacing: .help) { Button("Keyboard Shortcuts") { openWindow(id: "hotkeys-help") diff --git a/JammLab/Services/AudioAnalyzer.swift b/JammLab/Services/AudioAnalyzer.swift index ce99530..b93b876 100644 --- a/JammLab/Services/AudioAnalyzer.swift +++ b/JammLab/Services/AudioAnalyzer.swift @@ -5,16 +5,6 @@ protocol AudioAnalyzing { func analyze(url: URL, includesTempo: Bool, includesKey: Bool) async throws -> AnalysisResult } -extension AudioAnalyzing { - func analyze(url: URL) async throws -> AnalysisResult { - try await analyze(url: url, includesTempo: true, includesKey: true) - } - - func analyze(url: URL, includesTempo: Bool) async throws -> AnalysisResult { - try await analyze(url: url, includesTempo: includesTempo, includesKey: true) - } -} - enum AudioAnalyzerError: LocalizedError { case unsupportedBuffer case emptyAudio @@ -68,21 +58,10 @@ final class AudioAnalyzer: AudioAnalyzing { try file.read(into: buffer, frameCount: AVAudioFrameCount(framesToRead)) - guard let channels = buffer.floatChannelData else { + guard let mono = AudioSampleConverter.monoFloatSamples(from: buffer) else { throw AudioAnalyzerError.unsupportedBuffer } - let frameLength = Int(buffer.frameLength) - let channelCount = Int(format.channelCount) - var mono = Array(repeating: Float(0), count: frameLength) - - for channel in 0.. 0.0001 }) else { throw AudioAnalyzerError.emptyAudio } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index c6381d3..237fb15 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -114,12 +114,7 @@ extension AudioPlayerViewModel { harmonySymbols = [] notationItems = [] projectKeySelection = nil - selectedRegionID = nil - selectedHarmonySymbolID = nil - clearNotationMeasureSelectionAndClipboard() - pendingHarmonyEditorRequest = nil - activeLoopRegionID = nil - notationDurationDenominator = NotationDuration.defaultDenominator + clearTransientEditingState() loopRegion = .empty timelineVisibleRange = 0...0 userTimelineVisibleRange = 0...0 @@ -177,12 +172,7 @@ extension AudioPlayerViewModel { harmonySymbols = [] notationItems = [] projectKeySelection = nil - selectedRegionID = nil - selectedHarmonySymbolID = nil - clearNotationMeasureSelectionAndClipboard() - pendingHarmonyEditorRequest = nil - notationDurationDenominator = NotationDuration.defaultDenominator - activeLoopRegionID = nil + clearTransientEditingState() loopRegion = LoopRegion(start: 0, end: file.duration).clamped(to: file.duration) timelineVisibleRange = 0...file.duration userTimelineVisibleRange = timelineVisibleRange @@ -258,12 +248,7 @@ extension AudioPlayerViewModel { duration: resolvedProjectDuration ) projectKeySelection = project.projectKeySelection - selectedRegionID = nil - selectedHarmonySymbolID = nil - clearNotationMeasureSelectionAndClipboard() - pendingHarmonyEditorRequest = nil - notationDurationDenominator = NotationDuration.defaultDenominator - activeLoopRegionID = nil + clearTransientEditingState() loopRegion = ProjectStateNormalizer.normalizedLoopRegion( start: project.loopStart, end: project.loopEnd, @@ -309,6 +294,15 @@ extension AudioPlayerViewModel { } } + private func clearTransientEditingState() { + selectedRegionID = nil + selectedHarmonySymbolID = nil + clearNotationMeasureSelectionAndClipboard() + pendingHarmonyEditorRequest = nil + notationDurationDenominator = NotationDuration.defaultDenominator + activeLoopRegionID = nil + } + func cancelBackgroundWork() { analysisTask?.cancel() analysisTask = nil From 0e4978b0d007210eb44625c286cd934ee9c27d18 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 08:03:01 +0300 Subject: [PATCH 02/80] refactor: click tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 12 -- JammLabTests/ClickRenderStateTests.swift | 146 +++++++++++++++++++++++ 3 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 JammLabTests/ClickRenderStateTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 422acaf..1c0c9ba 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -110,6 +110,7 @@ 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; + 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -280,6 +281,7 @@ 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; + 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -535,6 +537,7 @@ 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, + 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -911,6 +914,7 @@ 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, + 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 31658f7..01b7383 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -193,18 +193,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(barStartTimes, [0, 1.5, 3.0, 4.5]) } - func testClickDelayLineDelaysSamplesByConfiguredFrameCount() { - var delayLine = ClickDelayLine() - delayLine.setDelayFrames(3) - - let output = [1, 2, 3, 4, 5].map { delayLine.process(Float($0)) } - - XCTAssertEqual(output, [0, 0, 0, 1, 2]) - - delayLine.setDelayFrames(0) - XCTAssertEqual(delayLine.process(9), 9) - } - func testBeatGridUsesFirstBeatOffsetForBarsAndSnap() throws { let settings = BeatGridSettings( bpm: 120, diff --git a/JammLabTests/ClickRenderStateTests.swift b/JammLabTests/ClickRenderStateTests.swift new file mode 100644 index 0000000..b360767 --- /dev/null +++ b/JammLabTests/ClickRenderStateTests.swift @@ -0,0 +1,146 @@ +import AVFoundation +import XCTest +@testable import JammLab + +final class ClickRenderStateTests: XCTestCase { + private let sampleRate = 1_000.0 + + func testClickDelayLineDelaysSamplesByConfiguredFrameCount() { + var delayLine = ClickDelayLine() + delayLine.setDelayFrames(3) + + let output = [1, 2, 3, 4, 5].map { delayLine.process(Float($0)) } + + XCTAssertEqual(output, [0, 0, 0, 1, 2]) + + delayLine.setDelayFrames(0) + XCTAssertEqual(delayLine.process(9), 9) + } + + func testRenderOutputsSilenceWhenClickIsDisabled() { + let state = makeClickState(settings: BeatGridSettings(bpm: 120, timeSignature: .fourFour)) + state.play(startFrame: 0) + + let samples = renderSamples(from: state, frameCount: 600) + + XCTAssertTrue(samples.allSatisfy { $0 == 0 }) + } + + func testRenderUsesFirstBeatOffsetAndAccents() { + let state = makeClickState(settings: BeatGridSettings( + bpm: 120, + firstBeatTime: 1, + timeSignature: .fourFour + )) + state.setEnabled(true) + state.play(startFrame: 0) + + let samples = renderSamples(from: state, frameCount: 1_101) + + XCTAssertEqual(samples[0], 0.62, accuracy: 0.0001) + XCTAssertEqual(samples[1_000], 0.95, accuracy: 0.0001) + } + + func testRenderStartsAfterSeekPosition() { + let state = makeClickState(settings: BeatGridSettings(bpm: 120, timeSignature: .fourFour)) + state.setEnabled(true) + state.play(startFrame: 1_250) + + let samples = renderSamples(from: state, frameCount: 801) + + XCTAssertEqual(nonZeroOnsetFrames(in: samples), [250, 750]) + XCTAssertEqual(samples[250], 0.62, accuracy: 0.0001) + XCTAssertEqual(samples[750], 0.95, accuracy: 0.0001) + } + + func testRenderSwitchesTempoMapAtMarker() { + let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let tempoMarker = TimecodedNote( + time: 2, + title: "60 BPM - 3/4", + metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3).metadata + ) + let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) + let state = makeClickState(settings: baseSettings, durationSeconds: 6) + state.setTempoMap(tempoMap) + state.setEnabled(true) + state.play(startFrame: 0) + + let samples = renderSamples(from: state, frameCount: 5_101) + + XCTAssertEqual(normalizedInitialOnsetFrames(in: samples), [0, 500, 1_000, 1_500, 2_000, 3_000, 4_000, 5_000]) + XCTAssertEqual(samples[2_000], 0.95, accuracy: 0.0001) + XCTAssertEqual(samples[3_000], 0.62, accuracy: 0.0001) + XCTAssertEqual(samples[5_000], 0.95, accuracy: 0.0001) + } + + func testRenderRecalculatesBeatAfterLoopRestart() { + let state = makeClickState(settings: BeatGridSettings(bpm: 120, timeSignature: .fourFour), durationSeconds: 4) + state.setEnabled(true) + state.setLoop(enabled: true, startFrame: 500, endFrame: 1_500) + state.play(startFrame: 500) + + let samples = renderSamples(from: state, frameCount: 1_101) + + XCTAssertEqual(normalizedInitialOnsetFrames(in: samples), [0, 500, 1_000]) + } + + func testRenderAppliesOutputDelay() { + let state = makeClickState(settings: BeatGridSettings(bpm: 120, timeSignature: .fourFour)) + state.setOutputDelay(seconds: 0.01) + state.setEnabled(true) + state.play(startFrame: 0) + + let samples = renderSamples(from: state, frameCount: 12) + + XCTAssertTrue(samples[0..<10].allSatisfy { $0 == 0 }) + XCTAssertEqual(samples[10], 0.95, accuracy: 0.0001) + } + + private func makeClickState( + settings: BeatGridSettings, + durationSeconds: TimeInterval = 6 + ) -> ClickRenderState { + let state = ClickRenderState() + state.configure( + durationFrames: AVAudioFramePosition(durationSeconds * sampleRate), + sourceSampleRate: sampleRate, + audioSampleRate: sampleRate + ) + state.setSettings(settings) + state.setVolume(1) + return state + } + + private func renderSamples(from state: ClickRenderState, frameCount: Int) -> [Float] { + let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)! + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount))! + buffer.frameLength = AVAudioFrameCount(frameCount) + + state.render(frameCount: AVAudioFrameCount(frameCount), outputData: buffer.mutableAudioBufferList) + + let data = buffer.floatChannelData![0] + return Array(UnsafeBufferPointer(start: data, count: frameCount)) + } + + private func nonZeroOnsetFrames(in samples: [Float]) -> [Int] { + onsetFrames(in: samples, threshold: 0.0001) + } + + private func normalizedInitialOnsetFrames(in samples: [Float]) -> [Int] { + onsetFrames(in: samples, threshold: 0.0001).map { $0 == 1 ? 0 : $0 } + } + + private func onsetFrames(in samples: [Float], threshold: Float) -> [Int] { + var frames: [Int] = [] + var previousFrame = -100 + + for (frame, sample) in samples.enumerated() where abs(sample) > threshold { + guard frame - previousFrame > 100 else { continue } + frames.append(frame) + previousFrame = frame + } + + return frames + } +} From 79b250ae1238c9df77f43be5fb3277c1cfb25de2 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 08:12:52 +0300 Subject: [PATCH 03/80] test: split timing calculator coverage --- JammLab.xcodeproj/project.pbxproj | 8 + JammLabTests/AudioTimingLogicTests.swift | 275 ------------------ .../MetronomeClickSchedulerTests.swift | 139 +++++++++ JammLabTests/TempoGridCalculatorTests.swift | 144 +++++++++ 4 files changed, 291 insertions(+), 275 deletions(-) create mode 100644 JammLabTests/MetronomeClickSchedulerTests.swift create mode 100644 JammLabTests/TempoGridCalculatorTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 1c0c9ba..aa88994 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -111,6 +111,8 @@ 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; + 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; + 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -282,6 +284,8 @@ 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; + 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; + 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -538,6 +542,8 @@ 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, + 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, + 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -915,6 +921,8 @@ 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, + 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, + 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 01b7383..ed342ea 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -231,62 +231,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(calculator.nearestBeatTime(to: 0.74, settings: settings, duration: 10)), 0.5, accuracy: 0.0001) } - func testMetronomeClickSchedulerUsesFirstBeatOffsetAndAccents() throws { - let settings = BeatGridSettings( - bpm: 120, - firstBeatTime: 1.0, - timeSignature: .fourFour - ) - let scheduler = MetronomeClickScheduler() - - let events = scheduler.events(settings: settings, segmentStartTime: 0, segmentEndTime: 3.1) - - XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0]) - XCTAssertEqual(events.map(\.kind), [.regular, .regular, .accent, .regular, .regular, .regular, .accent]) - } - - func testMetronomeClickSchedulerAccentsNegativeBarStarts() { - let settings = BeatGridSettings( - bpm: 120, - firstBeatTime: 2.0, - timeSignature: .fourFour - ) - let scheduler = MetronomeClickScheduler() - - let events = scheduler.events(settings: settings, segmentStartTime: 0, segmentEndTime: 2.1) - - XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5, 2.0]) - XCTAssertEqual(events.map(\.kind), [.accent, .regular, .regular, .regular, .accent]) - } - - func testMetronomeClickSchedulerAccentsBarStarts() { - let settings = BeatGridSettings( - bpm: 120, - firstBeatTime: 0, - timeSignature: .fourFour - ) - let scheduler = MetronomeClickScheduler() - - let events = scheduler.events(settings: settings, segmentStartTime: 0, segmentEndTime: 2.1) - - XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5, 2.0]) - XCTAssertEqual(events.map(\.kind), [.accent, .regular, .regular, .regular, .accent]) - } - - func testMetronomeClickSchedulerAccentsEditableBarStarts() { - let settings = BeatGridSettings( - bpm: 120, - firstBeatTime: 0, - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4) - ) - let scheduler = MetronomeClickScheduler() - - let events = scheduler.events(settings: settings, segmentStartTime: 0, segmentEndTime: 1.6) - - XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5]) - XCTAssertEqual(events.map(\.kind), [.accent, .regular, .regular, .accent]) - } - func testTempoTimeSignatureMarkerPayloadRoundTripsThroughMetadata() throws { let payload = TempoTimeSignatureMarkerPayload( bpm: 123.44, @@ -2707,225 +2651,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(BeatGridCalculator().nearestBeatTime(to: 2.6, tempoMap: tempoMap)), 3.0, accuracy: 0.0001) } - func testMetronomeClickSchedulerSwitchesTempoMapAtMarker() { - let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let tempoMarker = TimecodedNote( - time: 2, - title: "60 BPM · 3/4", - metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3).metadata - ) - let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) - - let events = MetronomeClickScheduler().events(tempoMap: tempoMap, segmentStartTime: 0, segmentEndTime: 5.1) - - XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0]) - XCTAssertEqual(events.map(\.kind), [.accent, .regular, .regular, .regular, .accent, .regular, .regular, .accent]) - } - - func testMetronomeClickSchedulerStartsAfterSeekSegment() { - let settings = BeatGridSettings( - bpm: 120, - firstBeatTime: 0, - timeSignature: .fourFour - ) - let scheduler = MetronomeClickScheduler() - - let events = scheduler.events(settings: settings, segmentStartTime: 1.25, segmentEndTime: 2.1) - - XCTAssertEqual(events.map(\.sourceTime), [1.5, 2.0]) - XCTAssertEqual(events.map(\.kind), [.regular, .accent]) - } - - func testMetronomeClickSchedulerRequiresTempo() { - let scheduler = MetronomeClickScheduler() - - let events = scheduler.events(settings: BeatGridSettings(), segmentStartTime: 0, segmentEndTime: 4) - - XCTAssertTrue(events.isEmpty) - } - - func testTempoGridCalculatorComputesBeatAndBarDurations() { - let calculator = TempoGridCalculator() - let result = calculator.grid( - settings: BeatGridSettings(bpm: 120, timeSignature: .fourFour), - viewport: TimelineViewport(duration: 20, visibleRange: 0...20), - width: 1_000, - minimumLabelSpacing: 86 - ) - - XCTAssertEqual(result.secondsPerBeat, 0.5, accuracy: 0.0001) - XCTAssertEqual(result.secondsPerBar, 2.0, accuracy: 0.0001) - } - - func testTempoGridCalculatorUsesEditableTimeSignatureForBarDuration() { - let calculator = TempoGridCalculator() - let result = calculator.grid( - settings: BeatGridSettings(bpm: 120, timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4)), - viewport: TimelineViewport(duration: 20, visibleRange: 0...20), - width: 1_000, - minimumLabelSpacing: 86 - ) - - XCTAssertEqual(result.secondsPerBeat, 0.5, accuracy: 0.0001) - XCTAssertEqual(result.secondsPerBar, 1.5, accuracy: 0.0001) - } - - func testTempoGridCalculatorFormatsBarAndTimeLabels() throws { - let calculator = TempoGridCalculator() - let result = calculator.grid( - settings: BeatGridSettings(bpm: 120, firstBeatTime: 2, timeSignature: .fourFour), - viewport: TimelineViewport(duration: 24, visibleRange: 0...8), - width: 800, - minimumLabelSpacing: 80 - ) - - let labeledMarkers = result.markers.filter { $0.kind == .majorLabeled } - - XCTAssertEqual(labeledMarkers.map(\.barBeatLabel), ["-1.1", "1.1", "2.1", "3.1", "4.1"]) - XCTAssertEqual(labeledMarkers.map(\.timeLabel), ["0:00.00", "0:02.00", "0:04.00", "0:06.00", "0:08.00"]) - XCTAssertEqual(try XCTUnwrap(labeledMarkers.first?.xPosition), 0, accuracy: 0.0001) - } - - func testTempoGridCalculatorContinuesLabelsAtTempoMarkerByDefault() { - let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let tempoMarker = TimecodedNote( - time: 2, - title: "60 BPM · 3/4", - metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3).metadata - ) - let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) - - let result = TempoGridCalculator().grid( - tempoMap: tempoMap, - viewport: TimelineViewport(duration: 6, visibleRange: 0...6), - width: 600, - minimumLabelSpacing: 20 - ) - let labeledMarkers = result.markers.filter { $0.kind == .majorLabeled } - - XCTAssertEqual(labeledMarkers.map(\.time), [0, 2, 5]) - XCTAssertEqual(labeledMarkers.map(\.barBeatLabel), ["1.1", "2.1", "3.1"]) - } - - func testTempoGridCalculatorRestartsLabelsWhenMarkerSetsNewFirstBeat() { - let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let tempoMarker = TimecodedNote( - time: 2, - title: "60 BPM · 3/4", - metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3, setsNewFirstBeat: true).metadata - ) - let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) - - let result = TempoGridCalculator().grid( - tempoMap: tempoMap, - viewport: TimelineViewport(duration: 6, visibleRange: 0...6), - width: 600, - minimumLabelSpacing: 20 - ) - let labeledMarkers = result.markers.filter { $0.kind == .majorLabeled } - - XCTAssertEqual(labeledMarkers.map(\.time), [0, 2, 5]) - XCTAssertEqual(labeledMarkers.map(\.barBeatLabel), ["1.1", "1.1", "2.1"]) - } - - func testTempoGridCalculatorChoosesBarStepFromLabelSpacing() { - XCTAssertEqual(TempoGridCalculator.barStep(for: 100, minimumLabelSpacing: 86), 1) - XCTAssertEqual(TempoGridCalculator.barStep(for: 30, minimumLabelSpacing: 86), 4) - XCTAssertEqual(TempoGridCalculator.barStep(for: 2, minimumLabelSpacing: 86), 32) - } - - func testTempoGridCalculatorAdaptsLabelsToZoomLevel() { - let calculator = TempoGridCalculator() - let settings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let zoomedIn = calculator.grid( - settings: settings, - viewport: TimelineViewport(duration: 120, visibleRange: 0...16), - width: 800, - minimumLabelSpacing: 86 - ) - let zoomedOut = calculator.grid( - settings: settings, - viewport: TimelineViewport(duration: 120, visibleRange: 0...120), - width: 800, - minimumLabelSpacing: 86 - ) - - XCTAssertEqual(zoomedIn.barStep, 1) - XCTAssertGreaterThan(zoomedOut.barStep, zoomedIn.barStep) - XCTAssertLessThan( - zoomedOut.markers.filter { $0.kind == .majorLabeled }.count, - zoomedIn.markers.filter { $0.kind == .majorLabeled }.count - ) - } - - func testTempoGridCalculatorBeatMarkersRespectPixelThreshold() { - let calculator = TempoGridCalculator() - let settings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let dense = calculator.grid( - settings: settings, - viewport: TimelineViewport(duration: 8, visibleRange: 0...8), - width: 800, - minimumLabelSpacing: 86 - ) - let sparse = calculator.grid( - settings: settings, - viewport: TimelineViewport(duration: 120, visibleRange: 0...120), - width: 800, - minimumLabelSpacing: 86 - ) - - XCTAssertFalse(dense.markers.filter { $0.kind == .beat }.isEmpty) - XCTAssertTrue(sparse.markers.filter { $0.kind == .beat }.isEmpty) - } - - func testTempoGridCalculatorFormatsTrackTime() { - XCTAssertEqual(TempoGridCalculator.formatTime(0), "0:00.00") - XCTAssertEqual(TempoGridCalculator.formatTime(2.66), "0:02.66") - XCTAssertEqual(TempoGridCalculator.formatTime(21.33), "0:21.33") - } - - func testMetronomeClickTimingMapperMapsSegmentStartToZero() throws { - let mapper = MetronomeClickTimingMapper() - let event = MetronomeClickEvent(sourceTime: 4, kind: .accent) - - let sampleTime = try XCTUnwrap(mapper.sampleTime( - for: event, - segmentStartTime: 4, - playbackRate: 1, - sampleRate: 44_100 - )) - - XCTAssertEqual(sampleTime, 0) - } - - func testMetronomeClickTimingMapperRejectsEventsBeforeSeekSegment() { - let mapper = MetronomeClickTimingMapper() - let event = MetronomeClickEvent(sourceTime: 3.9, kind: .regular) - - let sampleTime = mapper.sampleTime( - for: event, - segmentStartTime: 4, - playbackRate: 1, - sampleRate: 44_100 - ) - - XCTAssertNil(sampleTime) - } - - func testMetronomeClickTimingMapperUsesAudiblePlaybackRate() throws { - let mapper = MetronomeClickTimingMapper() - let event = MetronomeClickEvent(sourceTime: 5, kind: .regular) - - let sampleTime = try XCTUnwrap(mapper.sampleTime( - for: event, - segmentStartTime: 4, - playbackRate: 0.5, - sampleRate: 1_000 - )) - - XCTAssertEqual(sampleTime, 2_000) - } - private func fourFourTempoMap( duration: TimeInterval, firstBeatTime: TimeInterval = 0, diff --git a/JammLabTests/MetronomeClickSchedulerTests.swift b/JammLabTests/MetronomeClickSchedulerTests.swift new file mode 100644 index 0000000..dffeeac --- /dev/null +++ b/JammLabTests/MetronomeClickSchedulerTests.swift @@ -0,0 +1,139 @@ +import XCTest +@testable import JammLab + +final class MetronomeClickSchedulerTests: XCTestCase { + func testMetronomeClickSchedulerUsesFirstBeatOffsetAndAccents() throws { + let settings = BeatGridSettings( + bpm: 120, + firstBeatTime: 1.0, + timeSignature: .fourFour + ) + let scheduler = MetronomeClickScheduler() + + let events = scheduler.events(settings: settings, segmentStartTime: 0, segmentEndTime: 3.1) + + XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0]) + XCTAssertEqual(events.map(\.kind), [.regular, .regular, .accent, .regular, .regular, .regular, .accent]) + } + + func testMetronomeClickSchedulerAccentsNegativeBarStarts() { + let settings = BeatGridSettings( + bpm: 120, + firstBeatTime: 2.0, + timeSignature: .fourFour + ) + let scheduler = MetronomeClickScheduler() + + let events = scheduler.events(settings: settings, segmentStartTime: 0, segmentEndTime: 2.1) + + XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5, 2.0]) + XCTAssertEqual(events.map(\.kind), [.accent, .regular, .regular, .regular, .accent]) + } + + func testMetronomeClickSchedulerAccentsBarStarts() { + let settings = BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ) + let scheduler = MetronomeClickScheduler() + + let events = scheduler.events(settings: settings, segmentStartTime: 0, segmentEndTime: 2.1) + + XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5, 2.0]) + XCTAssertEqual(events.map(\.kind), [.accent, .regular, .regular, .regular, .accent]) + } + + func testMetronomeClickSchedulerAccentsEditableBarStarts() { + let settings = BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4) + ) + let scheduler = MetronomeClickScheduler() + + let events = scheduler.events(settings: settings, segmentStartTime: 0, segmentEndTime: 1.6) + + XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5]) + XCTAssertEqual(events.map(\.kind), [.accent, .regular, .regular, .accent]) + } + + func testMetronomeClickSchedulerSwitchesTempoMapAtMarker() { + let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let tempoMarker = TimecodedNote( + time: 2, + title: "60 BPM · 3/4", + metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3).metadata + ) + let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) + + let events = MetronomeClickScheduler().events(tempoMap: tempoMap, segmentStartTime: 0, segmentEndTime: 5.1) + + XCTAssertEqual(events.map(\.sourceTime), [0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0]) + XCTAssertEqual(events.map(\.kind), [.accent, .regular, .regular, .regular, .accent, .regular, .regular, .accent]) + } + + func testMetronomeClickSchedulerStartsAfterSeekSegment() { + let settings = BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ) + let scheduler = MetronomeClickScheduler() + + let events = scheduler.events(settings: settings, segmentStartTime: 1.25, segmentEndTime: 2.1) + + XCTAssertEqual(events.map(\.sourceTime), [1.5, 2.0]) + XCTAssertEqual(events.map(\.kind), [.regular, .accent]) + } + + func testMetronomeClickSchedulerRequiresTempo() { + let scheduler = MetronomeClickScheduler() + + let events = scheduler.events(settings: BeatGridSettings(), segmentStartTime: 0, segmentEndTime: 4) + + XCTAssertTrue(events.isEmpty) + } + + func testMetronomeClickTimingMapperMapsSegmentStartToZero() throws { + let mapper = MetronomeClickTimingMapper() + let event = MetronomeClickEvent(sourceTime: 4, kind: .accent) + + let sampleTime = try XCTUnwrap(mapper.sampleTime( + for: event, + segmentStartTime: 4, + playbackRate: 1, + sampleRate: 44_100 + )) + + XCTAssertEqual(sampleTime, 0) + } + + func testMetronomeClickTimingMapperRejectsEventsBeforeSeekSegment() { + let mapper = MetronomeClickTimingMapper() + let event = MetronomeClickEvent(sourceTime: 3.9, kind: .regular) + + let sampleTime = mapper.sampleTime( + for: event, + segmentStartTime: 4, + playbackRate: 1, + sampleRate: 44_100 + ) + + XCTAssertNil(sampleTime) + } + + func testMetronomeClickTimingMapperUsesAudiblePlaybackRate() throws { + let mapper = MetronomeClickTimingMapper() + let event = MetronomeClickEvent(sourceTime: 5, kind: .regular) + + let sampleTime = try XCTUnwrap(mapper.sampleTime( + for: event, + segmentStartTime: 4, + playbackRate: 0.5, + sampleRate: 1_000 + )) + + XCTAssertEqual(sampleTime, 2_000) + } +} diff --git a/JammLabTests/TempoGridCalculatorTests.swift b/JammLabTests/TempoGridCalculatorTests.swift new file mode 100644 index 0000000..050b4b6 --- /dev/null +++ b/JammLabTests/TempoGridCalculatorTests.swift @@ -0,0 +1,144 @@ +import XCTest +@testable import JammLab + +final class TempoGridCalculatorTests: XCTestCase { + func testComputesBeatAndBarDurations() { + let calculator = TempoGridCalculator() + let result = calculator.grid( + settings: BeatGridSettings(bpm: 120, timeSignature: .fourFour), + viewport: TimelineViewport(duration: 20, visibleRange: 0...20), + width: 1_000, + minimumLabelSpacing: 86 + ) + + XCTAssertEqual(result.secondsPerBeat, 0.5, accuracy: 0.0001) + XCTAssertEqual(result.secondsPerBar, 2.0, accuracy: 0.0001) + } + + func testUsesEditableTimeSignatureForBarDuration() { + let calculator = TempoGridCalculator() + let result = calculator.grid( + settings: BeatGridSettings(bpm: 120, timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4)), + viewport: TimelineViewport(duration: 20, visibleRange: 0...20), + width: 1_000, + minimumLabelSpacing: 86 + ) + + XCTAssertEqual(result.secondsPerBeat, 0.5, accuracy: 0.0001) + XCTAssertEqual(result.secondsPerBar, 1.5, accuracy: 0.0001) + } + + func testFormatsBarAndTimeLabels() throws { + let calculator = TempoGridCalculator() + let result = calculator.grid( + settings: BeatGridSettings(bpm: 120, firstBeatTime: 2, timeSignature: .fourFour), + viewport: TimelineViewport(duration: 24, visibleRange: 0...8), + width: 800, + minimumLabelSpacing: 80 + ) + + let labeledMarkers = result.markers.filter { $0.kind == .majorLabeled } + + XCTAssertEqual(labeledMarkers.map(\.barBeatLabel), ["-1.1", "1.1", "2.1", "3.1", "4.1"]) + XCTAssertEqual(labeledMarkers.map(\.timeLabel), ["0:00.00", "0:02.00", "0:04.00", "0:06.00", "0:08.00"]) + XCTAssertEqual(try XCTUnwrap(labeledMarkers.first?.xPosition), 0, accuracy: 0.0001) + } + + func testContinuesLabelsAtTempoMarkerByDefault() { + let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let tempoMarker = TimecodedNote( + time: 2, + title: "60 BPM · 3/4", + metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3).metadata + ) + let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) + + let result = TempoGridCalculator().grid( + tempoMap: tempoMap, + viewport: TimelineViewport(duration: 6, visibleRange: 0...6), + width: 600, + minimumLabelSpacing: 20 + ) + let labeledMarkers = result.markers.filter { $0.kind == .majorLabeled } + + XCTAssertEqual(labeledMarkers.map(\.time), [0, 2, 5]) + XCTAssertEqual(labeledMarkers.map(\.barBeatLabel), ["1.1", "2.1", "3.1"]) + } + + func testRestartsLabelsWhenMarkerSetsNewFirstBeat() { + let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let tempoMarker = TimecodedNote( + time: 2, + title: "60 BPM · 3/4", + metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3, setsNewFirstBeat: true).metadata + ) + let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) + + let result = TempoGridCalculator().grid( + tempoMap: tempoMap, + viewport: TimelineViewport(duration: 6, visibleRange: 0...6), + width: 600, + minimumLabelSpacing: 20 + ) + let labeledMarkers = result.markers.filter { $0.kind == .majorLabeled } + + XCTAssertEqual(labeledMarkers.map(\.time), [0, 2, 5]) + XCTAssertEqual(labeledMarkers.map(\.barBeatLabel), ["1.1", "1.1", "2.1"]) + } + + func testChoosesBarStepFromLabelSpacing() { + XCTAssertEqual(TempoGridCalculator.barStep(for: 100, minimumLabelSpacing: 86), 1) + XCTAssertEqual(TempoGridCalculator.barStep(for: 30, minimumLabelSpacing: 86), 4) + XCTAssertEqual(TempoGridCalculator.barStep(for: 2, minimumLabelSpacing: 86), 32) + } + + func testAdaptsLabelsToZoomLevel() { + let calculator = TempoGridCalculator() + let settings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let zoomedIn = calculator.grid( + settings: settings, + viewport: TimelineViewport(duration: 120, visibleRange: 0...16), + width: 800, + minimumLabelSpacing: 86 + ) + let zoomedOut = calculator.grid( + settings: settings, + viewport: TimelineViewport(duration: 120, visibleRange: 0...120), + width: 800, + minimumLabelSpacing: 86 + ) + + XCTAssertEqual(zoomedIn.barStep, 1) + XCTAssertGreaterThan(zoomedOut.barStep, zoomedIn.barStep) + XCTAssertLessThan( + zoomedOut.markers.filter { $0.kind == .majorLabeled }.count, + zoomedIn.markers.filter { $0.kind == .majorLabeled }.count + ) + } + + func testBeatMarkersRespectPixelThreshold() { + let calculator = TempoGridCalculator() + let settings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let dense = calculator.grid( + settings: settings, + viewport: TimelineViewport(duration: 8, visibleRange: 0...8), + width: 800, + minimumLabelSpacing: 86 + ) + let sparse = calculator.grid( + settings: settings, + viewport: TimelineViewport(duration: 120, visibleRange: 0...120), + width: 800, + minimumLabelSpacing: 86 + ) + + XCTAssertFalse(dense.markers.filter { $0.kind == .beat }.isEmpty) + XCTAssertTrue(sparse.markers.filter { $0.kind == .beat }.isEmpty) + } + + func testFormatsTrackTime() { + XCTAssertEqual(TempoGridCalculator.formatTime(0), "0:00.00") + XCTAssertEqual(TempoGridCalculator.formatTime(2.66), "0:02.66") + XCTAssertEqual(TempoGridCalculator.formatTime(21.33), "0:21.33") + } +} From 6397296b7858460f89470691e7efede3766c3a69 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 11:51:58 +0300 Subject: [PATCH 04/80] test: split beat grid tempo map coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 162 ------------------- JammLabTests/BeatGridAndTempoMapTests.swift | 166 ++++++++++++++++++++ 3 files changed, 170 insertions(+), 162 deletions(-) create mode 100644 JammLabTests/BeatGridAndTempoMapTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index aa88994..a899689 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -113,6 +113,7 @@ 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; + 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -286,6 +287,7 @@ 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; + 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridAndTempoMapTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -544,6 +546,7 @@ 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, + 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -923,6 +926,7 @@ 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, + 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index ed342ea..ba35235 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -19,20 +19,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(duration, 0.5, accuracy: 0.0001) } - func testBeatGridFourBarsAt120BPMUsesExpectedBarStarts() { - let settings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let markers = BeatGridCalculator().markers(settings: settings, visibleStartTime: 0, visibleEndTime: 8) - let barStartTimes = markers.filter(\.isBarStart).map(\.time) - - XCTAssertEqual(barStartTimes, [0, 2, 4, 6, 8]) - } - - func testTimeSignatureNormalizesSupportedRangeAndBeatUnit() { - XCTAssertEqual(TimeSignature(beatsPerBar: 0, beatUnit: 8), TimeSignature(beatsPerBar: 1, beatUnit: 4)) - XCTAssertEqual(TimeSignature(beatsPerBar: 9, beatUnit: 2), TimeSignature(beatsPerBar: 7, beatUnit: 4)) - XCTAssertEqual(BeatGridSettings(bpm: 120, timeSignature: TimeSignature(beatsPerBar: 9, beatUnit: 16)).clamped(to: 10).timeSignature.displayText, "7/4") - } - func testNotationSMuFLRestSymbolsMapDurationsToCodepoints() throws { let whole = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 1))) let half = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 2))) @@ -185,126 +171,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(y, 22, accuracy: 0.0001) } - func testBeatGridUsesEditableBeatsPerBarForBarStarts() { - let settings = BeatGridSettings(bpm: 120, timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4)) - let markers = BeatGridCalculator().markers(settings: settings, visibleStartTime: 0, visibleEndTime: 4.5) - let barStartTimes = markers.filter(\.isBarStart).map(\.time) - - XCTAssertEqual(barStartTimes, [0, 1.5, 3.0, 4.5]) - } - - func testBeatGridUsesFirstBeatOffsetForBarsAndSnap() throws { - let settings = BeatGridSettings( - bpm: 120, - firstBeatTime: 1.0, - timeSignature: .fourFour - ) - let calculator = BeatGridCalculator() - - let markers = calculator.markers(settings: settings, visibleStartTime: 0, visibleEndTime: 6) - let barStarts = markers.filter(\.isBarStart) - - let firstBar = try XCTUnwrap(barStarts.first) - let secondBar = try XCTUnwrap(barStarts.dropFirst().first) - - XCTAssertEqual(firstBar.time, 1.0, accuracy: 0.0001) - XCTAssertEqual(firstBar.barNumber(beatsPerBar: 4), 1) - XCTAssertEqual(secondBar.time, 3.0, accuracy: 0.0001) - XCTAssertEqual(secondBar.barNumber(beatsPerBar: 4), 2) - XCTAssertEqual(try XCTUnwrap(calculator.nearestBeatTime(to: 1.76, settings: settings, duration: 10)), 2.0, accuracy: 0.0001) - } - - func testBeatGridIncludesNegativeBarsBeforeFirstBeat() throws { - let settings = BeatGridSettings( - bpm: 120, - firstBeatTime: 2.0, - timeSignature: .fourFour - ) - let calculator = BeatGridCalculator() - - let markers = calculator.markers(settings: settings, visibleStartTime: 0, visibleEndTime: 2.1) - let barStarts = markers.filter(\.isBarStart) - - XCTAssertEqual(markers.map(\.time), [0, 0.5, 1.0, 1.5, 2.0]) - XCTAssertEqual(barStarts.map(\.time), [0, 2.0]) - XCTAssertEqual(barStarts.compactMap { $0.barNumber(beatsPerBar: 4) }, [-1, 1]) - XCTAssertEqual(try XCTUnwrap(calculator.nearestBeatTime(to: 0.74, settings: settings, duration: 10)), 0.5, accuracy: 0.0001) - } - - func testTempoTimeSignatureMarkerPayloadRoundTripsThroughMetadata() throws { - let payload = TempoTimeSignatureMarkerPayload( - bpm: 123.44, - beatsPerBar: 9, - beatUnit: 8, - setsNewFirstBeat: true - ) - let decoded = try XCTUnwrap(TempoTimeSignatureMarkerPayload(metadata: payload.metadata)) - - XCTAssertEqual(try XCTUnwrap(decoded.bpm), 123.4, accuracy: 0.0001) - XCTAssertEqual(decoded.beatsPerBar, 7) - XCTAssertEqual(decoded.beatUnit, 4) - XCTAssertTrue(decoded.setsNewFirstBeat) - XCTAssertEqual(decoded.metadata[TempoTimeSignatureMarkerPayload.typeKey], TempoTimeSignatureMarkerPayload.typeValue) - XCTAssertEqual(decoded.metadata[TempoTimeSignatureMarkerPayload.setsNewFirstBeatKey], "true") - XCTAssertEqual(decoded.title, "123.4 BPM · 7/4") - XCTAssertNil(TempoTimeSignatureMarkerPayload(metadata: [TempoTimeSignatureMarkerPayload.typeKey: TempoTimeSignatureMarkerPayload.typeValue])) - } - - func testTempoTimeSignatureMarkerPayloadDefaultsNewFirstBeatToFalse() throws { - let payload = try XCTUnwrap(TempoTimeSignatureMarkerPayload(metadata: [ - TempoTimeSignatureMarkerPayload.typeKey: TempoTimeSignatureMarkerPayload.typeValue, - TempoTimeSignatureMarkerPayload.bpmKey: "120.0" - ])) - - XCTAssertFalse(payload.setsNewFirstBeat) - } - - func testTempoTimeSignatureMarkerPayloadAllowsNewFirstBeatOnlyMarker() throws { - let payload = TempoTimeSignatureMarkerPayload(setsNewFirstBeat: true) - let decoded = try XCTUnwrap(TempoTimeSignatureMarkerPayload(metadata: payload.metadata)) - - XCTAssertNil(decoded.bpm) - XCTAssertNil(decoded.beatsPerBar) - XCTAssertTrue(decoded.setsNewFirstBeat) - XCTAssertEqual(decoded.title, "New First Beat") - } - - func testTempoMapContinuesBarNumberingByDefaultAndInheritsUnchangedValues() { - let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let tempoMarker = TimecodedNote( - time: 2, - title: "3/4", - metadata: TempoTimeSignatureMarkerPayload(beatsPerBar: 3).metadata - ) - - let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 8) - - XCTAssertEqual(tempoMap.segments.count, 2) - XCTAssertEqual(tempoMap.segments[0].startTime, 0, accuracy: 0.0001) - XCTAssertEqual(tempoMap.segments[0].endTime, 2, accuracy: 0.0001) - XCTAssertEqual(tempoMap.segments[0].settings.timeSignature, .fourFour) - XCTAssertEqual(tempoMap.segments[1].startTime, 2, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(tempoMap.segments[1].settings.bpm), 120, accuracy: 0.0001) - XCTAssertEqual(tempoMap.segments[1].settings.firstBeatTime, 2, accuracy: 0.0001) - XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 2) - XCTAssertEqual(tempoMap.segments[1].settings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) - } - - func testTempoMapRestartsBarNumberingWhenMarkerSetsNewFirstBeat() { - let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let tempoMarker = TimecodedNote( - time: 2, - title: "3/4", - metadata: TempoTimeSignatureMarkerPayload(beatsPerBar: 3, setsNewFirstBeat: true).metadata - ) - - let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 8) - - XCTAssertEqual(tempoMap.segments[1].settings.firstBeatTime, 2, accuracy: 0.0001) - XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 1) - XCTAssertEqual(tempoMap.segments[1].settings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) - } - func testNotationViewportUsesCurrentTimeWhilePlayingAndMarkerTimeWhenStopped() { let tempoMap = fourFourTempoMap(duration: 60) @@ -2623,34 +2489,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertGreaterThan(harmonyStartX, geometry.cellStartX) } - func testBeatGridCalculatorUsesTempoMapSegmentsWithoutBoundaryDuplicates() throws { - let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let tempoMarker = TimecodedNote( - time: 2, - title: "60 BPM · 3/4", - metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3).metadata - ) - let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) - - let markers = BeatGridCalculator().markers(tempoMap: tempoMap, visibleStartTime: 0, visibleEndTime: 6) - - XCTAssertEqual(markers.map(\.time), [0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0]) - XCTAssertEqual(markers.filter { $0.time == 2.0 }.count, 1) - XCTAssertTrue(try XCTUnwrap(markers.first { $0.time == 2.0 }).isBarStart) - } - - func testTempoMapSnappingUsesPostMarkerTempoAfterMarker() throws { - let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - let tempoMarker = TimecodedNote( - time: 2, - title: "60 BPM", - metadata: TempoTimeSignatureMarkerPayload(bpm: 60).metadata - ) - let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) - - XCTAssertEqual(try XCTUnwrap(BeatGridCalculator().nearestBeatTime(to: 2.6, tempoMap: tempoMap)), 3.0, accuracy: 0.0001) - } - private func fourFourTempoMap( duration: TimeInterval, firstBeatTime: TimeInterval = 0, diff --git a/JammLabTests/BeatGridAndTempoMapTests.swift b/JammLabTests/BeatGridAndTempoMapTests.swift new file mode 100644 index 0000000..da7bce3 --- /dev/null +++ b/JammLabTests/BeatGridAndTempoMapTests.swift @@ -0,0 +1,166 @@ +import XCTest +@testable import JammLab + +final class BeatGridAndTempoMapTests: XCTestCase { + func testBeatGridFourBarsAt120BPMUsesExpectedBarStarts() { + let settings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let markers = BeatGridCalculator().markers(settings: settings, visibleStartTime: 0, visibleEndTime: 8) + let barStartTimes = markers.filter(\.isBarStart).map(\.time) + + XCTAssertEqual(barStartTimes, [0, 2, 4, 6, 8]) + } + + func testTimeSignatureNormalizesSupportedRangeAndBeatUnit() { + XCTAssertEqual(TimeSignature(beatsPerBar: 0, beatUnit: 8), TimeSignature(beatsPerBar: 1, beatUnit: 4)) + XCTAssertEqual(TimeSignature(beatsPerBar: 9, beatUnit: 2), TimeSignature(beatsPerBar: 7, beatUnit: 4)) + XCTAssertEqual(BeatGridSettings(bpm: 120, timeSignature: TimeSignature(beatsPerBar: 9, beatUnit: 16)).clamped(to: 10).timeSignature.displayText, "7/4") + } + + func testBeatGridUsesEditableBeatsPerBarForBarStarts() { + let settings = BeatGridSettings(bpm: 120, timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4)) + let markers = BeatGridCalculator().markers(settings: settings, visibleStartTime: 0, visibleEndTime: 4.5) + let barStartTimes = markers.filter(\.isBarStart).map(\.time) + + XCTAssertEqual(barStartTimes, [0, 1.5, 3.0, 4.5]) + } + + func testBeatGridUsesFirstBeatOffsetForBarsAndSnap() throws { + let settings = BeatGridSettings( + bpm: 120, + firstBeatTime: 1.0, + timeSignature: .fourFour + ) + let calculator = BeatGridCalculator() + + let markers = calculator.markers(settings: settings, visibleStartTime: 0, visibleEndTime: 6) + let barStarts = markers.filter(\.isBarStart) + + let firstBar = try XCTUnwrap(barStarts.first) + let secondBar = try XCTUnwrap(barStarts.dropFirst().first) + + XCTAssertEqual(firstBar.time, 1.0, accuracy: 0.0001) + XCTAssertEqual(firstBar.barNumber(beatsPerBar: 4), 1) + XCTAssertEqual(secondBar.time, 3.0, accuracy: 0.0001) + XCTAssertEqual(secondBar.barNumber(beatsPerBar: 4), 2) + XCTAssertEqual(try XCTUnwrap(calculator.nearestBeatTime(to: 1.76, settings: settings, duration: 10)), 2.0, accuracy: 0.0001) + } + + func testBeatGridIncludesNegativeBarsBeforeFirstBeat() throws { + let settings = BeatGridSettings( + bpm: 120, + firstBeatTime: 2.0, + timeSignature: .fourFour + ) + let calculator = BeatGridCalculator() + + let markers = calculator.markers(settings: settings, visibleStartTime: 0, visibleEndTime: 2.1) + let barStarts = markers.filter(\.isBarStart) + + XCTAssertEqual(markers.map(\.time), [0, 0.5, 1.0, 1.5, 2.0]) + XCTAssertEqual(barStarts.map(\.time), [0, 2.0]) + XCTAssertEqual(barStarts.compactMap { $0.barNumber(beatsPerBar: 4) }, [-1, 1]) + XCTAssertEqual(try XCTUnwrap(calculator.nearestBeatTime(to: 0.74, settings: settings, duration: 10)), 0.5, accuracy: 0.0001) + } + + func testTempoTimeSignatureMarkerPayloadRoundTripsThroughMetadata() throws { + let payload = TempoTimeSignatureMarkerPayload( + bpm: 123.44, + beatsPerBar: 9, + beatUnit: 8, + setsNewFirstBeat: true + ) + let decoded = try XCTUnwrap(TempoTimeSignatureMarkerPayload(metadata: payload.metadata)) + + XCTAssertEqual(try XCTUnwrap(decoded.bpm), 123.4, accuracy: 0.0001) + XCTAssertEqual(decoded.beatsPerBar, 7) + XCTAssertEqual(decoded.beatUnit, 4) + XCTAssertTrue(decoded.setsNewFirstBeat) + XCTAssertEqual(decoded.metadata[TempoTimeSignatureMarkerPayload.typeKey], TempoTimeSignatureMarkerPayload.typeValue) + XCTAssertEqual(decoded.metadata[TempoTimeSignatureMarkerPayload.setsNewFirstBeatKey], "true") + XCTAssertEqual(decoded.title, "123.4 BPM · 7/4") + XCTAssertNil(TempoTimeSignatureMarkerPayload(metadata: [TempoTimeSignatureMarkerPayload.typeKey: TempoTimeSignatureMarkerPayload.typeValue])) + } + + func testTempoTimeSignatureMarkerPayloadDefaultsNewFirstBeatToFalse() throws { + let payload = try XCTUnwrap(TempoTimeSignatureMarkerPayload(metadata: [ + TempoTimeSignatureMarkerPayload.typeKey: TempoTimeSignatureMarkerPayload.typeValue, + TempoTimeSignatureMarkerPayload.bpmKey: "120.0" + ])) + + XCTAssertFalse(payload.setsNewFirstBeat) + } + + func testTempoTimeSignatureMarkerPayloadAllowsNewFirstBeatOnlyMarker() throws { + let payload = TempoTimeSignatureMarkerPayload(setsNewFirstBeat: true) + let decoded = try XCTUnwrap(TempoTimeSignatureMarkerPayload(metadata: payload.metadata)) + + XCTAssertNil(decoded.bpm) + XCTAssertNil(decoded.beatsPerBar) + XCTAssertTrue(decoded.setsNewFirstBeat) + XCTAssertEqual(decoded.title, "New First Beat") + } + + func testTempoMapContinuesBarNumberingByDefaultAndInheritsUnchangedValues() { + let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let tempoMarker = TimecodedNote( + time: 2, + title: "3/4", + metadata: TempoTimeSignatureMarkerPayload(beatsPerBar: 3).metadata + ) + + let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 8) + + XCTAssertEqual(tempoMap.segments.count, 2) + XCTAssertEqual(tempoMap.segments[0].startTime, 0, accuracy: 0.0001) + XCTAssertEqual(tempoMap.segments[0].endTime, 2, accuracy: 0.0001) + XCTAssertEqual(tempoMap.segments[0].settings.timeSignature, .fourFour) + XCTAssertEqual(tempoMap.segments[1].startTime, 2, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(tempoMap.segments[1].settings.bpm), 120, accuracy: 0.0001) + XCTAssertEqual(tempoMap.segments[1].settings.firstBeatTime, 2, accuracy: 0.0001) + XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 2) + XCTAssertEqual(tempoMap.segments[1].settings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) + } + + func testTempoMapRestartsBarNumberingWhenMarkerSetsNewFirstBeat() { + let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let tempoMarker = TimecodedNote( + time: 2, + title: "3/4", + metadata: TempoTimeSignatureMarkerPayload(beatsPerBar: 3, setsNewFirstBeat: true).metadata + ) + + let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 8) + + XCTAssertEqual(tempoMap.segments[1].settings.firstBeatTime, 2, accuracy: 0.0001) + XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 1) + XCTAssertEqual(tempoMap.segments[1].settings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) + } + + func testBeatGridCalculatorUsesTempoMapSegmentsWithoutBoundaryDuplicates() throws { + let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let tempoMarker = TimecodedNote( + time: 2, + title: "60 BPM · 3/4", + metadata: TempoTimeSignatureMarkerPayload(bpm: 60, beatsPerBar: 3).metadata + ) + let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) + + let markers = BeatGridCalculator().markers(tempoMap: tempoMap, visibleStartTime: 0, visibleEndTime: 6) + + XCTAssertEqual(markers.map(\.time), [0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0]) + XCTAssertEqual(markers.filter { $0.time == 2.0 }.count, 1) + XCTAssertTrue(try XCTUnwrap(markers.first { $0.time == 2.0 }).isBarStart) + } + + func testTempoMapSnappingUsesPostMarkerTempoAfterMarker() throws { + let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + let tempoMarker = TimecodedNote( + time: 2, + title: "60 BPM", + metadata: TempoTimeSignatureMarkerPayload(bpm: 60).metadata + ) + let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6) + + XCTAssertEqual(try XCTUnwrap(BeatGridCalculator().nearestBeatTime(to: 2.6, tempoMap: tempoMap)), 3.0, accuracy: 0.0001) + } +} From 6bb4eaab07766ecd562e64f77dffd0e9657f9b7c Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 12:01:50 +0300 Subject: [PATCH 05/80] test: split notation primitive coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 152 -------------------- JammLabTests/NotationPrimitivesTests.swift | 156 +++++++++++++++++++++ 3 files changed, 160 insertions(+), 152 deletions(-) create mode 100644 JammLabTests/NotationPrimitivesTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index a899689..5690c7c 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */; }; + 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -288,6 +289,7 @@ 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridAndTempoMapTests.swift; sourceTree = ""; }; + 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationPrimitivesTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -547,6 +549,7 @@ 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */, + 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -927,6 +930,7 @@ 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */, + 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index ba35235..468bfec 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -19,158 +19,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(duration, 0.5, accuracy: 0.0001) } - func testNotationSMuFLRestSymbolsMapDurationsToCodepoints() throws { - let whole = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 1))) - let half = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 2))) - let quarter = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 4))) - let eighth = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 8))) - - XCTAssertEqual(whole, .restWhole) - XCTAssertEqual(whole.codepoint, 0xE4E3) - XCTAssertEqual(half, .restHalf) - XCTAssertEqual(half.codepoint, 0xE4E4) - XCTAssertEqual(quarter, .restQuarter) - XCTAssertEqual(quarter.codepoint, 0xE4E5) - XCTAssertEqual(eighth, .rest8th) - XCTAssertEqual(eighth.codepoint, 0xE4E6) - XCTAssertEqual(quarter.glyph.unicodeScalars.first?.value, 0xE4E5) - } - - func testNotationSMuFLDurationControlSymbolsMapDurationsToLelandMetNoteCodepoints() throws { - let whole = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 1))) - let half = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 2))) - let quarter = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 4))) - let eighth = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 8))) - - XCTAssertEqual(whole, .whole) - XCTAssertEqual(whole.codepoint, 0xECA2) - XCTAssertEqual(half, .half) - XCTAssertEqual(half.codepoint, 0xECA3) - XCTAssertEqual(quarter, .quarter) - XCTAssertEqual(quarter.codepoint, 0xECA5) - XCTAssertEqual(eighth, .eighth) - XCTAssertEqual(eighth.codepoint, 0xECA7) - XCTAssertEqual(eighth.glyph.unicodeScalars.first?.value, 0xECA7) - } - - func testNotationRestItemFactoryUsesGreedyAllowedDurationDecomposition() { - let segments = NotationRestItemFactory.greedySegments(startOffset: 0, remaining: 3.5) - - XCTAssertEqual(segments.map(\.displayDuration.denominator), [2, 4, 8]) - XCTAssertEqual(segments.map(\.offsetInQuarterNotes), [0, 2, 3]) - XCTAssertEqual(segments.map(\.durationInQuarterNotes), [2, 1, 0.5]) - XCTAssertEqual(segments.map(\.isTail), [false, false, false]) - } - - func testNotationRestItemFactoryUsesTimelineToleranceForMinimumDuration() { - let tolerance = NotationMeasureTiming.timelineTolerance - - let withinTolerance = NotationRestItemFactory.greedySegments( - startOffset: 1, - remaining: 0.5 - tolerance / 2 - ) - let outsideTolerance = NotationRestItemFactory.greedySegments( - startOffset: 1, - remaining: 0.5 - tolerance * 2 - ) - - XCTAssertEqual(withinTolerance.map(\.displayDuration.denominator), [8]) - XCTAssertEqual(withinTolerance.map(\.offsetInQuarterNotes), [1]) - XCTAssertTrue(outsideTolerance.isEmpty) - } - - func testNotationRestItemFactoryLetsCallerControlIDsAndSynthesizedState() throws { - let synthesized = NotationRestItemFactory.restItems( - measureNumber: 2, - measureStartTime: 4, - startOffset: 1, - remaining: 1, - isSynthesized: true - ) { segment in - "rest-\(segment.offsetInQuarterNotes)-\(segment.displayDuration.denominator)" - } - let persisted = NotationRestItemFactory.restItems( - measureNumber: 2, - measureStartTime: 4, - startOffset: 1, - remaining: 1 - ) - - XCTAssertEqual(synthesized.map(\.id), ["rest-1.0-4"]) - XCTAssertEqual(synthesized.map(\.isSynthesized), [true]) - XCTAssertEqual(persisted.map(\.isSynthesized), [false]) - XCTAssertNotNil(UUID(uuidString: try XCTUnwrap(persisted.first?.id))) - } - - func testNotationViewportFactoryDefaultWholeRestKeepsSynthesizedFieldsAndID() throws { - let measure = ScoreMeasure( - number: 1, - startTime: 0, - endTime: 1.5, - attributes: MeasureAttributes( - keySignature: .cMajor, - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - ) - - let item = try XCTUnwrap(NotationViewportFactory.notationItems(for: measure, from: []).first) - - XCTAssertEqual(item.id, "default-rest-1-0.0-1.5") - XCTAssertEqual(item.kind, .rest) - XCTAssertEqual(item.measureNumber, 1) - XCTAssertEqual(item.measureStartTime, 0) - XCTAssertEqual(item.offsetInQuarterNotes, 0) - XCTAssertEqual(item.durationInQuarterNotes, 3) - XCTAssertEqual(item.displayDuration.denominator, 1) - XCTAssertTrue(item.isSynthesized) - } - - func testLelandFontResourceIsBundledAndRegistered() throws { - let url = try XCTUnwrap(Bundle.main.url(forResource: "Leland", withExtension: "otf")) - - XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) - XCTAssertEqual(NotationMusicFontRegistry.fontName, "Leland") - } - - func testLelandWholeRestGlyphPathHasBounds() throws { - let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( - for: .restWhole, - fontSize: 32.5 - )) - - XCTAssertFalse(glyphPath.path.isEmpty) - XCTAssertGreaterThan(glyphPath.bounds.width, 0) - XCTAssertGreaterThan(glyphPath.bounds.height, 0) - } - - func testLelandDurationControlGlyphPathsHaveBounds() throws { - for symbol in [ - NotationDurationControlSymbol.whole, - .half, - .quarter, - .eighth - ] { - let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( - for: symbol, - fontSize: AppTheme.ControlSize.notationDurationGlyphSize - )) - - XCTAssertFalse(glyphPath.path.isEmpty) - XCTAssertGreaterThan(glyphPath.bounds.width, 0) - XCTAssertGreaterThan(glyphPath.bounds.height, 0) - } - } - - func testWholeRestVisualCenterUsesStandardStaffPosition() { - let y = NotationMeasureLayout.wholeRestVisualCenterY( - staffTop: 10, - lineSpacing: 8 - ) - - XCTAssertEqual(y, 22, accuracy: 0.0001) - } - func testNotationViewportUsesCurrentTimeWhilePlayingAndMarkerTimeWhenStopped() { let tempoMap = fourFourTempoMap(duration: 60) diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift new file mode 100644 index 0000000..07c9946 --- /dev/null +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -0,0 +1,156 @@ +import XCTest +@testable import JammLab + +final class NotationPrimitivesTests: XCTestCase { + func testNotationSMuFLRestSymbolsMapDurationsToCodepoints() throws { + let whole = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 1))) + let half = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 2))) + let quarter = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 4))) + let eighth = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 8))) + + XCTAssertEqual(whole, .restWhole) + XCTAssertEqual(whole.codepoint, 0xE4E3) + XCTAssertEqual(half, .restHalf) + XCTAssertEqual(half.codepoint, 0xE4E4) + XCTAssertEqual(quarter, .restQuarter) + XCTAssertEqual(quarter.codepoint, 0xE4E5) + XCTAssertEqual(eighth, .rest8th) + XCTAssertEqual(eighth.codepoint, 0xE4E6) + XCTAssertEqual(quarter.glyph.unicodeScalars.first?.value, 0xE4E5) + } + + func testNotationSMuFLDurationControlSymbolsMapDurationsToLelandMetNoteCodepoints() throws { + let whole = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 1))) + let half = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 2))) + let quarter = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 4))) + let eighth = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 8))) + + XCTAssertEqual(whole, .whole) + XCTAssertEqual(whole.codepoint, 0xECA2) + XCTAssertEqual(half, .half) + XCTAssertEqual(half.codepoint, 0xECA3) + XCTAssertEqual(quarter, .quarter) + XCTAssertEqual(quarter.codepoint, 0xECA5) + XCTAssertEqual(eighth, .eighth) + XCTAssertEqual(eighth.codepoint, 0xECA7) + XCTAssertEqual(eighth.glyph.unicodeScalars.first?.value, 0xECA7) + } + + func testNotationRestItemFactoryUsesGreedyAllowedDurationDecomposition() { + let segments = NotationRestItemFactory.greedySegments(startOffset: 0, remaining: 3.5) + + XCTAssertEqual(segments.map(\.displayDuration.denominator), [2, 4, 8]) + XCTAssertEqual(segments.map(\.offsetInQuarterNotes), [0, 2, 3]) + XCTAssertEqual(segments.map(\.durationInQuarterNotes), [2, 1, 0.5]) + XCTAssertEqual(segments.map(\.isTail), [false, false, false]) + } + + func testNotationRestItemFactoryUsesTimelineToleranceForMinimumDuration() { + let tolerance = NotationMeasureTiming.timelineTolerance + + let withinTolerance = NotationRestItemFactory.greedySegments( + startOffset: 1, + remaining: 0.5 - tolerance / 2 + ) + let outsideTolerance = NotationRestItemFactory.greedySegments( + startOffset: 1, + remaining: 0.5 - tolerance * 2 + ) + + XCTAssertEqual(withinTolerance.map(\.displayDuration.denominator), [8]) + XCTAssertEqual(withinTolerance.map(\.offsetInQuarterNotes), [1]) + XCTAssertTrue(outsideTolerance.isEmpty) + } + + func testNotationRestItemFactoryLetsCallerControlIDsAndSynthesizedState() throws { + let synthesized = NotationRestItemFactory.restItems( + measureNumber: 2, + measureStartTime: 4, + startOffset: 1, + remaining: 1, + isSynthesized: true + ) { segment in + "rest-\(segment.offsetInQuarterNotes)-\(segment.displayDuration.denominator)" + } + let persisted = NotationRestItemFactory.restItems( + measureNumber: 2, + measureStartTime: 4, + startOffset: 1, + remaining: 1 + ) + + XCTAssertEqual(synthesized.map(\.id), ["rest-1.0-4"]) + XCTAssertEqual(synthesized.map(\.isSynthesized), [true]) + XCTAssertEqual(persisted.map(\.isSynthesized), [false]) + XCTAssertNotNil(UUID(uuidString: try XCTUnwrap(persisted.first?.id))) + } + + func testNotationViewportFactoryDefaultWholeRestKeepsSynthesizedFieldsAndID() throws { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 1.5, + attributes: MeasureAttributes( + keySignature: .cMajor, + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + ) + + let item = try XCTUnwrap(NotationViewportFactory.notationItems(for: measure, from: []).first) + + XCTAssertEqual(item.id, "default-rest-1-0.0-1.5") + XCTAssertEqual(item.kind, .rest) + XCTAssertEqual(item.measureNumber, 1) + XCTAssertEqual(item.measureStartTime, 0) + XCTAssertEqual(item.offsetInQuarterNotes, 0) + XCTAssertEqual(item.durationInQuarterNotes, 3) + XCTAssertEqual(item.displayDuration.denominator, 1) + XCTAssertTrue(item.isSynthesized) + } + + func testLelandFontResourceIsBundledAndRegistered() throws { + let url = try XCTUnwrap(Bundle.main.url(forResource: "Leland", withExtension: "otf")) + + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertEqual(NotationMusicFontRegistry.fontName, "Leland") + } + + func testLelandWholeRestGlyphPathHasBounds() throws { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: .restWhole, + fontSize: 32.5 + )) + + XCTAssertFalse(glyphPath.path.isEmpty) + XCTAssertGreaterThan(glyphPath.bounds.width, 0) + XCTAssertGreaterThan(glyphPath.bounds.height, 0) + } + + func testLelandDurationControlGlyphPathsHaveBounds() throws { + for symbol in [ + NotationDurationControlSymbol.whole, + .half, + .quarter, + .eighth + ] { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: AppTheme.ControlSize.notationDurationGlyphSize + )) + + XCTAssertFalse(glyphPath.path.isEmpty) + XCTAssertGreaterThan(glyphPath.bounds.width, 0) + XCTAssertGreaterThan(glyphPath.bounds.height, 0) + } + } + + func testWholeRestVisualCenterUsesStandardStaffPosition() { + let y = NotationMeasureLayout.wholeRestVisualCenterY( + staffTop: 10, + lineSpacing: 8 + ) + + XCTAssertEqual(y, 22, accuracy: 0.0001) + } +} From e66d735369f660eb872032143c3bdbe890aebd86 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 12:10:21 +0300 Subject: [PATCH 06/80] test: split notation musicxml coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 322 -------------------- JammLabTests/NotationMusicXMLTests.swift | 358 +++++++++++++++++++++++ 3 files changed, 362 insertions(+), 322 deletions(-) create mode 100644 JammLabTests/NotationMusicXMLTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 5690c7c..c928fa5 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */; }; 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */; }; + 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -290,6 +291,7 @@ 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridAndTempoMapTests.swift; sourceTree = ""; }; 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationPrimitivesTests.swift; sourceTree = ""; }; + 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -550,6 +552,7 @@ 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */, 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */, + 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -931,6 +934,7 @@ 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */, 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */, + 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 468bfec..aa2d5eb 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -451,328 +451,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(harmony.rawText, "Dm7") } - func testMusicXMLChordParserSupportsSemanticChords() throws { - let plain = try MusicXMLChordParser.parse("C", measureNumber: 1) - XCTAssertEqual(plain.root, MusicXMLPitchStep(step: "C", alter: 0)) - XCTAssertEqual(plain.kindValue, "major") - - let minor = try MusicXMLChordParser.parse("Am", measureNumber: 1) - XCTAssertEqual(minor.root, MusicXMLPitchStep(step: "A", alter: 0)) - XCTAssertEqual(minor.kindValue, "minor") - - let altered = try MusicXMLChordParser.parse("Bb13(#11)/D", measureNumber: 2) - XCTAssertEqual(altered.root, MusicXMLPitchStep(step: "B", alter: -1)) - XCTAssertEqual(altered.kindValue, "dominant-13th") - XCTAssertEqual(altered.bass, MusicXMLPitchStep(step: "D", alter: 0)) - XCTAssertEqual(altered.degrees, [ - MusicXMLChordDegree(value: 11, alter: 1, type: .alter) - ]) - - let halfDiminished = try MusicXMLChordParser.parse("C#m7b5", measureNumber: 3) - XCTAssertEqual(halfDiminished.root, MusicXMLPitchStep(step: "C", alter: 1)) - XCTAssertEqual(halfDiminished.kindValue, "half-diminished") - - let added = try MusicXMLChordParser.parse("Aadd9", measureNumber: 4) - XCTAssertEqual(added.kindValue, "major") - XCTAssertEqual(added.degrees, [ - MusicXMLChordDegree(value: 9, alter: 0, type: .add) - ]) - } - - func testMusicXMLChordParserRejectsUnsupportedChords() { - XCTAssertThrowsError(try MusicXMLChordParser.parse("", measureNumber: 1)) - XCTAssertThrowsError(try MusicXMLChordParser.parse("H7", measureNumber: 1)) - XCTAssertThrowsError(try MusicXMLChordParser.parse("G7alt", measureNumber: 1)) - XCTAssertThrowsError(try MusicXMLChordParser.parse("C(foo)", measureNumber: 1)) - XCTAssertThrowsError(try MusicXMLChordParser.parse("C/G/B", measureNumber: 1)) - } - - func testMusicXMLExportIncludesMeasuresAttributesHarmonyAndRegionDirections() throws { - let regionID = UUID(uuidString: "00000000-0000-0000-0000-000000000401")! - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap( - duration: 8, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] - ), - duration: 8, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "G major", - harmonySymbols: [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "Cmaj7"), - HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "Bb13(#11)/D") - ], - notes: [ - TimecodedNote(id: regionID, kind: .region, time: 2, duration: 2, title: "Verse") - ] - ) - let data = try NotationExportService(renderers: [ - MusicXMLNotationExportRenderer(appVersionProvider: { "9.8.7" }) - ]).export( - NotationExportRequest(displayName: "Song", score: state, tempoBPM: 120), - format: .musicXML - ) - let xml = try XCTUnwrap(String(data: data, encoding: .utf8)) - let document = try XMLDocument(data: data) - let root = try XCTUnwrap(document.rootElement()) - let rootElements = childElements(in: root) - let childNames = rootElements.compactMap(\.name) - let identification = try XCTUnwrap(rootElements.first { $0.name == "identification" }) - let credit = try XCTUnwrap(rootElements.first { $0.name == "credit" }) - let partList = try XCTUnwrap(rootElements.first { $0.name == "part-list" }) - let encoding = try firstXMLChild(named: "encoding", in: identification) - let software = try firstXMLChild(named: "software", in: encoding) - let scorePart = try firstXMLChild(named: "score-part", in: partList) - let partName = try firstXMLChild(named: "part-name", in: scorePart) - let creditWords = try firstXMLChild(named: "credit-words", in: credit) - let part = try XCTUnwrap(rootElements.first { $0.name == "part" }) - let measures = part.elements(forName: "measure") - let firstMeasure = try XCTUnwrap(measures.first { $0.attribute(forName: "number")?.stringValue == "1" }) - let changedTimeSignatureMeasure = try XCTUnwrap(measures.first { measure in - guard let attributes = measure.elements(forName: "attributes").first, - let time = attributes.elements(forName: "time").first else { - return false - } - return time.elements(forName: "beats").first?.stringValue == "3" - }) - let firstMeasureHarmonies = firstMeasure.elements(forName: "harmony") - let cMajorSeventhHarmony = try XCTUnwrap(firstMeasureHarmonies.first { harmony in - harmony.elements(forName: "kind").first?.attribute(forName: "text")?.stringValue == "Cmaj7" - }) - let alteredHarmony = try XCTUnwrap(firstMeasureHarmonies.first { harmony in - guard let root = harmony.elements(forName: "root").first, - let kind = harmony.elements(forName: "kind").first else { - return false - } - return root.elements(forName: "root-step").first?.stringValue == "B" - && kind.stringValue == "dominant-13th" - }) - let regionDirection = try XCTUnwrap(measures.lazy - .flatMap { $0.elements(forName: "direction") } - .first { direction in - guard let directionType = direction.elements(forName: "direction-type").first else { - return false - } - return directionType.elements(forName: "words").first?.stringValue == "Verse" - }) - let metronomeDirection = try XCTUnwrap(firstMeasure.elements(forName: "direction").first { direction in - guard let directionType = direction.elements(forName: "direction-type").first else { - return false - } - return !directionType.elements(forName: "metronome").isEmpty - }) - let firstMeasureRest = try XCTUnwrap(firstMeasure.elements(forName: "note").first { note in - note.elements(forName: "rest").first?.attribute(forName: "measure")?.stringValue == "yes" - }) - - XCTAssertTrue(xml.contains("")) - XCTAssertEqual(root.name, "score-partwise") - XCTAssertEqual(root.attribute(forName: "version")?.stringValue, "4.0") - XCTAssertEqual(software.stringValue, "JammLab 9.8.7") - try assertXMLChild(identification, precedes: credit, in: root) - XCTAssertLessThan( - try XCTUnwrap(childNames.firstIndex(of: "credit")), - try XCTUnwrap(childNames.firstIndex(of: "part-list")) - ) - XCTAssertLessThan( - try XCTUnwrap(childNames.firstIndex(of: "part-list")), - try XCTUnwrap(childNames.firstIndex(of: "part")) - ) - XCTAssertEqual(credit.attribute(forName: "page")?.stringValue, "1") - XCTAssertEqual(credit.elements(forName: "credit-type").first?.stringValue, "title") - XCTAssertEqual(creditWords.stringValue, "Song") - XCTAssertEqual(creditWords.attribute(forName: "default-x")?.stringValue, "600.17") - XCTAssertEqual(creditWords.attribute(forName: "default-y")?.stringValue, "1611.01") - XCTAssertEqual(creditWords.attribute(forName: "justify")?.stringValue, "center") - XCTAssertEqual(creditWords.attribute(forName: "valign")?.stringValue, "top") - XCTAssertEqual(creditWords.attribute(forName: "font-size")?.stringValue, "22") - XCTAssertEqual(partName.stringValue, "Song") - XCTAssertEqual(partName.attribute(forName: "print-object")?.stringValue, "no") - XCTAssertEqual(firstMeasure.attribute(forName: "number")?.stringValue, "1") - - let firstMeasureAttributes = try firstXMLChild(named: "attributes", in: firstMeasure) - let metronomeDirectionType = try firstXMLChild(named: "direction-type", in: metronomeDirection) - let metronome = try firstXMLChild(named: "metronome", in: metronomeDirectionType) - let firstMeasureKey = try firstXMLChild(named: "key", in: firstMeasureAttributes) - XCTAssertEqual(try firstXMLChild(named: "fifths", in: firstMeasureKey).stringValue, "1") - XCTAssertEqual(try firstXMLChild(named: "beat-unit", in: metronome).stringValue, "quarter") - XCTAssertEqual(try firstXMLChild(named: "per-minute", in: metronome).stringValue, "120") - try assertXMLChild(firstMeasureAttributes, precedes: metronomeDirection, in: firstMeasure) - - let changedTimeSignatureAttributes = try firstXMLChild(named: "attributes", in: changedTimeSignatureMeasure) - let changedTimeSignature = try firstXMLChild(named: "time", in: changedTimeSignatureAttributes) - XCTAssertEqual(try firstXMLChild(named: "beats", in: changedTimeSignature).stringValue, "3") - - let cMajorSeventhRoot = try firstXMLChild(named: "root", in: cMajorSeventhHarmony) - let cMajorSeventhKind = try firstXMLChild(named: "kind", in: cMajorSeventhHarmony) - XCTAssertEqual(try firstXMLChild(named: "root-step", in: cMajorSeventhRoot).stringValue, "C") - XCTAssertTrue(cMajorSeventhRoot.elements(forName: "root-alter").isEmpty) - XCTAssertEqual(cMajorSeventhKind.attribute(forName: "text")?.stringValue, "Cmaj7") - XCTAssertEqual(cMajorSeventhKind.stringValue, "major-seventh") - XCTAssertEqual(try firstXMLChild(named: "offset", in: cMajorSeventhHarmony).stringValue, "0") - - let alteredRoot = try firstXMLChild(named: "root", in: alteredHarmony) - let alteredDegree = try firstXMLChild(named: "degree", in: alteredHarmony) - let alteredBass = try firstXMLChild(named: "bass", in: alteredHarmony) - XCTAssertEqual(try firstXMLChild(named: "root-step", in: alteredRoot).stringValue, "B") - XCTAssertEqual(try firstXMLChild(named: "root-alter", in: alteredRoot).stringValue, "-1") - XCTAssertEqual(try firstXMLChild(named: "degree-value", in: alteredDegree).stringValue, "11") - XCTAssertEqual(try firstXMLChild(named: "bass-step", in: alteredBass).stringValue, "D") - XCTAssertTrue(alteredBass.elements(forName: "bass-alter").isEmpty) - XCTAssertEqual(try firstXMLChild(named: "offset", in: alteredHarmony).stringValue, "1440") - try assertXMLChild(alteredHarmony, precedes: firstMeasureRest, in: firstMeasure) - - let regionDirectionType = try firstXMLChild(named: "direction-type", in: regionDirection) - let regionWords = try firstXMLChild(named: "words", in: regionDirectionType) - XCTAssertEqual(regionWords.stringValue, "Verse") - XCTAssertEqual(regionWords.attribute(forName: "enclosure")?.stringValue, "rectangle") - XCTAssertEqual(regionWords.attribute(forName: "font-weight")?.stringValue, "bold") - let rest = try firstXMLChild(named: "rest", in: firstMeasureRest) - XCTAssertEqual(rest.attribute(forName: "measure")?.stringValue, "yes") - XCTAssertEqual(try firstXMLChild(named: "type", in: firstMeasureRest).stringValue, "whole") - } - - func testMusicXMLExportFailsForUnsupportedHarmony() throws { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 4), - duration: 4, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "C major", - harmonySymbols: [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "G7alt") - ] - ) - - XCTAssertThrowsError( - try NotationExportService().export( - NotationExportRequest(displayName: "Song", score: state), - format: .musicXML - ) - ) { error in - XCTAssertEqual(error as? NotationExportError, .unsupportedChord(rawText: "G7alt", measureNumber: 1)) - } - } - - func testMusicXMLExportIncludesSplitNotationRests() throws { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 4), - duration: 4, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "C major", - notationItems: splitQuarterQuarterHalfNotationItems(), - harmonySymbols: [ - HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "Fmaj7") - ] - ) - - let document = try exportedMusicXMLDocument(for: state) - let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) - let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) - let notes = firstMeasure.elements(forName: "note") - let harmonies = firstMeasure.elements(forName: "harmony") - let halfRest = try XCTUnwrap(notes.last) - let harmony = try XCTUnwrap(harmonies.first) - - XCTAssertEqual(notes.count, 3) - XCTAssertEqual(notes.map { $0.elements(forName: "duration").first?.stringValue }, ["480", "480", "960"]) - XCTAssertEqual(notes.map { $0.elements(forName: "type").first?.stringValue }, ["quarter", "quarter", "half"]) - XCTAssertEqual(try firstXMLChild(named: "offset", in: harmony).stringValue, "480") - try assertXMLChild(harmony, precedes: halfRest, in: firstMeasure) - XCTAssertTrue(notes.allSatisfy { note in - note.elements(forName: "rest").first?.attribute(forName: "measure") == nil - }) - } - - func testMusicXMLHarmonyAtNotationItemBoundaryUsesNextItemCursor() throws { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 4), - duration: 4, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "C major", - notationItems: splitQuarterQuarterHalfNotationItems(), - harmonySymbols: [ - HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "Dm7") - ] - ) - - let document = try exportedMusicXMLDocument(for: state) - let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) - let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) - let notes = firstMeasure.elements(forName: "note") - let harmony = try XCTUnwrap(firstMeasure.elements(forName: "harmony").first) - let thirdRest = try XCTUnwrap(notes.last) - - XCTAssertEqual(try firstXMLChild(named: "offset", in: harmony).stringValue, "0") - try assertXMLChild(harmony, precedes: thirdRest, in: firstMeasure) - } - - private func exportedMusicXMLDocument(for state: NotationScoreState) throws -> XMLDocument { - let data = try NotationExportService().export( - NotationExportRequest(displayName: "Song", score: state), - format: .musicXML - ) - return try XMLDocument(data: data) - } - - private func childElements(in element: XMLElement) -> [XMLElement] { - (element.children ?? []).compactMap { $0 as? XMLElement } - } - - private func assertXMLChild( - _ firstElement: XMLElement, - precedes secondElement: XMLElement, - in parentElement: XMLElement, - file: StaticString = #filePath, - line: UInt = #line - ) throws { - let elements = childElements(in: parentElement) - let firstIndex = try XCTUnwrap(elements.firstIndex { $0 === firstElement }, file: file, line: line) - let secondIndex = try XCTUnwrap(elements.firstIndex { $0 === secondElement }, file: file, line: line) - XCTAssertLessThan(firstIndex, secondIndex, file: file, line: line) - } - - private func firstXMLChild( - named name: String, - in element: XMLElement, - file: StaticString = #filePath, - line: UInt = #line - ) throws -> XMLElement { - try XCTUnwrap(element.elements(forName: name).first, file: file, line: line) - } - - private func splitQuarterQuarterHalfNotationItems() -> [NotationMeasureItem] { - [ - NotationMeasureItem( - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 0, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ), - NotationMeasureItem( - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 1, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ), - NotationMeasureItem( - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 2, - durationInQuarterNotes: 2, - displayDuration: NotationDuration(denominator: 2) - ) - ] - } - func testNotationViewportStateBuildsRegionLabelsFromRegionAndLegacyLoopStarts() throws { let introID = UUID(uuidString: "00000000-0000-0000-0000-000000000301")! let markerID = UUID(uuidString: "00000000-0000-0000-0000-000000000302")! diff --git a/JammLabTests/NotationMusicXMLTests.swift b/JammLabTests/NotationMusicXMLTests.swift new file mode 100644 index 0000000..fc90de5 --- /dev/null +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -0,0 +1,358 @@ +import Foundation +import XCTest +@testable import JammLab + +final class NotationMusicXMLTests: XCTestCase { + func testMusicXMLChordParserSupportsSemanticChords() throws { + let plain = try MusicXMLChordParser.parse("C", measureNumber: 1) + XCTAssertEqual(plain.root, MusicXMLPitchStep(step: "C", alter: 0)) + XCTAssertEqual(plain.kindValue, "major") + + let minor = try MusicXMLChordParser.parse("Am", measureNumber: 1) + XCTAssertEqual(minor.root, MusicXMLPitchStep(step: "A", alter: 0)) + XCTAssertEqual(minor.kindValue, "minor") + + let altered = try MusicXMLChordParser.parse("Bb13(#11)/D", measureNumber: 2) + XCTAssertEqual(altered.root, MusicXMLPitchStep(step: "B", alter: -1)) + XCTAssertEqual(altered.kindValue, "dominant-13th") + XCTAssertEqual(altered.bass, MusicXMLPitchStep(step: "D", alter: 0)) + XCTAssertEqual(altered.degrees, [ + MusicXMLChordDegree(value: 11, alter: 1, type: .alter) + ]) + + let halfDiminished = try MusicXMLChordParser.parse("C#m7b5", measureNumber: 3) + XCTAssertEqual(halfDiminished.root, MusicXMLPitchStep(step: "C", alter: 1)) + XCTAssertEqual(halfDiminished.kindValue, "half-diminished") + + let added = try MusicXMLChordParser.parse("Aadd9", measureNumber: 4) + XCTAssertEqual(added.kindValue, "major") + XCTAssertEqual(added.degrees, [ + MusicXMLChordDegree(value: 9, alter: 0, type: .add) + ]) + } + + func testMusicXMLChordParserRejectsUnsupportedChords() { + XCTAssertThrowsError(try MusicXMLChordParser.parse("", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("H7", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("G7alt", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("C(foo)", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("C/G/B", measureNumber: 1)) + } + + func testMusicXMLExportIncludesMeasuresAttributesHarmonyAndRegionDirections() throws { + let regionID = UUID(uuidString: "00000000-0000-0000-0000-000000000401")! + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap( + duration: 8, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] + ), + duration: 8, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "G major", + harmonySymbols: [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "Cmaj7"), + HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "Bb13(#11)/D") + ], + notes: [ + TimecodedNote(id: regionID, kind: .region, time: 2, duration: 2, title: "Verse") + ] + ) + let data = try NotationExportService(renderers: [ + MusicXMLNotationExportRenderer(appVersionProvider: { "9.8.7" }) + ]).export( + NotationExportRequest(displayName: "Song", score: state, tempoBPM: 120), + format: .musicXML + ) + let xml = try XCTUnwrap(String(data: data, encoding: .utf8)) + let document = try XMLDocument(data: data) + let root = try XCTUnwrap(document.rootElement()) + let rootElements = childElements(in: root) + let childNames = rootElements.compactMap(\.name) + let identification = try XCTUnwrap(rootElements.first { $0.name == "identification" }) + let credit = try XCTUnwrap(rootElements.first { $0.name == "credit" }) + let partList = try XCTUnwrap(rootElements.first { $0.name == "part-list" }) + let encoding = try firstXMLChild(named: "encoding", in: identification) + let software = try firstXMLChild(named: "software", in: encoding) + let scorePart = try firstXMLChild(named: "score-part", in: partList) + let partName = try firstXMLChild(named: "part-name", in: scorePart) + let creditWords = try firstXMLChild(named: "credit-words", in: credit) + let part = try XCTUnwrap(rootElements.first { $0.name == "part" }) + let measures = part.elements(forName: "measure") + let firstMeasure = try XCTUnwrap(measures.first { $0.attribute(forName: "number")?.stringValue == "1" }) + let changedTimeSignatureMeasure = try XCTUnwrap(measures.first { measure in + guard let attributes = measure.elements(forName: "attributes").first, + let time = attributes.elements(forName: "time").first else { + return false + } + return time.elements(forName: "beats").first?.stringValue == "3" + }) + let firstMeasureHarmonies = firstMeasure.elements(forName: "harmony") + let cMajorSeventhHarmony = try XCTUnwrap(firstMeasureHarmonies.first { harmony in + harmony.elements(forName: "kind").first?.attribute(forName: "text")?.stringValue == "Cmaj7" + }) + let alteredHarmony = try XCTUnwrap(firstMeasureHarmonies.first { harmony in + guard let root = harmony.elements(forName: "root").first, + let kind = harmony.elements(forName: "kind").first else { + return false + } + return root.elements(forName: "root-step").first?.stringValue == "B" + && kind.stringValue == "dominant-13th" + }) + let regionDirection = try XCTUnwrap(measures.lazy + .flatMap { $0.elements(forName: "direction") } + .first { direction in + guard let directionType = direction.elements(forName: "direction-type").first else { + return false + } + return directionType.elements(forName: "words").first?.stringValue == "Verse" + }) + let metronomeDirection = try XCTUnwrap(firstMeasure.elements(forName: "direction").first { direction in + guard let directionType = direction.elements(forName: "direction-type").first else { + return false + } + return !directionType.elements(forName: "metronome").isEmpty + }) + let firstMeasureRest = try XCTUnwrap(firstMeasure.elements(forName: "note").first { note in + note.elements(forName: "rest").first?.attribute(forName: "measure")?.stringValue == "yes" + }) + + XCTAssertTrue(xml.contains("")) + XCTAssertEqual(root.name, "score-partwise") + XCTAssertEqual(root.attribute(forName: "version")?.stringValue, "4.0") + XCTAssertEqual(software.stringValue, "JammLab 9.8.7") + try assertXMLChild(identification, precedes: credit, in: root) + XCTAssertLessThan( + try XCTUnwrap(childNames.firstIndex(of: "credit")), + try XCTUnwrap(childNames.firstIndex(of: "part-list")) + ) + XCTAssertLessThan( + try XCTUnwrap(childNames.firstIndex(of: "part-list")), + try XCTUnwrap(childNames.firstIndex(of: "part")) + ) + XCTAssertEqual(credit.attribute(forName: "page")?.stringValue, "1") + XCTAssertEqual(credit.elements(forName: "credit-type").first?.stringValue, "title") + XCTAssertEqual(creditWords.stringValue, "Song") + XCTAssertEqual(creditWords.attribute(forName: "default-x")?.stringValue, "600.17") + XCTAssertEqual(creditWords.attribute(forName: "default-y")?.stringValue, "1611.01") + XCTAssertEqual(creditWords.attribute(forName: "justify")?.stringValue, "center") + XCTAssertEqual(creditWords.attribute(forName: "valign")?.stringValue, "top") + XCTAssertEqual(creditWords.attribute(forName: "font-size")?.stringValue, "22") + XCTAssertEqual(partName.stringValue, "Song") + XCTAssertEqual(partName.attribute(forName: "print-object")?.stringValue, "no") + XCTAssertEqual(firstMeasure.attribute(forName: "number")?.stringValue, "1") + + let firstMeasureAttributes = try firstXMLChild(named: "attributes", in: firstMeasure) + let metronomeDirectionType = try firstXMLChild(named: "direction-type", in: metronomeDirection) + let metronome = try firstXMLChild(named: "metronome", in: metronomeDirectionType) + let firstMeasureKey = try firstXMLChild(named: "key", in: firstMeasureAttributes) + XCTAssertEqual(try firstXMLChild(named: "fifths", in: firstMeasureKey).stringValue, "1") + XCTAssertEqual(try firstXMLChild(named: "beat-unit", in: metronome).stringValue, "quarter") + XCTAssertEqual(try firstXMLChild(named: "per-minute", in: metronome).stringValue, "120") + try assertXMLChild(firstMeasureAttributes, precedes: metronomeDirection, in: firstMeasure) + + let changedTimeSignatureAttributes = try firstXMLChild(named: "attributes", in: changedTimeSignatureMeasure) + let changedTimeSignature = try firstXMLChild(named: "time", in: changedTimeSignatureAttributes) + XCTAssertEqual(try firstXMLChild(named: "beats", in: changedTimeSignature).stringValue, "3") + + let cMajorSeventhRoot = try firstXMLChild(named: "root", in: cMajorSeventhHarmony) + let cMajorSeventhKind = try firstXMLChild(named: "kind", in: cMajorSeventhHarmony) + XCTAssertEqual(try firstXMLChild(named: "root-step", in: cMajorSeventhRoot).stringValue, "C") + XCTAssertTrue(cMajorSeventhRoot.elements(forName: "root-alter").isEmpty) + XCTAssertEqual(cMajorSeventhKind.attribute(forName: "text")?.stringValue, "Cmaj7") + XCTAssertEqual(cMajorSeventhKind.stringValue, "major-seventh") + XCTAssertEqual(try firstXMLChild(named: "offset", in: cMajorSeventhHarmony).stringValue, "0") + + let alteredRoot = try firstXMLChild(named: "root", in: alteredHarmony) + let alteredDegree = try firstXMLChild(named: "degree", in: alteredHarmony) + let alteredBass = try firstXMLChild(named: "bass", in: alteredHarmony) + XCTAssertEqual(try firstXMLChild(named: "root-step", in: alteredRoot).stringValue, "B") + XCTAssertEqual(try firstXMLChild(named: "root-alter", in: alteredRoot).stringValue, "-1") + XCTAssertEqual(try firstXMLChild(named: "degree-value", in: alteredDegree).stringValue, "11") + XCTAssertEqual(try firstXMLChild(named: "bass-step", in: alteredBass).stringValue, "D") + XCTAssertTrue(alteredBass.elements(forName: "bass-alter").isEmpty) + XCTAssertEqual(try firstXMLChild(named: "offset", in: alteredHarmony).stringValue, "1440") + try assertXMLChild(alteredHarmony, precedes: firstMeasureRest, in: firstMeasure) + + let regionDirectionType = try firstXMLChild(named: "direction-type", in: regionDirection) + let regionWords = try firstXMLChild(named: "words", in: regionDirectionType) + XCTAssertEqual(regionWords.stringValue, "Verse") + XCTAssertEqual(regionWords.attribute(forName: "enclosure")?.stringValue, "rectangle") + XCTAssertEqual(regionWords.attribute(forName: "font-weight")?.stringValue, "bold") + let rest = try firstXMLChild(named: "rest", in: firstMeasureRest) + XCTAssertEqual(rest.attribute(forName: "measure")?.stringValue, "yes") + XCTAssertEqual(try firstXMLChild(named: "type", in: firstMeasureRest).stringValue, "whole") + } + + func testMusicXMLExportFailsForUnsupportedHarmony() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + harmonySymbols: [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "G7alt") + ] + ) + + XCTAssertThrowsError( + try NotationExportService().export( + NotationExportRequest(displayName: "Song", score: state), + format: .musicXML + ) + ) { error in + XCTAssertEqual(error as? NotationExportError, .unsupportedChord(rawText: "G7alt", measureNumber: 1)) + } + } + + func testMusicXMLExportIncludesSplitNotationRests() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + notationItems: splitQuarterQuarterHalfNotationItems(), + harmonySymbols: [ + HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "Fmaj7") + ] + ) + + let document = try exportedMusicXMLDocument(for: state) + let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) + let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) + let notes = firstMeasure.elements(forName: "note") + let harmonies = firstMeasure.elements(forName: "harmony") + let halfRest = try XCTUnwrap(notes.last) + let harmony = try XCTUnwrap(harmonies.first) + + XCTAssertEqual(notes.count, 3) + XCTAssertEqual(notes.map { $0.elements(forName: "duration").first?.stringValue }, ["480", "480", "960"]) + XCTAssertEqual(notes.map { $0.elements(forName: "type").first?.stringValue }, ["quarter", "quarter", "half"]) + XCTAssertEqual(try firstXMLChild(named: "offset", in: harmony).stringValue, "480") + try assertXMLChild(harmony, precedes: halfRest, in: firstMeasure) + XCTAssertTrue(notes.allSatisfy { note in + note.elements(forName: "rest").first?.attribute(forName: "measure") == nil + }) + } + + func testMusicXMLHarmonyAtNotationItemBoundaryUsesNextItemCursor() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + notationItems: splitQuarterQuarterHalfNotationItems(), + harmonySymbols: [ + HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "Dm7") + ] + ) + + let document = try exportedMusicXMLDocument(for: state) + let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) + let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) + let notes = firstMeasure.elements(forName: "note") + let harmony = try XCTUnwrap(firstMeasure.elements(forName: "harmony").first) + let thirdRest = try XCTUnwrap(notes.last) + + XCTAssertEqual(try firstXMLChild(named: "offset", in: harmony).stringValue, "0") + try assertXMLChild(harmony, precedes: thirdRest, in: firstMeasure) + } + + private func exportedMusicXMLDocument(for state: NotationScoreState) throws -> XMLDocument { + let data = try NotationExportService().export( + NotationExportRequest(displayName: "Song", score: state), + format: .musicXML + ) + return try XMLDocument(data: data) + } + + private func childElements(in element: XMLElement) -> [XMLElement] { + (element.children ?? []).compactMap { $0 as? XMLElement } + } + + private func assertXMLChild( + _ firstElement: XMLElement, + precedes secondElement: XMLElement, + in parentElement: XMLElement, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let elements = childElements(in: parentElement) + let firstIndex = try XCTUnwrap(elements.firstIndex { $0 === firstElement }, file: file, line: line) + let secondIndex = try XCTUnwrap(elements.firstIndex { $0 === secondElement }, file: file, line: line) + XCTAssertLessThan(firstIndex, secondIndex, file: file, line: line) + } + + private func firstXMLChild( + named name: String, + in element: XMLElement, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> XMLElement { + try XCTUnwrap(element.elements(forName: name).first, file: file, line: line) + } + + private func splitQuarterQuarterHalfNotationItems() -> [NotationMeasureItem] { + [ + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + } + + private func fourFourTempoMap( + duration: TimeInterval, + firstBeatTime: TimeInterval = 0, + markers: [TimecodedNote] = [] + ) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: firstBeatTime, + timeSignature: .fourFour + ), + markers: markers, + duration: duration + ) + } + + private func timeSignatureMarker( + time: TimeInterval, + beatsPerBar: Int, + setsNewFirstBeat: Bool = false + ) -> TimecodedNote { + TimecodedNote( + time: time, + title: "\(beatsPerBar)/4", + metadata: TempoTimeSignatureMarkerPayload( + beatsPerBar: beatsPerBar, + setsNewFirstBeat: setsNewFirstBeat + ).metadata + ) + } +} From c42f405fc688e1e03b4dc8f733c3bc666133c4f4 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 12:57:55 +0300 Subject: [PATCH 07/80] test: split notation attribute and key signature coverage --- JammLab.xcodeproj/project.pbxproj | 8 + JammLabTests/AudioTimingLogicTests.swift | 148 ------------------ .../NotationAttributeDisplayTests.swift | 71 +++++++++ JammLabTests/NotationKeySignatureTests.swift | 85 ++++++++++ 4 files changed, 164 insertions(+), 148 deletions(-) create mode 100644 JammLabTests/NotationAttributeDisplayTests.swift create mode 100644 JammLabTests/NotationKeySignatureTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index c928fa5..4e2119a 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -116,6 +116,8 @@ 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */; }; 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */; }; 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */; }; + 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */; }; + 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -292,6 +294,8 @@ 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridAndTempoMapTests.swift; sourceTree = ""; }; 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationPrimitivesTests.swift; sourceTree = ""; }; 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = ""; }; + 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationKeySignatureTests.swift; sourceTree = ""; }; + 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeDisplayTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -553,6 +557,8 @@ 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */, 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */, 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */, + 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */, + 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -935,6 +941,8 @@ 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */, 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */, 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */, + 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */, + 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index aa2d5eb..324c2bd 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -588,154 +588,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(previousMeasure.time, 1, accuracy: 0.0001) } - func testNotationKeySignatureParsingSupportsCommonDetectedKeysAndFallback() { - let fSharpMinor = KeySignature.normalized(from: "F# minor") - let bFlatMajor = KeySignature.normalized(from: "Bb major") - let aMinor = KeySignature.normalized(from: "Am") - let fallback = KeySignature.normalized(from: "Pending") - - XCTAssertEqual(fSharpMinor.fifths, 3) - XCTAssertEqual(fSharpMinor.mode, .minor) - XCTAssertEqual(bFlatMajor.fifths, -2) - XCTAssertEqual(bFlatMajor.mode, .major) - XCTAssertEqual(aMinor.fifths, 0) - XCTAssertEqual(aMinor.mode, .minor) - XCTAssertEqual(fallback, .cMajor) - } - - func testNotationKeySignatureAccidentalsUseTrebleStaffPositions() { - let cMajor = KeySignature.normalized(from: "C major") - let fMinor = KeySignature.normalized(from: "F minor") - let fSharpMinor = KeySignature.normalized(from: "F# minor") - - XCTAssertTrue(cMajor.notationAccidentalGlyphs(for: .treble).isEmpty) - XCTAssertEqual( - fMinor.notationAccidentalGlyphs(for: .treble), - [ - KeySignatureAccidental(symbol: "♭", staffPositionFromTopLine: 4), - KeySignatureAccidental(symbol: "♭", staffPositionFromTopLine: 1), - KeySignatureAccidental(symbol: "♭", staffPositionFromTopLine: 5), - KeySignatureAccidental(symbol: "♭", staffPositionFromTopLine: 2) - ] - ) - XCTAssertEqual( - fSharpMinor.notationAccidentalGlyphs(for: .treble), - [ - KeySignatureAccidental(symbol: "♯", staffPositionFromTopLine: 0), - KeySignatureAccidental(symbol: "♯", staffPositionFromTopLine: 3), - KeySignatureAccidental(symbol: "♯", staffPositionFromTopLine: -1) - ] - ) - } - - func testNotationKeySignatureAccidentalsUseFullTrebleOrder() { - let cSharpMajor = KeySignature.normalized(from: "C# major") - let cFlatMajor = KeySignature.normalized(from: "Cb major") - let cSharpMajorGlyphs = cSharpMajor.notationAccidentalGlyphs(for: .treble) - let cFlatMajorGlyphs = cFlatMajor.notationAccidentalGlyphs(for: .treble) - - XCTAssertEqual( - cSharpMajorGlyphs.map(\.staffPositionFromTopLine), - [0, 3, -1, 2, 5, 1, 4] - ) - XCTAssertEqual( - cSharpMajorGlyphs.map(\.symbol), - Array(repeating: "♯", count: 7) - ) - XCTAssertEqual( - cFlatMajorGlyphs.map(\.staffPositionFromTopLine), - [4, 1, 5, 2, 6, 3, 7] - ) - XCTAssertEqual( - cFlatMajorGlyphs.map(\.symbol), - Array(repeating: "♭", count: 7) - ) - } - - func testProjectKeySelectionMapsDetectedKeysToToolbarValuesAndCanonicalNames() throws { - let fSharpMinor = try XCTUnwrap(ProjectKeySelection.detected(from: "F# minor", confidence: 0.82)) - let bFlatMajor = try XCTUnwrap(ProjectKeySelection.detected(from: "Bb major", confidence: 0.76)) - let unknown = ProjectKeySelection.detected(from: "Pending", confidence: 0) - - XCTAssertEqual(fSharpMinor.tonic, .fSharpGb) - XCTAssertEqual(fSharpMinor.mode, .minor) - XCTAssertEqual(fSharpMinor.canonicalKeyName, "F# minor") - XCTAssertEqual(fSharpMinor.source, .auto) - XCTAssertEqual(try XCTUnwrap(fSharpMinor.confidence), 0.82, accuracy: 0.0001) - - XCTAssertEqual(bFlatMajor.tonic, .aSharpBb) - XCTAssertEqual(bFlatMajor.mode, .major) - XCTAssertEqual(bFlatMajor.canonicalKeyName, "Bb major") - XCTAssertNil(unknown) - } - - func testNotationAttributeDisplayShowsFullBlockForFirstVisibleMeasure() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - - let display = NotationAttributeDisplay.display( - for: attributes, - previousAttributes: nil - ) - - XCTAssertTrue(display.showsClef) - XCTAssertTrue(display.showsKeySignature) - XCTAssertTrue(display.showsTimeSignature) - XCTAssertFalse(display.isEmpty) - } - - func testNotationAttributeDisplayShowsOnlyChangedTimeSignature() { - let previous = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - let current = MeasureAttributes( - keySignature: previous.keySignature, - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: previous.clef - ) - - let display = NotationAttributeDisplay.display( - for: current, - previousAttributes: previous - ) - - XCTAssertFalse(display.showsClef) - XCTAssertFalse(display.showsKeySignature) - XCTAssertTrue(display.showsTimeSignature) - } - - func testNotationAttributeDisplayShowsOnlyChangedKeyComponentAndNoOpForUnchangedAttributes() { - let previous = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - let keyChange = MeasureAttributes( - keySignature: KeySignature.normalized(from: "D major"), - timeSignature: previous.timeSignature, - clef: previous.clef - ) - - let keyDisplay = NotationAttributeDisplay.display( - for: keyChange, - previousAttributes: previous - ) - let unchangedDisplay = NotationAttributeDisplay.display( - for: previous, - previousAttributes: previous - ) - - XCTAssertFalse(keyDisplay.showsClef) - XCTAssertTrue(keyDisplay.showsKeySignature) - XCTAssertFalse(keyDisplay.showsTimeSignature) - XCTAssertTrue(unchangedDisplay.isEmpty) - } - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { let cMajor = MeasureAttributes( keySignature: KeySignature.normalized(from: "C major"), diff --git a/JammLabTests/NotationAttributeDisplayTests.swift b/JammLabTests/NotationAttributeDisplayTests.swift new file mode 100644 index 0000000..703b646 --- /dev/null +++ b/JammLabTests/NotationAttributeDisplayTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import JammLab + +final class NotationAttributeDisplayTests: XCTestCase { + func testNotationAttributeDisplayShowsFullBlockForFirstVisibleMeasure() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + + let display = NotationAttributeDisplay.display( + for: attributes, + previousAttributes: nil + ) + + XCTAssertTrue(display.showsClef) + XCTAssertTrue(display.showsKeySignature) + XCTAssertTrue(display.showsTimeSignature) + XCTAssertFalse(display.isEmpty) + } + + func testNotationAttributeDisplayShowsOnlyChangedTimeSignature() { + let previous = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + let current = MeasureAttributes( + keySignature: previous.keySignature, + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: previous.clef + ) + + let display = NotationAttributeDisplay.display( + for: current, + previousAttributes: previous + ) + + XCTAssertFalse(display.showsClef) + XCTAssertFalse(display.showsKeySignature) + XCTAssertTrue(display.showsTimeSignature) + } + + func testNotationAttributeDisplayShowsOnlyChangedKeyComponentAndNoOpForUnchangedAttributes() { + let previous = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + let keyChange = MeasureAttributes( + keySignature: KeySignature.normalized(from: "D major"), + timeSignature: previous.timeSignature, + clef: previous.clef + ) + + let keyDisplay = NotationAttributeDisplay.display( + for: keyChange, + previousAttributes: previous + ) + let unchangedDisplay = NotationAttributeDisplay.display( + for: previous, + previousAttributes: previous + ) + + XCTAssertFalse(keyDisplay.showsClef) + XCTAssertTrue(keyDisplay.showsKeySignature) + XCTAssertFalse(keyDisplay.showsTimeSignature) + XCTAssertTrue(unchangedDisplay.isEmpty) + } +} diff --git a/JammLabTests/NotationKeySignatureTests.swift b/JammLabTests/NotationKeySignatureTests.swift new file mode 100644 index 0000000..cb1e2b5 --- /dev/null +++ b/JammLabTests/NotationKeySignatureTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import JammLab + +final class NotationKeySignatureTests: XCTestCase { + func testNotationKeySignatureParsingSupportsCommonDetectedKeysAndFallback() { + let fSharpMinor = KeySignature.normalized(from: "F# minor") + let bFlatMajor = KeySignature.normalized(from: "Bb major") + let aMinor = KeySignature.normalized(from: "Am") + let fallback = KeySignature.normalized(from: "Pending") + + XCTAssertEqual(fSharpMinor.fifths, 3) + XCTAssertEqual(fSharpMinor.mode, .minor) + XCTAssertEqual(bFlatMajor.fifths, -2) + XCTAssertEqual(bFlatMajor.mode, .major) + XCTAssertEqual(aMinor.fifths, 0) + XCTAssertEqual(aMinor.mode, .minor) + XCTAssertEqual(fallback, .cMajor) + } + + func testNotationKeySignatureAccidentalsUseTrebleStaffPositions() { + let cMajor = KeySignature.normalized(from: "C major") + let fMinor = KeySignature.normalized(from: "F minor") + let fSharpMinor = KeySignature.normalized(from: "F# minor") + + XCTAssertTrue(cMajor.notationAccidentalGlyphs(for: .treble).isEmpty) + XCTAssertEqual( + fMinor.notationAccidentalGlyphs(for: .treble), + [ + KeySignatureAccidental(symbol: "♭", staffPositionFromTopLine: 4), + KeySignatureAccidental(symbol: "♭", staffPositionFromTopLine: 1), + KeySignatureAccidental(symbol: "♭", staffPositionFromTopLine: 5), + KeySignatureAccidental(symbol: "♭", staffPositionFromTopLine: 2) + ] + ) + XCTAssertEqual( + fSharpMinor.notationAccidentalGlyphs(for: .treble), + [ + KeySignatureAccidental(symbol: "♯", staffPositionFromTopLine: 0), + KeySignatureAccidental(symbol: "♯", staffPositionFromTopLine: 3), + KeySignatureAccidental(symbol: "♯", staffPositionFromTopLine: -1) + ] + ) + } + + func testNotationKeySignatureAccidentalsUseFullTrebleOrder() { + let cSharpMajor = KeySignature.normalized(from: "C# major") + let cFlatMajor = KeySignature.normalized(from: "Cb major") + let cSharpMajorGlyphs = cSharpMajor.notationAccidentalGlyphs(for: .treble) + let cFlatMajorGlyphs = cFlatMajor.notationAccidentalGlyphs(for: .treble) + + XCTAssertEqual( + cSharpMajorGlyphs.map(\.staffPositionFromTopLine), + [0, 3, -1, 2, 5, 1, 4] + ) + XCTAssertEqual( + cSharpMajorGlyphs.map(\.symbol), + Array(repeating: "♯", count: 7) + ) + XCTAssertEqual( + cFlatMajorGlyphs.map(\.staffPositionFromTopLine), + [4, 1, 5, 2, 6, 3, 7] + ) + XCTAssertEqual( + cFlatMajorGlyphs.map(\.symbol), + Array(repeating: "♭", count: 7) + ) + } + + func testProjectKeySelectionMapsDetectedKeysToToolbarValuesAndCanonicalNames() throws { + let fSharpMinor = try XCTUnwrap(ProjectKeySelection.detected(from: "F# minor", confidence: 0.82)) + let bFlatMajor = try XCTUnwrap(ProjectKeySelection.detected(from: "Bb major", confidence: 0.76)) + let unknown = ProjectKeySelection.detected(from: "Pending", confidence: 0) + + XCTAssertEqual(fSharpMinor.tonic, .fSharpGb) + XCTAssertEqual(fSharpMinor.mode, .minor) + XCTAssertEqual(fSharpMinor.canonicalKeyName, "F# minor") + XCTAssertEqual(fSharpMinor.source, .auto) + XCTAssertEqual(try XCTUnwrap(fSharpMinor.confidence), 0.82, accuracy: 0.0001) + + XCTAssertEqual(bFlatMajor.tonic, .aSharpBb) + XCTAssertEqual(bFlatMajor.mode, .major) + XCTAssertEqual(bFlatMajor.canonicalKeyName, "Bb major") + XCTAssertNil(unknown) + } +} From 6d9a2ac80922b6f47cabc35e5541c15d6c6a8653 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 13:06:11 +0300 Subject: [PATCH 08/80] test: split notation region label coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 64 ------------- JammLabTests/NotationRegionLabelTests.swift | 99 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 64 deletions(-) create mode 100644 JammLabTests/NotationRegionLabelTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 4e2119a..8a35a9b 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */; }; 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */; }; 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */; }; + 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -296,6 +297,7 @@ 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = ""; }; 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationKeySignatureTests.swift; sourceTree = ""; }; 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeDisplayTests.swift; sourceTree = ""; }; + 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRegionLabelTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -559,6 +561,7 @@ 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */, 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */, 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */, + 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -943,6 +946,7 @@ 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */, 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */, 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */, + 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 324c2bd..5ad7606 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -451,70 +451,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(harmony.rawText, "Dm7") } - func testNotationViewportStateBuildsRegionLabelsFromRegionAndLegacyLoopStarts() throws { - let introID = UUID(uuidString: "00000000-0000-0000-0000-000000000301")! - let markerID = UUID(uuidString: "00000000-0000-0000-0000-000000000302")! - let verseID = UUID(uuidString: "00000000-0000-0000-0000-000000000303")! - let state = notationViewportState( - tempoMap: fourFourTempoMap(duration: 8), - currentTime: 0, - visibleMeasureCount: 4, - notes: [ - TimecodedNote(id: introID, kind: .region, time: 0.75, duration: 4, title: "Intro"), - TimecodedNote(id: markerID, kind: .marker, time: 1, title: "Marker"), - TimecodedNote(id: verseID, kind: .loop, time: 2.5, duration: 1, title: "Verse 1") - ] - ) - - let introLabel = try XCTUnwrap(state.visibleMeasures[0].regionLabels.first) - let verseLabel = try XCTUnwrap(state.visibleMeasures[1].regionLabels.first) - - XCTAssertEqual(state.visibleMeasures.flatMap(\.regionLabels).map(\.id), [introID, verseID]) - XCTAssertEqual(introLabel.measureNumber, 1) - XCTAssertEqual(introLabel.offsetInQuarterNotes, 1.5, accuracy: 0.0001) - XCTAssertEqual(introLabel.title, "Intro") - XCTAssertEqual(verseLabel.measureNumber, 2) - XCTAssertEqual(verseLabel.offsetInQuarterNotes, 1, accuracy: 0.0001) - XCTAssertEqual(verseLabel.title, "Verse 1") - } - - func testNotationRegionLabelsUseRegionStartOnlyAndExcludeMeasureEnd() throws { - let spanningID = UUID(uuidString: "00000000-0000-0000-0000-000000000304")! - let boundaryID = UUID(uuidString: "00000000-0000-0000-0000-000000000305")! - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 6), - duration: 6, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "C major", - notes: [ - TimecodedNote(id: spanningID, kind: .region, time: 1.75, duration: 3, title: "Bridge"), - TimecodedNote(id: boundaryID, kind: .region, time: 2, duration: 0.5, title: "Verse") - ] - ) - - XCTAssertEqual(state.measures[0].regionLabels.map(\.id), [spanningID]) - XCTAssertEqual(state.measures[1].regionLabels.map(\.id), [boundaryID]) - XCTAssertTrue(state.measures.dropFirst(2).allSatisfy { $0.regionLabels.isEmpty }) - } - - func testNotationRegionLabelsUseFallbackTitleForEmptyRegionNames() throws { - let regionID = UUID(uuidString: "00000000-0000-0000-0000-000000000306")! - let state = notationViewportState( - tempoMap: fourFourTempoMap(duration: 4), - currentTime: 0, - notes: [ - TimecodedNote(id: regionID, kind: .region, time: 0, duration: 1, title: " ") - ] - ) - - let label = try XCTUnwrap(state.visibleMeasures[0].regionLabels.first) - - XCTAssertEqual(label.id, regionID) - XCTAssertEqual(label.title, "Region") - } - func testNotationScoreStateIsPendingForZeroDuration() { let state = NotationViewportFactory().scoreState( tempoMap: fourFourTempoMap(duration: 0), diff --git a/JammLabTests/NotationRegionLabelTests.swift b/JammLabTests/NotationRegionLabelTests.swift new file mode 100644 index 0000000..c145996 --- /dev/null +++ b/JammLabTests/NotationRegionLabelTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import JammLab + +final class NotationRegionLabelTests: XCTestCase { + func testNotationViewportStateBuildsRegionLabelsFromRegionAndLegacyLoopStarts() throws { + let introID = UUID(uuidString: "00000000-0000-0000-0000-000000000301")! + let markerID = UUID(uuidString: "00000000-0000-0000-0000-000000000302")! + let verseID = UUID(uuidString: "00000000-0000-0000-0000-000000000303")! + let state = notationViewportState( + tempoMap: fourFourTempoMap(duration: 8), + currentTime: 0, + visibleMeasureCount: 4, + notes: [ + TimecodedNote(id: introID, kind: .region, time: 0.75, duration: 4, title: "Intro"), + TimecodedNote(id: markerID, kind: .marker, time: 1, title: "Marker"), + TimecodedNote(id: verseID, kind: .loop, time: 2.5, duration: 1, title: "Verse 1") + ] + ) + + let introLabel = try XCTUnwrap(state.visibleMeasures[0].regionLabels.first) + let verseLabel = try XCTUnwrap(state.visibleMeasures[1].regionLabels.first) + + XCTAssertEqual(state.visibleMeasures.flatMap(\.regionLabels).map(\.id), [introID, verseID]) + XCTAssertEqual(introLabel.measureNumber, 1) + XCTAssertEqual(introLabel.offsetInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertEqual(introLabel.title, "Intro") + XCTAssertEqual(verseLabel.measureNumber, 2) + XCTAssertEqual(verseLabel.offsetInQuarterNotes, 1, accuracy: 0.0001) + XCTAssertEqual(verseLabel.title, "Verse 1") + } + + func testNotationRegionLabelsUseRegionStartOnlyAndExcludeMeasureEnd() throws { + let spanningID = UUID(uuidString: "00000000-0000-0000-0000-000000000304")! + let boundaryID = UUID(uuidString: "00000000-0000-0000-0000-000000000305")! + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 6), + duration: 6, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + notes: [ + TimecodedNote(id: spanningID, kind: .region, time: 1.75, duration: 3, title: "Bridge"), + TimecodedNote(id: boundaryID, kind: .region, time: 2, duration: 0.5, title: "Verse") + ] + ) + + XCTAssertEqual(state.measures[0].regionLabels.map(\.id), [spanningID]) + XCTAssertEqual(state.measures[1].regionLabels.map(\.id), [boundaryID]) + XCTAssertTrue(state.measures.dropFirst(2).allSatisfy { $0.regionLabels.isEmpty }) + } + + func testNotationRegionLabelsUseFallbackTitleForEmptyRegionNames() throws { + let regionID = UUID(uuidString: "00000000-0000-0000-0000-000000000306")! + let state = notationViewportState( + tempoMap: fourFourTempoMap(duration: 4), + currentTime: 0, + notes: [ + TimecodedNote(id: regionID, kind: .region, time: 0, duration: 1, title: " ") + ] + ) + + let label = try XCTUnwrap(state.visibleMeasures[0].regionLabels.first) + + XCTAssertEqual(label.id, regionID) + XCTAssertEqual(label.title, "Region") + } + + private func fourFourTempoMap(duration: TimeInterval) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ), + markers: [], + duration: duration + ) + } + + private func notationViewportState( + tempoMap: TempoMap, + currentTime: TimeInterval, + visibleMeasureCount: Int = 8, + notes: [TimecodedNote] = [] + ) -> NotationViewportState { + NotationViewportFactory().viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: currentTime, + playbackMarkerTime: 0, + isPlaying: true, + keyName: "C major", + visibleMeasureCount: visibleMeasureCount, + harmonySymbols: [], + notes: notes + ) + } +} From abaa423ac017b4ab2007b0fbfc398ac5e834f200 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 13:10:45 +0300 Subject: [PATCH 09/80] test: split notation visible measure fitter coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 88 ------------- .../NotationVisibleMeasureFitterTests.swift | 124 ++++++++++++++++++ 3 files changed, 128 insertions(+), 88 deletions(-) create mode 100644 JammLabTests/NotationVisibleMeasureFitterTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 8a35a9b..704ce28 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -119,6 +119,7 @@ 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */; }; 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */; }; 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */; }; + 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -298,6 +299,7 @@ 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationKeySignatureTests.swift; sourceTree = ""; }; 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeDisplayTests.swift; sourceTree = ""; }; 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRegionLabelTests.swift; sourceTree = ""; }; + 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitterTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -562,6 +564,7 @@ 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */, 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */, 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */, + 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -947,6 +950,7 @@ 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */, 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */, 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */, + 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 5ad7606..6e4bdca 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -227,94 +227,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(state.visibleMeasureCount, 8) } - func testNotationVisibleMeasureFitterChoosesCountForAvailableWidth() { - let minimumWidth = AppTheme.Timeline.notationMeasureMinWidth - let stateForMeasureCount: (Int) -> NotationViewportState = { - NotationViewportState.pending(visibleMeasureCount: $0) - } - - XCTAssertEqual( - NotationVisibleMeasureFitter.fittedMeasureCount( - availableWidth: minimumWidth * 8, - maximumMeasureCount: 8, - stateForMeasureCount: stateForMeasureCount - ), - 8 - ) - XCTAssertEqual( - NotationVisibleMeasureFitter.fittedMeasureCount( - availableWidth: minimumWidth * 4 + 10, - maximumMeasureCount: 8, - stateForMeasureCount: stateForMeasureCount - ), - 4 - ) - XCTAssertEqual( - NotationVisibleMeasureFitter.fittedMeasureCount( - availableWidth: minimumWidth * 0.5, - maximumMeasureCount: 8, - stateForMeasureCount: stateForMeasureCount - ), - 1 - ) - } - - func testNotationVisibleMeasureFitterAccountsForAttributeReserveWidth() { - let tempoMap = fourFourTempoMap(duration: 120) - let availableWidth = AppTheme.Timeline.notationMeasureMinWidth * 4 - let stateForMeasureCount: (Int) -> NotationViewportState = { count in - self.notationViewportState( - tempoMap: tempoMap, - currentTime: 0, - keyName: "D major", - visibleMeasureCount: count - ) - } - - let fittedCount = NotationVisibleMeasureFitter.fittedMeasureCount( - availableWidth: availableWidth, - maximumMeasureCount: 4, - stateForMeasureCount: stateForMeasureCount - ) - let fittedState = stateForMeasureCount(fittedCount) - let fourMeasureState = stateForMeasureCount(4) - - XCTAssertLessThan(fittedCount, 4) - XCTAssertLessThanOrEqual( - NotationVisibleMeasureFitter.minimumRequiredWidth(for: fittedState), - availableWidth + NotationVisibleMeasureFitter.widthTolerance - ) - XCTAssertGreaterThan( - NotationVisibleMeasureFitter.minimumRequiredWidth(for: fourMeasureState), - availableWidth + NotationVisibleMeasureFitter.widthTolerance - ) - } - - func testNotationVisibleMeasureFitterFallsBackToOneWhenPreferredSingleMeasureIsTooWide() { - let tempoMap = fourFourTempoMap(duration: 120) - let availableWidth: CGFloat = 10 - let stateForMeasureCount: (Int) -> NotationViewportState = { count in - self.notationViewportState( - tempoMap: tempoMap, - currentTime: 0, - keyName: "D major", - visibleMeasureCount: count - ) - } - - let fittedCount = NotationVisibleMeasureFitter.fittedMeasureCount( - availableWidth: availableWidth, - maximumMeasureCount: 8, - stateForMeasureCount: stateForMeasureCount - ) - - XCTAssertEqual(fittedCount, 1) - XCTAssertGreaterThan( - NotationVisibleMeasureFitter.minimumRequiredWidth(for: stateForMeasureCount(1)), - availableWidth - ) - } - func testNotationViewportKeepsActiveMeasureVisibleWhenVisibleCountChanges() throws { let tempoMap = fourFourTempoMap(duration: 120) diff --git a/JammLabTests/NotationVisibleMeasureFitterTests.swift b/JammLabTests/NotationVisibleMeasureFitterTests.swift new file mode 100644 index 0000000..4f55172 --- /dev/null +++ b/JammLabTests/NotationVisibleMeasureFitterTests.swift @@ -0,0 +1,124 @@ +import CoreGraphics +import XCTest +@testable import JammLab + +final class NotationVisibleMeasureFitterTests: XCTestCase { + func testNotationVisibleMeasureFitterChoosesCountForAvailableWidth() { + let minimumWidth = AppTheme.Timeline.notationMeasureMinWidth + let stateForMeasureCount: (Int) -> NotationViewportState = { + NotationViewportState.pending(visibleMeasureCount: $0) + } + + XCTAssertEqual( + NotationVisibleMeasureFitter.fittedMeasureCount( + availableWidth: minimumWidth * 8, + maximumMeasureCount: 8, + stateForMeasureCount: stateForMeasureCount + ), + 8 + ) + XCTAssertEqual( + NotationVisibleMeasureFitter.fittedMeasureCount( + availableWidth: minimumWidth * 4 + 10, + maximumMeasureCount: 8, + stateForMeasureCount: stateForMeasureCount + ), + 4 + ) + XCTAssertEqual( + NotationVisibleMeasureFitter.fittedMeasureCount( + availableWidth: minimumWidth * 0.5, + maximumMeasureCount: 8, + stateForMeasureCount: stateForMeasureCount + ), + 1 + ) + } + + func testNotationVisibleMeasureFitterAccountsForAttributeReserveWidth() { + let tempoMap = fourFourTempoMap(duration: 120) + let availableWidth = AppTheme.Timeline.notationMeasureMinWidth * 4 + let stateForMeasureCount: (Int) -> NotationViewportState = { count in + self.notationViewportState( + tempoMap: tempoMap, + currentTime: 0, + keyName: "D major", + visibleMeasureCount: count + ) + } + + let fittedCount = NotationVisibleMeasureFitter.fittedMeasureCount( + availableWidth: availableWidth, + maximumMeasureCount: 4, + stateForMeasureCount: stateForMeasureCount + ) + let fittedState = stateForMeasureCount(fittedCount) + let fourMeasureState = stateForMeasureCount(4) + + XCTAssertLessThan(fittedCount, 4) + XCTAssertLessThanOrEqual( + NotationVisibleMeasureFitter.minimumRequiredWidth(for: fittedState), + availableWidth + NotationVisibleMeasureFitter.widthTolerance + ) + XCTAssertGreaterThan( + NotationVisibleMeasureFitter.minimumRequiredWidth(for: fourMeasureState), + availableWidth + NotationVisibleMeasureFitter.widthTolerance + ) + } + + func testNotationVisibleMeasureFitterFallsBackToOneWhenPreferredSingleMeasureIsTooWide() { + let tempoMap = fourFourTempoMap(duration: 120) + let availableWidth: CGFloat = 10 + let stateForMeasureCount: (Int) -> NotationViewportState = { count in + self.notationViewportState( + tempoMap: tempoMap, + currentTime: 0, + keyName: "D major", + visibleMeasureCount: count + ) + } + + let fittedCount = NotationVisibleMeasureFitter.fittedMeasureCount( + availableWidth: availableWidth, + maximumMeasureCount: 8, + stateForMeasureCount: stateForMeasureCount + ) + + XCTAssertEqual(fittedCount, 1) + XCTAssertGreaterThan( + NotationVisibleMeasureFitter.minimumRequiredWidth(for: stateForMeasureCount(1)), + availableWidth + ) + } + + private func fourFourTempoMap(duration: TimeInterval) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ), + markers: [], + duration: duration + ) + } + + private func notationViewportState( + tempoMap: TempoMap, + currentTime: TimeInterval, + keyName: String? = "C major", + visibleMeasureCount: Int = 8 + ) -> NotationViewportState { + NotationViewportFactory().viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: currentTime, + playbackMarkerTime: 0, + isPlaying: true, + keyName: keyName, + visibleMeasureCount: visibleMeasureCount, + harmonySymbols: [], + notes: [] + ) + } +} From 2e1121c81edde63bf0eff01725186af1fefc359b Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 13:13:50 +0300 Subject: [PATCH 10/80] test: split notation score state coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 83 --------------- JammLabTests/NotationScoreStateTests.swift | 113 +++++++++++++++++++++ 3 files changed, 117 insertions(+), 83 deletions(-) create mode 100644 JammLabTests/NotationScoreStateTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 704ce28..deeaa84 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */; }; 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */; }; 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */; }; + 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -300,6 +301,7 @@ 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeDisplayTests.swift; sourceTree = ""; }; 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRegionLabelTests.swift; sourceTree = ""; }; 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitterTests.swift; sourceTree = ""; }; + 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationScoreStateTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -565,6 +567,7 @@ 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */, 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */, 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */, + 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -951,6 +954,7 @@ 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */, 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */, 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */, + 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 6e4bdca..476b990 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -295,89 +295,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(secondHarmony.rawText, "G7") } - func testNotationScoreStateBuildsWholeDurationAndSystems() throws { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 8), - duration: 8, - currentTime: 4.1, - playbackMarkerTime: 1, - isPlaying: true, - keyName: "G major" - ) - - XCTAssertTrue(state.isReady) - XCTAssertEqual(state.measures.map(\.number), [1, 2, 3, 4]) - XCTAssertEqual(state.measures.map(\.attributes.keySignature).map(\.fifths), [1, 1, 1, 1]) - XCTAssertEqual(state.activeMeasureNumber, 3) - XCTAssertEqual(state.anchorTime, 4.1, accuracy: 0.0001) - - let systems = state.systems(measuresPerSystem: 2) - XCTAssertEqual(systems.count, 2) - XCTAssertEqual(systems[0].viewportState.visibleMeasures.map(\.number), [1, 2]) - XCTAssertEqual(systems[1].viewportState.visibleMeasures.map(\.number), [3, 4]) - XCTAssertEqual(systems[1].viewportState.activeMeasureNumber, 3) - } - - func testNotationScoreStateUsesPlaybackMarkerWhenNotPlayingAndClampsAtEnd() throws { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 8), - duration: 8, - currentTime: 6, - playbackMarkerTime: 20, - isPlaying: false, - keyName: "C major" - ) - - XCTAssertEqual(state.activeMeasureNumber, 4) - XCTAssertLessThan(state.anchorTime, 8) - XCTAssertGreaterThan(state.anchorTime, 7.9) - } - - func testNotationScoreStateTracksTimeSignatureMarkersAndHarmonies() throws { - let harmonyID = UUID(uuidString: "00000000-0000-0000-0000-000000000201")! - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap( - duration: 8, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] - ), - duration: 8, - currentTime: 5, - playbackMarkerTime: 0, - isPlaying: true, - keyName: "C major", - harmonySymbols: [ - HarmonySymbol( - id: harmonyID, - time: 5, - measureNumber: 99, - offsetInQuarterNotes: 99, - rawText: "Dm7" - ) - ] - ) - - XCTAssertEqual(state.measures.map(\.attributes.timeSignature.beatsPerBar), [4, 4, 3, 3, 3]) - let harmony = try XCTUnwrap(state.measures.flatMap(\.harmonies).first) - XCTAssertEqual(harmony.id, harmonyID) - XCTAssertEqual(harmony.measureNumber, 3) - XCTAssertEqual(harmony.rawText, "Dm7") - } - - func testNotationScoreStateIsPendingForZeroDuration() { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 0), - duration: 0, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "D major" - ) - - XCTAssertFalse(state.isReady) - XCTAssertTrue(state.measures.isEmpty) - XCTAssertEqual(state.keySignature.fifths, 2) - } - func testHarmonyPlacementUsesExactTimeAndNavigatesAcrossNotationItems() throws { let factory = NotationViewportFactory() let tempoMap = fourFourTempoMap(duration: 8) diff --git a/JammLabTests/NotationScoreStateTests.swift b/JammLabTests/NotationScoreStateTests.swift new file mode 100644 index 0000000..983d267 --- /dev/null +++ b/JammLabTests/NotationScoreStateTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import JammLab + +final class NotationScoreStateTests: XCTestCase { + func testNotationScoreStateBuildsWholeDurationAndSystems() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 8), + duration: 8, + currentTime: 4.1, + playbackMarkerTime: 1, + isPlaying: true, + keyName: "G major" + ) + + XCTAssertTrue(state.isReady) + XCTAssertEqual(state.measures.map(\.number), [1, 2, 3, 4]) + XCTAssertEqual(state.measures.map(\.attributes.keySignature).map(\.fifths), [1, 1, 1, 1]) + XCTAssertEqual(state.activeMeasureNumber, 3) + XCTAssertEqual(state.anchorTime, 4.1, accuracy: 0.0001) + + let systems = state.systems(measuresPerSystem: 2) + XCTAssertEqual(systems.count, 2) + XCTAssertEqual(systems[0].viewportState.visibleMeasures.map(\.number), [1, 2]) + XCTAssertEqual(systems[1].viewportState.visibleMeasures.map(\.number), [3, 4]) + XCTAssertEqual(systems[1].viewportState.activeMeasureNumber, 3) + } + + func testNotationScoreStateUsesPlaybackMarkerWhenNotPlayingAndClampsAtEnd() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 8), + duration: 8, + currentTime: 6, + playbackMarkerTime: 20, + isPlaying: false, + keyName: "C major" + ) + + XCTAssertEqual(state.activeMeasureNumber, 4) + XCTAssertLessThan(state.anchorTime, 8) + XCTAssertGreaterThan(state.anchorTime, 7.9) + } + + func testNotationScoreStateTracksTimeSignatureMarkersAndHarmonies() throws { + let harmonyID = UUID(uuidString: "00000000-0000-0000-0000-000000000201")! + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap( + duration: 8, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] + ), + duration: 8, + currentTime: 5, + playbackMarkerTime: 0, + isPlaying: true, + keyName: "C major", + harmonySymbols: [ + HarmonySymbol( + id: harmonyID, + time: 5, + measureNumber: 99, + offsetInQuarterNotes: 99, + rawText: "Dm7" + ) + ] + ) + + XCTAssertEqual(state.measures.map(\.attributes.timeSignature.beatsPerBar), [4, 4, 3, 3, 3]) + let harmony = try XCTUnwrap(state.measures.flatMap(\.harmonies).first) + XCTAssertEqual(harmony.id, harmonyID) + XCTAssertEqual(harmony.measureNumber, 3) + XCTAssertEqual(harmony.rawText, "Dm7") + } + + func testNotationScoreStateIsPendingForZeroDuration() { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 0), + duration: 0, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "D major" + ) + + XCTAssertFalse(state.isReady) + XCTAssertTrue(state.measures.isEmpty) + XCTAssertEqual(state.keySignature.fifths, 2) + } + + private func fourFourTempoMap( + duration: TimeInterval, + markers: [TimecodedNote] = [] + ) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ), + markers: markers, + duration: duration + ) + } + + private func timeSignatureMarker(time: TimeInterval, beatsPerBar: Int) -> TimecodedNote { + TimecodedNote( + time: time, + title: "\(beatsPerBar)/4", + metadata: TempoTimeSignatureMarkerPayload( + beatsPerBar: beatsPerBar, + setsNewFirstBeat: false + ).metadata + ) + } +} From 5d829709c412a70e91a5d3b10e58f3e960e29bc3 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 13:16:55 +0300 Subject: [PATCH 11/80] test: split notation harmony placement coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 96 ------------- .../NotationHarmonyPlacementTests.swift | 131 ++++++++++++++++++ 3 files changed, 135 insertions(+), 96 deletions(-) create mode 100644 JammLabTests/NotationHarmonyPlacementTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index deeaa84..d795b48 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */; }; 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */; }; 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */; }; + 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -302,6 +303,7 @@ 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRegionLabelTests.swift; sourceTree = ""; }; 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitterTests.swift; sourceTree = ""; }; 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationScoreStateTests.swift; sourceTree = ""; }; + 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyPlacementTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -568,6 +570,7 @@ 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */, 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */, 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */, + 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -955,6 +958,7 @@ 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */, 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */, 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */, + 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 476b990..34ceefa 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -257,102 +257,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertTrue(threeCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(threeCountState.activeMeasureNumber))) } - func testNotationViewportAttachesHarmonySymbolsToVisibleMeasures() throws { - let firstID = UUID(uuidString: "00000000-0000-0000-0000-000000000101")! - let secondID = UUID(uuidString: "00000000-0000-0000-0000-000000000102")! - let state = notationViewportState( - tempoMap: fourFourTempoMap(duration: 8), - currentTime: 0.1, - visibleMeasureCount: 2, - harmonySymbols: [ - HarmonySymbol( - id: firstID, - time: 0.75, - measureNumber: 99, - offsetInQuarterNotes: 99, - rawText: "Cmaj7" - ), - HarmonySymbol( - id: secondID, - time: 2.5, - measureNumber: 1, - offsetInQuarterNotes: 0, - rawText: "G7" - ) - ] - ) - - let firstHarmony = try XCTUnwrap(state.visibleMeasures[0].harmonies.first) - let secondHarmony = try XCTUnwrap(state.visibleMeasures[1].harmonies.first) - - XCTAssertEqual(firstHarmony.id, firstID) - XCTAssertEqual(firstHarmony.measureNumber, 1) - XCTAssertEqual(firstHarmony.offsetInQuarterNotes, 1.5, accuracy: 0.0001) - XCTAssertEqual(firstHarmony.rawText, "Cmaj7") - XCTAssertEqual(secondHarmony.id, secondID) - XCTAssertEqual(secondHarmony.measureNumber, 2) - XCTAssertEqual(secondHarmony.offsetInQuarterNotes, 1, accuracy: 0.0001) - XCTAssertEqual(secondHarmony.rawText, "G7") - } - - func testHarmonyPlacementUsesExactTimeAndNavigatesAcrossNotationItems() throws { - let factory = NotationViewportFactory() - let tempoMap = fourFourTempoMap(duration: 8) - let notationItems = [ - NotationMeasureItem( - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 0, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ), - NotationMeasureItem( - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 1, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ), - NotationMeasureItem( - measureNumber: 2, - measureStartTime: 2, - offsetInQuarterNotes: 0, - durationInQuarterNotes: 4, - displayDuration: NotationDuration(denominator: 1) - ) - ] - - let placement = try XCTUnwrap(factory.harmonyPlacement( - for: 0.87, - tempoMap: tempoMap, - duration: 8 - )) - let nextMeasure = try XCTUnwrap(factory.adjacentHarmonyPlacement( - from: 0, - direction: .next, - tempoMap: tempoMap, - duration: 8, - notationItems: notationItems - )) - let previousMeasure = try XCTUnwrap(factory.adjacentHarmonyPlacement( - from: 2, - direction: .previous, - tempoMap: tempoMap, - duration: 8, - notationItems: notationItems - )) - - XCTAssertEqual(placement.measureNumber, 1) - XCTAssertEqual(placement.offsetInQuarterNotes, 1.74, accuracy: 0.0001) - XCTAssertEqual(placement.time, 0.87, accuracy: 0.0001) - XCTAssertEqual(nextMeasure.measureNumber, 1) - XCTAssertEqual(nextMeasure.offsetInQuarterNotes, 1, accuracy: 0.0001) - XCTAssertEqual(nextMeasure.time, 0.5, accuracy: 0.0001) - XCTAssertEqual(previousMeasure.measureNumber, 1) - XCTAssertEqual(previousMeasure.offsetInQuarterNotes, 2, accuracy: 0.0001) - XCTAssertEqual(previousMeasure.time, 1, accuracy: 0.0001) - } - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { let cMajor = MeasureAttributes( keySignature: KeySignature.normalized(from: "C major"), diff --git a/JammLabTests/NotationHarmonyPlacementTests.swift b/JammLabTests/NotationHarmonyPlacementTests.swift new file mode 100644 index 0000000..f2777fa --- /dev/null +++ b/JammLabTests/NotationHarmonyPlacementTests.swift @@ -0,0 +1,131 @@ +import XCTest +@testable import JammLab + +final class NotationHarmonyPlacementTests: XCTestCase { + func testNotationViewportAttachesHarmonySymbolsToVisibleMeasures() throws { + let firstID = UUID(uuidString: "00000000-0000-0000-0000-000000000101")! + let secondID = UUID(uuidString: "00000000-0000-0000-0000-000000000102")! + let state = notationViewportState( + tempoMap: fourFourTempoMap(duration: 8), + currentTime: 0.1, + visibleMeasureCount: 2, + harmonySymbols: [ + HarmonySymbol( + id: firstID, + time: 0.75, + measureNumber: 99, + offsetInQuarterNotes: 99, + rawText: "Cmaj7" + ), + HarmonySymbol( + id: secondID, + time: 2.5, + measureNumber: 1, + offsetInQuarterNotes: 0, + rawText: "G7" + ) + ] + ) + + let firstHarmony = try XCTUnwrap(state.visibleMeasures[0].harmonies.first) + let secondHarmony = try XCTUnwrap(state.visibleMeasures[1].harmonies.first) + + XCTAssertEqual(firstHarmony.id, firstID) + XCTAssertEqual(firstHarmony.measureNumber, 1) + XCTAssertEqual(firstHarmony.offsetInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertEqual(firstHarmony.rawText, "Cmaj7") + XCTAssertEqual(secondHarmony.id, secondID) + XCTAssertEqual(secondHarmony.measureNumber, 2) + XCTAssertEqual(secondHarmony.offsetInQuarterNotes, 1, accuracy: 0.0001) + XCTAssertEqual(secondHarmony.rawText, "G7") + } + + func testHarmonyPlacementUsesExactTimeAndNavigatesAcrossNotationItems() throws { + let factory = NotationViewportFactory() + let tempoMap = fourFourTempoMap(duration: 8) + let notationItems = [ + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1) + ) + ] + + let placement = try XCTUnwrap(factory.harmonyPlacement( + for: 0.87, + tempoMap: tempoMap, + duration: 8 + )) + let nextMeasure = try XCTUnwrap(factory.adjacentHarmonyPlacement( + from: 0, + direction: .next, + tempoMap: tempoMap, + duration: 8, + notationItems: notationItems + )) + let previousMeasure = try XCTUnwrap(factory.adjacentHarmonyPlacement( + from: 2, + direction: .previous, + tempoMap: tempoMap, + duration: 8, + notationItems: notationItems + )) + + XCTAssertEqual(placement.measureNumber, 1) + XCTAssertEqual(placement.offsetInQuarterNotes, 1.74, accuracy: 0.0001) + XCTAssertEqual(placement.time, 0.87, accuracy: 0.0001) + XCTAssertEqual(nextMeasure.measureNumber, 1) + XCTAssertEqual(nextMeasure.offsetInQuarterNotes, 1, accuracy: 0.0001) + XCTAssertEqual(nextMeasure.time, 0.5, accuracy: 0.0001) + XCTAssertEqual(previousMeasure.measureNumber, 1) + XCTAssertEqual(previousMeasure.offsetInQuarterNotes, 2, accuracy: 0.0001) + XCTAssertEqual(previousMeasure.time, 1, accuracy: 0.0001) + } + + private func fourFourTempoMap(duration: TimeInterval) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ), + markers: [], + duration: duration + ) + } + + private func notationViewportState( + tempoMap: TempoMap, + currentTime: TimeInterval, + visibleMeasureCount: Int, + harmonySymbols: [HarmonySymbol] + ) -> NotationViewportState { + NotationViewportFactory().viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: currentTime, + playbackMarkerTime: 0, + isPlaying: true, + keyName: "C major", + visibleMeasureCount: visibleMeasureCount, + harmonySymbols: harmonySymbols, + notes: [] + ) + } +} From 35ca6e277a364a7153041d50a6ada629b6372922 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 13:22:15 +0300 Subject: [PATCH 12/80] test: split notation measure timing coverage --- JammLab.xcodeproj/project.pbxproj | 4 ++ JammLabTests/AudioTimingLogicTests.swift | 33 ----------------- JammLabTests/NotationMeasureTimingTests.swift | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 JammLabTests/NotationMeasureTimingTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index d795b48..163ab70 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -122,6 +122,7 @@ 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */; }; 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */; }; 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */; }; + 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -304,6 +305,7 @@ 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitterTests.swift; sourceTree = ""; }; 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationScoreStateTests.swift; sourceTree = ""; }; 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyPlacementTests.swift; sourceTree = ""; }; + 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureTimingTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -571,6 +573,7 @@ 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */, 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */, 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */, + 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -959,6 +962,7 @@ 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */, 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */, 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */, + 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 34ceefa..1c19bbb 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -1072,39 +1072,6 @@ final class AudioTimingLogicTests: XCTestCase { ) } - func testNotationMeasureTimingUsesHalfOpenMeasureBoundaries() { - let measure = ScoreMeasure( - number: 1, - startTime: 0, - endTime: 2, - attributes: .defaultTreble - ) - - XCTAssertTrue(NotationMeasureTiming.containsEventTime(0, in: measure)) - XCTAssertTrue(NotationMeasureTiming.containsEventTime(1.999, in: measure)) - XCTAssertFalse(NotationMeasureTiming.containsEventTime(2, in: measure)) - } - - func testNotationMeasureTimingRecomputesQuarterOffsetsFromMeasureTime() { - let measure = ScoreMeasure( - number: 1, - startTime: 2, - endTime: 4, - attributes: .defaultTreble - ) - - XCTAssertEqual( - NotationMeasureTiming.quarterOffset(for: 3, in: measure), - 2, - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureTiming.time(forQuarterOffset: 2, in: measure), - 3, - accuracy: 0.0001 - ) - } - func testNotationMeasureLayoutMapsAnchorXBackToProgress() { let geometry = NotationMeasureCanvasGeometry( measureIndex: 0, diff --git a/JammLabTests/NotationMeasureTimingTests.swift b/JammLabTests/NotationMeasureTimingTests.swift new file mode 100644 index 0000000..6607ee8 --- /dev/null +++ b/JammLabTests/NotationMeasureTimingTests.swift @@ -0,0 +1,37 @@ +import XCTest +@testable import JammLab + +final class NotationMeasureTimingTests: XCTestCase { + func testNotationMeasureTimingUsesHalfOpenMeasureBoundaries() { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble + ) + + XCTAssertTrue(NotationMeasureTiming.containsEventTime(0, in: measure)) + XCTAssertTrue(NotationMeasureTiming.containsEventTime(1.999, in: measure)) + XCTAssertFalse(NotationMeasureTiming.containsEventTime(2, in: measure)) + } + + func testNotationMeasureTimingRecomputesQuarterOffsetsFromMeasureTime() { + let measure = ScoreMeasure( + number: 1, + startTime: 2, + endTime: 4, + attributes: .defaultTreble + ) + + XCTAssertEqual( + NotationMeasureTiming.quarterOffset(for: 3, in: measure), + 2, + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureTiming.time(forQuarterOffset: 2, in: measure), + 3, + accuracy: 0.0001 + ) + } +} From 9ec0a564e51d5ec0a8a6251194987bb984f6be76 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 13:32:51 +0300 Subject: [PATCH 13/80] test: split notation viewport coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 292 ---------------------- JammLabTests/NotationViewportTests.swift | 296 +++++++++++++++++++++++ 3 files changed, 300 insertions(+), 292 deletions(-) create mode 100644 JammLabTests/NotationViewportTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 163ab70..3b53436 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */; }; 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */; }; 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */; }; + 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00152CE0000100112233 /* NotationViewportTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -306,6 +307,7 @@ 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationScoreStateTests.swift; sourceTree = ""; }; 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyPlacementTests.swift; sourceTree = ""; }; 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureTimingTests.swift; sourceTree = ""; }; + 9FAB00152CE0000100112233 /* NotationViewportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -574,6 +576,7 @@ 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */, 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */, 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */, + 9FAB00152CE0000100112233 /* NotationViewportTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -963,6 +966,7 @@ 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */, 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */, 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */, + 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 1c19bbb..7e24220 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -19,244 +19,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(duration, 0.5, accuracy: 0.0001) } - func testNotationViewportUsesCurrentTimeWhilePlayingAndMarkerTimeWhenStopped() { - let tempoMap = fourFourTempoMap(duration: 60) - - let playingState = notationViewportState( - tempoMap: tempoMap, - currentTime: 10.2, - playbackMarkerTime: 2.1, - isPlaying: true, - visibleMeasureCount: 4 - ) - let stoppedState = notationViewportState( - tempoMap: tempoMap, - currentTime: 10.2, - playbackMarkerTime: 2.1, - isPlaying: false, - visibleMeasureCount: 4 - ) - - XCTAssertEqual(playingState.firstVisibleMeasureNumber, 5) - XCTAssertEqual(playingState.activeMeasureNumber, 6) - XCTAssertEqual(stoppedState.firstVisibleMeasureNumber, 1) - XCTAssertEqual(stoppedState.activeMeasureNumber, 2) - } - - func testNotationViewportStartsAtCurrentMeasurePage() { - let state = notationViewportState( - tempoMap: fourFourTempoMap(duration: 120), - currentTime: 40.25, - visibleMeasureCount: 8 - ) - - XCTAssertEqual(state.firstVisibleMeasureNumber, 17) - XCTAssertEqual(state.activeMeasureNumber, 21) - XCTAssertEqual(state.visibleMeasures.map(\.number), [17, 18, 19, 20, 21, 22, 23, 24]) - XCTAssertEqual(state.visibleMeasureCount, 8) - } - - func testNotationViewportKeepsPageUntilPlaybackEntersNextPage() { - let tempoMap = fourFourTempoMap(duration: 120) - - let measureEightState = notationViewportState( - tempoMap: tempoMap, - currentTime: 14.1, - visibleMeasureCount: 8 - ) - let measureNineState = notationViewportState( - tempoMap: tempoMap, - currentTime: 16.1, - visibleMeasureCount: 8 - ) - - XCTAssertEqual(measureEightState.firstVisibleMeasureNumber, 1) - XCTAssertEqual(measureEightState.activeMeasureNumber, 8) - XCTAssertEqual(measureNineState.firstVisibleMeasureNumber, 9) - XCTAssertEqual(measureNineState.activeMeasureNumber, 9) - XCTAssertEqual(measureNineState.visibleMeasures.map(\.number), [9, 10, 11, 12, 13, 14, 15, 16]) - } - - func testNotationViewportStartsAtMeasureOneWhenTrackStartsAtZero() throws { - let state = notationViewportState( - tempoMap: fourFourTempoMap(duration: 120), - currentTime: 0, - playbackMarkerTime: 0, - visibleMeasureCount: 8 - ) - let firstMeasure = try XCTUnwrap(state.visibleMeasures.first) - - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 1) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - XCTAssertEqual(firstMeasure.startTime, 0, accuracy: 0.0001) - XCTAssertGreaterThan(firstMeasure.duration, 0) - } - - func testNotationViewportStartsAtMeasureOneBeforeDelayedFirstBeat() throws { - let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 0, - playbackMarkerTime: 0, - visibleMeasureCount: 8 - ) - let firstMeasure = try XCTUnwrap(state.visibleMeasures.first) - - XCTAssertTrue(state.isReady) - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 1) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - XCTAssertEqual(state.anchorTime, 0.78, accuracy: 0.0001) - XCTAssertEqual(firstMeasure.startTime, 0.78, accuracy: 0.0001) - XCTAssertGreaterThan(firstMeasure.duration, 0) - } - - func testNotationViewportKeepsMeasureOneAtDelayedFirstBeat() { - let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 0.78, - playbackMarkerTime: 0, - visibleMeasureCount: 8 - ) - - XCTAssertTrue(state.isReady) - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 1) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - XCTAssertEqual(state.anchorTime, 0.78, accuracy: 0.0001) - } - - func testNotationViewportKeepsFirstPageInsideSecondDelayedMeasure() { - let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 3.0, - playbackMarkerTime: 0, - visibleMeasureCount: 8 - ) - - XCTAssertTrue(state.isReady) - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 2) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - } - - func testNotationViewportCarriesMeasureAttributesAcrossTimeSignatureMarker() { - let tempoMap = fourFourTempoMap( - duration: 12, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] - ) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 2.1, - visibleMeasureCount: 4 - ) - - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4]) - XCTAssertEqual(state.visibleMeasures[0].attributes.timeSignature, .fourFour) - XCTAssertEqual(state.visibleMeasures[2].attributes.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) - } - - func testNotationViewportDoesNotRestartPageAtTimeSignatureMarkerInsideVisibleWindow() { - let tempoMap = fourFourTempoMap( - duration: 18, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] - ) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 5.1, - visibleMeasureCount: 8 - ) - - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 3) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - } - - func testNotationViewportHonorsBarNumberResetAtTimeSignatureMarker() { - let tempoMap = fourFourTempoMap( - duration: 12, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3, setsNewFirstBeat: true)] - ) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 2.1, - visibleMeasureCount: 4 - ) - - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2]) - } - - func testNotationViewportKeepsGlobalPageAcrossBarNumberReset() { - let tempoMap = fourFourTempoMap( - duration: 18, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3, setsNewFirstBeat: true)] - ) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 5.1, - visibleMeasureCount: 8 - ) - - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 1) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2, 3, 4, 5, 6]) - } - - func testNotationViewportReturnsPendingStateWhenTempoIsUnavailable() { - let tempoMap = TempoMap(baseSettings: BeatGridSettings(), markers: [], duration: 12) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 2, - playbackMarkerTime: 2, - visibleMeasureCount: 8 - ) - - XCTAssertFalse(state.isReady) - XCTAssertTrue(state.visibleMeasures.isEmpty) - XCTAssertEqual(state.visibleMeasureCount, 8) - } - - func testNotationViewportKeepsActiveMeasureVisibleWhenVisibleCountChanges() throws { - let tempoMap = fourFourTempoMap(duration: 120) - - let eightCountState = notationViewportState( - tempoMap: tempoMap, - currentTime: 14.1, - visibleMeasureCount: 8 - ) - let sevenCountState = notationViewportState( - tempoMap: tempoMap, - currentTime: 14.1, - visibleMeasureCount: 7 - ) - let fourCountState = notationViewportState( - tempoMap: tempoMap, - currentTime: 8.1, - visibleMeasureCount: 4 - ) - let threeCountState = notationViewportState( - tempoMap: tempoMap, - currentTime: 8.1, - visibleMeasureCount: 3 - ) - - XCTAssertTrue(eightCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(eightCountState.activeMeasureNumber))) - XCTAssertTrue(sevenCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(sevenCountState.activeMeasureNumber))) - XCTAssertTrue(fourCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(fourCountState.activeMeasureNumber))) - XCTAssertTrue(threeCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(threeCountState.activeMeasureNumber))) - } - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { let cMajor = MeasureAttributes( keySignature: KeySignature.normalized(from: "C major"), @@ -1503,60 +1265,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertGreaterThan(harmonyStartX, geometry.cellStartX) } - private func fourFourTempoMap( - duration: TimeInterval, - firstBeatTime: TimeInterval = 0, - markers: [TimecodedNote] = [] - ) -> TempoMap { - TempoMap( - baseSettings: BeatGridSettings( - bpm: 120, - firstBeatTime: firstBeatTime, - timeSignature: .fourFour - ), - markers: markers, - duration: duration - ) - } - - private func notationViewportState( - tempoMap: TempoMap, - currentTime: TimeInterval, - playbackMarkerTime: TimeInterval = 0, - isPlaying: Bool = true, - keyName: String? = "C major", - visibleMeasureCount: Int = 8, - harmonySymbols: [HarmonySymbol] = [], - notes: [TimecodedNote] = [] - ) -> NotationViewportState { - NotationViewportFactory().viewportState( - tempoMap: tempoMap, - duration: tempoMap.duration, - currentTime: currentTime, - playbackMarkerTime: playbackMarkerTime, - isPlaying: isPlaying, - keyName: keyName, - visibleMeasureCount: visibleMeasureCount, - harmonySymbols: harmonySymbols, - notes: notes - ) - } - - private func timeSignatureMarker( - time: TimeInterval, - beatsPerBar: Int, - setsNewFirstBeat: Bool = false - ) -> TimecodedNote { - TimecodedNote( - time: time, - title: "\(beatsPerBar)/4", - metadata: TempoTimeSignatureMarkerPayload( - beatsPerBar: beatsPerBar, - setsNewFirstBeat: setsNewFirstBeat - ).metadata - ) - } - private func selectionOverlayTestGeometries( count: Int, width: CGFloat diff --git a/JammLabTests/NotationViewportTests.swift b/JammLabTests/NotationViewportTests.swift new file mode 100644 index 0000000..ceddffe --- /dev/null +++ b/JammLabTests/NotationViewportTests.swift @@ -0,0 +1,296 @@ +import XCTest +@testable import JammLab + +final class NotationViewportTests: XCTestCase { + func testNotationViewportUsesCurrentTimeWhilePlayingAndMarkerTimeWhenStopped() { + let tempoMap = fourFourTempoMap(duration: 60) + + let playingState = notationViewportState( + tempoMap: tempoMap, + currentTime: 10.2, + playbackMarkerTime: 2.1, + isPlaying: true, + visibleMeasureCount: 4 + ) + let stoppedState = notationViewportState( + tempoMap: tempoMap, + currentTime: 10.2, + playbackMarkerTime: 2.1, + isPlaying: false, + visibleMeasureCount: 4 + ) + + XCTAssertEqual(playingState.firstVisibleMeasureNumber, 5) + XCTAssertEqual(playingState.activeMeasureNumber, 6) + XCTAssertEqual(stoppedState.firstVisibleMeasureNumber, 1) + XCTAssertEqual(stoppedState.activeMeasureNumber, 2) + } + + func testNotationViewportStartsAtCurrentMeasurePage() { + let state = notationViewportState( + tempoMap: fourFourTempoMap(duration: 120), + currentTime: 40.25, + visibleMeasureCount: 8 + ) + + XCTAssertEqual(state.firstVisibleMeasureNumber, 17) + XCTAssertEqual(state.activeMeasureNumber, 21) + XCTAssertEqual(state.visibleMeasures.map(\.number), [17, 18, 19, 20, 21, 22, 23, 24]) + XCTAssertEqual(state.visibleMeasureCount, 8) + } + + func testNotationViewportKeepsPageUntilPlaybackEntersNextPage() { + let tempoMap = fourFourTempoMap(duration: 120) + + let measureEightState = notationViewportState( + tempoMap: tempoMap, + currentTime: 14.1, + visibleMeasureCount: 8 + ) + let measureNineState = notationViewportState( + tempoMap: tempoMap, + currentTime: 16.1, + visibleMeasureCount: 8 + ) + + XCTAssertEqual(measureEightState.firstVisibleMeasureNumber, 1) + XCTAssertEqual(measureEightState.activeMeasureNumber, 8) + XCTAssertEqual(measureNineState.firstVisibleMeasureNumber, 9) + XCTAssertEqual(measureNineState.activeMeasureNumber, 9) + XCTAssertEqual(measureNineState.visibleMeasures.map(\.number), [9, 10, 11, 12, 13, 14, 15, 16]) + } + + func testNotationViewportStartsAtMeasureOneWhenTrackStartsAtZero() throws { + let state = notationViewportState( + tempoMap: fourFourTempoMap(duration: 120), + currentTime: 0, + playbackMarkerTime: 0, + visibleMeasureCount: 8 + ) + let firstMeasure = try XCTUnwrap(state.visibleMeasures.first) + + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 1) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + XCTAssertEqual(firstMeasure.startTime, 0, accuracy: 0.0001) + XCTAssertGreaterThan(firstMeasure.duration, 0) + } + + func testNotationViewportStartsAtMeasureOneBeforeDelayedFirstBeat() throws { + let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 0, + playbackMarkerTime: 0, + visibleMeasureCount: 8 + ) + let firstMeasure = try XCTUnwrap(state.visibleMeasures.first) + + XCTAssertTrue(state.isReady) + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 1) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + XCTAssertEqual(state.anchorTime, 0.78, accuracy: 0.0001) + XCTAssertEqual(firstMeasure.startTime, 0.78, accuracy: 0.0001) + XCTAssertGreaterThan(firstMeasure.duration, 0) + } + + func testNotationViewportKeepsMeasureOneAtDelayedFirstBeat() { + let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 0.78, + playbackMarkerTime: 0, + visibleMeasureCount: 8 + ) + + XCTAssertTrue(state.isReady) + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 1) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + XCTAssertEqual(state.anchorTime, 0.78, accuracy: 0.0001) + } + + func testNotationViewportKeepsFirstPageInsideSecondDelayedMeasure() { + let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 3.0, + playbackMarkerTime: 0, + visibleMeasureCount: 8 + ) + + XCTAssertTrue(state.isReady) + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 2) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + } + + func testNotationViewportCarriesMeasureAttributesAcrossTimeSignatureMarker() { + let tempoMap = fourFourTempoMap( + duration: 12, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] + ) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 2.1, + visibleMeasureCount: 4 + ) + + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4]) + XCTAssertEqual(state.visibleMeasures[0].attributes.timeSignature, .fourFour) + XCTAssertEqual(state.visibleMeasures[2].attributes.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) + } + + func testNotationViewportDoesNotRestartPageAtTimeSignatureMarkerInsideVisibleWindow() { + let tempoMap = fourFourTempoMap( + duration: 18, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] + ) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 5.1, + visibleMeasureCount: 8 + ) + + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 3) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + } + + func testNotationViewportHonorsBarNumberResetAtTimeSignatureMarker() { + let tempoMap = fourFourTempoMap( + duration: 12, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3, setsNewFirstBeat: true)] + ) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 2.1, + visibleMeasureCount: 4 + ) + + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2]) + } + + func testNotationViewportKeepsGlobalPageAcrossBarNumberReset() { + let tempoMap = fourFourTempoMap( + duration: 18, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3, setsNewFirstBeat: true)] + ) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 5.1, + visibleMeasureCount: 8 + ) + + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 1) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2, 3, 4, 5, 6]) + } + + func testNotationViewportReturnsPendingStateWhenTempoIsUnavailable() { + let tempoMap = TempoMap(baseSettings: BeatGridSettings(), markers: [], duration: 12) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 2, + playbackMarkerTime: 2, + visibleMeasureCount: 8 + ) + + XCTAssertFalse(state.isReady) + XCTAssertTrue(state.visibleMeasures.isEmpty) + XCTAssertEqual(state.visibleMeasureCount, 8) + } + + func testNotationViewportKeepsActiveMeasureVisibleWhenVisibleCountChanges() throws { + let tempoMap = fourFourTempoMap(duration: 120) + + let eightCountState = notationViewportState( + tempoMap: tempoMap, + currentTime: 14.1, + visibleMeasureCount: 8 + ) + let sevenCountState = notationViewportState( + tempoMap: tempoMap, + currentTime: 14.1, + visibleMeasureCount: 7 + ) + let fourCountState = notationViewportState( + tempoMap: tempoMap, + currentTime: 8.1, + visibleMeasureCount: 4 + ) + let threeCountState = notationViewportState( + tempoMap: tempoMap, + currentTime: 8.1, + visibleMeasureCount: 3 + ) + + XCTAssertTrue(eightCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(eightCountState.activeMeasureNumber))) + XCTAssertTrue(sevenCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(sevenCountState.activeMeasureNumber))) + XCTAssertTrue(fourCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(fourCountState.activeMeasureNumber))) + XCTAssertTrue(threeCountState.visibleMeasures.map(\.number).contains(try XCTUnwrap(threeCountState.activeMeasureNumber))) + } + + private func fourFourTempoMap( + duration: TimeInterval, + firstBeatTime: TimeInterval = 0, + markers: [TimecodedNote] = [] + ) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: firstBeatTime, + timeSignature: .fourFour + ), + markers: markers, + duration: duration + ) + } + + private func notationViewportState( + tempoMap: TempoMap, + currentTime: TimeInterval, + playbackMarkerTime: TimeInterval = 0, + isPlaying: Bool = true, + keyName: String? = "C major", + visibleMeasureCount: Int = 8, + harmonySymbols: [HarmonySymbol] = [], + notes: [TimecodedNote] = [] + ) -> NotationViewportState { + NotationViewportFactory().viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: currentTime, + playbackMarkerTime: playbackMarkerTime, + isPlaying: isPlaying, + keyName: keyName, + visibleMeasureCount: visibleMeasureCount, + harmonySymbols: harmonySymbols, + notes: notes + ) + } + + private func timeSignatureMarker( + time: TimeInterval, + beatsPerBar: Int, + setsNewFirstBeat: Bool = false + ) -> TimecodedNote { + TimecodedNote( + time: time, + title: "\(beatsPerBar)/4", + metadata: TempoTimeSignatureMarkerPayload( + beatsPerBar: beatsPerBar, + setsNewFirstBeat: setsNewFirstBeat + ).metadata + ) + } +} From ddd8bdcb63c7501c226f37ab7f99e1028cdd6c20 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 13:39:55 +0300 Subject: [PATCH 14/80] test: split notation measure geometry coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 406 ----------------- .../NotationMeasureGeometryTests.swift | 410 ++++++++++++++++++ 3 files changed, 414 insertions(+), 406 deletions(-) create mode 100644 JammLabTests/NotationMeasureGeometryTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 3b53436..863a53d 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */; }; 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */; }; 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00152CE0000100112233 /* NotationViewportTests.swift */; }; + 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -308,6 +309,7 @@ 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyPlacementTests.swift; sourceTree = ""; }; 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureTimingTests.swift; sourceTree = ""; }; 9FAB00152CE0000100112233 /* NotationViewportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTests.swift; sourceTree = ""; }; + 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureGeometryTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -577,6 +579,7 @@ 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */, 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */, 9FAB00152CE0000100112233 /* NotationViewportTests.swift */, + 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -967,6 +970,7 @@ 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */, 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */, 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */, + 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 7e24220..b0bd6a2 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -19,412 +19,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(duration, 0.5, accuracy: 0.0001) } - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { - let cMajor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "C major"), - timeSignature: .fourFour, - clef: .treble - ) - let aMinor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "A minor"), - timeSignature: .fourFour, - clef: .treble - ) - let fMajor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - - XCTAssertTrue(cMajor.keySignature.notationAccidentalGlyphs(for: cMajor.clef).isEmpty) - XCTAssertTrue(aMinor.keySignature.notationAccidentalGlyphs(for: aMinor.clef).isEmpty) - XCTAssertFalse(fMajor.keySignature.notationAccidentalGlyphs(for: fMajor.clef).isEmpty) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .full), - AppTheme.Timeline.notationAttributeStaffTopInset, - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: aMinor, display: .full), - NotationMeasureLayout.attributeStaffTopInset(for: fMajor, display: .full), - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .none), - 0, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForPartialAttributeBlocks() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "Bb major"), - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - let partialDisplays = [ - NotationAttributeDisplay(showsClef: true, showsKeySignature: false, showsTimeSignature: false), - NotationAttributeDisplay(showsClef: false, showsKeySignature: true, showsTimeSignature: false), - NotationAttributeDisplay(showsClef: false, showsKeySignature: false, showsTimeSignature: true) - ] - - for display in partialDisplays { - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: attributes, display: display), - AppTheme.Timeline.notationAttributeStaffTopInset, - accuracy: 0.0001 - ) - } - } - - func testNotationMeasureLayoutOffsetsAttributedMeasurePlayheadAfterAttributes() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4), - clef: .treble - ) - let cellWidth: CGFloat = 148 - let display = NotationAttributeDisplay.full - let attributeReserveWidth = NotationMeasureLayout.attributeReserveWidth( - for: attributes, - display: display - ) - - let attributedStart = NotationMeasureLayout.playheadX( - measureIndex: 0, - cellWidth: cellWidth, - progress: 0, - attributes: attributes, - display: display - ) - let attributedEnd = NotationMeasureLayout.playheadX( - measureIndex: 0, - cellWidth: cellWidth, - progress: 1, - attributes: attributes, - display: display - ) - let ordinaryStart = NotationMeasureLayout.playheadX( - measureIndex: 1, - cellWidth: cellWidth, - progress: 0, - attributes: attributes, - display: .none - ) - let contentStart = NotationMeasureLayout.contentStartX( - measureIndex: 0, - cellWidth: cellWidth, - attributes: attributes, - display: display - ) - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 0, - measureCount: 4, - cellWidth: cellWidth, - attributes: attributes, - display: display, - totalWidth: cellWidth * 4 - ) - let barlines = NotationMeasureLayout.barlineGeometries(for: [geometry]) - - XCTAssertGreaterThan(attributedStart, AppTheme.Spacing.md) - XCTAssertEqual(attributedStart, contentStart, accuracy: 0.0001) - XCTAssertEqual(attributedStart, attributeReserveWidth, accuracy: 0.0001) - XCTAssertEqual(attributedEnd, contentStart + cellWidth, accuracy: 0.0001) - XCTAssertEqual(ordinaryStart, cellWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.contentStartX, attributedStart, accuracy: 0.0001) - XCTAssertEqual(geometry.contentEndX, contentStart + cellWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, cellWidth, accuracy: 0.0001) - XCTAssertEqual( - NotationMeasureLayout.playheadX(geometry: geometry, progress: 1), - geometry.contentEndX, - accuracy: 0.0001 - ) - XCTAssertEqual(geometry.staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertFalse(geometry.includesRawStartBarline) - XCTAssertTrue(geometry.contentStartsAfterCellBoundary) - XCTAssertEqual(geometry.leadingBarlineX ?? -1, geometry.staffStartX, accuracy: 0.0001) - XCTAssertTrue(barlines.contains { abs($0.x - geometry.staffStartX) < 0.0001 }) - XCTAssertFalse(barlines.contains { abs($0.x - geometry.contentStartX) < 0.0001 }) - XCTAssertEqual(barlines.count, 2) - XCTAssertEqual(barlines[1].x, geometry.cellEndX, accuracy: 0.0001) - XCTAssertTrue(barlines[0].isOuterBoundary) - XCTAssertTrue(barlines[1].isOuterBoundary) - } - - func testNotationMeasureLayoutUsesTimeOnlyWidthAtTimeSignatureChange() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - let display = NotationAttributeDisplay( - showsClef: false, - showsKeySignature: false, - showsTimeSignature: true - ) - let cellWidth: CGFloat = 148 - - let blockWidth = NotationMeasureLayout.attributeBlockWidth( - for: attributes, - display: display, - cellWidth: cellWidth - ) - let contentStart = NotationMeasureLayout.contentStartX( - measureIndex: 2, - cellWidth: cellWidth, - attributes: attributes, - display: display - ) - let playheadStart = NotationMeasureLayout.playheadX( - measureIndex: 2, - cellWidth: cellWidth, - progress: 0, - attributes: attributes, - display: display - ) - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 2, - measureCount: 4, - cellWidth: cellWidth, - attributes: attributes, - display: display, - totalWidth: cellWidth * 4 - ) - let barlines = NotationMeasureLayout.barlineGeometries(for: [geometry]) - - XCTAssertEqual(blockWidth, AppTheme.Timeline.notationTimeSignatureWidth, accuracy: 0.0001) - XCTAssertEqual(contentStart, playheadStart, accuracy: 0.0001) - XCTAssertEqual(geometry.staffStartX, geometry.cellStartX, accuracy: 0.0001) - XCTAssertEqual(geometry.contentStartX, contentStart, accuracy: 0.0001) - XCTAssertTrue(geometry.includesRawStartBarline) - XCTAssertTrue(geometry.contentStartsAfterCellBoundary) - XCTAssertTrue(barlines.contains { abs($0.x - geometry.cellStartX) < 0.0001 }) - XCTAssertFalse(barlines.contains { abs($0.x - geometry.contentStartX) < 0.0001 }) - } - - func testNotationMeasureLayoutUsesThemeClefWidthInFullAttributeReserve() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - let blockWidth = NotationMeasureLayout.attributeBlockWidth( - for: attributes, - display: .full, - cellWidth: AppTheme.Timeline.notationMeasureMinWidth - ) - let reserveWidth = NotationMeasureLayout.attributeReserveWidth( - for: attributes, - display: .full - ) - let expectedBlockWidth = AppTheme.Timeline.notationClefWidth - + NotationMeasureLayout.keySignatureWidth(for: attributes) - + AppTheme.Timeline.notationTimeSignatureWidth - + NotationMeasureLayout.spacingWidth(forVisibleComponentCount: 3) - - XCTAssertEqual(blockWidth, expectedBlockWidth, accuracy: 0.0001) - XCTAssertEqual( - reserveWidth, - AppTheme.Spacing.md + expectedBlockWidth + AppTheme.Spacing.xs, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutKeepsOrdinaryMeasureAtRawBoundary() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "C major"), - timeSignature: .fourFour, - clef: .treble - ) - let cellWidth: CGFloat = 148 - - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 1, - measureCount: 4, - cellWidth: cellWidth, - attributes: attributes, - display: .none, - totalWidth: cellWidth * 4 - ) - - XCTAssertEqual(geometry.cellStartX, cellWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.contentStartX, cellWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.staffStartX, cellWidth, accuracy: 0.0001) - XCTAssertTrue(geometry.includesRawStartBarline) - XCTAssertFalse(geometry.contentStartsAfterCellBoundary) - } - - func testNotationMeasureLayoutKeepsPreviousBoundaryForAttributedMiddleMeasure() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "Bb major"), - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - let cellWidth: CGFloat = 148 - let display = NotationAttributeDisplay.full - let attributeReserveWidth = NotationMeasureLayout.attributeReserveWidth( - for: attributes, - display: display - ) - - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 2, - measureCount: 4, - cellWidth: cellWidth, - attributes: attributes, - display: display, - totalWidth: cellWidth * 4 - ) - - XCTAssertEqual(geometry.cellStartX, cellWidth * 2, accuracy: 0.0001) - XCTAssertGreaterThan(geometry.contentStartX, geometry.cellStartX) - XCTAssertEqual(geometry.contentStartX, geometry.cellStartX + attributeReserveWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, cellWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.cellEndX, geometry.contentStartX + cellWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.staffStartX, geometry.cellStartX, accuracy: 0.0001) - XCTAssertTrue(geometry.includesRawStartBarline) - XCTAssertTrue(geometry.contentStartsAfterCellBoundary) - XCTAssertFalse( - NotationMeasureLayout.barlineGeometries(for: [geometry]) - .contains { abs($0.x - geometry.contentStartX) < 0.0001 } - ) - } - - func testNotationMeasureLayoutExpandsAttributedMeasuresWithoutShrinkingBodies() { - let fullAttributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4), - clef: .treble - ) - let timeOnlyAttributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - let bodyWidth: CGFloat = 148 - let fullReserve = NotationMeasureLayout.attributeReserveWidth( - for: fullAttributes, - display: .full - ) - let timeReserve = NotationMeasureLayout.attributeReserveWidth( - for: timeOnlyAttributes, - display: NotationAttributeDisplay( - showsClef: false, - showsKeySignature: false, - showsTimeSignature: true - ) - ) - let totalWidth = NotationMeasureLayout.canvasWidth( - measureCount: 4, - availableWidth: bodyWidth * 4, - attributeReserveWidths: [fullReserve, 0, timeReserve, 0] - ) - - let geometries = NotationMeasureLayout.canvasGeometries( - measureCount: 4, - totalWidth: totalWidth, - attributeReserveWidths: [fullReserve, 0, timeReserve, 0] - ) - let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) - - XCTAssertEqual(geometries.count, 4) - XCTAssertEqual(totalWidth, bodyWidth * 4 + fullReserve + timeReserve, accuracy: 0.0001) - XCTAssertEqual(geometries[0].contentStartX, fullReserve, accuracy: 0.0001) - XCTAssertEqual(geometries[2].contentStartX, geometries[2].cellStartX + timeReserve, accuracy: 0.0001) - - for geometry in geometries { - XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, bodyWidth, accuracy: 0.0001) - } - - XCTAssertEqual(geometries[1].cellStartX, geometries[0].cellEndX, accuracy: 0.0001) - XCTAssertEqual(geometries[2].cellStartX, geometries[1].cellEndX, accuracy: 0.0001) - XCTAssertEqual(geometries[3].cellStartX, geometries[2].cellEndX, accuracy: 0.0001) - XCTAssertEqual(geometries[3].cellEndX, totalWidth, accuracy: 0.0001) - XCTAssertEqual(geometries[3].contentEndX, totalWidth, accuracy: 0.0001) - XCTAssertEqual( - NotationMeasureLayout.playheadX(geometry: geometries[3], progress: 1), - totalWidth, - accuracy: 0.0001 - ) - XCTAssertLessThanOrEqual( - NotationMeasureLayout.playheadIndicatorX( - geometry: geometries[3], - progress: 1, - indicatorWidth: AppTheme.Stroke.thick - ) + AppTheme.Stroke.thick, - geometries[3].staffEndX + 0.0001 - ) - XCTAssertEqual(geometries[0].staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertEqual(geometries[3].staffEndX, totalWidth - AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertEqual(barlines.last?.x ?? -1, totalWidth - AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertEqual(barlines.last?.x ?? -1, geometries[3].staffEndX, accuracy: 0.0001) - XCTAssertTrue(barlines.contains { abs($0.x - geometries[1].cellStartX) < 0.0001 }) - XCTAssertTrue(barlines.contains { abs($0.x - geometries[2].cellStartX) < 0.0001 }) - XCTAssertTrue(barlines.contains { abs($0.x - geometries[3].cellStartX) < 0.0001 }) - XCTAssertFalse(barlines.contains { abs($0.x - geometries[0].contentStartX) < 0.0001 }) - XCTAssertFalse(barlines.contains { abs($0.x - geometries[2].contentStartX) < 0.0001 }) - } - - func testNotationMeasureLayoutFallbackGeometryPreservesSymmetricOuterInsets() { - let totalWidth: CGFloat = 296 - - let geometries = NotationMeasureLayout.fallbackCanvasGeometries( - measureCount: 0, - totalWidth: totalWidth - ) - let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) - - XCTAssertEqual(geometries.count, 1) - XCTAssertEqual(geometries[0].contentStartX, 0, accuracy: 0.0001) - XCTAssertEqual(geometries[0].staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertEqual(geometries[0].staffEndX, totalWidth - AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertTrue(geometries[0].includesRawStartBarline) - XCTAssertFalse(geometries[0].contentStartsAfterCellBoundary) - XCTAssertEqual(geometries[0].leadingBarlineX ?? -1, geometries[0].staffStartX, accuracy: 0.0001) - XCTAssertEqual(barlines.last?.x ?? -1, geometries[0].staffEndX, accuracy: 0.0001) - XCTAssertEqual( - NotationMeasureLayout.playheadX(geometry: geometries[0], progress: 0), - geometries[0].contentStartX, - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.playheadIndicatorX( - geometry: geometries[0], - progress: 0, - indicatorWidth: AppTheme.Stroke.thick - ), - geometries[0].staffStartX, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutClampsSymmetricOuterInsetsForNarrowWidth() { - let inset = AppTheme.Timeline.notationStaffHorizontalInset - let totalWidth = inset * 1.5 - - let geometries = NotationMeasureLayout.fallbackCanvasGeometries( - measureCount: 1, - totalWidth: totalWidth - ) - let geometry = geometries[0] - let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) - - XCTAssertEqual(geometry.staffStartX, inset, accuracy: 0.0001) - XCTAssertEqual(geometry.staffEndX, geometry.staffStartX, accuracy: 0.0001) - XCTAssertEqual(barlines.first?.x ?? -1, geometry.staffStartX, accuracy: 0.0001) - XCTAssertEqual(barlines.last?.x ?? -1, geometry.staffEndX, accuracy: 0.0001) - XCTAssertEqual( - NotationMeasureLayout.playheadIndicatorX( - geometry: geometry, - progress: 1, - indicatorWidth: AppTheme.Stroke.thick - ), - geometry.staffStartX, - accuracy: 0.0001 - ) - } - func testNotationMeasureLayoutGroupsContiguousSelectionOverlayRuns() { let geometries = selectionOverlayTestGeometries(count: 4, width: 100) diff --git a/JammLabTests/NotationMeasureGeometryTests.swift b/JammLabTests/NotationMeasureGeometryTests.swift new file mode 100644 index 0000000..37d06cb --- /dev/null +++ b/JammLabTests/NotationMeasureGeometryTests.swift @@ -0,0 +1,410 @@ +import XCTest +@testable import JammLab + +final class NotationMeasureGeometryTests: XCTestCase { + func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { + let cMajor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "C major"), + timeSignature: .fourFour, + clef: .treble + ) + let aMinor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "A minor"), + timeSignature: .fourFour, + clef: .treble + ) + let fMajor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + + XCTAssertTrue(cMajor.keySignature.notationAccidentalGlyphs(for: cMajor.clef).isEmpty) + XCTAssertTrue(aMinor.keySignature.notationAccidentalGlyphs(for: aMinor.clef).isEmpty) + XCTAssertFalse(fMajor.keySignature.notationAccidentalGlyphs(for: fMajor.clef).isEmpty) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .full), + AppTheme.Timeline.notationAttributeStaffTopInset, + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: aMinor, display: .full), + NotationMeasureLayout.attributeStaffTopInset(for: fMajor, display: .full), + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .none), + 0, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForPartialAttributeBlocks() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "Bb major"), + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + let partialDisplays = [ + NotationAttributeDisplay(showsClef: true, showsKeySignature: false, showsTimeSignature: false), + NotationAttributeDisplay(showsClef: false, showsKeySignature: true, showsTimeSignature: false), + NotationAttributeDisplay(showsClef: false, showsKeySignature: false, showsTimeSignature: true) + ] + + for display in partialDisplays { + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: attributes, display: display), + AppTheme.Timeline.notationAttributeStaffTopInset, + accuracy: 0.0001 + ) + } + } + + func testNotationMeasureLayoutOffsetsAttributedMeasurePlayheadAfterAttributes() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4), + clef: .treble + ) + let cellWidth: CGFloat = 148 + let display = NotationAttributeDisplay.full + let attributeReserveWidth = NotationMeasureLayout.attributeReserveWidth( + for: attributes, + display: display + ) + + let attributedStart = NotationMeasureLayout.playheadX( + measureIndex: 0, + cellWidth: cellWidth, + progress: 0, + attributes: attributes, + display: display + ) + let attributedEnd = NotationMeasureLayout.playheadX( + measureIndex: 0, + cellWidth: cellWidth, + progress: 1, + attributes: attributes, + display: display + ) + let ordinaryStart = NotationMeasureLayout.playheadX( + measureIndex: 1, + cellWidth: cellWidth, + progress: 0, + attributes: attributes, + display: .none + ) + let contentStart = NotationMeasureLayout.contentStartX( + measureIndex: 0, + cellWidth: cellWidth, + attributes: attributes, + display: display + ) + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 0, + measureCount: 4, + cellWidth: cellWidth, + attributes: attributes, + display: display, + totalWidth: cellWidth * 4 + ) + let barlines = NotationMeasureLayout.barlineGeometries(for: [geometry]) + + XCTAssertGreaterThan(attributedStart, AppTheme.Spacing.md) + XCTAssertEqual(attributedStart, contentStart, accuracy: 0.0001) + XCTAssertEqual(attributedStart, attributeReserveWidth, accuracy: 0.0001) + XCTAssertEqual(attributedEnd, contentStart + cellWidth, accuracy: 0.0001) + XCTAssertEqual(ordinaryStart, cellWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.contentStartX, attributedStart, accuracy: 0.0001) + XCTAssertEqual(geometry.contentEndX, contentStart + cellWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, cellWidth, accuracy: 0.0001) + XCTAssertEqual( + NotationMeasureLayout.playheadX(geometry: geometry, progress: 1), + geometry.contentEndX, + accuracy: 0.0001 + ) + XCTAssertEqual(geometry.staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertFalse(geometry.includesRawStartBarline) + XCTAssertTrue(geometry.contentStartsAfterCellBoundary) + XCTAssertEqual(geometry.leadingBarlineX ?? -1, geometry.staffStartX, accuracy: 0.0001) + XCTAssertTrue(barlines.contains { abs($0.x - geometry.staffStartX) < 0.0001 }) + XCTAssertFalse(barlines.contains { abs($0.x - geometry.contentStartX) < 0.0001 }) + XCTAssertEqual(barlines.count, 2) + XCTAssertEqual(barlines[1].x, geometry.cellEndX, accuracy: 0.0001) + XCTAssertTrue(barlines[0].isOuterBoundary) + XCTAssertTrue(barlines[1].isOuterBoundary) + } + + func testNotationMeasureLayoutUsesTimeOnlyWidthAtTimeSignatureChange() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + let display = NotationAttributeDisplay( + showsClef: false, + showsKeySignature: false, + showsTimeSignature: true + ) + let cellWidth: CGFloat = 148 + + let blockWidth = NotationMeasureLayout.attributeBlockWidth( + for: attributes, + display: display, + cellWidth: cellWidth + ) + let contentStart = NotationMeasureLayout.contentStartX( + measureIndex: 2, + cellWidth: cellWidth, + attributes: attributes, + display: display + ) + let playheadStart = NotationMeasureLayout.playheadX( + measureIndex: 2, + cellWidth: cellWidth, + progress: 0, + attributes: attributes, + display: display + ) + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 2, + measureCount: 4, + cellWidth: cellWidth, + attributes: attributes, + display: display, + totalWidth: cellWidth * 4 + ) + let barlines = NotationMeasureLayout.barlineGeometries(for: [geometry]) + + XCTAssertEqual(blockWidth, AppTheme.Timeline.notationTimeSignatureWidth, accuracy: 0.0001) + XCTAssertEqual(contentStart, playheadStart, accuracy: 0.0001) + XCTAssertEqual(geometry.staffStartX, geometry.cellStartX, accuracy: 0.0001) + XCTAssertEqual(geometry.contentStartX, contentStart, accuracy: 0.0001) + XCTAssertTrue(geometry.includesRawStartBarline) + XCTAssertTrue(geometry.contentStartsAfterCellBoundary) + XCTAssertTrue(barlines.contains { abs($0.x - geometry.cellStartX) < 0.0001 }) + XCTAssertFalse(barlines.contains { abs($0.x - geometry.contentStartX) < 0.0001 }) + } + + func testNotationMeasureLayoutUsesThemeClefWidthInFullAttributeReserve() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + let blockWidth = NotationMeasureLayout.attributeBlockWidth( + for: attributes, + display: .full, + cellWidth: AppTheme.Timeline.notationMeasureMinWidth + ) + let reserveWidth = NotationMeasureLayout.attributeReserveWidth( + for: attributes, + display: .full + ) + let expectedBlockWidth = AppTheme.Timeline.notationClefWidth + + NotationMeasureLayout.keySignatureWidth(for: attributes) + + AppTheme.Timeline.notationTimeSignatureWidth + + NotationMeasureLayout.spacingWidth(forVisibleComponentCount: 3) + + XCTAssertEqual(blockWidth, expectedBlockWidth, accuracy: 0.0001) + XCTAssertEqual( + reserveWidth, + AppTheme.Spacing.md + expectedBlockWidth + AppTheme.Spacing.xs, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutKeepsOrdinaryMeasureAtRawBoundary() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "C major"), + timeSignature: .fourFour, + clef: .treble + ) + let cellWidth: CGFloat = 148 + + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 1, + measureCount: 4, + cellWidth: cellWidth, + attributes: attributes, + display: .none, + totalWidth: cellWidth * 4 + ) + + XCTAssertEqual(geometry.cellStartX, cellWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.contentStartX, cellWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.staffStartX, cellWidth, accuracy: 0.0001) + XCTAssertTrue(geometry.includesRawStartBarline) + XCTAssertFalse(geometry.contentStartsAfterCellBoundary) + } + + func testNotationMeasureLayoutKeepsPreviousBoundaryForAttributedMiddleMeasure() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "Bb major"), + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + let cellWidth: CGFloat = 148 + let display = NotationAttributeDisplay.full + let attributeReserveWidth = NotationMeasureLayout.attributeReserveWidth( + for: attributes, + display: display + ) + + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 2, + measureCount: 4, + cellWidth: cellWidth, + attributes: attributes, + display: display, + totalWidth: cellWidth * 4 + ) + + XCTAssertEqual(geometry.cellStartX, cellWidth * 2, accuracy: 0.0001) + XCTAssertGreaterThan(geometry.contentStartX, geometry.cellStartX) + XCTAssertEqual(geometry.contentStartX, geometry.cellStartX + attributeReserveWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, cellWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.cellEndX, geometry.contentStartX + cellWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.staffStartX, geometry.cellStartX, accuracy: 0.0001) + XCTAssertTrue(geometry.includesRawStartBarline) + XCTAssertTrue(geometry.contentStartsAfterCellBoundary) + XCTAssertFalse( + NotationMeasureLayout.barlineGeometries(for: [geometry]) + .contains { abs($0.x - geometry.contentStartX) < 0.0001 } + ) + } + + func testNotationMeasureLayoutExpandsAttributedMeasuresWithoutShrinkingBodies() { + let fullAttributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4), + clef: .treble + ) + let timeOnlyAttributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + let bodyWidth: CGFloat = 148 + let fullReserve = NotationMeasureLayout.attributeReserveWidth( + for: fullAttributes, + display: .full + ) + let timeReserve = NotationMeasureLayout.attributeReserveWidth( + for: timeOnlyAttributes, + display: NotationAttributeDisplay( + showsClef: false, + showsKeySignature: false, + showsTimeSignature: true + ) + ) + let totalWidth = NotationMeasureLayout.canvasWidth( + measureCount: 4, + availableWidth: bodyWidth * 4, + attributeReserveWidths: [fullReserve, 0, timeReserve, 0] + ) + + let geometries = NotationMeasureLayout.canvasGeometries( + measureCount: 4, + totalWidth: totalWidth, + attributeReserveWidths: [fullReserve, 0, timeReserve, 0] + ) + let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) + + XCTAssertEqual(geometries.count, 4) + XCTAssertEqual(totalWidth, bodyWidth * 4 + fullReserve + timeReserve, accuracy: 0.0001) + XCTAssertEqual(geometries[0].contentStartX, fullReserve, accuracy: 0.0001) + XCTAssertEqual(geometries[2].contentStartX, geometries[2].cellStartX + timeReserve, accuracy: 0.0001) + + for geometry in geometries { + XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, bodyWidth, accuracy: 0.0001) + } + + XCTAssertEqual(geometries[1].cellStartX, geometries[0].cellEndX, accuracy: 0.0001) + XCTAssertEqual(geometries[2].cellStartX, geometries[1].cellEndX, accuracy: 0.0001) + XCTAssertEqual(geometries[3].cellStartX, geometries[2].cellEndX, accuracy: 0.0001) + XCTAssertEqual(geometries[3].cellEndX, totalWidth, accuracy: 0.0001) + XCTAssertEqual(geometries[3].contentEndX, totalWidth, accuracy: 0.0001) + XCTAssertEqual( + NotationMeasureLayout.playheadX(geometry: geometries[3], progress: 1), + totalWidth, + accuracy: 0.0001 + ) + XCTAssertLessThanOrEqual( + NotationMeasureLayout.playheadIndicatorX( + geometry: geometries[3], + progress: 1, + indicatorWidth: AppTheme.Stroke.thick + ) + AppTheme.Stroke.thick, + geometries[3].staffEndX + 0.0001 + ) + XCTAssertEqual(geometries[0].staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertEqual(geometries[3].staffEndX, totalWidth - AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertEqual(barlines.last?.x ?? -1, totalWidth - AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertEqual(barlines.last?.x ?? -1, geometries[3].staffEndX, accuracy: 0.0001) + XCTAssertTrue(barlines.contains { abs($0.x - geometries[1].cellStartX) < 0.0001 }) + XCTAssertTrue(barlines.contains { abs($0.x - geometries[2].cellStartX) < 0.0001 }) + XCTAssertTrue(barlines.contains { abs($0.x - geometries[3].cellStartX) < 0.0001 }) + XCTAssertFalse(barlines.contains { abs($0.x - geometries[0].contentStartX) < 0.0001 }) + XCTAssertFalse(barlines.contains { abs($0.x - geometries[2].contentStartX) < 0.0001 }) + } + + func testNotationMeasureLayoutFallbackGeometryPreservesSymmetricOuterInsets() { + let totalWidth: CGFloat = 296 + + let geometries = NotationMeasureLayout.fallbackCanvasGeometries( + measureCount: 0, + totalWidth: totalWidth + ) + let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) + + XCTAssertEqual(geometries.count, 1) + XCTAssertEqual(geometries[0].contentStartX, 0, accuracy: 0.0001) + XCTAssertEqual(geometries[0].staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertEqual(geometries[0].staffEndX, totalWidth - AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertTrue(geometries[0].includesRawStartBarline) + XCTAssertFalse(geometries[0].contentStartsAfterCellBoundary) + XCTAssertEqual(geometries[0].leadingBarlineX ?? -1, geometries[0].staffStartX, accuracy: 0.0001) + XCTAssertEqual(barlines.last?.x ?? -1, geometries[0].staffEndX, accuracy: 0.0001) + XCTAssertEqual( + NotationMeasureLayout.playheadX(geometry: geometries[0], progress: 0), + geometries[0].contentStartX, + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.playheadIndicatorX( + geometry: geometries[0], + progress: 0, + indicatorWidth: AppTheme.Stroke.thick + ), + geometries[0].staffStartX, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutClampsSymmetricOuterInsetsForNarrowWidth() { + let inset = AppTheme.Timeline.notationStaffHorizontalInset + let totalWidth = inset * 1.5 + + let geometries = NotationMeasureLayout.fallbackCanvasGeometries( + measureCount: 1, + totalWidth: totalWidth + ) + let geometry = geometries[0] + let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) + + XCTAssertEqual(geometry.staffStartX, inset, accuracy: 0.0001) + XCTAssertEqual(geometry.staffEndX, geometry.staffStartX, accuracy: 0.0001) + XCTAssertEqual(barlines.first?.x ?? -1, geometry.staffStartX, accuracy: 0.0001) + XCTAssertEqual(barlines.last?.x ?? -1, geometry.staffEndX, accuracy: 0.0001) + XCTAssertEqual( + NotationMeasureLayout.playheadIndicatorX( + geometry: geometry, + progress: 1, + indicatorWidth: AppTheme.Stroke.thick + ), + geometry.staffStartX, + accuracy: 0.0001 + ) + } +} From 69e2b0ac6254dbfe5a5f8e4995852542a61bf572 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 13:42:40 +0300 Subject: [PATCH 15/80] test: split notation selection overlay coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 82 ------------------ .../NotationSelectionOverlayLayoutTests.swift | 86 +++++++++++++++++++ 3 files changed, 90 insertions(+), 82 deletions(-) create mode 100644 JammLabTests/NotationSelectionOverlayLayoutTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 863a53d..8f01dd3 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */; }; 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00152CE0000100112233 /* NotationViewportTests.swift */; }; 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */; }; + 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -310,6 +311,7 @@ 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureTimingTests.swift; sourceTree = ""; }; 9FAB00152CE0000100112233 /* NotationViewportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTests.swift; sourceTree = ""; }; 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureGeometryTests.swift; sourceTree = ""; }; + 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSelectionOverlayLayoutTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -580,6 +582,7 @@ 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */, 9FAB00152CE0000100112233 /* NotationViewportTests.swift */, 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, + 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -971,6 +974,7 @@ 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */, 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */, 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */, + 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index b0bd6a2..77949f5 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -19,70 +19,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(duration, 0.5, accuracy: 0.0001) } - func testNotationMeasureLayoutGroupsContiguousSelectionOverlayRuns() { - let geometries = selectionOverlayTestGeometries(count: 4, width: 100) - - let runs = NotationMeasureLayout.selectionOverlayRuns( - selectedMeasureIndices: [1, 2], - geometries: geometries - ) - - XCTAssertEqual(runs.count, 1) - XCTAssertEqual(runs[0].startMeasureIndex, 1) - XCTAssertEqual(runs[0].endMeasureIndex, 2) - XCTAssertEqual(runs[0].x, geometries[1].cellStartX, accuracy: 0.0001) - XCTAssertEqual(runs[0].width, geometries[2].cellEndX - geometries[1].cellStartX, accuracy: 0.0001) - } - - func testNotationMeasureLayoutKeepsSingleAndNonContiguousSelectionOverlayRunsSeparate() { - let geometries = selectionOverlayTestGeometries(count: 4, width: 100) - - let singleRun = NotationMeasureLayout.selectionOverlayRuns( - selectedMeasureIndices: [1], - geometries: geometries - ) - let separatedRuns = NotationMeasureLayout.selectionOverlayRuns( - selectedMeasureIndices: [0, 2], - geometries: geometries - ) - - XCTAssertEqual(singleRun.map(\.startMeasureIndex), [1]) - XCTAssertEqual(singleRun.map(\.endMeasureIndex), [1]) - XCTAssertEqual(separatedRuns.map(\.startMeasureIndex), [0, 2]) - XCTAssertEqual(separatedRuns.map(\.endMeasureIndex), [0, 2]) - } - - func testNotationMeasureLayoutNormalizesSelectionOverlayRunIndices() { - let geometries = selectionOverlayTestGeometries(count: 4, width: 100) - - let runs = NotationMeasureLayout.selectionOverlayRuns( - selectedMeasureIndices: [2, 1, 1, -1, 9], - geometries: geometries - ) - - XCTAssertEqual(runs.count, 1) - XCTAssertEqual(runs[0].startMeasureIndex, 1) - XCTAssertEqual(runs[0].endMeasureIndex, 2) - } - - func testNotationMeasureLayoutSelectionOverlayRunsStayWithinProvidedRowGeometry() { - let rowGeometries = selectionOverlayTestGeometries(count: 2, width: 100) - - let runs = NotationMeasureLayout.selectionOverlayRuns( - selectedMeasureIndices: [0, 1, 2], - geometries: rowGeometries - ) - let emptyRuns = NotationMeasureLayout.selectionOverlayRuns( - selectedMeasureIndices: [], - geometries: rowGeometries - ) - - XCTAssertEqual(runs.count, 1) - XCTAssertEqual(runs[0].startMeasureIndex, 0) - XCTAssertEqual(runs[0].endMeasureIndex, 1) - XCTAssertEqual(emptyRuns, []) - } - func testNotationMeasureLayoutPositionsSlashBeatCentersForFourFour() { let geometry = NotationMeasureCanvasGeometry( measureIndex: 0, @@ -859,22 +795,4 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertGreaterThan(harmonyStartX, geometry.cellStartX) } - private func selectionOverlayTestGeometries( - count: Int, - width: CGFloat - ) -> [NotationMeasureCanvasGeometry] { - (0.. [NotationMeasureCanvasGeometry] { + (0.. Date: Tue, 7 Jul 2026 13:46:47 +0300 Subject: [PATCH 16/80] test: split notation slash beat layout coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 151 ----------------- .../NotationSlashBeatLayoutTests.swift | 155 ++++++++++++++++++ 3 files changed, 159 insertions(+), 151 deletions(-) create mode 100644 JammLabTests/NotationSlashBeatLayoutTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 8f01dd3..db17a99 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -126,6 +126,7 @@ 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00152CE0000100112233 /* NotationViewportTests.swift */; }; 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */; }; 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */; }; + 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -312,6 +313,7 @@ 9FAB00152CE0000100112233 /* NotationViewportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTests.swift; sourceTree = ""; }; 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureGeometryTests.swift; sourceTree = ""; }; 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSelectionOverlayLayoutTests.swift; sourceTree = ""; }; + 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSlashBeatLayoutTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -583,6 +585,7 @@ 9FAB00152CE0000100112233 /* NotationViewportTests.swift */, 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */, + 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -975,6 +978,7 @@ 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */, 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */, 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */, + 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 77949f5..687c283 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -19,157 +19,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(duration, 0.5, accuracy: 0.0001) } - func testNotationMeasureLayoutPositionsSlashBeatCentersForFourFour() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 160, - contentStartX: 0, - contentEndX: 160, - staffStartX: 10, - staffEndX: 150 - ) - - let centers = NotationMeasureLayout.slashBeatCenters( - geometry: geometry, - timeSignature: .fourFour - ) - - XCTAssertEqual(centers.count, 4) - XCTAssertEqual(centers[0], 10, accuracy: 0.0001) - XCTAssertEqual(centers[1], 50, accuracy: 0.0001) - XCTAssertEqual(centers[2], 90, accuracy: 0.0001) - XCTAssertEqual(centers[3], 130, accuracy: 0.0001) - } - - func testNotationMeasureLayoutPositionsSlashBeatCentersAfterAttributes() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "Bb major"), - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 0, - measureCount: 4, - cellWidth: 148, - attributes: attributes, - display: .full, - totalWidth: 592 - ) - - let centers = NotationMeasureLayout.slashBeatCenters( - geometry: geometry, - timeSignature: attributes.timeSignature - ) - let beatSpacing = (geometry.contentEndX - geometry.contentStartX) / 3 - - XCTAssertEqual(centers.count, 3) - XCTAssertEqual( - centers[0], - geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset, - accuracy: 0.0001 - ) - XCTAssertEqual( - centers[1], - geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset + beatSpacing, - accuracy: 0.0001 - ) - XCTAssertEqual( - centers[2], - geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset + beatSpacing * 2, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutPositionsSlashBeatCentersForSevenFour() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 1, - cellStartX: 0, - cellEndX: 210, - contentStartX: 0, - contentEndX: 210, - staffStartX: 0, - staffEndX: 210 - ) - - let centers = NotationMeasureLayout.slashBeatCenters( - geometry: geometry, - timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4) - ) - - XCTAssertEqual(centers.count, 7) - XCTAssertEqual(centers[0], 10, accuracy: 0.0001) - XCTAssertEqual(centers[6], 190, accuracy: 0.0001) - } - - func testNotationMeasureLayoutPositionsSlashBeatCentersForNonQuarterBeatUnit() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 180, - contentStartX: 0, - contentEndX: 180, - staffStartX: 0, - staffEndX: 180 - ) - var sixEight = TimeSignature.fourFour - sixEight.beatsPerBar = 6 - sixEight.beatUnit = 8 - - let centers = NotationMeasureLayout.slashBeatCenters( - geometry: geometry, - timeSignature: sixEight - ) - - XCTAssertEqual(centers.count, 6) - XCTAssertEqual(centers[0], 10, accuracy: 0.0001) - XCTAssertEqual(centers[1], 40, accuracy: 0.0001) - XCTAssertEqual(centers[5], 160, accuracy: 0.0001) - } - - func testNotationMeasureLayoutOmitsSlashBeatCentersWhenContentIsInvalidOrTooNarrow() { - let zeroWidthGeometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 80, - contentStartX: 40, - contentEndX: 40, - staffStartX: 0, - staffEndX: 80 - ) - let negativeWidthGeometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 80, - contentStartX: 50, - contentEndX: 40, - staffStartX: 0, - staffEndX: 80 - ) - let narrowGeometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 40, - contentStartX: 0, - contentEndX: 40, - staffStartX: 0, - staffEndX: 40 - ) - - XCTAssertTrue(NotationMeasureLayout.slashBeatCenters( - geometry: zeroWidthGeometry, - timeSignature: .fourFour - ).isEmpty) - XCTAssertTrue(NotationMeasureLayout.slashBeatCenters( - geometry: negativeWidthGeometry, - timeSignature: .fourFour - ).isEmpty) - XCTAssertTrue(NotationMeasureLayout.slashBeatCenters( - geometry: narrowGeometry, - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4) - ).isEmpty) - } - func testNotationMeasureLayoutAlignsHarmonyAndSlashAnchors() { let geometry = NotationMeasureCanvasGeometry( measureIndex: 0, diff --git a/JammLabTests/NotationSlashBeatLayoutTests.swift b/JammLabTests/NotationSlashBeatLayoutTests.swift new file mode 100644 index 0000000..2e27a1c --- /dev/null +++ b/JammLabTests/NotationSlashBeatLayoutTests.swift @@ -0,0 +1,155 @@ +import XCTest +@testable import JammLab + +final class NotationSlashBeatLayoutTests: XCTestCase { + func testNotationMeasureLayoutPositionsSlashBeatCentersForFourFour() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 160, + contentStartX: 0, + contentEndX: 160, + staffStartX: 10, + staffEndX: 150 + ) + + let centers = NotationMeasureLayout.slashBeatCenters( + geometry: geometry, + timeSignature: .fourFour + ) + + XCTAssertEqual(centers.count, 4) + XCTAssertEqual(centers[0], 10, accuracy: 0.0001) + XCTAssertEqual(centers[1], 50, accuracy: 0.0001) + XCTAssertEqual(centers[2], 90, accuracy: 0.0001) + XCTAssertEqual(centers[3], 130, accuracy: 0.0001) + } + + func testNotationMeasureLayoutPositionsSlashBeatCentersAfterAttributes() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "Bb major"), + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 0, + measureCount: 4, + cellWidth: 148, + attributes: attributes, + display: .full, + totalWidth: 592 + ) + + let centers = NotationMeasureLayout.slashBeatCenters( + geometry: geometry, + timeSignature: attributes.timeSignature + ) + let beatSpacing = (geometry.contentEndX - geometry.contentStartX) / 3 + + XCTAssertEqual(centers.count, 3) + XCTAssertEqual( + centers[0], + geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset, + accuracy: 0.0001 + ) + XCTAssertEqual( + centers[1], + geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset + beatSpacing, + accuracy: 0.0001 + ) + XCTAssertEqual( + centers[2], + geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset + beatSpacing * 2, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutPositionsSlashBeatCentersForSevenFour() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 1, + cellStartX: 0, + cellEndX: 210, + contentStartX: 0, + contentEndX: 210, + staffStartX: 0, + staffEndX: 210 + ) + + let centers = NotationMeasureLayout.slashBeatCenters( + geometry: geometry, + timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4) + ) + + XCTAssertEqual(centers.count, 7) + XCTAssertEqual(centers[0], 10, accuracy: 0.0001) + XCTAssertEqual(centers[6], 190, accuracy: 0.0001) + } + + func testNotationMeasureLayoutPositionsSlashBeatCentersForNonQuarterBeatUnit() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 180, + contentStartX: 0, + contentEndX: 180, + staffStartX: 0, + staffEndX: 180 + ) + var sixEight = TimeSignature.fourFour + sixEight.beatsPerBar = 6 + sixEight.beatUnit = 8 + + let centers = NotationMeasureLayout.slashBeatCenters( + geometry: geometry, + timeSignature: sixEight + ) + + XCTAssertEqual(centers.count, 6) + XCTAssertEqual(centers[0], 10, accuracy: 0.0001) + XCTAssertEqual(centers[1], 40, accuracy: 0.0001) + XCTAssertEqual(centers[5], 160, accuracy: 0.0001) + } + + func testNotationMeasureLayoutOmitsSlashBeatCentersWhenContentIsInvalidOrTooNarrow() { + let zeroWidthGeometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 80, + contentStartX: 40, + contentEndX: 40, + staffStartX: 0, + staffEndX: 80 + ) + let negativeWidthGeometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 80, + contentStartX: 50, + contentEndX: 40, + staffStartX: 0, + staffEndX: 80 + ) + let narrowGeometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 40, + contentStartX: 0, + contentEndX: 40, + staffStartX: 0, + staffEndX: 40 + ) + + XCTAssertTrue(NotationMeasureLayout.slashBeatCenters( + geometry: zeroWidthGeometry, + timeSignature: .fourFour + ).isEmpty) + XCTAssertTrue(NotationMeasureLayout.slashBeatCenters( + geometry: negativeWidthGeometry, + timeSignature: .fourFour + ).isEmpty) + XCTAssertTrue(NotationMeasureLayout.slashBeatCenters( + geometry: narrowGeometry, + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4) + ).isEmpty) + } +} From bd3cd0951451392dce60f6f795d2266d43b0498b Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 14:17:13 +0300 Subject: [PATCH 17/80] test: split notation harmony label layout coverage --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioTimingLogicTests.swift | 153 ----------------- .../NotationHarmonyLabelLayoutTests.swift | 157 ++++++++++++++++++ 3 files changed, 161 insertions(+), 153 deletions(-) create mode 100644 JammLabTests/NotationHarmonyLabelLayoutTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index db17a99..29cad71 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -127,6 +127,7 @@ 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */; }; 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */; }; 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */; }; + 9FAB01192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -314,6 +315,7 @@ 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureGeometryTests.swift; sourceTree = ""; }; 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSelectionOverlayLayoutTests.swift; sourceTree = ""; }; 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSlashBeatLayoutTests.swift; sourceTree = ""; }; + 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyLabelLayoutTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -586,6 +588,7 @@ 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */, 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */, + 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -979,6 +982,7 @@ 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */, 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */, 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */, + 9FAB01192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index 687c283..eaa2dd2 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -246,140 +246,6 @@ final class AudioTimingLogicTests: XCTestCase { ) } - func testNotationMeasureLayoutPositionsHarmonyLabelBeforeInnerBeatAnchor() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 1, - cellStartX: 0, - cellEndX: 160, - contentStartX: 0, - contentEndX: 160, - staffStartX: 0, - staffEndX: 160 - ) - - let anchorX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 2, - timeSignature: .fourFour - ) - let labelX = NotationMeasureLayout.harmonyLabelX( - geometry: geometry, - offsetInQuarterNotes: 2, - timeSignature: .fourFour - ) - - XCTAssertEqual( - labelX, - anchorX - AppTheme.Timeline.notationHarmonyAnchorLeadingOffset, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutClampsFirstHarmonyLabelToVisibleStaffStart() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 160, - contentStartX: 0, - contentEndX: 160, - staffStartX: AppTheme.Timeline.notationStaffHorizontalInset, - staffEndX: 150 - ) - - let labelX = NotationMeasureLayout.harmonyLabelX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: .fourFour - ) - - XCTAssertEqual(labelX, geometry.staffStartX, accuracy: 0.0001) - } - - func testNotationMeasureLayoutKeepsAttributedFirstHarmonyLabelAfterAttributes() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 0, - measureCount: 4, - cellWidth: 148, - attributes: attributes, - display: .full, - totalWidth: 592 - ) - - let labelX = NotationMeasureLayout.harmonyLabelX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: attributes.timeSignature - ) - let anchorX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: attributes.timeSignature - ) - - XCTAssertGreaterThanOrEqual(labelX, geometry.contentStartX) - XCTAssertEqual( - labelX, - anchorX - AppTheme.Timeline.notationHarmonyAnchorLeadingOffset, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutPositionsNonFirstMeasureHarmonyLabelNearContentStart() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 1, - cellStartX: 160, - cellEndX: 320, - contentStartX: 160, - contentEndX: 320, - staffStartX: 160, - staffEndX: 320 - ) - - let labelX = NotationMeasureLayout.harmonyLabelX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: .fourFour - ) - let anchorX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: .fourFour - ) - - XCTAssertGreaterThanOrEqual(labelX, geometry.contentStartX) - XCTAssertEqual( - labelX, - anchorX - AppTheme.Timeline.notationHarmonyAnchorLeadingOffset, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutKeepsHarmonyLabelXBoundedForInvalidGeometry() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 40, - contentStartX: 40, - contentEndX: 40, - staffStartX: 20, - staffEndX: 40 - ) - - let labelX = NotationMeasureLayout.harmonyLabelX( - geometry: geometry, - offsetInQuarterNotes: 99, - timeSignature: .fourFour - ) - - XCTAssertFalse(labelX.isNaN) - XCTAssertEqual(labelX, geometry.contentStartX, accuracy: 0.0001) - } - func testNotationMeasureLayoutPositionsSystemMeasureNumberAtStaffStart() { let cellWidth: CGFloat = 148 let attributes = MeasureAttributes( @@ -427,25 +293,6 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(shallowLabelY, AppTheme.Spacing.xs, accuracy: 0.0001) } - func testNotationMeasureLayoutKeepsHarmonyLabelAboveStaff() { - let defaultStaffTop: CGFloat = 32 - let lowerStaffTop: CGFloat = 60 - - let defaultY = NotationMeasureLayout.harmonyLabelY(staffTop: defaultStaffTop) - let lowerY = NotationMeasureLayout.harmonyLabelY(staffTop: lowerStaffTop) - - XCTAssertEqual(defaultY, AppTheme.Spacing.xs, accuracy: 0.0001) - XCTAssertLessThanOrEqual( - defaultY + AppTheme.ControlSize.abletonNumberFieldHeight + AppTheme.Spacing.xs, - defaultStaffTop - ) - XCTAssertEqual( - lowerY + AppTheme.ControlSize.abletonNumberFieldHeight + AppTheme.Spacing.xs, - lowerStaffTop, - accuracy: 0.0001 - ) - } - func testNotationMeasureLayoutKeepsRegionLabelAboveHarmonyLabel() { let staffTop: CGFloat = 50 diff --git a/JammLabTests/NotationHarmonyLabelLayoutTests.swift b/JammLabTests/NotationHarmonyLabelLayoutTests.swift new file mode 100644 index 0000000..8859aa8 --- /dev/null +++ b/JammLabTests/NotationHarmonyLabelLayoutTests.swift @@ -0,0 +1,157 @@ +import XCTest +@testable import JammLab + +final class NotationHarmonyLabelLayoutTests: XCTestCase { + func testNotationMeasureLayoutPositionsHarmonyLabelBeforeInnerBeatAnchor() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 1, + cellStartX: 0, + cellEndX: 160, + contentStartX: 0, + contentEndX: 160, + staffStartX: 0, + staffEndX: 160 + ) + + let anchorX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 2, + timeSignature: .fourFour + ) + let labelX = NotationMeasureLayout.harmonyLabelX( + geometry: geometry, + offsetInQuarterNotes: 2, + timeSignature: .fourFour + ) + + XCTAssertEqual( + labelX, + anchorX - AppTheme.Timeline.notationHarmonyAnchorLeadingOffset, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutClampsFirstHarmonyLabelToVisibleStaffStart() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 160, + contentStartX: 0, + contentEndX: 160, + staffStartX: AppTheme.Timeline.notationStaffHorizontalInset, + staffEndX: 150 + ) + + let labelX = NotationMeasureLayout.harmonyLabelX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: .fourFour + ) + + XCTAssertEqual(labelX, geometry.staffStartX, accuracy: 0.0001) + } + + func testNotationMeasureLayoutKeepsAttributedFirstHarmonyLabelAfterAttributes() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 0, + measureCount: 4, + cellWidth: 148, + attributes: attributes, + display: .full, + totalWidth: 592 + ) + + let labelX = NotationMeasureLayout.harmonyLabelX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: attributes.timeSignature + ) + let anchorX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: attributes.timeSignature + ) + + XCTAssertGreaterThanOrEqual(labelX, geometry.contentStartX) + XCTAssertEqual( + labelX, + anchorX - AppTheme.Timeline.notationHarmonyAnchorLeadingOffset, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutPositionsNonFirstMeasureHarmonyLabelNearContentStart() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 1, + cellStartX: 160, + cellEndX: 320, + contentStartX: 160, + contentEndX: 320, + staffStartX: 160, + staffEndX: 320 + ) + + let labelX = NotationMeasureLayout.harmonyLabelX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: .fourFour + ) + let anchorX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: .fourFour + ) + + XCTAssertGreaterThanOrEqual(labelX, geometry.contentStartX) + XCTAssertEqual( + labelX, + anchorX - AppTheme.Timeline.notationHarmonyAnchorLeadingOffset, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutKeepsHarmonyLabelXBoundedForInvalidGeometry() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 40, + contentStartX: 40, + contentEndX: 40, + staffStartX: 20, + staffEndX: 40 + ) + + let labelX = NotationMeasureLayout.harmonyLabelX( + geometry: geometry, + offsetInQuarterNotes: 99, + timeSignature: .fourFour + ) + + XCTAssertFalse(labelX.isNaN) + XCTAssertEqual(labelX, geometry.contentStartX, accuracy: 0.0001) + } + + func testNotationMeasureLayoutKeepsHarmonyLabelAboveStaff() { + let defaultStaffTop: CGFloat = 32 + let lowerStaffTop: CGFloat = 60 + + let defaultY = NotationMeasureLayout.harmonyLabelY(staffTop: defaultStaffTop) + let lowerY = NotationMeasureLayout.harmonyLabelY(staffTop: lowerStaffTop) + + XCTAssertEqual(defaultY, AppTheme.Spacing.xs, accuracy: 0.0001) + XCTAssertLessThanOrEqual( + defaultY + AppTheme.ControlSize.abletonNumberFieldHeight + AppTheme.Spacing.xs, + defaultStaffTop + ) + XCTAssertEqual( + lowerY + AppTheme.ControlSize.abletonNumberFieldHeight + AppTheme.Spacing.xs, + lowerStaffTop, + accuracy: 0.0001 + ) + } +} From 1a6a2db6962759adc263dcf9f0f20dadb49304b5 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 14:41:22 +0300 Subject: [PATCH 18/80] test: split audio timing and notation layout tests --- JammLab.xcodeproj/project.pbxproj | 28 +- .../AudioFileImporterDurationTests.swift | 21 + JammLabTests/AudioTimingLogicTests.swift | 494 ------------------ JammLabTests/NotationAnchorLayoutTests.swift | 123 +++++ .../NotationRegionLabelLayoutTests.swift | 66 +++ JammLabTests/NotationRestLayoutTests.swift | 142 +++++ ...tationSystemMeasureNumberLayoutTests.swift | 51 ++ .../NotationTrackLayoutItemsTests.swift | 110 ++++ 8 files changed, 537 insertions(+), 498 deletions(-) create mode 100644 JammLabTests/AudioFileImporterDurationTests.swift delete mode 100644 JammLabTests/AudioTimingLogicTests.swift create mode 100644 JammLabTests/NotationAnchorLayoutTests.swift create mode 100644 JammLabTests/NotationRegionLabelLayoutTests.swift create mode 100644 JammLabTests/NotationRestLayoutTests.swift create mode 100644 JammLabTests/NotationSystemMeasureNumberLayoutTests.swift create mode 100644 JammLabTests/NotationTrackLayoutItemsTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 29cad71..d697547 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -103,7 +103,7 @@ 9F9601012CC0000100112233 /* JammValueSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9600012CC0000100112233 /* JammValueSlider.swift */; }; 9F9701012CD0000100112233 /* TimelineViewportControlBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9700012CD0000100112233 /* TimelineViewportControlBar.swift */; }; 9F9801012CF0000100112233 /* RenameNoteDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9800012CF0000100112233 /* RenameNoteDialog.swift */; }; - 9FAB01012CE0000100112233 /* AudioTimingLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00012CE0000100112233 /* AudioTimingLogicTests.swift */; }; + 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */; }; 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */; }; 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */; }; 9FAB01042CE0000100112233 /* ViewModelLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */; }; @@ -128,6 +128,11 @@ 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */; }; 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */; }; 9FAB01192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */; }; + 9FAB011A2CE0000100112233 /* NotationAnchorLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001A2CE0000100112233 /* NotationAnchorLayoutTests.swift */; }; + 9FAB011B2CE0000100112233 /* NotationRestLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001B2CE0000100112233 /* NotationRestLayoutTests.swift */; }; + 9FAB011C2CE0000100112233 /* NotationSystemMeasureNumberLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001C2CE0000100112233 /* NotationSystemMeasureNumberLayoutTests.swift */; }; + 9FAB011D2CE0000100112233 /* NotationRegionLabelLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001D2CE0000100112233 /* NotationRegionLabelLayoutTests.swift */; }; + 9FAB011E2CE0000100112233 /* NotationTrackLayoutItemsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001E2CE0000100112233 /* NotationTrackLayoutItemsTests.swift */; }; 9FAC01012CF0000100112233 /* ProjectEditableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAC00012CF0000100112233 /* ProjectEditableState.swift */; }; 9FAE01012D01000100112233 /* VideoAudioExtractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */; }; 9FAE01022D01000100112233 /* VideoFollowerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022D01000100112233 /* VideoFollowerController.swift */; }; @@ -291,7 +296,7 @@ 9F9600012CC0000100112233 /* JammValueSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JammValueSlider.swift; sourceTree = ""; }; 9F9700012CD0000100112233 /* TimelineViewportControlBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewportControlBar.swift; sourceTree = ""; }; 9F9800012CF0000100112233 /* RenameNoteDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameNoteDialog.swift; sourceTree = ""; }; - 9FAB00012CE0000100112233 /* AudioTimingLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTimingLogicTests.swift; sourceTree = ""; }; + 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileImporterDurationTests.swift; sourceTree = ""; }; 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeakformLogicTests.swift; sourceTree = ""; }; 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProjectLogicTests.swift; sourceTree = ""; }; 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelLifecycleTests.swift; sourceTree = ""; }; @@ -316,6 +321,11 @@ 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSelectionOverlayLayoutTests.swift; sourceTree = ""; }; 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSlashBeatLayoutTests.swift; sourceTree = ""; }; 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyLabelLayoutTests.swift; sourceTree = ""; }; + 9FAB001A2CE0000100112233 /* NotationAnchorLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAnchorLayoutTests.swift; sourceTree = ""; }; + 9FAB001B2CE0000100112233 /* NotationRestLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRestLayoutTests.swift; sourceTree = ""; }; + 9FAB001C2CE0000100112233 /* NotationSystemMeasureNumberLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSystemMeasureNumberLayoutTests.swift; sourceTree = ""; }; + 9FAB001D2CE0000100112233 /* NotationRegionLabelLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRegionLabelLayoutTests.swift; sourceTree = ""; }; + 9FAB001E2CE0000100112233 /* NotationTrackLayoutItemsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackLayoutItemsTests.swift; sourceTree = ""; }; 9FAC00012CF0000100112233 /* ProjectEditableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectEditableState.swift; sourceTree = ""; }; 9FAE00012D01000100112233 /* VideoAudioExtractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAudioExtractionService.swift; sourceTree = ""; }; 9FAE00022D01000100112233 /* VideoFollowerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFollowerController.swift; sourceTree = ""; }; @@ -563,7 +573,7 @@ 9F8B03012C10000100112233 /* JammLabTests */ = { isa = PBXGroup; children = ( - 9FAB00012CE0000100112233 /* AudioTimingLogicTests.swift */, + 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */, 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */, 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, @@ -589,6 +599,11 @@ 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */, 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */, 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */, + 9FAB001A2CE0000100112233 /* NotationAnchorLayoutTests.swift */, + 9FAB001B2CE0000100112233 /* NotationRestLayoutTests.swift */, + 9FAB001C2CE0000100112233 /* NotationSystemMeasureNumberLayoutTests.swift */, + 9FAB001D2CE0000100112233 /* NotationRegionLabelLayoutTests.swift */, + 9FAB001E2CE0000100112233 /* NotationTrackLayoutItemsTests.swift */, 9F8B00072C10000100112233 /* TestSupport.swift */, ); path = JammLabTests; @@ -957,7 +972,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9FAB01012CE0000100112233 /* AudioTimingLogicTests.swift in Sources */, + 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */, 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */, 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, @@ -983,6 +998,11 @@ 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */, 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */, 9FAB01192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift in Sources */, + 9FAB011A2CE0000100112233 /* NotationAnchorLayoutTests.swift in Sources */, + 9FAB011B2CE0000100112233 /* NotationRestLayoutTests.swift in Sources */, + 9FAB011C2CE0000100112233 /* NotationSystemMeasureNumberLayoutTests.swift in Sources */, + 9FAB011D2CE0000100112233 /* NotationRegionLabelLayoutTests.swift in Sources */, + 9FAB011E2CE0000100112233 /* NotationTrackLayoutItemsTests.swift in Sources */, 9F8B01062C10000100112233 /* TestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JammLabTests/AudioFileImporterDurationTests.swift b/JammLabTests/AudioFileImporterDurationTests.swift new file mode 100644 index 0000000..a1a7403 --- /dev/null +++ b/JammLabTests/AudioFileImporterDurationTests.swift @@ -0,0 +1,21 @@ +import AVFoundation +import XCTest +@testable import JammLab + +final class AudioFileImporterDurationTests: XCTestCase { + func testDecodedAudioDurationUsesPCMFrameLength() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("jammlab-duration-\(UUID().uuidString).caf") + defer { try? FileManager.default.removeItem(at: url) } + + let format = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)! + let file = try AVAudioFile(forWriting: url, settings: format.settings) + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 22_050)! + buffer.frameLength = 22_050 + try file.write(from: buffer) + + let duration = try AudioFileImporter.decodedDuration(for: url) + + XCTAssertEqual(duration, 0.5, accuracy: 0.0001) + } +} diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift deleted file mode 100644 index eaa2dd2..0000000 --- a/JammLabTests/AudioTimingLogicTests.swift +++ /dev/null @@ -1,494 +0,0 @@ -import AVFoundation -import XCTest -@testable import JammLab - -final class AudioTimingLogicTests: XCTestCase { - func testDecodedAudioDurationUsesPCMFrameLength() throws { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("jammlab-duration-\(UUID().uuidString).caf") - defer { try? FileManager.default.removeItem(at: url) } - - let format = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)! - let file = try AVAudioFile(forWriting: url, settings: format.settings) - let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 22_050)! - buffer.frameLength = 22_050 - try file.write(from: buffer) - - let duration = try AudioFileImporter.decodedDuration(for: url) - - XCTAssertEqual(duration, 0.5, accuracy: 0.0001) - } - - func testNotationMeasureLayoutAlignsHarmonyAndSlashAnchors() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 160, - contentStartX: 0, - contentEndX: 160, - staffStartX: 0, - staffEndX: 160 - ) - let slashCenters = NotationMeasureLayout.slashBeatCenters( - geometry: geometry, - timeSignature: .fourFour - ) - - let firstHarmonyX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: .fourFour - ) - let thirdHarmonyX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 2, - timeSignature: .fourFour - ) - - XCTAssertEqual(firstHarmonyX, slashCenters[0], accuracy: 0.0001) - XCTAssertEqual(thirdHarmonyX, slashCenters[2], accuracy: 0.0001) - } - - func testNotationMeasureLayoutClampsHarmonyAnchorsInsideMeasure() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 160, - contentStartX: 0, - contentEndX: 160, - staffStartX: 0, - staffEndX: 160 - ) - - let endX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: NotationMeasureLayout.quarterLength(for: .fourFour), - timeSignature: .fourFour - ) - let outOfRangeX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 99, - timeSignature: .fourFour - ) - - XCTAssertEqual(endX, geometry.contentEndX, accuracy: 0.0001) - XCTAssertEqual(outOfRangeX, geometry.contentEndX, accuracy: 0.0001) - } - - func testNotationMeasureLayoutCentersSingleFullMeasureWholeRest() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 200, - contentStartX: 20, - contentEndX: 180, - staffStartX: 0, - staffEndX: 200 - ) - let item = NotationMeasureItem( - id: "whole-rest", - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 0, - durationInQuarterNotes: 3, - displayDuration: NotationDuration(denominator: 1) - ) - let measure = ScoreMeasure( - number: 1, - startTime: 0, - endTime: 1.5, - attributes: MeasureAttributes( - keySignature: .cMajor, - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ), - notationItems: [item] - ) - - let x = NotationMeasureLayout.notationItemX( - geometry: geometry, - measure: measure, - item: item - ) - - XCTAssertEqual(x, 100, accuracy: 0.0001) - } - - func testNotationMeasureLayoutDoesNotCenterNonFullMeasureWholeRestCases() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 200, - contentStartX: 20, - contentEndX: 180, - staffStartX: 0, - staffEndX: 200 - ) - let partialItem = NotationMeasureItem( - id: "partial", - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 0, - durationInQuarterNotes: 2, - displayDuration: NotationDuration(denominator: 1) - ) - let offsetItem = NotationMeasureItem( - id: "offset", - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 1, - durationInQuarterNotes: 4, - displayDuration: NotationDuration(denominator: 1) - ) - let quarterItem = NotationMeasureItem( - id: "quarter", - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 0, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ) - - for item in [partialItem, offsetItem, quarterItem] { - let measure = ScoreMeasure( - number: 1, - startTime: 0, - endTime: 2, - attributes: .defaultTreble, - notationItems: [item] - ) - let expectedX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: item.offsetInQuarterNotes, - timeSignature: measure.attributes.timeSignature - ) - - XCTAssertEqual( - NotationMeasureLayout.notationItemX( - geometry: geometry, - measure: measure, - item: item - ), - expectedX, - accuracy: 0.0001 - ) - } - - let firstSplitItem = NotationMeasureItem( - id: "split-a", - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 0, - durationInQuarterNotes: 2, - displayDuration: NotationDuration(denominator: 1) - ) - let secondSplitItem = NotationMeasureItem( - id: "split-b", - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 2, - durationInQuarterNotes: 2, - displayDuration: NotationDuration(denominator: 2) - ) - let splitMeasure = ScoreMeasure( - number: 1, - startTime: 0, - endTime: 2, - attributes: .defaultTreble, - notationItems: [firstSplitItem, secondSplitItem] - ) - - XCTAssertEqual( - NotationMeasureLayout.notationItemX( - geometry: geometry, - measure: splitMeasure, - item: firstSplitItem - ), - NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: firstSplitItem.offsetInQuarterNotes, - timeSignature: splitMeasure.attributes.timeSignature - ), - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutMapsAnchorXBackToProgress() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 160, - contentStartX: 0, - contentEndX: 160, - staffStartX: 0, - staffEndX: 160 - ) - let firstAnchorX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: .fourFour - ) - let thirdAnchorX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 2, - timeSignature: .fourFour - ) - - XCTAssertEqual( - NotationMeasureLayout.notationAnchorProgress(atX: firstAnchorX, geometry: geometry), - 0, - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.notationAnchorProgress(atX: thirdAnchorX, geometry: geometry), - 0.5, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutPositionsSystemMeasureNumberAtStaffStart() { - let cellWidth: CGFloat = 148 - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "C major"), - timeSignature: .fourFour, - clef: .treble - ) - let firstGeometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 0, - measureCount: 4, - cellWidth: cellWidth, - attributes: attributes, - display: .full, - totalWidth: cellWidth * 4 - ) - - let labelX = NotationMeasureLayout.systemMeasureNumberLabelX( - geometry: firstGeometry - ) - let labelTrailingX = NotationMeasureLayout.systemMeasureNumberLabelTrailingX( - geometry: firstGeometry - ) - let expectedTrailingX = firstGeometry.staffStartX + AppTheme.Spacing.sm - let expectedX = expectedTrailingX - NotationMeasureLayout.measureNumberLabelWidth - - let staffTop: CGFloat = 32 - let labelY = NotationMeasureLayout.systemMeasureNumberLabelY(staffTop: staffTop) - let shallowLabelY = NotationMeasureLayout.systemMeasureNumberLabelY( - staffTop: AppTheme.Spacing.xs - ) - - XCTAssertEqual(labelTrailingX, expectedTrailingX, accuracy: 0.0001) - XCTAssertEqual( - labelX + NotationMeasureLayout.measureNumberLabelWidth, - expectedTrailingX, - accuracy: 0.0001 - ) - XCTAssertEqual(labelX, expectedX, accuracy: 0.0001) - XCTAssertLessThan(labelX, firstGeometry.staffStartX) - XCTAssertEqual( - labelY, - staffTop - NotationMeasureLayout.systemMeasureNumberStaffGap, - accuracy: 0.0001 - ) - XCTAssertEqual(shallowLabelY, AppTheme.Spacing.xs, accuracy: 0.0001) - } - - func testNotationMeasureLayoutKeepsRegionLabelAboveHarmonyLabel() { - let staffTop: CGFloat = 50 - - let regionY = NotationMeasureLayout.regionLabelY(staffTop: staffTop) - let harmonyY = NotationMeasureLayout.harmonyLabelY(staffTop: staffTop) - - XCTAssertLessThan(regionY, harmonyY) - XCTAssertLessThanOrEqual( - regionY - + AppTheme.Timeline.notationRegionLabelHeight - + AppTheme.Timeline.notationRegionLabelGap, - harmonyY + 0.0001 - ) - } - - func testNotationMeasureLayoutKeepsFirstRegionLabelAfterMeasureNumber() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 180, - contentStartX: 0, - contentEndX: 180, - staffStartX: 10, - staffEndX: 180 - ) - - let x = NotationMeasureLayout.regionLabelX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: .fourFour, - avoidsSystemMeasureNumber: true - ) - let minimumX = NotationMeasureLayout.systemMeasureNumberLabelTrailingX( - geometry: geometry - ) + AppTheme.Spacing.sm - - XCTAssertGreaterThanOrEqual(x, minimumX) - } - - func testNotationMeasureLayoutClampsRegionLabelInsideVisibleBounds() { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 1, - cellStartX: 180, - cellEndX: 360, - contentStartX: 180, - contentEndX: 360, - staffStartX: 180, - staffEndX: 350 - ) - let labelWidth: CGFloat = 64 - - let x = NotationMeasureLayout.regionLabelX( - geometry: geometry, - offsetInQuarterNotes: 99, - timeSignature: .fourFour, - labelWidth: labelWidth - ) - - XCTAssertGreaterThanOrEqual(x, geometry.staffStartX) - XCTAssertLessThanOrEqual(x + labelWidth, geometry.staffEndX + 0.0001) - } - - func testNotationTrackLayoutItemsBuildsMeasureItemsFromPureInputs() throws { - let geometry = NotationMeasureCanvasGeometry( - measureIndex: 0, - cellStartX: 0, - cellEndX: 520, - contentStartX: 0, - contentEndX: 520, - staffStartX: 20, - staffEndX: 520 - ) - let firstRegionID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) - let secondRegionID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000002")) - let harmonyID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000003")) - let notationItem = NotationMeasureItem( - id: "quarter-rest", - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 1, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ) - let harmony = HarmonySymbol( - id: harmonyID, - time: 1, - measureNumber: 1, - offsetInQuarterNotes: 2, - rawText: "G7" - ) - let measure = ScoreMeasure( - number: 1, - startTime: 0, - endTime: 2, - attributes: .defaultTreble, - notationItems: [notationItem], - harmonies: [harmony], - regionLabels: [ - NotationRegionLabel( - id: firstRegionID, - time: 0, - measureNumber: 1, - offsetInQuarterNotes: 0, - title: "Intro" - ), - NotationRegionLabel( - id: secondRegionID, - time: 0.25, - measureNumber: 1, - offsetInQuarterNotes: 0, - title: "Verse" - ) - ] - ) - - let regionItems = NotationTrackLayoutItems.regionLabels( - visibleMeasures: [measure], - geometries: [geometry] - ) - let harmonyItems = NotationTrackLayoutItems.harmonies( - visibleMeasures: [measure], - geometries: [geometry] - ) - let notationItems = NotationTrackLayoutItems.notationItems( - visibleMeasures: [measure], - geometries: [geometry] - ) - - XCTAssertEqual(regionItems.map(\.label.id), [firstRegionID, secondRegionID]) - XCTAssertGreaterThanOrEqual( - try XCTUnwrap(regionItems.first).x, - NotationMeasureLayout.systemMeasureNumberLabelTrailingX(geometry: geometry) - + AppTheme.Spacing.sm - ) - XCTAssertGreaterThanOrEqual( - try XCTUnwrap(regionItems.last).x, - try XCTUnwrap(regionItems.first).x - + AppTheme.Timeline.notationRegionLabelMaxWidth - + AppTheme.Timeline.notationRegionLabelGap - - 0.0001 - ) - - let harmonyItem = try XCTUnwrap(harmonyItems.first) - XCTAssertEqual(harmonyItem.symbol, harmony) - XCTAssertEqual( - harmonyItem.x, - NotationMeasureLayout.harmonyLabelX( - geometry: geometry, - offsetInQuarterNotes: harmony.offsetInQuarterNotes, - timeSignature: measure.attributes.timeSignature - ), - accuracy: 0.0001 - ) - - let layoutNotationItem = try XCTUnwrap(notationItems.first) - XCTAssertEqual(layoutNotationItem.notationItem, notationItem) - XCTAssertTrue(layoutNotationItem.selection.matches(measure, item: notationItem)) - XCTAssertEqual( - layoutNotationItem.x, - NotationMeasureLayout.notationItemX( - geometry: geometry, - measure: measure, - item: notationItem - ), - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutPositionsHarmonyAfterAttributes() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4), - clef: .treble - ) - let cellWidth: CGFloat = 148 - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 0, - measureCount: 4, - cellWidth: cellWidth, - attributes: attributes, - display: .full, - totalWidth: cellWidth * 4 - ) - - let harmonyStartX = NotationMeasureLayout.harmonyX( - geometry: geometry, - offsetInQuarterNotes: 0, - timeSignature: attributes.timeSignature - ) - - XCTAssertEqual( - harmonyStartX, - geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset, - accuracy: 0.0001 - ) - XCTAssertGreaterThan(harmonyStartX, geometry.cellStartX) - } - -} diff --git a/JammLabTests/NotationAnchorLayoutTests.swift b/JammLabTests/NotationAnchorLayoutTests.swift new file mode 100644 index 0000000..2465acc --- /dev/null +++ b/JammLabTests/NotationAnchorLayoutTests.swift @@ -0,0 +1,123 @@ +import XCTest +@testable import JammLab + +final class NotationAnchorLayoutTests: XCTestCase { + func testNotationMeasureLayoutAlignsHarmonyAndSlashAnchors() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 160, + contentStartX: 0, + contentEndX: 160, + staffStartX: 0, + staffEndX: 160 + ) + let slashCenters = NotationMeasureLayout.slashBeatCenters( + geometry: geometry, + timeSignature: .fourFour + ) + + let firstHarmonyX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: .fourFour + ) + let thirdHarmonyX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 2, + timeSignature: .fourFour + ) + + XCTAssertEqual(firstHarmonyX, slashCenters[0], accuracy: 0.0001) + XCTAssertEqual(thirdHarmonyX, slashCenters[2], accuracy: 0.0001) + } + + func testNotationMeasureLayoutClampsHarmonyAnchorsInsideMeasure() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 160, + contentStartX: 0, + contentEndX: 160, + staffStartX: 0, + staffEndX: 160 + ) + + let endX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: NotationMeasureLayout.quarterLength(for: .fourFour), + timeSignature: .fourFour + ) + let outOfRangeX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 99, + timeSignature: .fourFour + ) + + XCTAssertEqual(endX, geometry.contentEndX, accuracy: 0.0001) + XCTAssertEqual(outOfRangeX, geometry.contentEndX, accuracy: 0.0001) + } + + func testNotationMeasureLayoutMapsAnchorXBackToProgress() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 160, + contentStartX: 0, + contentEndX: 160, + staffStartX: 0, + staffEndX: 160 + ) + let firstAnchorX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: .fourFour + ) + let thirdAnchorX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 2, + timeSignature: .fourFour + ) + + XCTAssertEqual( + NotationMeasureLayout.notationAnchorProgress(atX: firstAnchorX, geometry: geometry), + 0, + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.notationAnchorProgress(atX: thirdAnchorX, geometry: geometry), + 0.5, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutPositionsHarmonyAfterAttributes() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4), + clef: .treble + ) + let cellWidth: CGFloat = 148 + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 0, + measureCount: 4, + cellWidth: cellWidth, + attributes: attributes, + display: .full, + totalWidth: cellWidth * 4 + ) + + let harmonyStartX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: attributes.timeSignature + ) + + XCTAssertEqual( + harmonyStartX, + geometry.contentStartX + AppTheme.Timeline.notationItemAnchorInset, + accuracy: 0.0001 + ) + XCTAssertGreaterThan(harmonyStartX, geometry.cellStartX) + } +} diff --git a/JammLabTests/NotationRegionLabelLayoutTests.swift b/JammLabTests/NotationRegionLabelLayoutTests.swift new file mode 100644 index 0000000..cf80dc0 --- /dev/null +++ b/JammLabTests/NotationRegionLabelLayoutTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import JammLab + +final class NotationRegionLabelLayoutTests: XCTestCase { + func testNotationMeasureLayoutKeepsRegionLabelAboveHarmonyLabel() { + let staffTop: CGFloat = 50 + + let regionY = NotationMeasureLayout.regionLabelY(staffTop: staffTop) + let harmonyY = NotationMeasureLayout.harmonyLabelY(staffTop: staffTop) + + XCTAssertLessThan(regionY, harmonyY) + XCTAssertLessThanOrEqual( + regionY + + AppTheme.Timeline.notationRegionLabelHeight + + AppTheme.Timeline.notationRegionLabelGap, + harmonyY + 0.0001 + ) + } + + func testNotationMeasureLayoutKeepsFirstRegionLabelAfterMeasureNumber() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 180, + contentStartX: 0, + contentEndX: 180, + staffStartX: 10, + staffEndX: 180 + ) + + let x = NotationMeasureLayout.regionLabelX( + geometry: geometry, + offsetInQuarterNotes: 0, + timeSignature: .fourFour, + avoidsSystemMeasureNumber: true + ) + let minimumX = NotationMeasureLayout.systemMeasureNumberLabelTrailingX( + geometry: geometry + ) + AppTheme.Spacing.sm + + XCTAssertGreaterThanOrEqual(x, minimumX) + } + + func testNotationMeasureLayoutClampsRegionLabelInsideVisibleBounds() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 1, + cellStartX: 180, + cellEndX: 360, + contentStartX: 180, + contentEndX: 360, + staffStartX: 180, + staffEndX: 350 + ) + let labelWidth: CGFloat = 64 + + let x = NotationMeasureLayout.regionLabelX( + geometry: geometry, + offsetInQuarterNotes: 99, + timeSignature: .fourFour, + labelWidth: labelWidth + ) + + XCTAssertGreaterThanOrEqual(x, geometry.staffStartX) + XCTAssertLessThanOrEqual(x + labelWidth, geometry.staffEndX + 0.0001) + } +} diff --git a/JammLabTests/NotationRestLayoutTests.swift b/JammLabTests/NotationRestLayoutTests.swift new file mode 100644 index 0000000..1cfac67 --- /dev/null +++ b/JammLabTests/NotationRestLayoutTests.swift @@ -0,0 +1,142 @@ +import XCTest +@testable import JammLab + +final class NotationRestLayoutTests: XCTestCase { + func testNotationMeasureLayoutCentersSingleFullMeasureWholeRest() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let item = NotationMeasureItem( + id: "whole-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 1) + ) + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 1.5, + attributes: MeasureAttributes( + keySignature: .cMajor, + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ), + notationItems: [item] + ) + + let x = NotationMeasureLayout.notationItemX( + geometry: geometry, + measure: measure, + item: item + ) + + XCTAssertEqual(x, 100, accuracy: 0.0001) + } + + func testNotationMeasureLayoutDoesNotCenterNonFullMeasureWholeRestCases() { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let partialItem = NotationMeasureItem( + id: "partial", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 1) + ) + let offsetItem = NotationMeasureItem( + id: "offset", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1) + ) + let quarterItem = NotationMeasureItem( + id: "quarter", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + + for item in [partialItem, offsetItem, quarterItem] { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [item] + ) + let expectedX = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: item.offsetInQuarterNotes, + timeSignature: measure.attributes.timeSignature + ) + + XCTAssertEqual( + NotationMeasureLayout.notationItemX( + geometry: geometry, + measure: measure, + item: item + ), + expectedX, + accuracy: 0.0001 + ) + } + + let firstSplitItem = NotationMeasureItem( + id: "split-a", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 1) + ) + let secondSplitItem = NotationMeasureItem( + id: "split-b", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + let splitMeasure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [firstSplitItem, secondSplitItem] + ) + + XCTAssertEqual( + NotationMeasureLayout.notationItemX( + geometry: geometry, + measure: splitMeasure, + item: firstSplitItem + ), + NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: firstSplitItem.offsetInQuarterNotes, + timeSignature: splitMeasure.attributes.timeSignature + ), + accuracy: 0.0001 + ) + } +} diff --git a/JammLabTests/NotationSystemMeasureNumberLayoutTests.swift b/JammLabTests/NotationSystemMeasureNumberLayoutTests.swift new file mode 100644 index 0000000..da4e342 --- /dev/null +++ b/JammLabTests/NotationSystemMeasureNumberLayoutTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import JammLab + +final class NotationSystemMeasureNumberLayoutTests: XCTestCase { + func testNotationMeasureLayoutPositionsSystemMeasureNumberAtStaffStart() { + let cellWidth: CGFloat = 148 + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "C major"), + timeSignature: .fourFour, + clef: .treble + ) + let firstGeometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 0, + measureCount: 4, + cellWidth: cellWidth, + attributes: attributes, + display: .full, + totalWidth: cellWidth * 4 + ) + + let labelX = NotationMeasureLayout.systemMeasureNumberLabelX( + geometry: firstGeometry + ) + let labelTrailingX = NotationMeasureLayout.systemMeasureNumberLabelTrailingX( + geometry: firstGeometry + ) + let expectedTrailingX = firstGeometry.staffStartX + AppTheme.Spacing.sm + let expectedX = expectedTrailingX - NotationMeasureLayout.measureNumberLabelWidth + + let staffTop: CGFloat = 32 + let labelY = NotationMeasureLayout.systemMeasureNumberLabelY(staffTop: staffTop) + let shallowLabelY = NotationMeasureLayout.systemMeasureNumberLabelY( + staffTop: AppTheme.Spacing.xs + ) + + XCTAssertEqual(labelTrailingX, expectedTrailingX, accuracy: 0.0001) + XCTAssertEqual( + labelX + NotationMeasureLayout.measureNumberLabelWidth, + expectedTrailingX, + accuracy: 0.0001 + ) + XCTAssertEqual(labelX, expectedX, accuracy: 0.0001) + XCTAssertLessThan(labelX, firstGeometry.staffStartX) + XCTAssertEqual( + labelY, + staffTop - NotationMeasureLayout.systemMeasureNumberStaffGap, + accuracy: 0.0001 + ) + XCTAssertEqual(shallowLabelY, AppTheme.Spacing.xs, accuracy: 0.0001) + } +} diff --git a/JammLabTests/NotationTrackLayoutItemsTests.swift b/JammLabTests/NotationTrackLayoutItemsTests.swift new file mode 100644 index 0000000..88882b5 --- /dev/null +++ b/JammLabTests/NotationTrackLayoutItemsTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import JammLab + +final class NotationTrackLayoutItemsTests: XCTestCase { + func testNotationTrackLayoutItemsBuildsMeasureItemsFromPureInputs() throws { + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 520, + contentStartX: 0, + contentEndX: 520, + staffStartX: 20, + staffEndX: 520 + ) + let firstRegionID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000001")) + let secondRegionID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000002")) + let harmonyID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000003")) + let notationItem = NotationMeasureItem( + id: "quarter-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let harmony = HarmonySymbol( + id: harmonyID, + time: 1, + measureNumber: 1, + offsetInQuarterNotes: 2, + rawText: "G7" + ) + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [notationItem], + harmonies: [harmony], + regionLabels: [ + NotationRegionLabel( + id: firstRegionID, + time: 0, + measureNumber: 1, + offsetInQuarterNotes: 0, + title: "Intro" + ), + NotationRegionLabel( + id: secondRegionID, + time: 0.25, + measureNumber: 1, + offsetInQuarterNotes: 0, + title: "Verse" + ) + ] + ) + + let regionItems = NotationTrackLayoutItems.regionLabels( + visibleMeasures: [measure], + geometries: [geometry] + ) + let harmonyItems = NotationTrackLayoutItems.harmonies( + visibleMeasures: [measure], + geometries: [geometry] + ) + let notationItems = NotationTrackLayoutItems.notationItems( + visibleMeasures: [measure], + geometries: [geometry] + ) + + XCTAssertEqual(regionItems.map(\.label.id), [firstRegionID, secondRegionID]) + XCTAssertGreaterThanOrEqual( + try XCTUnwrap(regionItems.first).x, + NotationMeasureLayout.systemMeasureNumberLabelTrailingX(geometry: geometry) + + AppTheme.Spacing.sm + ) + XCTAssertGreaterThanOrEqual( + try XCTUnwrap(regionItems.last).x, + try XCTUnwrap(regionItems.first).x + + AppTheme.Timeline.notationRegionLabelMaxWidth + + AppTheme.Timeline.notationRegionLabelGap + - 0.0001 + ) + + let harmonyItem = try XCTUnwrap(harmonyItems.first) + XCTAssertEqual(harmonyItem.symbol, harmony) + XCTAssertEqual( + harmonyItem.x, + NotationMeasureLayout.harmonyLabelX( + geometry: geometry, + offsetInQuarterNotes: harmony.offsetInQuarterNotes, + timeSignature: measure.attributes.timeSignature + ), + accuracy: 0.0001 + ) + + let layoutNotationItem = try XCTUnwrap(notationItems.first) + XCTAssertEqual(layoutNotationItem.notationItem, notationItem) + XCTAssertTrue(layoutNotationItem.selection.matches(measure, item: notationItem)) + XCTAssertEqual( + layoutNotationItem.x, + NotationMeasureLayout.notationItemX( + geometry: geometry, + measure: measure, + item: notationItem + ), + accuracy: 0.0001 + ) + } +} From 653b891c09b1004ccf51a3d596daeeff01ba3221 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 14:49:00 +0300 Subject: [PATCH 19/80] test: split view model reset and playback state tests --- JammLab.xcodeproj/project.pbxproj | 8 + JammLabTests/ViewModelLifecycleTests.swift | 319 ------------------ .../ViewModelPlaybackStateTests.swift | 226 +++++++++++++ JammLabTests/ViewModelResetTests.swift | 101 ++++++ 4 files changed, 335 insertions(+), 319 deletions(-) create mode 100644 JammLabTests/ViewModelPlaybackStateTests.swift create mode 100644 JammLabTests/ViewModelResetTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index d697547..95a1cb2 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -107,6 +107,8 @@ 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */; }; 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */; }; 9FAB01042CE0000100112233 /* ViewModelLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */; }; + 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; + 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -300,6 +302,8 @@ 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeakformLogicTests.swift; sourceTree = ""; }; 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProjectLogicTests.swift; sourceTree = ""; }; 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelLifecycleTests.swift; sourceTree = ""; }; + 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; + 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -578,6 +582,8 @@ 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */, + 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, + 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -977,6 +983,8 @@ 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, 9FAB01042CE0000100112233 /* ViewModelLifecycleTests.swift in Sources */, + 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, + 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 3358b84..90a834e 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -10,325 +10,6 @@ final class ViewModelLifecycleTests: XCTestCase { } } - @MainActor - func testViewModelResetMethodsUseSliderDefaults() throws { - let clickVolumeKey = "metronome.volume" - let originalClickVolumeValue = UserDefaults.standard.object(forKey: clickVolumeKey) - defer { - if let originalClickVolumeValue { - UserDefaults.standard.set(originalClickVolumeValue, forKey: clickVolumeKey) - } else { - UserDefaults.standard.removeObject(forKey: clickVolumeKey) - } - } - - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - - XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), AppDefaults.defaultTempoBPM, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(viewModel.beatGridSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(engine.clickSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) - - viewModel.setPlaybackRate(0.5) - viewModel.setPitchShift(semitones: 7) - viewModel.setMainTrackVolume(0.1) - viewModel.setStemVolume(.vocals, volume: 0.2) - viewModel.setStemVolume(.drums, volume: 0.4) - viewModel.toggleStemMute(.vocals) - viewModel.toggleStemSolo(.drums) - viewModel.setClickVolume(0.2) - - viewModel.resetPlaybackRateToDefault() - viewModel.resetPitchShiftToDefault() - viewModel.resetMainTrackVolumeToDefault() - viewModel.resetStemVolumeToDefault(.vocals) - viewModel.resetClickVolumeToDefault() - - XCTAssertEqual(viewModel.playbackRate, AppSliderDefaults.playbackRate, accuracy: 0.0001) - XCTAssertEqual(viewModel.pitchShiftSemitones, AppSliderDefaults.pitchShiftSemitones, accuracy: 0.0001) - XCTAssertEqual(viewModel.mainTrackVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) - XCTAssertEqual(viewModel.clickVolume, AppSliderDefaults.clickVolume, accuracy: 0.0001) - XCTAssertEqual(viewModel.stemMixState.item(for: .vocals).volume, AppSliderDefaults.stemTrackVolume, accuracy: 0.0001) - XCTAssertEqual(viewModel.stemMixState.item(for: .drums).volume, 0.4, accuracy: 0.0001) - XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) - XCTAssertTrue(viewModel.stemMixState.item(for: .drums).isSoloed) - if let originalClickVolumeValue { - XCTAssertEqual(UserDefaults.standard.object(forKey: clickVolumeKey) as? Float, originalClickVolumeValue as? Float) - } else { - XCTAssertNil(UserDefaults.standard.object(forKey: clickVolumeKey)) - } - XCTAssertEqual(engine.playbackRate, AppSliderDefaults.playbackRate, accuracy: 0.0001) - XCTAssertEqual(engine.pitchShiftSemitones, AppSliderDefaults.pitchShiftSemitones, accuracy: 0.0001) - XCTAssertEqual(engine.mainVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) - XCTAssertEqual(engine.clickVolume, AppSliderDefaults.clickVolume, accuracy: 0.0001) - } - - @MainActor - func testViewModelNewProjectResetsPlaybackControlsAndUnloadsEngine() throws { - let clickVolumeKey = "metronome.volume" - let originalClickVolumeValue = UserDefaults.standard.object(forKey: clickVolumeKey) - defer { - if let originalClickVolumeValue { - UserDefaults.standard.set(originalClickVolumeValue, forKey: clickVolumeKey) - } else { - UserDefaults.standard.removeObject(forKey: clickVolumeKey) - } - } - - let engine = MockPlaybackEngine() - engine.isLoaded = true - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - - viewModel.setPlaybackRate(0.5) - viewModel.setPitchShift(semitones: 7) - viewModel.setMainTrackVolume(0.1) - viewModel.setClickVolume(0.2) - viewModel.setStemVolume(.vocals, volume: 0.2) - viewModel.toggleStemMute(.vocals) - viewModel.toggleSnap() - viewModel.setLooping(true) - viewModel.setNotationTrackCollapsed(false) - - viewModel.newProject() - - XCTAssertEqual(engine.unloadCount, 1) - XCTAssertFalse(engine.clickEnabled) - XCTAssertEqual(viewModel.playbackRate, AppSliderDefaults.playbackRate, accuracy: 0.0001) - XCTAssertEqual(viewModel.pitchShiftSemitones, AppSliderDefaults.pitchShiftSemitones, accuracy: 0.0001) - XCTAssertEqual(viewModel.mainTrackVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) - XCTAssertEqual(viewModel.stemMixState.item(for: .vocals).volume, AppSliderDefaults.stemTrackVolume, accuracy: 0.0001) - XCTAssertFalse(viewModel.stemMixState.item(for: .vocals).isMuted) - XCTAssertFalse(viewModel.isSnapEnabled) - XCTAssertFalse(viewModel.isLooping) - XCTAssertTrue(viewModel.isNotationTrackCollapsed) - XCTAssertNil(viewModel.importedFile) - XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), AppDefaults.defaultTempoBPM, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(viewModel.beatGridSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(engine.clickSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) - } - - @MainActor - func testViewModelLoopingDoesNotSeekPlaybackEngine() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.currentTime = 12 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - - viewModel.setLooping(true) - - XCTAssertTrue(viewModel.isLooping) - XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) - XCTAssertEqual(engine.seekCount, 0) - XCTAssertTrue(engine.loopEnabled) - } - - @MainActor - func testViewModelPlayStartsFromPlaybackMarkerNotLoopStart() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.currentTime = 12 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 20 - viewModel.setPlaybackMarkerExactly(to: 4) - - viewModel.setLooping(true) - viewModel.play() - - XCTAssertTrue(engine.isPlaying) - XCTAssertEqual(engine.currentTime, 4, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 4, accuracy: 0.0001) - XCTAssertEqual(engine.seekCount, 2) - } - - @MainActor - func testViewModelStopReturnsToPlaybackMarker() throws { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.currentTime = 12 - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) - viewModel.duration = 20 - viewModel.setPlaybackMarkerExactly(to: 3) - - viewModel.play() - engine.currentTime = 12 - viewModel.stop() - - XCTAssertFalse(engine.isPlaying) - XCTAssertEqual(engine.currentTime, 3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 3, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 3, accuracy: 0.0001) - } - - @MainActor - func testViewModelPauseMovesPlaybackMarkerToPausedPosition() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.currentTime = 12 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 20 - viewModel.setPlaybackMarkerExactly(to: 3) - engine.currentTime = 12 - - viewModel.pause() - - XCTAssertFalse(engine.isPlaying) - XCTAssertEqual(viewModel.playbackMarkerTime, 12, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 12, accuracy: 0.0001) - } - - @MainActor - func testLocatingPlaybackMarkerAppliesSnapAndMarksProjectModified() throws { - let audioURL = try temporaryAudioFile(duration: 4) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "marker.wav", duration: 4) - try viewModel.loadImportedAudio(media) - viewModel.isSnapEnabled = true - viewModel.beatGridSettings = BeatGridSettings(bpm: 120, firstBeatTime: 0, timeSignature: .fourFour) - viewModel.markProjectClean() - - viewModel.locatePlaybackMarker(to: 0.74) - - XCTAssertEqual(viewModel.playbackMarkerTime, 0.5, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 0.5, accuracy: 0.0001) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testPlaybackClockMovementDoesNotMarkProjectModified() throws { - let audioURL = try temporaryAudioFile(duration: 4) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "clock.wav", duration: 4) - try viewModel.loadImportedAudio(media) - viewModel.markProjectClean() - - engine.currentTime = 1.25 - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.currentTime, 1.25, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testPlaybackClockFollowsZoomedTimelineNearRightEdge() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.isPlaying = true - engine.currentTime = 18.4 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 100 - viewModel.setTimelineVisibleRange(0...20) - viewModel.playbackState = .playing - - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound - viewModel.timelineVisibleRange.lowerBound, 20, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 16.8, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineViewport.xPosition(for: viewModel.currentTime, width: 100), 8, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 20, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testPlaybackFollowDoesNotDirtyOrReplaceUserTimelineRange() throws { - let audioURL = try temporaryAudioFile(duration: 100) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "follow.wav", duration: 100) - try viewModel.loadImportedAudio(media) - viewModel.setTimelineVisibleRange(0...20) - viewModel.markProjectClean() - engine.isPlaying = true - engine.currentTime = 18.4 - viewModel.playbackState = .playing - - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 16.8, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 36.8, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 20, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testPlaybackClockDoesNotFollowFullTimelineRange() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.isPlaying = true - engine.currentTime = 95 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 100 - viewModel.setTimelineVisibleRange(0...100) - viewModel.playbackState = .playing - - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 100, accuracy: 0.0001) - } - - @MainActor - func testStopReturnsZoomedTimelineToPlaybackMarker() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.currentTime = 70 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 100 - viewModel.setPlaybackMarkerExactly(to: 30) - viewModel.setTimelineVisibleRange(60...80) - viewModel.playbackState = .playing - - viewModel.stop() - - XCTAssertEqual(viewModel.currentTime, 30, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 30, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound - viewModel.timelineVisibleRange.lowerBound, 20, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 28.4, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineViewport.xPosition(for: viewModel.playbackMarkerTime, width: 100), 8, accuracy: 0.0001) - } - - @MainActor - func testPlaybackAutoStopAtEndReturnsToPlaybackMarker() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.isPlaying = false - engine.currentTime = 4 - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) - viewModel.duration = 4 - viewModel.setPlaybackMarkerExactly(to: 1) - viewModel.setTimelineVisibleRange(2...4) - engine.currentTime = 4 - viewModel.playbackState = .playing - - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.playbackState, .stopped) - XCTAssertEqual(viewModel.currentTime, 1, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 1, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0.84, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 2.84, accuracy: 0.0001) - } - @MainActor func testViewModelTogglePlaybackModeStaysOriginalWhenStemsUnavailable() { let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) diff --git a/JammLabTests/ViewModelPlaybackStateTests.swift b/JammLabTests/ViewModelPlaybackStateTests.swift new file mode 100644 index 0000000..76b4401 --- /dev/null +++ b/JammLabTests/ViewModelPlaybackStateTests.swift @@ -0,0 +1,226 @@ +import XCTest +@testable import JammLab + +final class ViewModelPlaybackStateTests: XCTestCase { + @MainActor + func testViewModelLoopingDoesNotSeekPlaybackEngine() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.currentTime = 12 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + + viewModel.setLooping(true) + + XCTAssertTrue(viewModel.isLooping) + XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) + XCTAssertEqual(engine.seekCount, 0) + XCTAssertTrue(engine.loopEnabled) + } + + @MainActor + func testViewModelPlayStartsFromPlaybackMarkerNotLoopStart() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.currentTime = 12 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 20 + viewModel.setPlaybackMarkerExactly(to: 4) + + viewModel.setLooping(true) + viewModel.play() + + XCTAssertTrue(engine.isPlaying) + XCTAssertEqual(engine.currentTime, 4, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 4, accuracy: 0.0001) + XCTAssertEqual(engine.seekCount, 2) + } + + @MainActor + func testViewModelStopReturnsToPlaybackMarker() throws { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.currentTime = 12 + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) + viewModel.duration = 20 + viewModel.setPlaybackMarkerExactly(to: 3) + + viewModel.play() + engine.currentTime = 12 + viewModel.stop() + + XCTAssertFalse(engine.isPlaying) + XCTAssertEqual(engine.currentTime, 3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 3, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 3, accuracy: 0.0001) + } + + @MainActor + func testViewModelPauseMovesPlaybackMarkerToPausedPosition() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.currentTime = 12 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 20 + viewModel.setPlaybackMarkerExactly(to: 3) + engine.currentTime = 12 + + viewModel.pause() + + XCTAssertFalse(engine.isPlaying) + XCTAssertEqual(viewModel.playbackMarkerTime, 12, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 12, accuracy: 0.0001) + } + + @MainActor + func testLocatingPlaybackMarkerAppliesSnapAndMarksProjectModified() throws { + let audioURL = try temporaryAudioFile(duration: 4) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "marker.wav", duration: 4) + try viewModel.loadImportedAudio(media) + viewModel.isSnapEnabled = true + viewModel.beatGridSettings = BeatGridSettings(bpm: 120, firstBeatTime: 0, timeSignature: .fourFour) + viewModel.markProjectClean() + + viewModel.locatePlaybackMarker(to: 0.74) + + XCTAssertEqual(viewModel.playbackMarkerTime, 0.5, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 0.5, accuracy: 0.0001) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testPlaybackClockMovementDoesNotMarkProjectModified() throws { + let audioURL = try temporaryAudioFile(duration: 4) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "clock.wav", duration: 4) + try viewModel.loadImportedAudio(media) + viewModel.markProjectClean() + + engine.currentTime = 1.25 + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.currentTime, 1.25, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testPlaybackClockFollowsZoomedTimelineNearRightEdge() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.isPlaying = true + engine.currentTime = 18.4 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 100 + viewModel.setTimelineVisibleRange(0...20) + viewModel.playbackState = .playing + + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound - viewModel.timelineVisibleRange.lowerBound, 20, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 16.8, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineViewport.xPosition(for: viewModel.currentTime, width: 100), 8, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 20, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testPlaybackFollowDoesNotDirtyOrReplaceUserTimelineRange() throws { + let audioURL = try temporaryAudioFile(duration: 100) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "follow.wav", duration: 100) + try viewModel.loadImportedAudio(media) + viewModel.setTimelineVisibleRange(0...20) + viewModel.markProjectClean() + engine.isPlaying = true + engine.currentTime = 18.4 + viewModel.playbackState = .playing + + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 16.8, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 36.8, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 20, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testPlaybackClockDoesNotFollowFullTimelineRange() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.isPlaying = true + engine.currentTime = 95 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 100 + viewModel.setTimelineVisibleRange(0...100) + viewModel.playbackState = .playing + + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 100, accuracy: 0.0001) + } + + @MainActor + func testStopReturnsZoomedTimelineToPlaybackMarker() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.currentTime = 70 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 100 + viewModel.setPlaybackMarkerExactly(to: 30) + viewModel.setTimelineVisibleRange(60...80) + viewModel.playbackState = .playing + + viewModel.stop() + + XCTAssertEqual(viewModel.currentTime, 30, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 30, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound - viewModel.timelineVisibleRange.lowerBound, 20, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 28.4, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineViewport.xPosition(for: viewModel.playbackMarkerTime, width: 100), 8, accuracy: 0.0001) + } + + @MainActor + func testPlaybackAutoStopAtEndReturnsToPlaybackMarker() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.isPlaying = false + engine.currentTime = 4 + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) + viewModel.duration = 4 + viewModel.setPlaybackMarkerExactly(to: 1) + viewModel.setTimelineVisibleRange(2...4) + engine.currentTime = 4 + viewModel.playbackState = .playing + + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.playbackState, .stopped) + XCTAssertEqual(viewModel.currentTime, 1, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 1, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0.84, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 2.84, accuracy: 0.0001) + } +} diff --git a/JammLabTests/ViewModelResetTests.swift b/JammLabTests/ViewModelResetTests.swift new file mode 100644 index 0000000..1ea76f9 --- /dev/null +++ b/JammLabTests/ViewModelResetTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import JammLab + +final class ViewModelResetTests: XCTestCase { + @MainActor + func testViewModelResetMethodsUseSliderDefaults() throws { + let clickVolumeKey = "metronome.volume" + let originalClickVolumeValue = UserDefaults.standard.object(forKey: clickVolumeKey) + defer { + if let originalClickVolumeValue { + UserDefaults.standard.set(originalClickVolumeValue, forKey: clickVolumeKey) + } else { + UserDefaults.standard.removeObject(forKey: clickVolumeKey) + } + } + + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + + XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), AppDefaults.defaultTempoBPM, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(viewModel.beatGridSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(engine.clickSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) + + viewModel.setPlaybackRate(0.5) + viewModel.setPitchShift(semitones: 7) + viewModel.setMainTrackVolume(0.1) + viewModel.setStemVolume(.vocals, volume: 0.2) + viewModel.setStemVolume(.drums, volume: 0.4) + viewModel.toggleStemMute(.vocals) + viewModel.toggleStemSolo(.drums) + viewModel.setClickVolume(0.2) + + viewModel.resetPlaybackRateToDefault() + viewModel.resetPitchShiftToDefault() + viewModel.resetMainTrackVolumeToDefault() + viewModel.resetStemVolumeToDefault(.vocals) + viewModel.resetClickVolumeToDefault() + + XCTAssertEqual(viewModel.playbackRate, AppSliderDefaults.playbackRate, accuracy: 0.0001) + XCTAssertEqual(viewModel.pitchShiftSemitones, AppSliderDefaults.pitchShiftSemitones, accuracy: 0.0001) + XCTAssertEqual(viewModel.mainTrackVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) + XCTAssertEqual(viewModel.clickVolume, AppSliderDefaults.clickVolume, accuracy: 0.0001) + XCTAssertEqual(viewModel.stemMixState.item(for: .vocals).volume, AppSliderDefaults.stemTrackVolume, accuracy: 0.0001) + XCTAssertEqual(viewModel.stemMixState.item(for: .drums).volume, 0.4, accuracy: 0.0001) + XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) + XCTAssertTrue(viewModel.stemMixState.item(for: .drums).isSoloed) + if let originalClickVolumeValue { + XCTAssertEqual(UserDefaults.standard.object(forKey: clickVolumeKey) as? Float, originalClickVolumeValue as? Float) + } else { + XCTAssertNil(UserDefaults.standard.object(forKey: clickVolumeKey)) + } + XCTAssertEqual(engine.playbackRate, AppSliderDefaults.playbackRate, accuracy: 0.0001) + XCTAssertEqual(engine.pitchShiftSemitones, AppSliderDefaults.pitchShiftSemitones, accuracy: 0.0001) + XCTAssertEqual(engine.mainVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) + XCTAssertEqual(engine.clickVolume, AppSliderDefaults.clickVolume, accuracy: 0.0001) + } + + @MainActor + func testViewModelNewProjectResetsPlaybackControlsAndUnloadsEngine() throws { + let clickVolumeKey = "metronome.volume" + let originalClickVolumeValue = UserDefaults.standard.object(forKey: clickVolumeKey) + defer { + if let originalClickVolumeValue { + UserDefaults.standard.set(originalClickVolumeValue, forKey: clickVolumeKey) + } else { + UserDefaults.standard.removeObject(forKey: clickVolumeKey) + } + } + + let engine = MockPlaybackEngine() + engine.isLoaded = true + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + + viewModel.setPlaybackRate(0.5) + viewModel.setPitchShift(semitones: 7) + viewModel.setMainTrackVolume(0.1) + viewModel.setClickVolume(0.2) + viewModel.setStemVolume(.vocals, volume: 0.2) + viewModel.toggleStemMute(.vocals) + viewModel.toggleSnap() + viewModel.setLooping(true) + viewModel.setNotationTrackCollapsed(false) + + viewModel.newProject() + + XCTAssertEqual(engine.unloadCount, 1) + XCTAssertFalse(engine.clickEnabled) + XCTAssertEqual(viewModel.playbackRate, AppSliderDefaults.playbackRate, accuracy: 0.0001) + XCTAssertEqual(viewModel.pitchShiftSemitones, AppSliderDefaults.pitchShiftSemitones, accuracy: 0.0001) + XCTAssertEqual(viewModel.mainTrackVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) + XCTAssertEqual(viewModel.stemMixState.item(for: .vocals).volume, AppSliderDefaults.stemTrackVolume, accuracy: 0.0001) + XCTAssertFalse(viewModel.stemMixState.item(for: .vocals).isMuted) + XCTAssertFalse(viewModel.isSnapEnabled) + XCTAssertFalse(viewModel.isLooping) + XCTAssertTrue(viewModel.isNotationTrackCollapsed) + XCTAssertNil(viewModel.importedFile) + XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), AppDefaults.defaultTempoBPM, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(viewModel.beatGridSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(engine.clickSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) + } +} From 21b6c10ed612966afb74069db3b9854bf2690d68 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 15:39:43 +0300 Subject: [PATCH 20/80] test: split view model stem playback tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 212 ----------------- JammLabTests/ViewModelStemPlaybackTests.swift | 216 ++++++++++++++++++ 3 files changed, 220 insertions(+), 212 deletions(-) create mode 100644 JammLabTests/ViewModelStemPlaybackTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 95a1cb2..039338d 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ 9FAB01042CE0000100112233 /* ViewModelLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */; }; 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; + 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -304,6 +305,7 @@ 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelLifecycleTests.swift; sourceTree = ""; }; 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; + 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -584,6 +586,7 @@ 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */, 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, + 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -985,6 +988,7 @@ 9FAB01042CE0000100112233 /* ViewModelLifecycleTests.swift in Sources */, 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, + 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 90a834e..8a909ba 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -10,199 +10,6 @@ final class ViewModelLifecycleTests: XCTestCase { } } - @MainActor - func testViewModelTogglePlaybackModeStaysOriginalWhenStemsUnavailable() { - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - - viewModel.togglePlaybackMode() - - XCTAssertEqual(viewModel.playbackMode, .original) - } - - @MainActor - func testViewModelSetPlaybackModeStaysOriginalWhenStemsUnavailable() { - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - - viewModel.setPlaybackMode(.stems) - - XCTAssertEqual(viewModel.playbackMode, .original) - } - - @MainActor - func testRegisterStemMetadataActivatesStemPlaybackWhenRequested() { - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 30 - viewModel.currentTime = 12 - - viewModel.registerStemMetadata(testStemMetadata(), activatePlayback: true) - - XCTAssertEqual(viewModel.playbackMode, .stems) - XCTAssertEqual(viewModel.stemFiles.map(\.type), StemSeparationMethod.fourStem.stemTypes) - XCTAssertTrue(engine.isLoaded) - XCTAssertTrue(engine.mixState.item(for: .vocals).isAvailable) - XCTAssertEqual(viewModel.currentTime, 12, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) - } - - @MainActor - func testRegisterStemMetadataPreservesPlayingPositionWhenActivatingStems() { - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 30 - viewModel.currentTime = 12 - viewModel.playbackMarkerTime = 2 - viewModel.playbackState = .playing - engine.isLoaded = true - engine.isPlaying = true - engine.currentTime = 12 - - viewModel.registerStemMetadata(testStemMetadata(), activatePlayback: true) - - XCTAssertEqual(viewModel.playbackMode, .stems) - XCTAssertEqual(viewModel.playbackState, .playing) - XCTAssertTrue(engine.isPlaying) - XCTAssertEqual(viewModel.currentTime, 12, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 2, accuracy: 0.0001) - } - - @MainActor - func testRegisterStemMetadataDoesNotActivateStemPlaybackByDefault() { - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 30 - viewModel.currentTime = 12 - - viewModel.registerStemMetadata(testStemMetadata()) - - XCTAssertEqual(viewModel.playbackMode, .original) - XCTAssertEqual(viewModel.stemFiles.map(\.type), StemSeparationMethod.fourStem.stemTypes) - XCTAssertFalse(engine.isLoaded) - } - - @MainActor - func testRegisterStemMetadataLoadsPlaybackWhenProjectRestoresStemMode() { - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 30 - viewModel.currentTime = 12 - viewModel.playbackMode = .stems - - viewModel.registerStemMetadata(testStemMetadata()) - - XCTAssertEqual(viewModel.playbackMode, .stems) - XCTAssertTrue(engine.isLoaded) - XCTAssertTrue(engine.mixState.item(for: .drums).isAvailable) - XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) - } - - @MainActor - func testCancelledStemSeparationDoesNotOverwriteCancelledStateWhenTaskFinishesLater() async throws { - let audioURL = try temporaryAudioFile(duration: 1, namePrefix: "stems") - let supportDirectory = temporaryDirectory() - let jobsDirectory = StemJobFiles.currentJobsDirectory(in: supportDirectory) - try FileManager.default.createDirectory(at: jobsDirectory, withIntermediateDirectories: true) - try writeHeartbeat( - to: jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename), - updatedAt: Date() - ) - defer { - try? FileManager.default.removeItem(at: audioURL.deletingLastPathComponent()) - try? FileManager.default.removeItem(at: supportDirectory) - } - - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false }, - applicationSupportDirectory: supportDirectory - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - stemSeparationService: service - ) - let media = ImportedAudioFile(url: audioURL, displayName: "stems.caf", duration: 1) - try viewModel.loadImportedAudio(media) - - viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) - - viewModel.cancelStemSeparation() - try await Task.sleep(nanoseconds: 700_000_000) - - if case .cancelled = viewModel.stemSeparationState.phase { - } else { - XCTFail("Expected cancelled stem separation phase, got \(viewModel.stemSeparationState.phase)") - } - XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") - XCTAssertNil(viewModel.errorMessage) - XCTAssertNil(viewModel.stemSeparationTask) - XCTAssertNil(viewModel.stemSeparationRunID) - XCTAssertTrue(viewModel.stemFiles.isEmpty) - } - - @MainActor - func testStemSeparationRestartIsBlockedUntilCancelledTaskFinishes() async throws { - let audioURL = try temporaryAudioFile(duration: 1, namePrefix: "stems-restart") - let supportDirectory = temporaryDirectory() - let jobsDirectory = StemJobFiles.currentJobsDirectory(in: supportDirectory) - try FileManager.default.createDirectory(at: jobsDirectory, withIntermediateDirectories: true) - try writeHeartbeat( - to: jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename), - updatedAt: Date() - ) - defer { - try? FileManager.default.removeItem(at: audioURL.deletingLastPathComponent()) - try? FileManager.default.removeItem(at: supportDirectory) - } - - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false }, - applicationSupportDirectory: supportDirectory - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - stemSeparationService: service - ) - let media = ImportedAudioFile(url: audioURL, displayName: "stems-restart.caf", duration: 1) - try viewModel.loadImportedAudio(media) - - viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) - let firstRunID = try XCTUnwrap(viewModel.stemSeparationRunID) - - viewModel.cancelStemSeparation() - viewModel.separateStems() - - XCTAssertEqual(viewModel.stemSeparationRunID, firstRunID) - XCTAssertNotNil(viewModel.stemSeparationTask) - XCTAssertEqual(viewModel.stemSeparationState.phase, .processing) - XCTAssertEqual(viewModel.stemSeparationState.status, "Cancelling stem separation") - - try await Task.sleep(nanoseconds: 700_000_000) - XCTAssertNil(viewModel.stemSeparationTask) - XCTAssertNil(viewModel.stemSeparationRunID) - - viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) - let secondRunID = try XCTUnwrap(viewModel.stemSeparationRunID) - XCTAssertNotEqual(secondRunID, firstRunID) - - viewModel.cancelStemSeparation() - try await Task.sleep(nanoseconds: 700_000_000) - - XCTAssertEqual(viewModel.stemSeparationState.phase, .cancelled) - XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") - XCTAssertNil(viewModel.stemSeparationTask) - XCTAssertNil(viewModel.stemSeparationRunID) - XCTAssertTrue(viewModel.stemFiles.isEmpty) - } - @MainActor func testViewModelSetTimelineVisibleRangeClampsWithoutAudio() { let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) @@ -2595,23 +2402,4 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, MarkerColor.markerOrange.defaultHex) } - private func testStemMetadata() -> StemCacheMetadata { - StemCacheMetadata( - cacheKey: "test-cache", - sourceFingerprint: StemSourceFingerprint(path: "/tmp/song.wav", fileSize: 42, modificationTime: 10), - backendIdentifier: "test-backend", - separationMethodID: StemSeparationMethod.fourStem.id, - modelName: StemSeparationMethod.fourStem.modelName, - settingsVersion: 1, - createdAt: Date(timeIntervalSince1970: 1), - stems: StemSeparationMethod.fourStem.stemTypes.map { type in - StemFile( - type: type, - url: URL(fileURLWithPath: "/tmp/\(type.canonicalStemFilename)"), - displayName: type.title - ) - } - ) - } - } diff --git a/JammLabTests/ViewModelStemPlaybackTests.swift b/JammLabTests/ViewModelStemPlaybackTests.swift new file mode 100644 index 0000000..d1c394e --- /dev/null +++ b/JammLabTests/ViewModelStemPlaybackTests.swift @@ -0,0 +1,216 @@ +import XCTest +@testable import JammLab + +final class ViewModelStemPlaybackTests: XCTestCase { + @MainActor + func testViewModelTogglePlaybackModeStaysOriginalWhenStemsUnavailable() { + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + + viewModel.togglePlaybackMode() + + XCTAssertEqual(viewModel.playbackMode, .original) + } + + @MainActor + func testViewModelSetPlaybackModeStaysOriginalWhenStemsUnavailable() { + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + + viewModel.setPlaybackMode(.stems) + + XCTAssertEqual(viewModel.playbackMode, .original) + } + + @MainActor + func testRegisterStemMetadataActivatesStemPlaybackWhenRequested() { + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 30 + viewModel.currentTime = 12 + + viewModel.registerStemMetadata(testStemMetadata(), activatePlayback: true) + + XCTAssertEqual(viewModel.playbackMode, .stems) + XCTAssertEqual(viewModel.stemFiles.map(\.type), StemSeparationMethod.fourStem.stemTypes) + XCTAssertTrue(engine.isLoaded) + XCTAssertTrue(engine.mixState.item(for: .vocals).isAvailable) + XCTAssertEqual(viewModel.currentTime, 12, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) + } + + @MainActor + func testRegisterStemMetadataPreservesPlayingPositionWhenActivatingStems() { + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 30 + viewModel.currentTime = 12 + viewModel.playbackMarkerTime = 2 + viewModel.playbackState = .playing + engine.isLoaded = true + engine.isPlaying = true + engine.currentTime = 12 + + viewModel.registerStemMetadata(testStemMetadata(), activatePlayback: true) + + XCTAssertEqual(viewModel.playbackMode, .stems) + XCTAssertEqual(viewModel.playbackState, .playing) + XCTAssertTrue(engine.isPlaying) + XCTAssertEqual(viewModel.currentTime, 12, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 2, accuracy: 0.0001) + } + + @MainActor + func testRegisterStemMetadataDoesNotActivateStemPlaybackByDefault() { + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 30 + viewModel.currentTime = 12 + + viewModel.registerStemMetadata(testStemMetadata()) + + XCTAssertEqual(viewModel.playbackMode, .original) + XCTAssertEqual(viewModel.stemFiles.map(\.type), StemSeparationMethod.fourStem.stemTypes) + XCTAssertFalse(engine.isLoaded) + } + + @MainActor + func testRegisterStemMetadataLoadsPlaybackWhenProjectRestoresStemMode() { + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 30 + viewModel.currentTime = 12 + viewModel.playbackMode = .stems + + viewModel.registerStemMetadata(testStemMetadata()) + + XCTAssertEqual(viewModel.playbackMode, .stems) + XCTAssertTrue(engine.isLoaded) + XCTAssertTrue(engine.mixState.item(for: .drums).isAvailable) + XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) + } + + @MainActor + func testCancelledStemSeparationDoesNotOverwriteCancelledStateWhenTaskFinishesLater() async throws { + let audioURL = try temporaryAudioFile(duration: 1, namePrefix: "stems") + let supportDirectory = temporaryDirectory() + let jobsDirectory = StemJobFiles.currentJobsDirectory(in: supportDirectory) + try FileManager.default.createDirectory(at: jobsDirectory, withIntermediateDirectories: true) + try writeHeartbeat( + to: jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename), + updatedAt: Date() + ) + defer { + try? FileManager.default.removeItem(at: audioURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: supportDirectory) + } + + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false }, + applicationSupportDirectory: supportDirectory + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + stemSeparationService: service + ) + let media = ImportedAudioFile(url: audioURL, displayName: "stems.caf", duration: 1) + try viewModel.loadImportedAudio(media) + + viewModel.separateStems() + try await Task.sleep(nanoseconds: 100_000_000) + + viewModel.cancelStemSeparation() + try await Task.sleep(nanoseconds: 700_000_000) + + if case .cancelled = viewModel.stemSeparationState.phase { + } else { + XCTFail("Expected cancelled stem separation phase, got \(viewModel.stemSeparationState.phase)") + } + XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") + XCTAssertNil(viewModel.errorMessage) + XCTAssertNil(viewModel.stemSeparationTask) + XCTAssertNil(viewModel.stemSeparationRunID) + XCTAssertTrue(viewModel.stemFiles.isEmpty) + } + + @MainActor + func testStemSeparationRestartIsBlockedUntilCancelledTaskFinishes() async throws { + let audioURL = try temporaryAudioFile(duration: 1, namePrefix: "stems-restart") + let supportDirectory = temporaryDirectory() + let jobsDirectory = StemJobFiles.currentJobsDirectory(in: supportDirectory) + try FileManager.default.createDirectory(at: jobsDirectory, withIntermediateDirectories: true) + try writeHeartbeat( + to: jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename), + updatedAt: Date() + ) + defer { + try? FileManager.default.removeItem(at: audioURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: supportDirectory) + } + + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false }, + applicationSupportDirectory: supportDirectory + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + stemSeparationService: service + ) + let media = ImportedAudioFile(url: audioURL, displayName: "stems-restart.caf", duration: 1) + try viewModel.loadImportedAudio(media) + + viewModel.separateStems() + try await Task.sleep(nanoseconds: 100_000_000) + let firstRunID = try XCTUnwrap(viewModel.stemSeparationRunID) + + viewModel.cancelStemSeparation() + viewModel.separateStems() + + XCTAssertEqual(viewModel.stemSeparationRunID, firstRunID) + XCTAssertNotNil(viewModel.stemSeparationTask) + XCTAssertEqual(viewModel.stemSeparationState.phase, .processing) + XCTAssertEqual(viewModel.stemSeparationState.status, "Cancelling stem separation") + + try await Task.sleep(nanoseconds: 700_000_000) + XCTAssertNil(viewModel.stemSeparationTask) + XCTAssertNil(viewModel.stemSeparationRunID) + + viewModel.separateStems() + try await Task.sleep(nanoseconds: 100_000_000) + let secondRunID = try XCTUnwrap(viewModel.stemSeparationRunID) + XCTAssertNotEqual(secondRunID, firstRunID) + + viewModel.cancelStemSeparation() + try await Task.sleep(nanoseconds: 700_000_000) + + XCTAssertEqual(viewModel.stemSeparationState.phase, .cancelled) + XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") + XCTAssertNil(viewModel.stemSeparationTask) + XCTAssertNil(viewModel.stemSeparationRunID) + XCTAssertTrue(viewModel.stemFiles.isEmpty) + } + + private func testStemMetadata() -> StemCacheMetadata { + StemCacheMetadata( + cacheKey: "test-cache", + sourceFingerprint: StemSourceFingerprint(path: "/tmp/song.wav", fileSize: 42, modificationTime: 10), + backendIdentifier: "test-backend", + separationMethodID: StemSeparationMethod.fourStem.id, + modelName: StemSeparationMethod.fourStem.modelName, + settingsVersion: 1, + createdAt: Date(timeIntervalSince1970: 1), + stems: StemSeparationMethod.fourStem.stemTypes.map { type in + StemFile( + type: type, + url: URL(fileURLWithPath: "/tmp/\(type.canonicalStemFilename)"), + displayName: type.title + ) + } + ) + } +} From eb93c4599892c5013231a0cfc087fc745c61203a Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 16:06:33 +0300 Subject: [PATCH 21/80] test: split view model video window tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 175 ------------------ JammLabTests/ViewModelVideoWindowTests.swift | 179 +++++++++++++++++++ 3 files changed, 183 insertions(+), 175 deletions(-) create mode 100644 JammLabTests/ViewModelVideoWindowTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 039338d..1762132 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -110,6 +110,7 @@ 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; + 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -306,6 +307,7 @@ 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; + 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -587,6 +589,7 @@ 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, + 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -989,6 +992,7 @@ 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, + 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 8a909ba..256d59c 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -20,181 +20,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 0, accuracy: 0.0001) } - @MainActor - func testViewModelForwardsTransportCommandsToVideoFollower() throws { - let engine = MockPlaybackEngine() - engine.isLoaded = true - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) - viewModel.duration = 20 - - viewModel.setPlaybackRate(0.5) - viewModel.play() - viewModel.seek(to: 10) - viewModel.pause() - viewModel.stop() - - XCTAssertEqual(try XCTUnwrap(videoFollower.playbackRate), 0.5, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.playRate), 0.5, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 10, accuracy: 0.0001) - XCTAssertTrue(videoFollower.didPause) - XCTAssertTrue(videoFollower.didStop) - } - - @MainActor - func testVideoImportAutoShowsVideoWindowAndStartsClean() throws { - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) - let audioURL = try temporaryAudioFile(duration: 2) - defer { try? FileManager.default.removeItem(at: audioURL) } - let videoURL = URL(fileURLWithPath: "/tmp/lesson.mov") - let media = ImportedAudioFile( - url: audioURL, - sourceMediaURL: videoURL, - displayName: "lesson.mov", - duration: 0.5, - mediaKind: .video - ) - - try viewModel.loadImportedAudio(media) - - XCTAssertTrue(viewModel.canShowVideoWindow) - XCTAssertTrue(viewModel.canToggleVideoWindow) - XCTAssertTrue(viewModel.isVideoWindowOpen) - XCTAssertEqual(videoFollower.loadedVideoURL, videoURL) - XCTAssertEqual(videoFollower.showWindowEvents.count, 1) - XCTAssertFalse(viewModel.isProjectModified) - - let importEvent = try XCTUnwrap(videoFollower.showWindowEvents.last) - XCTAssertEqual(importEvent.time, 0, accuracy: 0.0001) - XCTAssertFalse(importEvent.isPlaying) - XCTAssertEqual(importEvent.rate, AppSliderDefaults.playbackRate, accuracy: 0.0001) - - viewModel.setPlaybackRate(0.5) - viewModel.play() - - viewModel.showVideoWindow() - - let event = try XCTUnwrap(videoFollower.showWindowEvents.last) - XCTAssertEqual(event.time, viewModel.currentTime, accuracy: 0.0001) - XCTAssertTrue(event.isPlaying) - XCTAssertEqual(event.rate, 0.5, accuracy: 0.0001) - - viewModel.newProject() - } - - @MainActor - func testAudioImportDoesNotShowVideoWindow() throws { - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(videoFollower: videoFollower) - let audioURL = try temporaryAudioFile(duration: 2) - defer { try? FileManager.default.removeItem(at: audioURL) } - let media = ImportedAudioFile(url: audioURL, displayName: "lesson.wav", duration: 0.5) - - try viewModel.loadImportedAudio(media) - - XCTAssertFalse(viewModel.canShowVideoWindow) - XCTAssertFalse(viewModel.isVideoWindowOpen) - XCTAssertTrue(videoFollower.showWindowEvents.isEmpty) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testToggleVideoWindowIsNoOpWithoutVideoMedia() { - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(videoFollower: videoFollower) - - XCTAssertFalse(viewModel.canToggleVideoWindow) - - viewModel.toggleVideoWindow() - - XCTAssertTrue(videoFollower.toggleWindowEvents.isEmpty) - } - - @MainActor - func testToggleVideoWindowForwardsCurrentPlaybackStateForVideoMedia() throws { - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) - let audioURL = try temporaryAudioFile(duration: 2) - defer { try? FileManager.default.removeItem(at: audioURL) } - let videoURL = URL(fileURLWithPath: "/tmp/lesson.mov") - let media = ImportedAudioFile( - url: audioURL, - sourceMediaURL: videoURL, - displayName: "lesson.mov", - duration: 0.5, - mediaKind: .video - ) - - try viewModel.loadImportedAudio(media) - videoFollower.closeWindow() - viewModel.markProjectClean() - viewModel.setPlaybackRate(0.5) - viewModel.play() - viewModel.toggleVideoWindow() - - let event = try XCTUnwrap(videoFollower.toggleWindowEvents.last) - XCTAssertEqual(event.time, viewModel.currentTime, accuracy: 0.0001) - XCTAssertTrue(event.isPlaying) - XCTAssertEqual(event.rate, 0.5, accuracy: 0.0001) - XCTAssertEqual(videoFollower.showWindowEvents.count, 1) - } - - @MainActor - func testVideoWindowOpenCloseUpdatesProjectModifiedState() throws { - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(videoFollower: videoFollower) - let audioURL = try temporaryAudioFile(duration: 2) - defer { try? FileManager.default.removeItem(at: audioURL) } - let media = ImportedAudioFile( - url: audioURL, - sourceMediaURL: URL(fileURLWithPath: "/tmp/lesson.mov"), - displayName: "lesson.mov", - duration: 0.5, - mediaKind: .video - ) - - try viewModel.loadImportedAudio(media) - - XCTAssertTrue(viewModel.isVideoWindowOpen) - XCTAssertFalse(viewModel.isProjectModified) - - videoFollower.closeWindow() - - XCTAssertFalse(viewModel.isVideoWindowOpen) - XCTAssertTrue(viewModel.isProjectModified) - - viewModel.showVideoWindow() - - XCTAssertTrue(viewModel.isVideoWindowOpen) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testNewProjectUnloadsPreparedVideoFollower() throws { - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) - let audioURL = try temporaryAudioFile() - defer { try? FileManager.default.removeItem(at: audioURL) } - let media = ImportedAudioFile( - url: audioURL, - sourceMediaURL: URL(fileURLWithPath: "/tmp/lesson.mov"), - displayName: "lesson.mov", - duration: 0.5, - mediaKind: .video - ) - - try viewModel.loadImportedAudio(media) - viewModel.newProject() - - XCTAssertTrue(videoFollower.didUnload) - XCTAssertFalse(viewModel.canShowVideoWindow) - XCTAssertFalse(viewModel.isVideoWindowOpen) - } - @MainActor func testViewModelForwardsClickSoundSettingsUpdatesToPlaybackEngine() throws { let defaults = try temporaryUserDefaults() diff --git a/JammLabTests/ViewModelVideoWindowTests.swift b/JammLabTests/ViewModelVideoWindowTests.swift new file mode 100644 index 0000000..07f08c2 --- /dev/null +++ b/JammLabTests/ViewModelVideoWindowTests.swift @@ -0,0 +1,179 @@ +import XCTest +@testable import JammLab + +final class ViewModelVideoWindowTests: XCTestCase { + @MainActor + func testViewModelForwardsTransportCommandsToVideoFollower() throws { + let engine = MockPlaybackEngine() + engine.isLoaded = true + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) + viewModel.duration = 20 + + viewModel.setPlaybackRate(0.5) + viewModel.play() + viewModel.seek(to: 10) + viewModel.pause() + viewModel.stop() + + XCTAssertEqual(try XCTUnwrap(videoFollower.playbackRate), 0.5, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.playRate), 0.5, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 10, accuracy: 0.0001) + XCTAssertTrue(videoFollower.didPause) + XCTAssertTrue(videoFollower.didStop) + } + + @MainActor + func testVideoImportAutoShowsVideoWindowAndStartsClean() throws { + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) + let audioURL = try temporaryAudioFile(duration: 2) + defer { try? FileManager.default.removeItem(at: audioURL) } + let videoURL = URL(fileURLWithPath: "/tmp/lesson.mov") + let media = ImportedAudioFile( + url: audioURL, + sourceMediaURL: videoURL, + displayName: "lesson.mov", + duration: 0.5, + mediaKind: .video + ) + + try viewModel.loadImportedAudio(media) + + XCTAssertTrue(viewModel.canShowVideoWindow) + XCTAssertTrue(viewModel.canToggleVideoWindow) + XCTAssertTrue(viewModel.isVideoWindowOpen) + XCTAssertEqual(videoFollower.loadedVideoURL, videoURL) + XCTAssertEqual(videoFollower.showWindowEvents.count, 1) + XCTAssertFalse(viewModel.isProjectModified) + + let importEvent = try XCTUnwrap(videoFollower.showWindowEvents.last) + XCTAssertEqual(importEvent.time, 0, accuracy: 0.0001) + XCTAssertFalse(importEvent.isPlaying) + XCTAssertEqual(importEvent.rate, AppSliderDefaults.playbackRate, accuracy: 0.0001) + + viewModel.setPlaybackRate(0.5) + viewModel.play() + + viewModel.showVideoWindow() + + let event = try XCTUnwrap(videoFollower.showWindowEvents.last) + XCTAssertEqual(event.time, viewModel.currentTime, accuracy: 0.0001) + XCTAssertTrue(event.isPlaying) + XCTAssertEqual(event.rate, 0.5, accuracy: 0.0001) + + viewModel.newProject() + } + + @MainActor + func testAudioImportDoesNotShowVideoWindow() throws { + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(videoFollower: videoFollower) + let audioURL = try temporaryAudioFile(duration: 2) + defer { try? FileManager.default.removeItem(at: audioURL) } + let media = ImportedAudioFile(url: audioURL, displayName: "lesson.wav", duration: 0.5) + + try viewModel.loadImportedAudio(media) + + XCTAssertFalse(viewModel.canShowVideoWindow) + XCTAssertFalse(viewModel.isVideoWindowOpen) + XCTAssertTrue(videoFollower.showWindowEvents.isEmpty) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testToggleVideoWindowIsNoOpWithoutVideoMedia() { + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(videoFollower: videoFollower) + + XCTAssertFalse(viewModel.canToggleVideoWindow) + + viewModel.toggleVideoWindow() + + XCTAssertTrue(videoFollower.toggleWindowEvents.isEmpty) + } + + @MainActor + func testToggleVideoWindowForwardsCurrentPlaybackStateForVideoMedia() throws { + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) + let audioURL = try temporaryAudioFile(duration: 2) + defer { try? FileManager.default.removeItem(at: audioURL) } + let videoURL = URL(fileURLWithPath: "/tmp/lesson.mov") + let media = ImportedAudioFile( + url: audioURL, + sourceMediaURL: videoURL, + displayName: "lesson.mov", + duration: 0.5, + mediaKind: .video + ) + + try viewModel.loadImportedAudio(media) + videoFollower.closeWindow() + viewModel.markProjectClean() + viewModel.setPlaybackRate(0.5) + viewModel.play() + viewModel.toggleVideoWindow() + + let event = try XCTUnwrap(videoFollower.toggleWindowEvents.last) + XCTAssertEqual(event.time, viewModel.currentTime, accuracy: 0.0001) + XCTAssertTrue(event.isPlaying) + XCTAssertEqual(event.rate, 0.5, accuracy: 0.0001) + XCTAssertEqual(videoFollower.showWindowEvents.count, 1) + } + + @MainActor + func testVideoWindowOpenCloseUpdatesProjectModifiedState() throws { + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(videoFollower: videoFollower) + let audioURL = try temporaryAudioFile(duration: 2) + defer { try? FileManager.default.removeItem(at: audioURL) } + let media = ImportedAudioFile( + url: audioURL, + sourceMediaURL: URL(fileURLWithPath: "/tmp/lesson.mov"), + displayName: "lesson.mov", + duration: 0.5, + mediaKind: .video + ) + + try viewModel.loadImportedAudio(media) + + XCTAssertTrue(viewModel.isVideoWindowOpen) + XCTAssertFalse(viewModel.isProjectModified) + + videoFollower.closeWindow() + + XCTAssertFalse(viewModel.isVideoWindowOpen) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.showVideoWindow() + + XCTAssertTrue(viewModel.isVideoWindowOpen) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testNewProjectUnloadsPreparedVideoFollower() throws { + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) + let audioURL = try temporaryAudioFile() + defer { try? FileManager.default.removeItem(at: audioURL) } + let media = ImportedAudioFile( + url: audioURL, + sourceMediaURL: URL(fileURLWithPath: "/tmp/lesson.mov"), + displayName: "lesson.mov", + duration: 0.5, + mediaKind: .video + ) + + try viewModel.loadImportedAudio(media) + viewModel.newProject() + + XCTAssertTrue(videoFollower.didUnload) + XCTAssertFalse(viewModel.canShowVideoWindow) + XCTAssertFalse(viewModel.isVideoWindowOpen) + } +} From dfbb001acf45431ef8d917f4514304e8713c5639 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 16:09:46 +0300 Subject: [PATCH 22/80] test: split view model settings tests --- JammLab.xcodeproj/project.pbxproj | 4 ++ JammLabTests/ViewModelLifecycleTests.swift | 70 -------------------- JammLabTests/ViewModelSettingsTests.swift | 74 ++++++++++++++++++++++ 3 files changed, 78 insertions(+), 70 deletions(-) create mode 100644 JammLabTests/ViewModelSettingsTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 1762132..9bd96ce 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -111,6 +111,7 @@ 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */; }; + 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -308,6 +309,7 @@ 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowTests.swift; sourceTree = ""; }; + 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -590,6 +592,7 @@ 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */, + 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -993,6 +996,7 @@ 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */, + 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 256d59c..dfffe8b 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -10,76 +10,6 @@ final class ViewModelLifecycleTests: XCTestCase { } } - @MainActor - func testViewModelSetTimelineVisibleRangeClampsWithoutAudio() { - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - - viewModel.setTimelineVisibleRange(-20...80) - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 0, accuracy: 0.0001) - } - - @MainActor - func testViewModelForwardsClickSoundSettingsUpdatesToPlaybackEngine() throws { - let defaults = try temporaryUserDefaults() - let settingsStore = JammLab.AppSettingsStore(defaults: defaults) - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, appSettingsStore: settingsStore) - _ = viewModel - - XCTAssertEqual(engine.clickSoundSettings, .defaultValue) - - let custom = JammLab.ClickSoundSettings( - accentFrequencyHz: 2_400, - regularFrequencyHz: 1_000, - accentLengthMs: 44, - regularLengthMs: 18 - ) - settingsStore.updateClickSoundSettings(custom) - - let expectation = expectation(description: "click sound settings forwarded") - DispatchQueue.main.async { - XCTAssertEqual(engine.clickSoundSettings, custom) - expectation.fulfill() - } - wait(for: [expectation], timeout: 1) - } - - @MainActor - func testViewModelAppliesAndForwardsAudioOutputDeviceSettings() throws { - let defaults = try temporaryUserDefaults() - let savedSettings = AudioDeviceSettings(inputDeviceUID: "input-1", outputDeviceUID: "output-1") - defaults.set(try JSONEncoder().encode(savedSettings), forKey: AppSettingsStore.audioDeviceSettingsKey) - - let settingsStore = JammLab.AppSettingsStore(defaults: defaults) - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, appSettingsStore: settingsStore) - _ = viewModel - - XCTAssertEqual(engine.audioOutputDeviceUID, "output-1") - - settingsStore.updateAudioOutputDeviceUID("output-2") - - let outputExpectation = expectation(description: "output device setting forwarded") - DispatchQueue.main.async { - XCTAssertEqual(engine.audioOutputDeviceUID, "output-2") - XCTAssertEqual(Array(engine.audioOutputDeviceUIDs.suffix(2)), ["output-1", "output-2"]) - outputExpectation.fulfill() - } - wait(for: [outputExpectation], timeout: 1) - - settingsStore.updateAudioInputDeviceUID("input-2") - - let inputExpectation = expectation(description: "input device setting not forwarded to playback") - DispatchQueue.main.async { - XCTAssertEqual(engine.audioOutputDeviceUID, "output-2") - XCTAssertEqual(Array(engine.audioOutputDeviceUIDs.suffix(2)), ["output-1", "output-2"]) - inputExpectation.fulfill() - } - wait(for: [inputExpectation], timeout: 1) - } - @MainActor func testProjectOpenRestoresLoopClickAndSnapButtonStates() async throws { let audioURL = try temporaryAudioFile() diff --git a/JammLabTests/ViewModelSettingsTests.swift b/JammLabTests/ViewModelSettingsTests.swift new file mode 100644 index 0000000..5b2d925 --- /dev/null +++ b/JammLabTests/ViewModelSettingsTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import JammLab + +final class ViewModelSettingsTests: XCTestCase { + @MainActor + func testViewModelSetTimelineVisibleRangeClampsWithoutAudio() { + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + + viewModel.setTimelineVisibleRange(-20...80) + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 0, accuracy: 0.0001) + } + + @MainActor + func testViewModelForwardsClickSoundSettingsUpdatesToPlaybackEngine() throws { + let defaults = try temporaryUserDefaults() + let settingsStore = JammLab.AppSettingsStore(defaults: defaults) + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, appSettingsStore: settingsStore) + _ = viewModel + + XCTAssertEqual(engine.clickSoundSettings, .defaultValue) + + let custom = JammLab.ClickSoundSettings( + accentFrequencyHz: 2_400, + regularFrequencyHz: 1_000, + accentLengthMs: 44, + regularLengthMs: 18 + ) + settingsStore.updateClickSoundSettings(custom) + + let expectation = expectation(description: "click sound settings forwarded") + DispatchQueue.main.async { + XCTAssertEqual(engine.clickSoundSettings, custom) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + } + + @MainActor + func testViewModelAppliesAndForwardsAudioOutputDeviceSettings() throws { + let defaults = try temporaryUserDefaults() + let savedSettings = AudioDeviceSettings(inputDeviceUID: "input-1", outputDeviceUID: "output-1") + defaults.set(try JSONEncoder().encode(savedSettings), forKey: AppSettingsStore.audioDeviceSettingsKey) + + let settingsStore = JammLab.AppSettingsStore(defaults: defaults) + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, appSettingsStore: settingsStore) + _ = viewModel + + XCTAssertEqual(engine.audioOutputDeviceUID, "output-1") + + settingsStore.updateAudioOutputDeviceUID("output-2") + + let outputExpectation = expectation(description: "output device setting forwarded") + DispatchQueue.main.async { + XCTAssertEqual(engine.audioOutputDeviceUID, "output-2") + XCTAssertEqual(Array(engine.audioOutputDeviceUIDs.suffix(2)), ["output-1", "output-2"]) + outputExpectation.fulfill() + } + wait(for: [outputExpectation], timeout: 1) + + settingsStore.updateAudioInputDeviceUID("input-2") + + let inputExpectation = expectation(description: "input device setting not forwarded to playback") + DispatchQueue.main.async { + XCTAssertEqual(engine.audioOutputDeviceUID, "output-2") + XCTAssertEqual(Array(engine.audioOutputDeviceUIDs.suffix(2)), ["output-1", "output-2"]) + inputExpectation.fulfill() + } + wait(for: [inputExpectation], timeout: 1) + } +} From 140e1664aa767aaac5e4d58364d8e92c42e58be6 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 16:13:37 +0300 Subject: [PATCH 23/80] test: split view model project key persistence tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 204 ----------------- .../ViewModelProjectKeyPersistenceTests.swift | 208 ++++++++++++++++++ 3 files changed, 212 insertions(+), 204 deletions(-) create mode 100644 JammLabTests/ViewModelProjectKeyPersistenceTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 9bd96ce..ea84d68 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -112,6 +112,7 @@ 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */; }; 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; + 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -310,6 +311,7 @@ 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowTests.swift; sourceTree = ""; }; 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; + 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectKeyPersistenceTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -593,6 +595,7 @@ 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */, 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, + 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -997,6 +1000,7 @@ 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */, 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, + 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index dfffe8b..2cfdddd 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -2,14 +2,6 @@ import XCTest @testable import JammLab final class ViewModelLifecycleTests: XCTestCase { - @MainActor - private func waitForAnalysisToFinish(_ viewModel: AudioPlayerViewModel) async { - for _ in 0..<50 { - if !viewModel.isAnalyzing { return } - await Task.yield() - } - } - @MainActor func testProjectOpenRestoresLoopClickAndSnapButtonStates() async throws { let audioURL = try temporaryAudioFile() @@ -144,46 +136,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(savedProject.playbackMarkerTime), 1.25, accuracy: 0.0001) } - @MainActor - func testImportAutoDetectsProjectKeyAndPersistsOnSave() async throws { - let audioURL = try temporaryAudioFile(duration: 2) - let projectURL = temporaryDirectory().appendingPathComponent("auto-key-save.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let analyzer = MockAnalyzer() - analyzer.result = AnalysisResult(bpm: 120, keyName: "F# minor", keyConfidence: 0.82) - let projectService = ProjectDocumentService() - let viewModel = AudioPlayerViewModel( - analyzer: analyzer, - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - try viewModel.loadImportedAudio(ImportedAudioFile(url: audioURL, displayName: "auto-key.wav", duration: 2)) - await waitForAnalysisToFinish(viewModel) - - XCTAssertEqual(analyzer.calls.map(\.includesTempo), [true]) - XCTAssertEqual(analyzer.calls.map(\.includesKey), [true]) - XCTAssertEqual(viewModel.projectKeySelection?.tonic, .fSharpGb) - XCTAssertEqual(viewModel.projectKeySelection?.mode, .minor) - XCTAssertEqual(viewModel.projectKeySelection?.source, .auto) - XCTAssertFalse(viewModel.isProjectModified) - - let didSave = await viewModel.saveProject(to: projectURL) - - XCTAssertTrue(didSave) - let savedProject = try projectService.load(from: projectURL) - XCTAssertEqual(savedProject.projectKeySelection?.canonicalKeyName, "F# minor") - XCTAssertEqual(savedProject.projectKeySelection?.source, .auto) - } - @MainActor func testManualTimelineRangeChangesDirtyStateAndPersistsOnSave() async throws { let audioURL = try temporaryAudioFile(duration: 4) @@ -267,162 +219,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } - @MainActor - func testProjectOpenWithSavedKeySkipsKeyDetection() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("saved-key-open.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let savedKey = ProjectKeySelection( - tonic: .aSharpBb, - mode: .major, - source: .user, - confidence: nil - ) - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - projectKeySelection: savedKey, - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - try projectService.save(project, to: projectURL) - let analyzer = MockAnalyzer() - analyzer.result = AnalysisResult(bpm: 90, keyName: "D minor", keyConfidence: 0.9) - let viewModel = AudioPlayerViewModel( - analyzer: analyzer, - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: projectURL) - await waitForAnalysisToFinish(viewModel) - - XCTAssertTrue(analyzer.calls.isEmpty) - XCTAssertEqual(viewModel.projectKeySelection, savedKey) - XCTAssertEqual(viewModel.effectiveKeyName, "Bb major") - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testProjectOpenWithSavedKeyAndMissingTempoRunsTempoOnlyAnalysis() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("saved-key-missing-tempo.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let savedKey = ProjectKeySelection( - tonic: .gSharpAb, - mode: .minor, - source: .user, - confidence: nil - ) - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - projectKeySelection: savedKey, - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones - ) - try projectService.save(project, to: projectURL) - let analyzer = MockAnalyzer() - analyzer.result = AnalysisResult(bpm: 96, keyName: "D major", keyConfidence: 0.9) - let viewModel = AudioPlayerViewModel( - analyzer: analyzer, - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: projectURL) - await waitForAnalysisToFinish(viewModel) - - XCTAssertEqual(analyzer.calls.count, 1) - XCTAssertEqual(analyzer.calls.first?.includesTempo, true) - XCTAssertEqual(analyzer.calls.first?.includesKey, false) - XCTAssertEqual(viewModel.projectKeySelection, savedKey) - XCTAssertEqual(viewModel.effectiveKeyName, "G# minor") - XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), 96, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testProjectOpenWithoutSavedKeyRunsKeyDetectionAndMarksDirtyForPersistence() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("legacy-auto-key-open.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - try projectService.save(project, to: projectURL) - let analyzer = MockAnalyzer() - analyzer.result = AnalysisResult(bpm: 140, keyName: "Bb major", keyConfidence: 0.74) - let viewModel = AudioPlayerViewModel( - analyzer: analyzer, - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: projectURL) - await waitForAnalysisToFinish(viewModel) - - XCTAssertEqual(analyzer.calls.count, 1) - XCTAssertEqual(analyzer.calls.first?.includesTempo, false) - XCTAssertEqual(analyzer.calls.first?.includesKey, true) - XCTAssertEqual(viewModel.projectKeySelection?.tonic, .aSharpBb) - XCTAssertEqual(viewModel.projectKeySelection?.mode, .major) - XCTAssertEqual(viewModel.projectKeySelection?.source, .auto) - XCTAssertTrue(viewModel.isProjectModified) - - let didSave = await viewModel.saveProjectForClose() - - XCTAssertTrue(didSave) - let savedProject = try projectService.load(from: projectURL) - XCTAssertEqual(savedProject.projectKeySelection?.canonicalKeyName, "Bb major") - } - @MainActor func testProjectOpenDefaultsInvalidTimelineVisibleRangeToFullDuration() async throws { let audioURL = try temporaryAudioFile(duration: 4) diff --git a/JammLabTests/ViewModelProjectKeyPersistenceTests.swift b/JammLabTests/ViewModelProjectKeyPersistenceTests.swift new file mode 100644 index 0000000..d6f2168 --- /dev/null +++ b/JammLabTests/ViewModelProjectKeyPersistenceTests.swift @@ -0,0 +1,208 @@ +import XCTest +@testable import JammLab + +final class ViewModelProjectKeyPersistenceTests: XCTestCase { + @MainActor + private func waitForAnalysisToFinish(_ viewModel: AudioPlayerViewModel) async { + for _ in 0..<50 { + if !viewModel.isAnalyzing { return } + await Task.yield() + } + } + + @MainActor + func testImportAutoDetectsProjectKeyAndPersistsOnSave() async throws { + let audioURL = try temporaryAudioFile(duration: 2) + let projectURL = temporaryDirectory().appendingPathComponent("auto-key-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let analyzer = MockAnalyzer() + analyzer.result = AnalysisResult(bpm: 120, keyName: "F# minor", keyConfidence: 0.82) + let projectService = ProjectDocumentService() + let viewModel = AudioPlayerViewModel( + analyzer: analyzer, + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + try viewModel.loadImportedAudio(ImportedAudioFile(url: audioURL, displayName: "auto-key.wav", duration: 2)) + await waitForAnalysisToFinish(viewModel) + + XCTAssertEqual(analyzer.calls.map(\.includesTempo), [true]) + XCTAssertEqual(analyzer.calls.map(\.includesKey), [true]) + XCTAssertEqual(viewModel.projectKeySelection?.tonic, .fSharpGb) + XCTAssertEqual(viewModel.projectKeySelection?.mode, .minor) + XCTAssertEqual(viewModel.projectKeySelection?.source, .auto) + XCTAssertFalse(viewModel.isProjectModified) + + let didSave = await viewModel.saveProject(to: projectURL) + + XCTAssertTrue(didSave) + let savedProject = try projectService.load(from: projectURL) + XCTAssertEqual(savedProject.projectKeySelection?.canonicalKeyName, "F# minor") + XCTAssertEqual(savedProject.projectKeySelection?.source, .auto) + } + + @MainActor + func testProjectOpenWithSavedKeySkipsKeyDetection() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("saved-key-open.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let savedKey = ProjectKeySelection( + tonic: .aSharpBb, + mode: .major, + source: .user, + confidence: nil + ) + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + projectKeySelection: savedKey, + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + try projectService.save(project, to: projectURL) + let analyzer = MockAnalyzer() + analyzer.result = AnalysisResult(bpm: 90, keyName: "D minor", keyConfidence: 0.9) + let viewModel = AudioPlayerViewModel( + analyzer: analyzer, + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: projectURL) + await waitForAnalysisToFinish(viewModel) + + XCTAssertTrue(analyzer.calls.isEmpty) + XCTAssertEqual(viewModel.projectKeySelection, savedKey) + XCTAssertEqual(viewModel.effectiveKeyName, "Bb major") + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testProjectOpenWithSavedKeyAndMissingTempoRunsTempoOnlyAnalysis() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("saved-key-missing-tempo.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let savedKey = ProjectKeySelection( + tonic: .gSharpAb, + mode: .minor, + source: .user, + confidence: nil + ) + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + projectKeySelection: savedKey, + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones + ) + try projectService.save(project, to: projectURL) + let analyzer = MockAnalyzer() + analyzer.result = AnalysisResult(bpm: 96, keyName: "D major", keyConfidence: 0.9) + let viewModel = AudioPlayerViewModel( + analyzer: analyzer, + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: projectURL) + await waitForAnalysisToFinish(viewModel) + + XCTAssertEqual(analyzer.calls.count, 1) + XCTAssertEqual(analyzer.calls.first?.includesTempo, true) + XCTAssertEqual(analyzer.calls.first?.includesKey, false) + XCTAssertEqual(viewModel.projectKeySelection, savedKey) + XCTAssertEqual(viewModel.effectiveKeyName, "G# minor") + XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), 96, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testProjectOpenWithoutSavedKeyRunsKeyDetectionAndMarksDirtyForPersistence() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("legacy-auto-key-open.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + try projectService.save(project, to: projectURL) + let analyzer = MockAnalyzer() + analyzer.result = AnalysisResult(bpm: 140, keyName: "Bb major", keyConfidence: 0.74) + let viewModel = AudioPlayerViewModel( + analyzer: analyzer, + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: projectURL) + await waitForAnalysisToFinish(viewModel) + + XCTAssertEqual(analyzer.calls.count, 1) + XCTAssertEqual(analyzer.calls.first?.includesTempo, false) + XCTAssertEqual(analyzer.calls.first?.includesKey, true) + XCTAssertEqual(viewModel.projectKeySelection?.tonic, .aSharpBb) + XCTAssertEqual(viewModel.projectKeySelection?.mode, .major) + XCTAssertEqual(viewModel.projectKeySelection?.source, .auto) + XCTAssertTrue(viewModel.isProjectModified) + + let didSave = await viewModel.saveProjectForClose() + + XCTAssertTrue(didSave) + let savedProject = try projectService.load(from: projectURL) + XCTAssertEqual(savedProject.projectKeySelection?.canonicalKeyName, "Bb major") + } +} From 7bf0b42cf7caca2b3dc26f09d86bf5235fbc300a Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 16:47:50 +0300 Subject: [PATCH 24/80] test: split view model project restore tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 338 ----------------- .../ViewModelProjectRestoreTests.swift | 342 ++++++++++++++++++ 3 files changed, 346 insertions(+), 338 deletions(-) create mode 100644 JammLabTests/ViewModelProjectRestoreTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index ea84d68..36d3ae5 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -113,6 +113,7 @@ 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */; }; 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */; }; + 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -312,6 +313,7 @@ 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowTests.swift; sourceTree = ""; }; 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectKeyPersistenceTests.swift; sourceTree = ""; }; + 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectRestoreTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -596,6 +598,7 @@ 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */, 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, + 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -1001,6 +1004,7 @@ 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */, 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */, + 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 2cfdddd..04c3124 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -2,344 +2,6 @@ import XCTest @testable import JammLab final class ViewModelLifecycleTests: XCTestCase { - @MainActor - func testProjectOpenRestoresLoopClickAndSnapButtonStates() async throws { - let audioURL = try temporaryAudioFile() - let projectURL = temporaryDirectory().appendingPathComponent("toggles.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 2, - notes: [], - loopStart: 0.25, - loopEnd: 1.25, - isLoopEnabled: true, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - mainTrackVolume: AppSliderDefaults.mainTrackVolume, - isClickEnabled: true, - clickVolume: 0.33, - isSnapEnabled: true - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "toggles", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - playbackEngine: engine, - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) - ) - - await viewModel.openRecentProject(entry) - - XCTAssertTrue(viewModel.isLooping) - XCTAssertTrue(viewModel.isClickEnabled) - XCTAssertTrue(viewModel.isSnapEnabled) - XCTAssertEqual(viewModel.clickVolume, 0.33, accuracy: 0.0001) - XCTAssertTrue(engine.loopEnabled) - XCTAssertEqual(engine.loopRegion.start, 0.25, accuracy: 0.0001) - XCTAssertEqual(engine.loopRegion.end, 1.25, accuracy: 0.0001) - XCTAssertTrue(engine.clickEnabled) - } - - @MainActor - func testProjectOpenRestoresAndClampsPlaybackMarkerTime() async throws { - let audioURL = try temporaryAudioFile(duration: 2) - let projectURL = temporaryDirectory().appendingPathComponent("playback-marker-open.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 2, - notes: [], - loopStart: 0, - loopEnd: 2, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - playbackMarkerTime: 99 - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "playback-marker-open", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - playbackEngine: engine, - videoFollower: videoFollower, - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) - ) - - await viewModel.openRecentProject(entry) - - XCTAssertEqual(viewModel.playbackMarkerTime, 2, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 2, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 2, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testSaveProjectPersistsPlaybackMarkerTime() async throws { - let audioURL = try temporaryAudioFile(duration: 2) - let projectURL = temporaryDirectory().appendingPathComponent("playback-marker-save.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine, - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - let media = ImportedAudioFile(url: audioURL, displayName: "marker.wav", duration: 2) - try viewModel.loadImportedAudio(media) - - viewModel.locatePlaybackMarker(to: 1.25) - - XCTAssertTrue(viewModel.isProjectModified) - - let didSave = await viewModel.saveProject(to: projectURL) - - XCTAssertTrue(didSave) - XCTAssertFalse(viewModel.isProjectModified) - let savedProject = try projectService.load(from: projectURL) - XCTAssertEqual(try XCTUnwrap(savedProject.playbackMarkerTime), 1.25, accuracy: 0.0001) - } - - @MainActor - func testManualTimelineRangeChangesDirtyStateAndPersistsOnSave() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-save.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - let media = ImportedAudioFile(url: audioURL, displayName: "viewport.wav", duration: 4) - try viewModel.loadImportedAudio(media) - - viewModel.setTimelineVisibleRange(1...2.5) - - XCTAssertTrue(viewModel.isProjectModified) - - let didSave = await viewModel.saveProject(to: projectURL) - - XCTAssertTrue(didSave) - XCTAssertFalse(viewModel.isProjectModified) - let savedProject = try projectService.load(from: projectURL) - let savedRange = try XCTUnwrap(savedProject.timelineVisibleRange) - XCTAssertEqual(savedRange.start, 1, accuracy: 0.0001) - XCTAssertEqual(savedRange.end, 2.5, accuracy: 0.0001) - } - - @MainActor - func testProjectOpenRestoresTimelineVisibleRange() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-open.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - timelineVisibleRange: ProjectTimelineVisibleRange(start: 1, end: 2.5) - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "timeline-range-open", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openRecentProject(entry) - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 1, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 2.5, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 1, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 2.5, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testProjectOpenDefaultsInvalidTimelineVisibleRangeToFullDuration() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-invalid.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - timelineVisibleRange: ProjectTimelineVisibleRange(start: -1, end: 99) - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "timeline-range-invalid", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openRecentProject(entry) - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 4, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 4, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testLegacyProjectOpenDefaultsLoopClickAndSnapToOff() async throws { - let audioURL = try temporaryAudioFile() - let projectURL = temporaryDirectory().appendingPathComponent("legacy-toggles.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - formatVersion: 4, - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 0.5, - notes: [], - loopStart: 0.1, - loopEnd: 0.4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "legacy-toggles", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - playbackEngine: engine, - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) - ) - - await viewModel.openRecentProject(entry) - - XCTAssertFalse(viewModel.isLooping) - XCTAssertFalse(viewModel.isClickEnabled) - XCTAssertFalse(viewModel.isSnapEnabled) - XCTAssertEqual(viewModel.playbackMarkerTime, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 0.5, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 0.5, accuracy: 0.0001) - XCTAssertFalse(engine.loopEnabled) - XCTAssertFalse(engine.clickEnabled) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testOpenRecentProjectRemovesMissingProjectEntry() async throws { - let defaults = try temporaryUserDefaults() - let projectURL = try temporaryFile(name: "missing-recent.jammlab", contents: "{}") - let projectDirectory = projectURL.deletingLastPathComponent() - let projectService = ProjectDocumentService() - let store = RecentProjectsStore(defaults: defaults) - store.addProject(url: projectURL, bookmarkData: try projectService.bookmarkData(for: projectURL)) - let entry = try XCTUnwrap(store.entries.first) - try FileManager.default.removeItem(at: projectDirectory) - let viewModel = AudioPlayerViewModel( - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: store - ) - - await viewModel.openRecentProject(entry) - - XCTAssertTrue(store.entries.isEmpty) - XCTAssertEqual(viewModel.errorMessage, "Could not open recent project: The file doesn’t exist.") - } - @MainActor func testOpeningVideoProjectWithoutLocalAudioDoesNotCreateProjectMediaDirectory() async throws { let missingVideoURL = try temporaryFile(name: "missing-video.mov", contents: "video") diff --git a/JammLabTests/ViewModelProjectRestoreTests.swift b/JammLabTests/ViewModelProjectRestoreTests.swift new file mode 100644 index 0000000..9be4aa4 --- /dev/null +++ b/JammLabTests/ViewModelProjectRestoreTests.swift @@ -0,0 +1,342 @@ +import XCTest +@testable import JammLab + +final class ViewModelProjectRestoreTests: XCTestCase { + @MainActor + func testProjectOpenRestoresLoopClickAndSnapButtonStates() async throws { + let audioURL = try temporaryAudioFile() + let projectURL = temporaryDirectory().appendingPathComponent("toggles.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 2, + notes: [], + loopStart: 0.25, + loopEnd: 1.25, + isLoopEnabled: true, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + mainTrackVolume: AppSliderDefaults.mainTrackVolume, + isClickEnabled: true, + clickVolume: 0.33, + isSnapEnabled: true + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "toggles", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + playbackEngine: engine, + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + + await viewModel.openRecentProject(entry) + + XCTAssertTrue(viewModel.isLooping) + XCTAssertTrue(viewModel.isClickEnabled) + XCTAssertTrue(viewModel.isSnapEnabled) + XCTAssertEqual(viewModel.clickVolume, 0.33, accuracy: 0.0001) + XCTAssertTrue(engine.loopEnabled) + XCTAssertEqual(engine.loopRegion.start, 0.25, accuracy: 0.0001) + XCTAssertEqual(engine.loopRegion.end, 1.25, accuracy: 0.0001) + XCTAssertTrue(engine.clickEnabled) + } + + @MainActor + func testProjectOpenRestoresAndClampsPlaybackMarkerTime() async throws { + let audioURL = try temporaryAudioFile(duration: 2) + let projectURL = temporaryDirectory().appendingPathComponent("playback-marker-open.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 2, + notes: [], + loopStart: 0, + loopEnd: 2, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + playbackMarkerTime: 99 + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "playback-marker-open", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + playbackEngine: engine, + videoFollower: videoFollower, + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.playbackMarkerTime, 2, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 2, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 2, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testSaveProjectPersistsPlaybackMarkerTime() async throws { + let audioURL = try temporaryAudioFile(duration: 2) + let projectURL = temporaryDirectory().appendingPathComponent("playback-marker-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + let media = ImportedAudioFile(url: audioURL, displayName: "marker.wav", duration: 2) + try viewModel.loadImportedAudio(media) + + viewModel.locatePlaybackMarker(to: 1.25) + + XCTAssertTrue(viewModel.isProjectModified) + + let didSave = await viewModel.saveProject(to: projectURL) + + XCTAssertTrue(didSave) + XCTAssertFalse(viewModel.isProjectModified) + let savedProject = try projectService.load(from: projectURL) + XCTAssertEqual(try XCTUnwrap(savedProject.playbackMarkerTime), 1.25, accuracy: 0.0001) + } + + @MainActor + func testManualTimelineRangeChangesDirtyStateAndPersistsOnSave() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + let media = ImportedAudioFile(url: audioURL, displayName: "viewport.wav", duration: 4) + try viewModel.loadImportedAudio(media) + + viewModel.setTimelineVisibleRange(1...2.5) + + XCTAssertTrue(viewModel.isProjectModified) + + let didSave = await viewModel.saveProject(to: projectURL) + + XCTAssertTrue(didSave) + XCTAssertFalse(viewModel.isProjectModified) + let savedProject = try projectService.load(from: projectURL) + let savedRange = try XCTUnwrap(savedProject.timelineVisibleRange) + XCTAssertEqual(savedRange.start, 1, accuracy: 0.0001) + XCTAssertEqual(savedRange.end, 2.5, accuracy: 0.0001) + } + + @MainActor + func testProjectOpenRestoresTimelineVisibleRange() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-open.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + timelineVisibleRange: ProjectTimelineVisibleRange(start: 1, end: 2.5) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "timeline-range-open", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 1, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 2.5, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 1, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 2.5, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testProjectOpenDefaultsInvalidTimelineVisibleRangeToFullDuration() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-invalid.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + timelineVisibleRange: ProjectTimelineVisibleRange(start: -1, end: 99) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "timeline-range-invalid", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 4, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 4, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testLegacyProjectOpenDefaultsLoopClickAndSnapToOff() async throws { + let audioURL = try temporaryAudioFile() + let projectURL = temporaryDirectory().appendingPathComponent("legacy-toggles.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + formatVersion: 4, + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 0.5, + notes: [], + loopStart: 0.1, + loopEnd: 0.4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "legacy-toggles", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + playbackEngine: engine, + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + + await viewModel.openRecentProject(entry) + + XCTAssertFalse(viewModel.isLooping) + XCTAssertFalse(viewModel.isClickEnabled) + XCTAssertFalse(viewModel.isSnapEnabled) + XCTAssertEqual(viewModel.playbackMarkerTime, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 0.5, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 0.5, accuracy: 0.0001) + XCTAssertFalse(engine.loopEnabled) + XCTAssertFalse(engine.clickEnabled) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testOpenRecentProjectRemovesMissingProjectEntry() async throws { + let defaults = try temporaryUserDefaults() + let projectURL = try temporaryFile(name: "missing-recent.jammlab", contents: "{}") + let projectDirectory = projectURL.deletingLastPathComponent() + let projectService = ProjectDocumentService() + let store = RecentProjectsStore(defaults: defaults) + store.addProject(url: projectURL, bookmarkData: try projectService.bookmarkData(for: projectURL)) + let entry = try XCTUnwrap(store.entries.first) + try FileManager.default.removeItem(at: projectDirectory) + let viewModel = AudioPlayerViewModel( + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: store + ) + + await viewModel.openRecentProject(entry) + + XCTAssertTrue(store.entries.isEmpty) + XCTAssertEqual(viewModel.errorMessage, "Could not open recent project: The file doesn’t exist.") + } +} From 0948fbe5f7f1e5dc0b6b61d68fe11ce60f56926f Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 16:53:55 +0300 Subject: [PATCH 25/80] test: split view model editing state tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelEditingStateTests.swift | 236 ++++++++++++++++++ JammLabTests/ViewModelLifecycleTests.swift | 232 ----------------- 3 files changed, 240 insertions(+), 232 deletions(-) create mode 100644 JammLabTests/ViewModelEditingStateTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 36d3ae5..459954c 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */; }; 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */; }; + 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -314,6 +315,7 @@ 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectKeyPersistenceTests.swift; sourceTree = ""; }; 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectRestoreTests.swift; sourceTree = ""; }; + 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelEditingStateTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -599,6 +601,7 @@ 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, + 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -1005,6 +1008,7 @@ 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */, 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */, + 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelEditingStateTests.swift b/JammLabTests/ViewModelEditingStateTests.swift new file mode 100644 index 0000000..70e83e9 --- /dev/null +++ b/JammLabTests/ViewModelEditingStateTests.swift @@ -0,0 +1,236 @@ +import XCTest +@testable import JammLab + +final class ViewModelEditingStateTests: XCTestCase { + @MainActor + func testImportedAudioStartsCleanAndPersistedEditMarksProjectModified() throws { + let audioURL = try temporaryAudioFile() + defer { try? FileManager.default.removeItem(at: audioURL) } + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine() + ) + let media = ImportedAudioFile(url: audioURL, displayName: "loop.wav", duration: 0.5) + + try viewModel.loadImportedAudio(media) + + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertEqual(viewModel.windowTitle, "loop.wav - JammLab") + + viewModel.setMainTrackVolume(0.2) + + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertEqual(viewModel.windowTitle, "loop.wav [modified] - JammLab") + } + + @MainActor + func testUndoRedoLoopingUpdatesModifiedState() throws { + let audioURL = try temporaryAudioFile() + defer { try? FileManager.default.removeItem(at: audioURL) } + let undoManager = UndoManager() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine() + ) + let media = ImportedAudioFile(url: audioURL, displayName: "loop.wav", duration: 0.5) + try viewModel.loadImportedAudio(media) + viewModel.undoManager = undoManager + + viewModel.setLooping(true) + + XCTAssertTrue(viewModel.isLooping) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + XCTAssertFalse(viewModel.isLooping) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.redoLastEdit() + + XCTAssertTrue(viewModel.isLooping) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testTimeSignatureChangeUpdatesClickSettingsModifiedStateAndUndo() throws { + let audioURL = try temporaryAudioFile() + defer { try? FileManager.default.removeItem(at: audioURL) } + let undoManager = UndoManager() + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "meter.wav", duration: 2) + try viewModel.loadImportedAudio(media) + viewModel.undoManager = undoManager + + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + + XCTAssertEqual(viewModel.beatGridSettings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) + XCTAssertEqual(engine.clickSettings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.beatGridSettings.timeSignature, .fourFour) + XCTAssertEqual(engine.clickSettings.timeSignature, .fourFour) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.redoLastEdit() + + XCTAssertEqual(viewModel.beatGridSettings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testProjectEditableStateRestoreAppliesEngineBackedSettings() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + let noteID = TimecodedNote.ID() + let regionID = TimecodedNote.ID() + let notes = [ + TimecodedNote(id: noteID, time: 1, title: "Marker A"), + TimecodedNote(id: regionID, kind: .region, time: 2, duration: 3, title: "Region A", color: .regionGreen) + ] + var mix = StemMixState() + mix.update(.vocals) { + $0.volume = 0.2 + $0.isMuted = true + } + let beatGrid = BeatGridSettings(bpm: 140, firstBeatTime: 0.5, timeSignature: .fourFour) + let state = ProjectEditableState( + notes: notes, + selectedRegionID: regionID, + activeLoopRegionID: regionID, + loopRegion: LoopRegion(start: 2, end: 5), + isLooping: true, + tempoBPM: 140, + beatGridSettings: beatGrid, + playbackRate: 0.5, + pitchShiftSemitones: -3, + mainTrackVolume: 0.4, + stemMixState: mix, + playbackMode: .original, + isClickEnabled: true, + clickVolume: 0.25, + isSnapEnabled: true + ) + + viewModel.restoreEditableState(state) + + XCTAssertEqual(viewModel.notes.count, 2) + XCTAssertEqual(viewModel.selectedRegionID, regionID) + XCTAssertEqual(viewModel.activeLoopRegionID, regionID) + XCTAssertTrue(viewModel.isLooping) + XCTAssertEqual(viewModel.playbackRate, 0.5, accuracy: 0.0001) + XCTAssertEqual(viewModel.pitchShiftSemitones, -3, accuracy: 0.0001) + XCTAssertEqual(viewModel.mainTrackVolume, 0.4, accuracy: 0.0001) + XCTAssertEqual(viewModel.clickVolume, 0.25, accuracy: 0.0001) + XCTAssertTrue(viewModel.isClickEnabled) + XCTAssertTrue(viewModel.isSnapEnabled) + XCTAssertEqual(engine.playbackRate, 0.5, accuracy: 0.0001) + XCTAssertEqual(engine.pitchShiftSemitones, -3, accuracy: 0.0001) + XCTAssertEqual(engine.mainVolume, 0.4, accuracy: 0.0001) + XCTAssertEqual(engine.clickVolume, 0.25, accuracy: 0.0001) + XCTAssertTrue(engine.clickEnabled) + XCTAssertEqual(try XCTUnwrap(engine.clickSettings.bpm), 140, accuracy: 0.0001) + XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) + } + + @MainActor + func testUndoRestoresStemMuteAndRedoReappliesIt() { + let undoManager = UndoManager() + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + viewModel.undoManager = undoManager + + viewModel.toggleStemMute(.vocals) + + XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) + XCTAssertTrue(viewModel.canUndo) + + viewModel.undoLastEdit() + + XCTAssertFalse(viewModel.stemMixState.item(for: .vocals).isMuted) + XCTAssertTrue(viewModel.canRedo) + + viewModel.redoLastEdit() + + XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) + } + + @MainActor + func testUndoRestoresNoteUpdateOrderAndTitle() { + let undoManager = UndoManager() + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + let markerA = TimecodedNote(time: 10, title: "A") + let markerB = TimecodedNote(time: 2, title: "B") + let state = ProjectEditableState( + notes: [markerA, markerB], + selectedRegionID: nil, + activeLoopRegionID: nil, + loopRegion: .empty, + isLooping: false, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + mainTrackVolume: AppSliderDefaults.mainTrackVolume, + stemMixState: StemMixState(), + playbackMode: .original, + isClickEnabled: false, + clickVolume: AppSliderDefaults.clickVolume, + isSnapEnabled: false + ) + viewModel.restoreEditableState(state) + viewModel.undoManager = undoManager + + viewModel.updateNoteTitle(id: markerA.id, title: "Renamed") + + XCTAssertEqual(viewModel.notes.map(\.title), ["Renamed", "B"]) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.notes.map(\.title), ["A", "B"]) + } + + @MainActor + func testViewModelUpdatesPresetAndCustomNoteColors() { + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + let marker = TimecodedNote(time: 2, title: "A", color: .markerBlue, customColorHex: "#123456") + let state = ProjectEditableState( + notes: [marker], + selectedRegionID: nil, + activeLoopRegionID: nil, + loopRegion: .empty, + isLooping: false, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + mainTrackVolume: AppSliderDefaults.mainTrackVolume, + stemMixState: StemMixState(), + playbackMode: .original, + isClickEnabled: false, + clickVolume: AppSliderDefaults.clickVolume, + isSnapEnabled: false + ) + viewModel.restoreEditableState(state) + + viewModel.updateNoteCustomColor(id: marker.id, hex: "abcdef") + + XCTAssertEqual(viewModel.notes.first?.customColorHex, "#ABCDEF") + XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, "#ABCDEF") + + viewModel.updateNoteColor(id: marker.id, color: .markerOrange) + + XCTAssertEqual(viewModel.notes.first?.color, .markerOrange) + XCTAssertNil(viewModel.notes.first?.customColorHex) + XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, MarkerColor.markerOrange.defaultHex) + } +} diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 04c3124..21a03da 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -48,91 +48,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: artifactStore.mediaDirectory(for: projectURL).path)) } - @MainActor - func testImportedAudioStartsCleanAndPersistedEditMarksProjectModified() throws { - let audioURL = try temporaryAudioFile() - defer { try? FileManager.default.removeItem(at: audioURL) } - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine() - ) - let media = ImportedAudioFile(url: audioURL, displayName: "loop.wav", duration: 0.5) - - try viewModel.loadImportedAudio(media) - - XCTAssertFalse(viewModel.isProjectModified) - XCTAssertEqual(viewModel.windowTitle, "loop.wav - JammLab") - - viewModel.setMainTrackVolume(0.2) - - XCTAssertTrue(viewModel.isProjectModified) - XCTAssertEqual(viewModel.windowTitle, "loop.wav [modified] - JammLab") - } - - @MainActor - func testUndoRedoLoopingUpdatesModifiedState() throws { - let audioURL = try temporaryAudioFile() - defer { try? FileManager.default.removeItem(at: audioURL) } - let undoManager = UndoManager() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine() - ) - let media = ImportedAudioFile(url: audioURL, displayName: "loop.wav", duration: 0.5) - try viewModel.loadImportedAudio(media) - viewModel.undoManager = undoManager - - viewModel.setLooping(true) - - XCTAssertTrue(viewModel.isLooping) - XCTAssertTrue(viewModel.isProjectModified) - - viewModel.undoLastEdit() - - XCTAssertFalse(viewModel.isLooping) - XCTAssertFalse(viewModel.isProjectModified) - - viewModel.redoLastEdit() - - XCTAssertTrue(viewModel.isLooping) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testTimeSignatureChangeUpdatesClickSettingsModifiedStateAndUndo() throws { - let audioURL = try temporaryAudioFile() - defer { try? FileManager.default.removeItem(at: audioURL) } - let undoManager = UndoManager() - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "meter.wav", duration: 2) - try viewModel.loadImportedAudio(media) - viewModel.undoManager = undoManager - - viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) - - XCTAssertEqual(viewModel.beatGridSettings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) - XCTAssertEqual(engine.clickSettings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) - XCTAssertTrue(viewModel.isProjectModified) - - viewModel.undoLastEdit() - - XCTAssertEqual(viewModel.beatGridSettings.timeSignature, .fourFour) - XCTAssertEqual(engine.clickSettings.timeSignature, .fourFour) - XCTAssertFalse(viewModel.isProjectModified) - - viewModel.redoLastEdit() - - XCTAssertEqual(viewModel.beatGridSettings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) - XCTAssertTrue(viewModel.isProjectModified) - } - @MainActor func testAddingTempoTimeSignatureMarkerUpdatesPlaybackTempoMap() throws { let audioURL = try temporaryAudioFile(duration: 6) @@ -1468,151 +1383,4 @@ final class ViewModelLifecycleTests: XCTestCase { ) } - @MainActor - func testProjectEditableStateRestoreAppliesEngineBackedSettings() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - let noteID = TimecodedNote.ID() - let regionID = TimecodedNote.ID() - let notes = [ - TimecodedNote(id: noteID, time: 1, title: "Marker A"), - TimecodedNote(id: regionID, kind: .region, time: 2, duration: 3, title: "Region A", color: .regionGreen) - ] - var mix = StemMixState() - mix.update(.vocals) { - $0.volume = 0.2 - $0.isMuted = true - } - let beatGrid = BeatGridSettings(bpm: 140, firstBeatTime: 0.5, timeSignature: .fourFour) - let state = ProjectEditableState( - notes: notes, - selectedRegionID: regionID, - activeLoopRegionID: regionID, - loopRegion: LoopRegion(start: 2, end: 5), - isLooping: true, - tempoBPM: 140, - beatGridSettings: beatGrid, - playbackRate: 0.5, - pitchShiftSemitones: -3, - mainTrackVolume: 0.4, - stemMixState: mix, - playbackMode: .original, - isClickEnabled: true, - clickVolume: 0.25, - isSnapEnabled: true - ) - - viewModel.restoreEditableState(state) - - XCTAssertEqual(viewModel.notes.count, 2) - XCTAssertEqual(viewModel.selectedRegionID, regionID) - XCTAssertEqual(viewModel.activeLoopRegionID, regionID) - XCTAssertTrue(viewModel.isLooping) - XCTAssertEqual(viewModel.playbackRate, 0.5, accuracy: 0.0001) - XCTAssertEqual(viewModel.pitchShiftSemitones, -3, accuracy: 0.0001) - XCTAssertEqual(viewModel.mainTrackVolume, 0.4, accuracy: 0.0001) - XCTAssertEqual(viewModel.clickVolume, 0.25, accuracy: 0.0001) - XCTAssertTrue(viewModel.isClickEnabled) - XCTAssertTrue(viewModel.isSnapEnabled) - XCTAssertEqual(engine.playbackRate, 0.5, accuracy: 0.0001) - XCTAssertEqual(engine.pitchShiftSemitones, -3, accuracy: 0.0001) - XCTAssertEqual(engine.mainVolume, 0.4, accuracy: 0.0001) - XCTAssertEqual(engine.clickVolume, 0.25, accuracy: 0.0001) - XCTAssertTrue(engine.clickEnabled) - XCTAssertEqual(try XCTUnwrap(engine.clickSettings.bpm), 140, accuracy: 0.0001) - XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) - } - - @MainActor - func testUndoRestoresStemMuteAndRedoReappliesIt() { - let undoManager = UndoManager() - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - viewModel.undoManager = undoManager - - viewModel.toggleStemMute(.vocals) - - XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) - XCTAssertTrue(viewModel.canUndo) - - viewModel.undoLastEdit() - - XCTAssertFalse(viewModel.stemMixState.item(for: .vocals).isMuted) - XCTAssertTrue(viewModel.canRedo) - - viewModel.redoLastEdit() - - XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) - } - - @MainActor - func testUndoRestoresNoteUpdateOrderAndTitle() { - let undoManager = UndoManager() - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - let markerA = TimecodedNote(time: 10, title: "A") - let markerB = TimecodedNote(time: 2, title: "B") - let state = ProjectEditableState( - notes: [markerA, markerB], - selectedRegionID: nil, - activeLoopRegionID: nil, - loopRegion: .empty, - isLooping: false, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - mainTrackVolume: AppSliderDefaults.mainTrackVolume, - stemMixState: StemMixState(), - playbackMode: .original, - isClickEnabled: false, - clickVolume: AppSliderDefaults.clickVolume, - isSnapEnabled: false - ) - viewModel.restoreEditableState(state) - viewModel.undoManager = undoManager - - viewModel.updateNoteTitle(id: markerA.id, title: "Renamed") - - XCTAssertEqual(viewModel.notes.map(\.title), ["Renamed", "B"]) - - viewModel.undoLastEdit() - - XCTAssertEqual(viewModel.notes.map(\.title), ["A", "B"]) - } - - @MainActor - func testViewModelUpdatesPresetAndCustomNoteColors() { - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - let marker = TimecodedNote(time: 2, title: "A", color: .markerBlue, customColorHex: "#123456") - let state = ProjectEditableState( - notes: [marker], - selectedRegionID: nil, - activeLoopRegionID: nil, - loopRegion: .empty, - isLooping: false, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - mainTrackVolume: AppSliderDefaults.mainTrackVolume, - stemMixState: StemMixState(), - playbackMode: .original, - isClickEnabled: false, - clickVolume: AppSliderDefaults.clickVolume, - isSnapEnabled: false - ) - viewModel.restoreEditableState(state) - - viewModel.updateNoteCustomColor(id: marker.id, hex: "abcdef") - - XCTAssertEqual(viewModel.notes.first?.customColorHex, "#ABCDEF") - XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, "#ABCDEF") - - viewModel.updateNoteColor(id: marker.id, color: .markerOrange) - - XCTAssertEqual(viewModel.notes.first?.color, .markerOrange) - XCTAssertNil(viewModel.notes.first?.customColorHex) - XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, MarkerColor.markerOrange.defaultHex) - } - } From a1bda7a71bdc89d6e077eb86a0d262c5fbc0b118 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:01:40 +0300 Subject: [PATCH 26/80] test: split view model tempo map tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 120 -------------------- JammLabTests/ViewModelTempoMapTests.swift | 124 +++++++++++++++++++++ 3 files changed, 128 insertions(+), 120 deletions(-) create mode 100644 JammLabTests/ViewModelTempoMapTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 459954c..57cf400 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -115,6 +115,7 @@ 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */; }; 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */; }; 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */; }; + 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -316,6 +317,7 @@ 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectKeyPersistenceTests.swift; sourceTree = ""; }; 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectRestoreTests.swift; sourceTree = ""; }; 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelEditingStateTests.swift; sourceTree = ""; }; + 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTempoMapTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -602,6 +604,7 @@ 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, + 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -1009,6 +1012,7 @@ 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */, 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */, 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */, + 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 21a03da..19c86eb 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -48,126 +48,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: artifactStore.mediaDirectory(for: projectURL).path)) } - @MainActor - func testAddingTempoTimeSignatureMarkerUpdatesPlaybackTempoMap() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) - try viewModel.loadImportedAudio(media) - - viewModel.setTempoBPM(120) - viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 60, beatsPerBar: 3) - - let marker = try XCTUnwrap(viewModel.notes.first) - let payload = try XCTUnwrap(marker.tempoTimeSignaturePayload) - let tempoMap = try XCTUnwrap(engine.tempoMap) - - XCTAssertTrue(marker.isTempoTimeSignatureMarker) - XCTAssertEqual(marker.title, "60 BPM · 3/4") - XCTAssertEqual(try XCTUnwrap(payload.bpm), 60, accuracy: 0.0001) - XCTAssertEqual(payload.beatsPerBar, 3) - XCTAssertFalse(payload.setsNewFirstBeat) - XCTAssertEqual(tempoMap.segments.count, 2) - XCTAssertEqual(tempoMap.segments[1].startTime, 2, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(tempoMap.segments[1].settings.bpm), 60, accuracy: 0.0001) - XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 2) - XCTAssertEqual(tempoMap.segments[1].settings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) - } - - @MainActor - func testAddingNewFirstBeatOnlyMarkerUpdatesPlaybackTempoMap() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) - try viewModel.loadImportedAudio(media) - - viewModel.setTempoBPM(120) - viewModel.addTempoTimeSignatureMarker( - at: 2, - bpm: 120, - beatsPerBar: 4, - setsNewFirstBeat: true - ) - - let marker = try XCTUnwrap(viewModel.notes.first) - let payload = try XCTUnwrap(marker.tempoTimeSignaturePayload) - let tempoMap = try XCTUnwrap(engine.tempoMap) - - XCTAssertEqual(marker.title, "New First Beat") - XCTAssertTrue(payload.setsNewFirstBeat) - XCTAssertNil(payload.bpm) - XCTAssertNil(payload.beatsPerBar) - XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 1) - } - - @MainActor - func testEditingTempoTimeSignatureMarkerBackToEffectiveSettingsRemovesNoOpMarker() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) - try viewModel.loadImportedAudio(media) - - viewModel.setTempoBPM(120) - viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 60, beatsPerBar: 3) - let marker = try XCTUnwrap(viewModel.notes.first) - - viewModel.updateTempoTimeSignatureMarker(id: marker.id, bpm: 120, beatsPerBar: 4) - - let updatedTempoMap = try XCTUnwrap(engine.tempoMap) - XCTAssertTrue(viewModel.notes.isEmpty) - XCTAssertEqual(updatedTempoMap.segments.count, 1) - XCTAssertEqual(try XCTUnwrap(updatedTempoMap.segments.first?.settings.bpm), 120, accuracy: 0.0001) - } - - @MainActor - func testEditingTempoTimeSignatureMarkerUpdatesNewFirstBeatFlag() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) - try viewModel.loadImportedAudio(media) - - viewModel.setTempoBPM(120) - viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 60, beatsPerBar: 3) - let marker = try XCTUnwrap(viewModel.notes.first) - - viewModel.updateTempoTimeSignatureMarker( - id: marker.id, - bpm: 60, - beatsPerBar: 3, - setsNewFirstBeat: true - ) - - let updatedMarker = try XCTUnwrap(viewModel.notes.first) - let payload = try XCTUnwrap(updatedMarker.tempoTimeSignaturePayload) - let tempoMap = try XCTUnwrap(engine.tempoMap) - XCTAssertTrue(payload.setsNewFirstBeat) - XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 1) - } - @MainActor func testSelectionOnlyRegionFocusDoesNotMarkProjectModified() async throws { let audioURL = try temporaryAudioFile(duration: 2) diff --git a/JammLabTests/ViewModelTempoMapTests.swift b/JammLabTests/ViewModelTempoMapTests.swift new file mode 100644 index 0000000..b44dea9 --- /dev/null +++ b/JammLabTests/ViewModelTempoMapTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import JammLab + +final class ViewModelTempoMapTests: XCTestCase { + @MainActor + func testAddingTempoTimeSignatureMarkerUpdatesPlaybackTempoMap() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) + try viewModel.loadImportedAudio(media) + + viewModel.setTempoBPM(120) + viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 60, beatsPerBar: 3) + + let marker = try XCTUnwrap(viewModel.notes.first) + let payload = try XCTUnwrap(marker.tempoTimeSignaturePayload) + let tempoMap = try XCTUnwrap(engine.tempoMap) + + XCTAssertTrue(marker.isTempoTimeSignatureMarker) + XCTAssertEqual(marker.title, "60 BPM · 3/4") + XCTAssertEqual(try XCTUnwrap(payload.bpm), 60, accuracy: 0.0001) + XCTAssertEqual(payload.beatsPerBar, 3) + XCTAssertFalse(payload.setsNewFirstBeat) + XCTAssertEqual(tempoMap.segments.count, 2) + XCTAssertEqual(tempoMap.segments[1].startTime, 2, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(tempoMap.segments[1].settings.bpm), 60, accuracy: 0.0001) + XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 2) + XCTAssertEqual(tempoMap.segments[1].settings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) + } + + @MainActor + func testAddingNewFirstBeatOnlyMarkerUpdatesPlaybackTempoMap() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) + try viewModel.loadImportedAudio(media) + + viewModel.setTempoBPM(120) + viewModel.addTempoTimeSignatureMarker( + at: 2, + bpm: 120, + beatsPerBar: 4, + setsNewFirstBeat: true + ) + + let marker = try XCTUnwrap(viewModel.notes.first) + let payload = try XCTUnwrap(marker.tempoTimeSignaturePayload) + let tempoMap = try XCTUnwrap(engine.tempoMap) + + XCTAssertEqual(marker.title, "New First Beat") + XCTAssertTrue(payload.setsNewFirstBeat) + XCTAssertNil(payload.bpm) + XCTAssertNil(payload.beatsPerBar) + XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 1) + } + + @MainActor + func testEditingTempoTimeSignatureMarkerBackToEffectiveSettingsRemovesNoOpMarker() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) + try viewModel.loadImportedAudio(media) + + viewModel.setTempoBPM(120) + viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 60, beatsPerBar: 3) + let marker = try XCTUnwrap(viewModel.notes.first) + + viewModel.updateTempoTimeSignatureMarker(id: marker.id, bpm: 120, beatsPerBar: 4) + + let updatedTempoMap = try XCTUnwrap(engine.tempoMap) + XCTAssertTrue(viewModel.notes.isEmpty) + XCTAssertEqual(updatedTempoMap.segments.count, 1) + XCTAssertEqual(try XCTUnwrap(updatedTempoMap.segments.first?.settings.bpm), 120, accuracy: 0.0001) + } + + @MainActor + func testEditingTempoTimeSignatureMarkerUpdatesNewFirstBeatFlag() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) + try viewModel.loadImportedAudio(media) + + viewModel.setTempoBPM(120) + viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 60, beatsPerBar: 3) + let marker = try XCTUnwrap(viewModel.notes.first) + + viewModel.updateTempoTimeSignatureMarker( + id: marker.id, + bpm: 60, + beatsPerBar: 3, + setsNewFirstBeat: true + ) + + let updatedMarker = try XCTUnwrap(viewModel.notes.first) + let payload = try XCTUnwrap(updatedMarker.tempoTimeSignaturePayload) + let tempoMap = try XCTUnwrap(engine.tempoMap) + XCTAssertTrue(payload.setsNewFirstBeat) + XCTAssertEqual(tempoMap.segments[1].firstBarNumber, 1) + } +} From 72140fa6b88f6aa4fc3dd599298883794670ac85 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:06:19 +0300 Subject: [PATCH 27/80] test: split view model region loop tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 238 ------------------- JammLabTests/ViewModelRegionLoopTests.swift | 242 ++++++++++++++++++++ 3 files changed, 246 insertions(+), 238 deletions(-) create mode 100644 JammLabTests/ViewModelRegionLoopTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 57cf400..4c9dc29 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -116,6 +116,7 @@ 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */; }; 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */; }; 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */; }; + 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -318,6 +319,7 @@ 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectRestoreTests.swift; sourceTree = ""; }; 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelEditingStateTests.swift; sourceTree = ""; }; 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTempoMapTests.swift; sourceTree = ""; }; + 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -605,6 +607,7 @@ 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, + 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -1013,6 +1016,7 @@ 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */, 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */, 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, + 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 19c86eb..adda31a 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -562,38 +562,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertNil(viewModel.errorMessage) } - @MainActor - func testLocateRegionStartSelectsRegionAndMovesPlaybackMarkerWithoutActivatingLoop() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - viewModel.isSnapEnabled = true - viewModel.beatGridSettings = BeatGridSettings(bpm: 120, firstBeatTime: 0, timeSignature: .fourFour) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.loopRegion = LoopRegion(start: 0, end: 6) - viewModel.activeLoopRegionID = nil - viewModel.markProjectClean() - - viewModel.locateRegionStart(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertNil(viewModel.activeLoopRegionID) - XCTAssertEqual(viewModel.loopRegion.start, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 6, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) - XCTAssertTrue(viewModel.isProjectModified) - } - @MainActor private func loadedNotationViewModel(duration: TimeInterval) throws -> AudioPlayerViewModel { let audioURL = try temporaryAudioFile(duration: duration) @@ -627,212 +595,6 @@ final class ViewModelLifecycleTests: XCTestCase { return try XCTUnwrap(score.measures.first { $0.number == number }) } - @MainActor - func testActivateRegionAsLoopWithoutSeekingPreservesPlaybackPosition() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.loopRegion = LoopRegion(start: 0, end: 6) - viewModel.activeLoopRegionID = nil - viewModel.setPlaybackMarkerExactly(to: 1.1) - let initialSeekCount = engine.seekCount - viewModel.markProjectClean() - - viewModel.activateRegionAsLoop(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(engine.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 1.1, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 1.1, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 1.1, accuracy: 0.0001) - XCTAssertEqual(engine.seekCount, initialSeekCount) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testActivateRegionAsLoopAndMoveMarkerWhenStoppedMovesPlaybackPosition() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine, - videoFollower: videoFollower - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.setPlaybackMarkerExactly(to: 1.1) - viewModel.playbackState = .stopped - viewModel.markProjectClean() - - viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2.3, accuracy: 0.0001) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testActivateRegionAsLoopAndMoveMarkerWhenPausedMovesPlaybackPosition() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine, - videoFollower: videoFollower - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.setPlaybackMarkerExactly(to: 1.1) - viewModel.playbackState = .paused - viewModel.markProjectClean() - - viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2.3, accuracy: 0.0001) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testActivateRegionAsLoopAndMoveMarkerWhilePlayingDoesNotSeekPlayback() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine, - videoFollower: videoFollower - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.setPlaybackMarkerExactly(to: 1.1) - let seekCountAfterInitialPosition = engine.seekCount - let videoSeekCountAfterInitialPosition = videoFollower.seekTimes.count - viewModel.playbackState = .playing - engine.isPlaying = true - engine.currentTime = 4.5 - viewModel.currentTime = 4.5 - viewModel.markProjectClean() - - viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 4.5, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 4.5, accuracy: 0.0001) - XCTAssertEqual(engine.seekCount, seekCountAfterInitialPosition) - XCTAssertEqual(videoFollower.seekTimes.count, videoSeekCountAfterInitialPosition) - XCTAssertEqual(viewModel.playbackState, .playing) - XCTAssertTrue(engine.isPlaying) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testActivateInspectorItemMovesMarkerForRegionsAndKeepsMarkerSeek() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "inspector.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let marker = TimecodedNote(time: 4.2, title: "Marker") - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [marker, region] - viewModel.setPlaybackMarkerExactly(to: 1.1) - let seekCountAfterInitialPosition = engine.seekCount - - viewModel.activateInspectorItem(region) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.seekCount, seekCountAfterInitialPosition + 1) - - viewModel.activateInspectorItem(marker) - - XCTAssertEqual(viewModel.currentTime, 4.2, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 4.2, accuracy: 0.0001) - XCTAssertEqual(engine.seekCount, seekCountAfterInitialPosition + 2) - } - - @MainActor - func testEditingActiveRegionDetachesLoopWithoutMovingLoopRange() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2, duration: 1, title: "Region") - viewModel.notes = [region] - viewModel.activateRegionAsLoop(id: region.id) - viewModel.markProjectClean() - - viewModel.updateRegionRange(id: region.id, start: 3, end: 4) - - XCTAssertNil(viewModel.activeLoopRegionID) - XCTAssertEqual(viewModel.loopRegion.start, 2, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 3, accuracy: 0.0001) - XCTAssertEqual(engine.loopRegion.start, 2, accuracy: 0.0001) - XCTAssertEqual(engine.loopRegion.end, 3, accuracy: 0.0001) - let updatedRegion = try XCTUnwrap(viewModel.notes.first { $0.id == region.id }) - XCTAssertEqual(updatedRegion.time, 3, accuracy: 0.0001) - XCTAssertEqual(updatedRegion.regionEndTime, 4, accuracy: 0.0001) - XCTAssertTrue(viewModel.isProjectModified) - } - @MainActor func testSaveProjectForClosePersistsAndClearsModifiedState() async throws { let audioURL = try temporaryAudioFile() diff --git a/JammLabTests/ViewModelRegionLoopTests.swift b/JammLabTests/ViewModelRegionLoopTests.swift new file mode 100644 index 0000000..cd4e668 --- /dev/null +++ b/JammLabTests/ViewModelRegionLoopTests.swift @@ -0,0 +1,242 @@ +import XCTest +@testable import JammLab + +final class ViewModelRegionLoopTests: XCTestCase { + @MainActor + func testLocateRegionStartSelectsRegionAndMovesPlaybackMarkerWithoutActivatingLoop() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + viewModel.isSnapEnabled = true + viewModel.beatGridSettings = BeatGridSettings(bpm: 120, firstBeatTime: 0, timeSignature: .fourFour) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.loopRegion = LoopRegion(start: 0, end: 6) + viewModel.activeLoopRegionID = nil + viewModel.markProjectClean() + + viewModel.locateRegionStart(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertNil(viewModel.activeLoopRegionID) + XCTAssertEqual(viewModel.loopRegion.start, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 6, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testActivateRegionAsLoopWithoutSeekingPreservesPlaybackPosition() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.loopRegion = LoopRegion(start: 0, end: 6) + viewModel.activeLoopRegionID = nil + viewModel.setPlaybackMarkerExactly(to: 1.1) + let initialSeekCount = engine.seekCount + viewModel.markProjectClean() + + viewModel.activateRegionAsLoop(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(engine.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 1.1, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 1.1, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 1.1, accuracy: 0.0001) + XCTAssertEqual(engine.seekCount, initialSeekCount) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testActivateRegionAsLoopAndMoveMarkerWhenStoppedMovesPlaybackPosition() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + videoFollower: videoFollower + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.setPlaybackMarkerExactly(to: 1.1) + viewModel.playbackState = .stopped + viewModel.markProjectClean() + + viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2.3, accuracy: 0.0001) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testActivateRegionAsLoopAndMoveMarkerWhenPausedMovesPlaybackPosition() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + videoFollower: videoFollower + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.setPlaybackMarkerExactly(to: 1.1) + viewModel.playbackState = .paused + viewModel.markProjectClean() + + viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2.3, accuracy: 0.0001) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testActivateRegionAsLoopAndMoveMarkerWhilePlayingDoesNotSeekPlayback() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + videoFollower: videoFollower + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.setPlaybackMarkerExactly(to: 1.1) + let seekCountAfterInitialPosition = engine.seekCount + let videoSeekCountAfterInitialPosition = videoFollower.seekTimes.count + viewModel.playbackState = .playing + engine.isPlaying = true + engine.currentTime = 4.5 + viewModel.currentTime = 4.5 + viewModel.markProjectClean() + + viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 4.5, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 4.5, accuracy: 0.0001) + XCTAssertEqual(engine.seekCount, seekCountAfterInitialPosition) + XCTAssertEqual(videoFollower.seekTimes.count, videoSeekCountAfterInitialPosition) + XCTAssertEqual(viewModel.playbackState, .playing) + XCTAssertTrue(engine.isPlaying) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testActivateInspectorItemMovesMarkerForRegionsAndKeepsMarkerSeek() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "inspector.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let marker = TimecodedNote(time: 4.2, title: "Marker") + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [marker, region] + viewModel.setPlaybackMarkerExactly(to: 1.1) + let seekCountAfterInitialPosition = engine.seekCount + + viewModel.activateInspectorItem(region) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.seekCount, seekCountAfterInitialPosition + 1) + + viewModel.activateInspectorItem(marker) + + XCTAssertEqual(viewModel.currentTime, 4.2, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 4.2, accuracy: 0.0001) + XCTAssertEqual(engine.seekCount, seekCountAfterInitialPosition + 2) + } + + @MainActor + func testEditingActiveRegionDetachesLoopWithoutMovingLoopRange() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2, duration: 1, title: "Region") + viewModel.notes = [region] + viewModel.activateRegionAsLoop(id: region.id) + viewModel.markProjectClean() + + viewModel.updateRegionRange(id: region.id, start: 3, end: 4) + + XCTAssertNil(viewModel.activeLoopRegionID) + XCTAssertEqual(viewModel.loopRegion.start, 2, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 3, accuracy: 0.0001) + XCTAssertEqual(engine.loopRegion.start, 2, accuracy: 0.0001) + XCTAssertEqual(engine.loopRegion.end, 3, accuracy: 0.0001) + let updatedRegion = try XCTUnwrap(viewModel.notes.first { $0.id == region.id }) + XCTAssertEqual(updatedRegion.time, 3, accuracy: 0.0001) + XCTAssertEqual(updatedRegion.regionEndTime, 4, accuracy: 0.0001) + XCTAssertTrue(viewModel.isProjectModified) + } +} From 74de1d113cf9ffac1ce22c61c495ee8439a6342f Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:12:04 +0300 Subject: [PATCH 28/80] test: split view model notation selection tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 498 ----------------- .../ViewModelNotationSelectionTests.swift | 502 ++++++++++++++++++ 3 files changed, 506 insertions(+), 498 deletions(-) create mode 100644 JammLabTests/ViewModelNotationSelectionTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 4c9dc29..43bb333 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -117,6 +117,7 @@ 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */; }; 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */; }; 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; + 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -320,6 +321,7 @@ 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelEditingStateTests.swift; sourceTree = ""; }; 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTempoMapTests.swift; sourceTree = ""; }; 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; + 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -608,6 +610,7 @@ 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, + 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -1017,6 +1020,7 @@ 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */, 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, + 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index adda31a..37b1699 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -97,504 +97,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } - @MainActor - func testSelectingNotationMeasureDoesNotMarkProjectModified() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - - viewModel.selectNotationMeasure(measure) - - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) - XCTAssertTrue(viewModel.canCopySelectedNotationMeasure) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testSelectingNotationItemDoesNotMarkProjectModifiedAndClearsMeasureSelection() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - let item = try XCTUnwrap(measure.notationItems.first) - - viewModel.selectNotationMeasure(measure) - viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) - - XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) - XCTAssertEqual(viewModel.selectedNotationItem?.measureNumber, 1) - XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 0) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testRequestEditSelectedNotationItemUsesExactItemOffsetAndExistingHarmony() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - let item = NotationMeasureItem( - measureNumber: measure.number, - measureStartTime: measure.startTime, - offsetInQuarterNotes: 1, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ) - let harmony = HarmonySymbol( - time: NotationMeasureTiming.time(forQuarterOffset: 1, in: measure), - measureNumber: measure.number, - offsetInQuarterNotes: 1, - rawText: "Fmaj7" - ) - viewModel.harmonySymbols = [harmony] - viewModel.notationItems = [item] - viewModel.markProjectClean() - - let updatedMeasure = try notationMeasure(1, in: viewModel) - let updatedItem = try XCTUnwrap(updatedMeasure.notationItems.first { $0.offsetInQuarterNotes == 1 }) - viewModel.selectNotationItem(NotationItemSelection(measure: updatedMeasure, item: updatedItem)) - - XCTAssertTrue(viewModel.requestEditSelectedNotationItem()) - let request = try XCTUnwrap(viewModel.pendingHarmonyEditorRequest) - XCTAssertEqual(request.time, harmony.time, accuracy: 0.0001) - XCTAssertEqual(viewModel.selectedHarmonySymbolID, harmony.id) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testNotationItemSelectionClearsForTempoMapAndUndoChanges() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let firstMeasure = try notationMeasure(1, in: viewModel) - let firstItem = try XCTUnwrap(firstMeasure.notationItems.first) - - viewModel.selectNotationItem(NotationItemSelection(measure: firstMeasure, item: firstItem)) - viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) - - XCTAssertNil(viewModel.selectedNotationItem) - - let undoManager = UndoManager() - viewModel.undoManager = undoManager - let updatedMeasure = try notationMeasure(1, in: viewModel) - let updatedItem = try XCTUnwrap(updatedMeasure.notationItems.first) - viewModel.markProjectClean() - viewModel.selectNotationItem(NotationItemSelection(measure: updatedMeasure, item: updatedItem)) - viewModel.addNote(at: 0.5) - - XCTAssertNotNil(viewModel.selectedNotationItem) - - viewModel.undoLastEdit() - - XCTAssertNil(viewModel.selectedNotationItem) - } - - @MainActor - func testChangingSelectedWholeRestToQuarterCreatesTwoQuartersAndHalfInFourFour() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - let item = try XCTUnwrap(measure.notationItems.first) - viewModel.markProjectClean() - - viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) - viewModel.setNotationDurationDenominator(4) - - let updatedMeasure = try notationMeasure(1, in: viewModel) - XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [4, 4, 2]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 2]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 1, 2]) - XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 0) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testChangingSelectedWholeRestToHalfInThreeFourCreatesHalfAndQuarter() throws { - let viewModel = try loadedNotationViewModel(duration: 6) - viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) - let measure = try notationMeasure(1, in: viewModel) - let item = try XCTUnwrap(measure.notationItems.first) - viewModel.markProjectClean() - - viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) - viewModel.setNotationDurationDenominator(2) - - let updatedMeasure = try notationMeasure(1, in: viewModel) - XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [2, 4]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 2]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [2, 1]) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testShiftSelectingNotationMeasuresBuildsContiguousRange() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let firstMeasure = try notationMeasure(1, in: viewModel) - let thirdMeasure = try notationMeasure(3, in: viewModel) - - viewModel.selectNotationMeasure(firstMeasure) - viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) - - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1, 2, 3]) - - viewModel.selectNotationMeasure(firstMeasure, extendingSelection: true) - - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) - } - - @MainActor - func testShiftSelectingNotationMeasureWithoutAnchorFallsBackToSingleSelection() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let secondMeasure = try notationMeasure(2, in: viewModel) - - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [2]) - } - - @MainActor - func testCopyNotationMeasureCopiesOnlyHarmonies() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - viewModel.notes = [ - TimecodedNote(kind: .region, time: measure.startTime, duration: 1, title: "Intro") - ] - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0.5, measureNumber: 99, offsetInQuarterNotes: 99, rawText: "F"), - HarmonySymbol(time: measure.endTime, measureNumber: 1, offsetInQuarterNotes: 4, rawText: "G") - ] - - viewModel.selectNotationMeasure(measure) - - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [[ - NotationMeasureClipboardItem(offsetInQuarterNotes: 1, rawText: "F") - ]]) - } - - @MainActor - func testCopyNotationMeasureRangePreservesOrderAndEmptyMeasures() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let firstMeasure = try notationMeasure(1, in: viewModel) - let thirdMeasure = try notationMeasure(3, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(firstMeasure) - viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) - - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [ - [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "C")], - [], - [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "Am")] - ]) - } - - @MainActor - func testPasteNotationMeasureReplacesTargetAndSupportsUndoRedo() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let undoManager = UndoManager() - viewModel.undoManager = undoManager - let sourceMeasure = try notationMeasure(1, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - let sourceA = HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") - let sourceB = HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "F") - let targetExisting = HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") - viewModel.harmonySymbols = [sourceA, sourceB, targetExisting] - viewModel.markProjectClean() - - viewModel.selectNotationMeasure(sourceMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - let beforePaste = viewModel.harmonySymbols - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let targetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(targetSymbols.map(\.rawText), ["C", "F"]) - XCTAssertEqual(targetSymbols.map(\.id).contains(sourceA.id), false) - XCTAssertEqual(targetSymbols.map(\.id).contains(sourceB.id), false) - XCTAssertEqual(targetSymbols[0].time, 2, accuracy: 0.0001) - XCTAssertEqual(targetSymbols[1].time, 3, accuracy: 0.0001) - XCTAssertNil(viewModel.selectedHarmonySymbolID) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [targetMeasure.number]) - XCTAssertTrue(viewModel.isProjectModified) - - viewModel.undoLastEdit() - - XCTAssertEqual(viewModel.harmonySymbols, beforePaste) - XCTAssertFalse(viewModel.isProjectModified) - - viewModel.redoLastEdit() - - let redoneTargetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(redoneTargetSymbols.map(\.rawText), ["C", "F"]) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - let targetMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let thirdMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - let fourthMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - } - XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) - } - - @MainActor - func testPastingEmptyNotationMeasureClearsTarget() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let emptyMeasure = try notationMeasure(3, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") - ] - - viewModel.selectNotationMeasure(emptyMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - XCTAssertFalse(viewModel.harmonySymbols.contains { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - }) - } - - @MainActor - func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - let targetMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - XCTAssertEqual(viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - }.map(\.rawText), ["C"]) - XCTAssertFalse(viewModel.harmonySymbols.contains { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - }) - } - - @MainActor - func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let thirdMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(fourthMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let fourthMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - } - XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) - } - - @MainActor - func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 120, beatsPerBar: 3) - viewModel.markProjectClean() - let sourceMeasure = try notationMeasure(1, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "D") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let targetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(targetSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(try XCTUnwrap(targetSymbols.first).time, 2, accuracy: 0.0001) - } - - @MainActor - func testTempoMapChangesClearSelectedNotationMeasure() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - - viewModel.selectNotationMeasure(measure) - viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) - - XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) - } - - @MainActor - func testCopyRejectsPartialStaleNotationMeasureSelection() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let firstMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - - viewModel.selectedNotationMeasures = [ - NotationMeasureSelection(measure: firstMeasure), - NotationMeasureSelection( - measure: ScoreMeasure( - number: secondMeasure.number, - startTime: secondMeasure.startTime, - endTime: secondMeasure.endTime + 0.25, - attributes: secondMeasure.attributes - ) - ) - ] - - XCTAssertFalse(viewModel.copySelectedNotationMeasure()) - XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) - } - - @MainActor - func testClearingNotationMeasureSelectionDoesNotClearClipboardOrMarkDirty() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") - ] - viewModel.selectNotationMeasure(measure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.markProjectClean() - - viewModel.clearNotationMeasureSelection() - - XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) - XCTAssertNotNil(viewModel.notationMeasureClipboard) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testExportNotationWritesMusicXMLWithoutChangingDirtyOrUndoState() async throws { - let emptyViewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine() - ) - XCTAssertFalse(emptyViewModel.canExportNotation) - - let viewModel = try loadedNotationViewModel(duration: 8) - let undoManager = UndoManager() - viewModel.undoManager = undoManager - viewModel.tempoBPM = 132.5 - viewModel.beatGridSettings.bpm = 132.5 - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "Cmaj7") - ] - viewModel.markProjectClean() - - let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent("notation-export-\(UUID().uuidString)") - .appendingPathExtension("musicxml") - defer { try? FileManager.default.removeItem(at: outputURL) } - - XCTAssertTrue(viewModel.canExportNotation) - let didExportCleanProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) - - XCTAssertTrue(didExportCleanProject) - XCTAssertFalse(viewModel.isProjectModified) - XCTAssertFalse(viewModel.canUndo) - XCTAssertNil(viewModel.errorMessage) - let xml = try String(contentsOf: outputURL, encoding: .utf8) - XCTAssertTrue(xml.contains("")) - XCTAssertTrue(xml.contains("major-seventh")) - XCTAssertTrue(xml.contains("132.5")) - - viewModel.setLooping(true) - XCTAssertTrue(viewModel.isProjectModified) - XCTAssertTrue(viewModel.canUndo) - - let didExportDirtyProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) - - XCTAssertTrue(didExportDirtyProject) - XCTAssertTrue(viewModel.isProjectModified) - XCTAssertTrue(viewModel.canUndo) - XCTAssertNil(viewModel.errorMessage) - } - - @MainActor - private func loadedNotationViewModel(duration: TimeInterval) throws -> AudioPlayerViewModel { - let audioURL = try temporaryAudioFile(duration: duration) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine() - ) - let media = ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: duration) - try viewModel.loadImportedAudio(media) - viewModel.beatGridSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) - viewModel.tempoBPM = 120 - viewModel.applyTempoMapToPlaybackEngine() - viewModel.markProjectClean() - return viewModel - } - - @MainActor - private func notationMeasure(_ number: Int, in viewModel: AudioPlayerViewModel) throws -> ScoreMeasure { - let score = NotationViewportFactory().scoreState( - tempoMap: viewModel.tempoMap, - duration: viewModel.duration, - currentTime: viewModel.currentTime, - playbackMarkerTime: viewModel.playbackMarkerTime, - isPlaying: viewModel.playbackState == .playing, - keyName: viewModel.effectiveKeyName, - notationItems: viewModel.notationItems, - harmonySymbols: viewModel.harmonySymbols, - notes: viewModel.notes - ) - return try XCTUnwrap(score.measures.first { $0.number == number }) - } - @MainActor func testSaveProjectForClosePersistsAndClearsModifiedState() async throws { let audioURL = try temporaryAudioFile() diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift new file mode 100644 index 0000000..54c17d5 --- /dev/null +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -0,0 +1,502 @@ +import XCTest +@testable import JammLab + +final class ViewModelNotationSelectionTests: XCTestCase { + @MainActor + func testSelectingNotationMeasureDoesNotMarkProjectModified() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + + viewModel.selectNotationMeasure(measure) + + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) + XCTAssertTrue(viewModel.canCopySelectedNotationMeasure) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testSelectingNotationItemDoesNotMarkProjectModifiedAndClearsMeasureSelection() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + + viewModel.selectNotationMeasure(measure) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + XCTAssertEqual(viewModel.selectedNotationItem?.measureNumber, 1) + XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 0) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testRequestEditSelectedNotationItemUsesExactItemOffsetAndExistingHarmony() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let item = NotationMeasureItem( + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let harmony = HarmonySymbol( + time: NotationMeasureTiming.time(forQuarterOffset: 1, in: measure), + measureNumber: measure.number, + offsetInQuarterNotes: 1, + rawText: "Fmaj7" + ) + viewModel.harmonySymbols = [harmony] + viewModel.notationItems = [item] + viewModel.markProjectClean() + + let updatedMeasure = try notationMeasure(1, in: viewModel) + let updatedItem = try XCTUnwrap(updatedMeasure.notationItems.first { $0.offsetInQuarterNotes == 1 }) + viewModel.selectNotationItem(NotationItemSelection(measure: updatedMeasure, item: updatedItem)) + + XCTAssertTrue(viewModel.requestEditSelectedNotationItem()) + let request = try XCTUnwrap(viewModel.pendingHarmonyEditorRequest) + XCTAssertEqual(request.time, harmony.time, accuracy: 0.0001) + XCTAssertEqual(viewModel.selectedHarmonySymbolID, harmony.id) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testNotationItemSelectionClearsForTempoMapAndUndoChanges() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let firstItem = try XCTUnwrap(firstMeasure.notationItems.first) + + viewModel.selectNotationItem(NotationItemSelection(measure: firstMeasure, item: firstItem)) + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + + XCTAssertNil(viewModel.selectedNotationItem) + + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let updatedMeasure = try notationMeasure(1, in: viewModel) + let updatedItem = try XCTUnwrap(updatedMeasure.notationItems.first) + viewModel.markProjectClean() + viewModel.selectNotationItem(NotationItemSelection(measure: updatedMeasure, item: updatedItem)) + viewModel.addNote(at: 0.5) + + XCTAssertNotNil(viewModel.selectedNotationItem) + + viewModel.undoLastEdit() + + XCTAssertNil(viewModel.selectedNotationItem) + } + + @MainActor + func testChangingSelectedWholeRestToQuarterCreatesTwoQuartersAndHalfInFourFour() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + viewModel.markProjectClean() + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + viewModel.setNotationDurationDenominator(4) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [4, 4, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 1, 2]) + XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 0) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testChangingSelectedWholeRestToHalfInThreeFourCreatesHalfAndQuarter() throws { + let viewModel = try loadedNotationViewModel(duration: 6) + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + viewModel.markProjectClean() + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + viewModel.setNotationDurationDenominator(2) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [2, 4]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [2, 1]) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testShiftSelectingNotationMeasuresBuildsContiguousRange() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + + viewModel.selectNotationMeasure(firstMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1, 2, 3]) + + viewModel.selectNotationMeasure(firstMeasure, extendingSelection: true) + + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) + } + + @MainActor + func testShiftSelectingNotationMeasureWithoutAnchorFallsBackToSingleSelection() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let secondMeasure = try notationMeasure(2, in: viewModel) + + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [2]) + } + + @MainActor + func testCopyNotationMeasureCopiesOnlyHarmonies() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + viewModel.notes = [ + TimecodedNote(kind: .region, time: measure.startTime, duration: 1, title: "Intro") + ] + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0.5, measureNumber: 99, offsetInQuarterNotes: 99, rawText: "F"), + HarmonySymbol(time: measure.endTime, measureNumber: 1, offsetInQuarterNotes: 4, rawText: "G") + ] + + viewModel.selectNotationMeasure(measure) + + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [[ + NotationMeasureClipboardItem(offsetInQuarterNotes: 1, rawText: "F") + ]]) + } + + @MainActor + func testCopyNotationMeasureRangePreservesOrderAndEmptyMeasures() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(firstMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [ + [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "C")], + [], + [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "Am")] + ]) + } + + @MainActor + func testPasteNotationMeasureReplacesTargetAndSupportsUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + let sourceA = HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + let sourceB = HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "F") + let targetExisting = HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") + viewModel.harmonySymbols = [sourceA, sourceB, targetExisting] + viewModel.markProjectClean() + + viewModel.selectNotationMeasure(sourceMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + let beforePaste = viewModel.harmonySymbols + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let targetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(targetSymbols.map(\.rawText), ["C", "F"]) + XCTAssertEqual(targetSymbols.map(\.id).contains(sourceA.id), false) + XCTAssertEqual(targetSymbols.map(\.id).contains(sourceB.id), false) + XCTAssertEqual(targetSymbols[0].time, 2, accuracy: 0.0001) + XCTAssertEqual(targetSymbols[1].time, 3, accuracy: 0.0001) + XCTAssertNil(viewModel.selectedHarmonySymbolID) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [targetMeasure.number]) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.harmonySymbols, beforePaste) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.redoLastEdit() + + let redoneTargetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(redoneTargetSymbols.map(\.rawText), ["C", "F"]) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let thirdMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) + } + + @MainActor + func testPastingEmptyNotationMeasureClearsTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let emptyMeasure = try notationMeasure(3, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") + ] + + viewModel.selectNotationMeasure(emptyMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertEqual(viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }.map(\.rawText), ["C"]) + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(fourthMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) + } + + @MainActor + func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 120, beatsPerBar: 3) + viewModel.markProjectClean() + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "D") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let targetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(targetSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(try XCTUnwrap(targetSymbols.first).time, 2, accuracy: 0.0001) + } + + @MainActor + func testTempoMapChangesClearSelectedNotationMeasure() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + + viewModel.selectNotationMeasure(measure) + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + } + + @MainActor + func testCopyRejectsPartialStaleNotationMeasureSelection() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + + viewModel.selectedNotationMeasures = [ + NotationMeasureSelection(measure: firstMeasure), + NotationMeasureSelection( + measure: ScoreMeasure( + number: secondMeasure.number, + startTime: secondMeasure.startTime, + endTime: secondMeasure.endTime + 0.25, + attributes: secondMeasure.attributes + ) + ) + ] + + XCTAssertFalse(viewModel.copySelectedNotationMeasure()) + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + } + + @MainActor + func testClearingNotationMeasureSelectionDoesNotClearClipboardOrMarkDirty() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + ] + viewModel.selectNotationMeasure(measure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.markProjectClean() + + viewModel.clearNotationMeasureSelection() + + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + XCTAssertNotNil(viewModel.notationMeasureClipboard) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testExportNotationWritesMusicXMLWithoutChangingDirtyOrUndoState() async throws { + let emptyViewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine() + ) + XCTAssertFalse(emptyViewModel.canExportNotation) + + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.tempoBPM = 132.5 + viewModel.beatGridSettings.bpm = 132.5 + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "Cmaj7") + ] + viewModel.markProjectClean() + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("notation-export-\(UUID().uuidString)") + .appendingPathExtension("musicxml") + defer { try? FileManager.default.removeItem(at: outputURL) } + + XCTAssertTrue(viewModel.canExportNotation) + let didExportCleanProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) + + XCTAssertTrue(didExportCleanProject) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(viewModel.canUndo) + XCTAssertNil(viewModel.errorMessage) + let xml = try String(contentsOf: outputURL, encoding: .utf8) + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.contains("major-seventh")) + XCTAssertTrue(xml.contains("132.5")) + + viewModel.setLooping(true) + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertTrue(viewModel.canUndo) + + let didExportDirtyProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) + + XCTAssertTrue(didExportDirtyProject) + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertTrue(viewModel.canUndo) + XCTAssertNil(viewModel.errorMessage) + } + + @MainActor + private func loadedNotationViewModel(duration: TimeInterval) throws -> AudioPlayerViewModel { + let audioURL = try temporaryAudioFile(duration: duration) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine() + ) + let media = ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: duration) + try viewModel.loadImportedAudio(media) + viewModel.beatGridSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + viewModel.tempoBPM = 120 + viewModel.applyTempoMapToPlaybackEngine() + viewModel.markProjectClean() + return viewModel + } + + @MainActor + private func notationMeasure(_ number: Int, in viewModel: AudioPlayerViewModel) throws -> ScoreMeasure { + let score = NotationViewportFactory().scoreState( + tempoMap: viewModel.tempoMap, + duration: viewModel.duration, + currentTime: viewModel.currentTime, + playbackMarkerTime: viewModel.playbackMarkerTime, + isPlaying: viewModel.playbackState == .playing, + keyName: viewModel.effectiveKeyName, + notationItems: viewModel.notationItems, + harmonySymbols: viewModel.harmonySymbols, + notes: viewModel.notes + ) + return try XCTUnwrap(score.measures.first { $0.number == number }) + } +} From 087a5abecdc7933c8a99bc33e44dbe065a8649aa Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:16:30 +0300 Subject: [PATCH 29/80] test: split view model notation collapse tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 127 ----------------- .../ViewModelNotationTrackCollapseTests.swift | 131 ++++++++++++++++++ 3 files changed, 135 insertions(+), 127 deletions(-) create mode 100644 JammLabTests/ViewModelNotationTrackCollapseTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 43bb333..eaddf9b 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */; }; 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; + 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -322,6 +323,7 @@ 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTempoMapTests.swift; sourceTree = ""; }; 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; + 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -611,6 +613,7 @@ 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, + 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -1021,6 +1024,7 @@ 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, + 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 37b1699..e08cfae 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -198,113 +198,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } - @MainActor - func testNotationTrackCollapsedDefaultsToClosedForImportedMediaAndPersistsChanges() async throws { - let audioURL = try temporaryAudioFile() - let projectURL = temporaryDirectory().appendingPathComponent("notation-collapse-save.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - let media = ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: 0.5) - - try viewModel.loadImportedAudio(media) - - XCTAssertTrue(viewModel.isNotationTrackCollapsed) - XCTAssertFalse(viewModel.isProjectModified) - - viewModel.setNotationTrackCollapsed(false) - - XCTAssertFalse(viewModel.isNotationTrackCollapsed) - XCTAssertTrue(viewModel.isProjectModified) - - let didSave = await viewModel.saveProject(to: projectURL) - - XCTAssertTrue(didSave) - XCTAssertFalse(viewModel.isProjectModified) - XCTAssertEqual(try projectService.load(from: projectURL).isNotationTrackCollapsed, false) - } - - @MainActor - func testOpenProjectRestoresNotationTrackCollapsedStateAndKeepsLegacyClean() async throws { - let audioURL = try temporaryAudioFile() - let directory = temporaryDirectory() - let expandedProjectURL = directory.appendingPathComponent("notation-expanded.jammlab") - let collapsedProjectURL = directory.appendingPathComponent("notation-collapsed.jammlab") - let legacyProjectURL = directory.appendingPathComponent("notation-legacy.jammlab") - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: directory) - } - - let projectService = ProjectDocumentService() - try projectService.save( - notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: false), - to: expandedProjectURL - ) - try projectService.save( - notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: true), - to: collapsedProjectURL - ) - try projectService.save( - notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: nil), - to: legacyProjectURL - ) - - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: expandedProjectURL) - XCTAssertFalse(viewModel.isNotationTrackCollapsed) - XCTAssertFalse(viewModel.isProjectModified) - - await viewModel.openProject(at: collapsedProjectURL) - XCTAssertTrue(viewModel.isNotationTrackCollapsed) - XCTAssertFalse(viewModel.isProjectModified) - - await viewModel.openProject(at: legacyProjectURL) - XCTAssertTrue(viewModel.isNotationTrackCollapsed) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testNotationTrackCollapsedStateIsNotUndoable() throws { - let undoManager = UndoManager() - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - viewModel.undoManager = undoManager - - viewModel.setNotationTrackCollapsed(false) - - XCTAssertFalse(viewModel.isNotationTrackCollapsed) - XCTAssertFalse(viewModel.canUndo) - - viewModel.setMainTrackVolume(0.25) - XCTAssertTrue(viewModel.canUndo) - - viewModel.undoLastEdit() - - XCTAssertFalse(viewModel.isNotationTrackCollapsed) - XCTAssertEqual(viewModel.mainTrackVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) - } - @MainActor func testOpenProjectRestoresSavedVideoWindowOpenState() async throws { let fixture = try makeVideoProjectFixture( @@ -507,24 +400,4 @@ final class ViewModelLifecycleTests: XCTestCase { ) } - private func notationCollapseProject( - audioURL: URL, - projectService: ProjectDocumentService, - collapsed: Bool? - ) throws -> JammLabProject { - JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 0.5, - notes: [], - loopStart: 0, - loopEnd: 0.5, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - isNotationTrackCollapsed: collapsed - ) - } - } diff --git a/JammLabTests/ViewModelNotationTrackCollapseTests.swift b/JammLabTests/ViewModelNotationTrackCollapseTests.swift new file mode 100644 index 0000000..69e808a --- /dev/null +++ b/JammLabTests/ViewModelNotationTrackCollapseTests.swift @@ -0,0 +1,131 @@ +import XCTest +@testable import JammLab + +final class ViewModelNotationTrackCollapseTests: XCTestCase { + @MainActor + func testNotationTrackCollapsedDefaultsToClosedForImportedMediaAndPersistsChanges() async throws { + let audioURL = try temporaryAudioFile() + let projectURL = temporaryDirectory().appendingPathComponent("notation-collapse-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + let media = ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: 0.5) + + try viewModel.loadImportedAudio(media) + + XCTAssertTrue(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.setNotationTrackCollapsed(false) + + XCTAssertFalse(viewModel.isNotationTrackCollapsed) + XCTAssertTrue(viewModel.isProjectModified) + + let didSave = await viewModel.saveProject(to: projectURL) + + XCTAssertTrue(didSave) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertEqual(try projectService.load(from: projectURL).isNotationTrackCollapsed, false) + } + + @MainActor + func testOpenProjectRestoresNotationTrackCollapsedStateAndKeepsLegacyClean() async throws { + let audioURL = try temporaryAudioFile() + let directory = temporaryDirectory() + let expandedProjectURL = directory.appendingPathComponent("notation-expanded.jammlab") + let collapsedProjectURL = directory.appendingPathComponent("notation-collapsed.jammlab") + let legacyProjectURL = directory.appendingPathComponent("notation-legacy.jammlab") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: directory) + } + + let projectService = ProjectDocumentService() + try projectService.save( + notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: false), + to: expandedProjectURL + ) + try projectService.save( + notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: true), + to: collapsedProjectURL + ) + try projectService.save( + notationCollapseProject(audioURL: audioURL, projectService: projectService, collapsed: nil), + to: legacyProjectURL + ) + + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: expandedProjectURL) + XCTAssertFalse(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.isProjectModified) + + await viewModel.openProject(at: collapsedProjectURL) + XCTAssertTrue(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.isProjectModified) + + await viewModel.openProject(at: legacyProjectURL) + XCTAssertTrue(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testNotationTrackCollapsedStateIsNotUndoable() throws { + let undoManager = UndoManager() + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + viewModel.undoManager = undoManager + + viewModel.setNotationTrackCollapsed(false) + + XCTAssertFalse(viewModel.isNotationTrackCollapsed) + XCTAssertFalse(viewModel.canUndo) + + viewModel.setMainTrackVolume(0.25) + XCTAssertTrue(viewModel.canUndo) + + viewModel.undoLastEdit() + + XCTAssertFalse(viewModel.isNotationTrackCollapsed) + XCTAssertEqual(viewModel.mainTrackVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) + } + + private func notationCollapseProject( + audioURL: URL, + projectService: ProjectDocumentService, + collapsed: Bool? + ) throws -> JammLabProject { + JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 0.5, + notes: [], + loopStart: 0, + loopEnd: 0.5, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + isNotationTrackCollapsed: collapsed + ) + } +} From 2e2e1be0c6c04fe8bb416ed922117b30df54f3c6 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:20:06 +0300 Subject: [PATCH 30/80] test: split view model video persistence tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 192 ----------------- ...iewModelVideoProjectPersistenceTests.swift | 196 ++++++++++++++++++ 3 files changed, 200 insertions(+), 192 deletions(-) create mode 100644 JammLabTests/ViewModelVideoProjectPersistenceTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index eaddf9b..9a15fd4 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -119,6 +119,7 @@ 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; + 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -324,6 +325,7 @@ 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; + 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -614,6 +616,7 @@ 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, + 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -1025,6 +1028,7 @@ 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, + 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index e08cfae..4ea742a 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -151,142 +151,6 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertNil(savedProject.isVideoWindowOpen) } - @MainActor - func testSaveProjectPersistsVideoWindowOpenState() async throws { - let audioURL = try temporaryAudioFile() - let videoURL = try temporaryFile(name: "lesson.mov", contents: "video") - let projectURL = temporaryDirectory().appendingPathComponent("video-window-save.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: videoURL.deletingLastPathComponent()) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - videoFollower: videoFollower, - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - let media = ImportedAudioFile( - url: audioURL, - sourceMediaURL: videoURL, - displayName: "lesson.mov", - duration: 0.5, - mediaKind: .video - ) - - try viewModel.loadImportedAudio(media) - - let didSaveOpenState = await viewModel.saveProject(to: projectURL) - XCTAssertTrue(didSaveOpenState) - XCTAssertEqual(try projectService.load(from: projectURL).isVideoWindowOpen, true) - XCTAssertFalse(viewModel.isProjectModified) - - videoFollower.closeWindow() - - XCTAssertTrue(viewModel.isProjectModified) - let didSaveClosedState = await viewModel.saveProject(to: projectURL) - XCTAssertTrue(didSaveClosedState) - XCTAssertEqual(try projectService.load(from: projectURL).isVideoWindowOpen, false) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testOpenProjectRestoresSavedVideoWindowOpenState() async throws { - let fixture = try makeVideoProjectFixture( - name: "video-window-open", - isVideoWindowOpen: true - ) - defer { try? FileManager.default.removeItem(at: fixture.directory) } - - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - videoFollower: videoFollower, - projectService: fixture.projectService, - projectArtifactStore: fixture.artifactStore, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: fixture.projectURL) - - XCTAssertNil(viewModel.errorMessage) - XCTAssertEqual(videoFollower.loadedVideoURL, fixture.videoURL) - XCTAssertTrue(viewModel.isVideoWindowOpen) - XCTAssertEqual(videoFollower.showWindowEvents.count, 1) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testOpenProjectClosesVideoWindowWhenSavedStateIsClosed() async throws { - let fixture = try makeVideoProjectFixture( - name: "video-window-closed", - isVideoWindowOpen: false - ) - defer { try? FileManager.default.removeItem(at: fixture.directory) } - - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - videoFollower: videoFollower, - projectService: fixture.projectService, - projectArtifactStore: fixture.artifactStore, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - videoFollower.showWindow(at: 0, isPlaying: false, rate: 1) - - await viewModel.openProject(at: fixture.projectURL) - - XCTAssertNil(viewModel.errorMessage) - XCTAssertFalse(viewModel.isVideoWindowOpen) - XCTAssertFalse(videoFollower.isWindowOpen) - XCTAssertEqual(videoFollower.showWindowEvents.count, 1) - XCTAssertGreaterThanOrEqual(videoFollower.closeWindowCount, 1) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testOpenLegacyVideoProjectWithoutWindowStateKeepsVideoWindowClosed() async throws { - let fixture = try makeVideoProjectFixture( - name: "video-window-legacy", - isVideoWindowOpen: nil - ) - defer { try? FileManager.default.removeItem(at: fixture.directory) } - - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - videoFollower: videoFollower, - projectService: fixture.projectService, - projectArtifactStore: fixture.artifactStore, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: fixture.projectURL) - - XCTAssertNil(viewModel.errorMessage) - XCTAssertFalse(viewModel.isVideoWindowOpen) - XCTAssertFalse(videoFollower.isWindowOpen) - XCTAssertTrue(videoFollower.showWindowEvents.isEmpty) - XCTAssertFalse(viewModel.isProjectModified) - } - @MainActor func testSaveProjectForCloseWithoutMediaReturnsFalse() async { let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) @@ -344,60 +208,4 @@ final class ViewModelLifecycleTests: XCTestCase { ) } - private struct VideoProjectFixture { - let directory: URL - let projectURL: URL - let videoURL: URL - let projectService: ProjectDocumentService - let artifactStore: ProjectArtifactStore - } - - private func makeVideoProjectFixture( - name: String, - isVideoWindowOpen: Bool? - ) throws -> VideoProjectFixture { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - - let projectURL = directory.appendingPathComponent("\(name).jammlab") - let videoURL = try temporaryFile(in: directory, name: "\(name).mov", contents: "video") - let localAudioURL = try temporaryAudioFile() - defer { try? FileManager.default.removeItem(at: localAudioURL) } - - let artifactStore = ProjectArtifactStore() - try FileManager.default.createDirectory( - at: artifactStore.mediaDirectory(for: projectURL), - withIntermediateDirectories: true - ) - try FileManager.default.copyItem( - at: localAudioURL, - to: artifactStore.videoAudioURL(for: projectURL) - ) - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: videoURL), - audioDisplayName: videoURL.lastPathComponent, - audioDuration: 0.5, - mediaKind: .video, - notes: [], - loopStart: 0, - loopEnd: 0.5, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - isVideoWindowOpen: isVideoWindowOpen - ) - try projectService.save(project, to: projectURL) - - return VideoProjectFixture( - directory: directory, - projectURL: projectURL, - videoURL: videoURL, - projectService: projectService, - artifactStore: artifactStore - ) - } - } diff --git a/JammLabTests/ViewModelVideoProjectPersistenceTests.swift b/JammLabTests/ViewModelVideoProjectPersistenceTests.swift new file mode 100644 index 0000000..d5eaee0 --- /dev/null +++ b/JammLabTests/ViewModelVideoProjectPersistenceTests.swift @@ -0,0 +1,196 @@ +import XCTest +@testable import JammLab + +final class ViewModelVideoProjectPersistenceTests: XCTestCase { + @MainActor + func testSaveProjectPersistsVideoWindowOpenState() async throws { + let audioURL = try temporaryAudioFile() + let videoURL = try temporaryFile(name: "lesson.mov", contents: "video") + let projectURL = temporaryDirectory().appendingPathComponent("video-window-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: videoURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + videoFollower: videoFollower, + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + let media = ImportedAudioFile( + url: audioURL, + sourceMediaURL: videoURL, + displayName: "lesson.mov", + duration: 0.5, + mediaKind: .video + ) + + try viewModel.loadImportedAudio(media) + + let didSaveOpenState = await viewModel.saveProject(to: projectURL) + XCTAssertTrue(didSaveOpenState) + XCTAssertEqual(try projectService.load(from: projectURL).isVideoWindowOpen, true) + XCTAssertFalse(viewModel.isProjectModified) + + videoFollower.closeWindow() + + XCTAssertTrue(viewModel.isProjectModified) + let didSaveClosedState = await viewModel.saveProject(to: projectURL) + XCTAssertTrue(didSaveClosedState) + XCTAssertEqual(try projectService.load(from: projectURL).isVideoWindowOpen, false) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testOpenProjectRestoresSavedVideoWindowOpenState() async throws { + let fixture = try makeVideoProjectFixture( + name: "video-window-open", + isVideoWindowOpen: true + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + videoFollower: videoFollower, + projectService: fixture.projectService, + projectArtifactStore: fixture.artifactStore, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: fixture.projectURL) + + XCTAssertNil(viewModel.errorMessage) + XCTAssertEqual(videoFollower.loadedVideoURL, fixture.videoURL) + XCTAssertTrue(viewModel.isVideoWindowOpen) + XCTAssertEqual(videoFollower.showWindowEvents.count, 1) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testOpenProjectClosesVideoWindowWhenSavedStateIsClosed() async throws { + let fixture = try makeVideoProjectFixture( + name: "video-window-closed", + isVideoWindowOpen: false + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + videoFollower: videoFollower, + projectService: fixture.projectService, + projectArtifactStore: fixture.artifactStore, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + videoFollower.showWindow(at: 0, isPlaying: false, rate: 1) + + await viewModel.openProject(at: fixture.projectURL) + + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.isVideoWindowOpen) + XCTAssertFalse(videoFollower.isWindowOpen) + XCTAssertEqual(videoFollower.showWindowEvents.count, 1) + XCTAssertGreaterThanOrEqual(videoFollower.closeWindowCount, 1) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testOpenLegacyVideoProjectWithoutWindowStateKeepsVideoWindowClosed() async throws { + let fixture = try makeVideoProjectFixture( + name: "video-window-legacy", + isVideoWindowOpen: nil + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + videoFollower: videoFollower, + projectService: fixture.projectService, + projectArtifactStore: fixture.artifactStore, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: fixture.projectURL) + + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.isVideoWindowOpen) + XCTAssertFalse(videoFollower.isWindowOpen) + XCTAssertTrue(videoFollower.showWindowEvents.isEmpty) + XCTAssertFalse(viewModel.isProjectModified) + } + + private struct VideoProjectFixture { + let directory: URL + let projectURL: URL + let videoURL: URL + let projectService: ProjectDocumentService + let artifactStore: ProjectArtifactStore + } + + private func makeVideoProjectFixture( + name: String, + isVideoWindowOpen: Bool? + ) throws -> VideoProjectFixture { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let projectURL = directory.appendingPathComponent("\(name).jammlab") + let videoURL = try temporaryFile(in: directory, name: "\(name).mov", contents: "video") + let localAudioURL = try temporaryAudioFile() + defer { try? FileManager.default.removeItem(at: localAudioURL) } + + let artifactStore = ProjectArtifactStore() + try FileManager.default.createDirectory( + at: artifactStore.mediaDirectory(for: projectURL), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem( + at: localAudioURL, + to: artifactStore.videoAudioURL(for: projectURL) + ) + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: videoURL), + audioDisplayName: videoURL.lastPathComponent, + audioDuration: 0.5, + mediaKind: .video, + notes: [], + loopStart: 0, + loopEnd: 0.5, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + isVideoWindowOpen: isVideoWindowOpen + ) + try projectService.save(project, to: projectURL) + + return VideoProjectFixture( + directory: directory, + projectURL: projectURL, + videoURL: videoURL, + projectService: projectService, + artifactStore: artifactStore + ) + } +} From cc1ea25a9030c40a52d588ce792983987512d2ef Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:23:05 +0300 Subject: [PATCH 31/80] test: split view model save close tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelLifecycleTests.swift | 111 ----------------- .../ViewModelProjectSaveCloseTests.swift | 115 ++++++++++++++++++ 3 files changed, 119 insertions(+), 111 deletions(-) create mode 100644 JammLabTests/ViewModelProjectSaveCloseTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 9a15fd4..eda42c2 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; + 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; @@ -326,6 +327,7 @@ 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; + 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSaveCloseTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; @@ -617,6 +619,7 @@ 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, + 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, @@ -1029,6 +1032,7 @@ 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, + 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 4ea742a..602313a 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -97,115 +97,4 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } - @MainActor - func testSaveProjectForClosePersistsAndClearsModifiedState() async throws { - let audioURL = try temporaryAudioFile() - let projectURL = temporaryDirectory().appendingPathComponent("save-close.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 0.5, - notes: [], - loopStart: 0, - loopEnd: 0.5, - isLoopEnabled: false, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "save-close", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openRecentProject(entry) - viewModel.setMainTrackVolume(0.2) - - XCTAssertTrue(viewModel.isProjectModified) - - let didSave = await viewModel.saveProjectForClose() - - XCTAssertTrue(didSave) - XCTAssertFalse(viewModel.isProjectModified) - - let savedProject = try projectService.load(from: projectURL) - XCTAssertEqual(try XCTUnwrap(savedProject.mainTrackVolume), 0.2, accuracy: 0.0001) - XCTAssertNotNil(savedProject.artifactRootBookmarkData) - XCTAssertNil(savedProject.isVideoWindowOpen) - } - - @MainActor - func testSaveProjectForCloseWithoutMediaReturnsFalse() async { - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - - let didSave = await viewModel.saveProjectForClose() - - XCTAssertFalse(didSave) - } - - @MainActor - func testSandboxSaveRequiresProjectArtifactFolderAccess() async throws { - let audioURL = try temporaryAudioFile() - let projectURL = temporaryDirectory().appendingPathComponent("sandbox-save.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - formatVersion: 5, - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 0.5, - notes: [], - loopStart: 0, - loopEnd: 0.5, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "sandbox-save", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { true } - ) - - await viewModel.openRecentProject(entry) - viewModel.setMainTrackVolume(0.2) - - let didSave = await viewModel.saveProjectForClose() - - XCTAssertFalse(didSave) - XCTAssertEqual( - viewModel.errorMessage, - "Project save failed: \(ProjectDocumentError.projectArtifactAccessDenied.localizedDescription)" - ) - } - } diff --git a/JammLabTests/ViewModelProjectSaveCloseTests.swift b/JammLabTests/ViewModelProjectSaveCloseTests.swift new file mode 100644 index 0000000..0b25fa2 --- /dev/null +++ b/JammLabTests/ViewModelProjectSaveCloseTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import JammLab + +final class ViewModelProjectSaveCloseTests: XCTestCase { + @MainActor + func testSaveProjectForClosePersistsAndClearsModifiedState() async throws { + let audioURL = try temporaryAudioFile() + let projectURL = temporaryDirectory().appendingPathComponent("save-close.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 0.5, + notes: [], + loopStart: 0, + loopEnd: 0.5, + isLoopEnabled: false, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "save-close", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openRecentProject(entry) + viewModel.setMainTrackVolume(0.2) + + XCTAssertTrue(viewModel.isProjectModified) + + let didSave = await viewModel.saveProjectForClose() + + XCTAssertTrue(didSave) + XCTAssertFalse(viewModel.isProjectModified) + + let savedProject = try projectService.load(from: projectURL) + XCTAssertEqual(try XCTUnwrap(savedProject.mainTrackVolume), 0.2, accuracy: 0.0001) + XCTAssertNotNil(savedProject.artifactRootBookmarkData) + XCTAssertNil(savedProject.isVideoWindowOpen) + } + + @MainActor + func testSaveProjectForCloseWithoutMediaReturnsFalse() async { + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + + let didSave = await viewModel.saveProjectForClose() + + XCTAssertFalse(didSave) + } + + @MainActor + func testSandboxSaveRequiresProjectArtifactFolderAccess() async throws { + let audioURL = try temporaryAudioFile() + let projectURL = temporaryDirectory().appendingPathComponent("sandbox-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + formatVersion: 5, + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 0.5, + notes: [], + loopStart: 0, + loopEnd: 0.5, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "sandbox-save", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { true } + ) + + await viewModel.openRecentProject(entry) + viewModel.setMainTrackVolume(0.2) + + let didSave = await viewModel.saveProjectForClose() + + XCTAssertFalse(didSave) + XCTAssertEqual( + viewModel.errorMessage, + "Project save failed: \(ProjectDocumentError.projectArtifactAccessDenied.localizedDescription)" + ) + } +} From 301ef7e91dd7a3363b84c904d4d87e873dec4e65 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:27:28 +0300 Subject: [PATCH 32/80] test: remove leftover view model lifecycle test file --- JammLab.xcodeproj/project.pbxproj | 4 - JammLabTests/ViewModelLifecycleTests.swift | 100 ------------------ JammLabTests/ViewModelRegionLoopTests.swift | 49 +++++++++ ...iewModelVideoProjectPersistenceTests.swift | 46 ++++++++ 4 files changed, 95 insertions(+), 104 deletions(-) delete mode 100644 JammLabTests/ViewModelLifecycleTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index eda42c2..358b1a3 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -106,7 +106,6 @@ 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */; }; 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */; }; 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */; }; - 9FAB01042CE0000100112233 /* ViewModelLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */; }; 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; @@ -313,7 +312,6 @@ 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileImporterDurationTests.swift; sourceTree = ""; }; 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeakformLogicTests.swift; sourceTree = ""; }; 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProjectLogicTests.swift; sourceTree = ""; }; - 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelLifecycleTests.swift; sourceTree = ""; }; 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; @@ -605,7 +603,6 @@ 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */, 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, - 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */, 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, @@ -1018,7 +1015,6 @@ 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */, 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, - 9FAB01042CE0000100112233 /* ViewModelLifecycleTests.swift in Sources */, 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift deleted file mode 100644 index 602313a..0000000 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -import XCTest -@testable import JammLab - -final class ViewModelLifecycleTests: XCTestCase { - @MainActor - func testOpeningVideoProjectWithoutLocalAudioDoesNotCreateProjectMediaDirectory() async throws { - let missingVideoURL = try temporaryFile(name: "missing-video.mov", contents: "video") - let projectDirectory = temporaryDirectory() - let projectURL = projectDirectory.appendingPathComponent("video-readonly.jammlab") - try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: missingVideoURL) - try? FileManager.default.removeItem(at: projectDirectory) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: missingVideoURL), - audioDisplayName: "lesson.mov", - audioDuration: 0.5, - mediaKind: .video, - notes: [], - loopStart: 0, - loopEnd: 0.5, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - try projectService.save(project, to: projectURL) - try FileManager.default.removeItem(at: missingVideoURL) - let entry = RecentProjectEntry( - displayName: "video-readonly", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let artifactStore = ProjectArtifactStore() - let viewModel = AudioPlayerViewModel( - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - projectArtifactStore: artifactStore, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) - ) - - await viewModel.openRecentProject(entry) - - XCTAssertNotNil(viewModel.errorMessage) - XCTAssertNil(viewModel.importedFile) - XCTAssertFalse(FileManager.default.fileExists(atPath: artifactStore.mediaDirectory(for: projectURL).path)) - } - - @MainActor - func testSelectionOnlyRegionFocusDoesNotMarkProjectModified() async throws { - let audioURL = try temporaryAudioFile(duration: 2) - let projectURL = temporaryDirectory().appendingPathComponent("selection.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let region = TimecodedNote(kind: .region, time: 0.25, duration: 0.5, title: "Region") - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 2, - notes: [region], - loopStart: 0, - loopEnd: 2, - isLoopEnabled: false, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "selection", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openRecentProject(entry) - - XCTAssertFalse(viewModel.isProjectModified) - - viewModel.focusRegion(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertFalse(viewModel.isProjectModified) - } - -} diff --git a/JammLabTests/ViewModelRegionLoopTests.swift b/JammLabTests/ViewModelRegionLoopTests.swift index cd4e668..3ab7668 100644 --- a/JammLabTests/ViewModelRegionLoopTests.swift +++ b/JammLabTests/ViewModelRegionLoopTests.swift @@ -2,6 +2,55 @@ import XCTest @testable import JammLab final class ViewModelRegionLoopTests: XCTestCase { + @MainActor + func testSelectionOnlyRegionFocusDoesNotMarkProjectModified() async throws { + let audioURL = try temporaryAudioFile(duration: 2) + let projectURL = temporaryDirectory().appendingPathComponent("selection.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let region = TimecodedNote(kind: .region, time: 0.25, duration: 0.5, title: "Region") + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 2, + notes: [region], + loopStart: 0, + loopEnd: 2, + isLoopEnabled: false, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "selection", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openRecentProject(entry) + + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.focusRegion(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertFalse(viewModel.isProjectModified) + } + @MainActor func testLocateRegionStartSelectsRegionAndMovesPlaybackMarkerWithoutActivatingLoop() throws { let audioURL = try temporaryAudioFile(duration: 6) diff --git a/JammLabTests/ViewModelVideoProjectPersistenceTests.swift b/JammLabTests/ViewModelVideoProjectPersistenceTests.swift index d5eaee0..73919bf 100644 --- a/JammLabTests/ViewModelVideoProjectPersistenceTests.swift +++ b/JammLabTests/ViewModelVideoProjectPersistenceTests.swift @@ -2,6 +2,52 @@ import XCTest @testable import JammLab final class ViewModelVideoProjectPersistenceTests: XCTestCase { + @MainActor + func testOpeningVideoProjectWithoutLocalAudioDoesNotCreateProjectMediaDirectory() async throws { + let missingVideoURL = try temporaryFile(name: "missing-video.mov", contents: "video") + let projectDirectory = temporaryDirectory() + let projectURL = projectDirectory.appendingPathComponent("video-readonly.jammlab") + try FileManager.default.createDirectory(at: projectDirectory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: missingVideoURL) + try? FileManager.default.removeItem(at: projectDirectory) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: missingVideoURL), + audioDisplayName: "lesson.mov", + audioDuration: 0.5, + mediaKind: .video, + notes: [], + loopStart: 0, + loopEnd: 0.5, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + try projectService.save(project, to: projectURL) + try FileManager.default.removeItem(at: missingVideoURL) + let entry = RecentProjectEntry( + displayName: "video-readonly", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let artifactStore = ProjectArtifactStore() + let viewModel = AudioPlayerViewModel( + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + projectArtifactStore: artifactStore, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + + await viewModel.openRecentProject(entry) + + XCTAssertNotNil(viewModel.errorMessage) + XCTAssertNil(viewModel.importedFile) + XCTAssertFalse(FileManager.default.fileExists(atPath: artifactStore.mediaDirectory(for: projectURL).path)) + } + @MainActor func testSaveProjectPersistsVideoWindowOpenState() async throws { let audioURL = try temporaryAudioFile() From 95a8f18560eac741d711619f85dae970e16b75ce Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:39:07 +0300 Subject: [PATCH 33/80] test: split control logic tests from settings suite --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ControlLogicTests.swift | 209 ++++++++++++++++++ .../SettingsAndControlLogicTests.swift | 205 ----------------- 3 files changed, 213 insertions(+), 205 deletions(-) create mode 100644 JammLabTests/ControlLogicTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 358b1a3..c97d74d 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; + 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; @@ -329,6 +330,7 @@ 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; + 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; @@ -620,6 +622,7 @@ 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, + 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, @@ -1032,6 +1035,7 @@ 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, + 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, diff --git a/JammLabTests/ControlLogicTests.swift b/JammLabTests/ControlLogicTests.swift new file mode 100644 index 0000000..73d0568 --- /dev/null +++ b/JammLabTests/ControlLogicTests.swift @@ -0,0 +1,209 @@ +import XCTest +@testable import JammLab + +final class ControlLogicTests: XCTestCase { + func testAbletonNumberFieldLogicClampsAndResetsDefault() { + let config = AbletonNumberFieldConfiguration( + minValue: 40, + maxValue: 240, + defaultValue: 300, + step: 1, + precision: 0 + ) + + XCTAssertEqual(AbletonNumberFieldLogic.clamp(20, configuration: config), 40, accuracy: 0.0001) + XCTAssertEqual(AbletonNumberFieldLogic.clamp(300, configuration: config), 240, accuracy: 0.0001) + XCTAssertEqual(AbletonNumberFieldLogic.resetValue(configuration: config), 240, accuracy: 0.0001) + } + + func testAbletonNumberFieldLogicSnapsToIntegerAndFractionalSteps() { + let integerConfig = AbletonNumberFieldConfiguration( + minValue: 40, + maxValue: 240, + defaultValue: 120, + step: 1, + precision: 0 + ) + let fractionalConfig = AbletonNumberFieldConfiguration( + minValue: -1, + maxValue: 1, + defaultValue: 0, + step: 0.25, + precision: 2 + ) + + XCTAssertEqual(AbletonNumberFieldLogic.snapToStep(120.4, configuration: integerConfig), 120, accuracy: 0.0001) + XCTAssertEqual(AbletonNumberFieldLogic.snapToStep(120.6, configuration: integerConfig), 121, accuracy: 0.0001) + XCTAssertEqual(AbletonNumberFieldLogic.snapToStep(0.38, configuration: fractionalConfig), 0.5, accuracy: 0.0001) + } + + func testAbletonNumberFieldLogicFormatsPrecision() { + let integerConfig = AbletonNumberFieldConfiguration( + minValue: 0, + maxValue: 200, + defaultValue: 120, + step: 1, + precision: 0 + ) + let decimalConfig = AbletonNumberFieldConfiguration( + minValue: 0, + maxValue: 2, + defaultValue: 1, + step: 0.01, + precision: 2 + ) + let tempoConfig = AbletonNumberFieldConfiguration( + minValue: 40, + maxValue: 240, + defaultValue: 120, + step: 0.01, + precision: 2 + ) + + XCTAssertEqual(AbletonNumberFieldLogic.format(119.6, configuration: integerConfig), "120") + XCTAssertEqual(AbletonNumberFieldLogic.format(1.235, configuration: decimalConfig), "1.24") + XCTAssertEqual(AbletonNumberFieldLogic.format(120, configuration: tempoConfig), "120.00") + XCTAssertEqual(AbletonNumberFieldLogic.format(240, configuration: tempoConfig), "240.00") + } + + func testAbletonNumberFieldLogicParsesDecimalSeparatorsAndNegativeRules() { + let positiveConfig = AbletonNumberFieldConfiguration( + minValue: 0, + maxValue: 200, + defaultValue: 120, + step: 0.1, + precision: 1 + ) + let negativeConfig = AbletonNumberFieldConfiguration( + minValue: -12, + maxValue: 12, + defaultValue: 0, + step: 0.5, + precision: 1 + ) + let hundredthsConfig = AbletonNumberFieldConfiguration( + minValue: 40, + maxValue: 240, + defaultValue: 120, + step: 0.01, + precision: 2 + ) + + XCTAssertEqual(try XCTUnwrap(AbletonNumberFieldLogic.parse("123,4", configuration: positiveConfig)), 123.4, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(AbletonNumberFieldLogic.parse("123.45", configuration: positiveConfig)), 123.5, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(AbletonNumberFieldLogic.parse("123.45", configuration: hundredthsConfig)), 123.45, accuracy: 0.0001) + XCTAssertNil(AbletonNumberFieldLogic.parse("-1", configuration: positiveConfig)) + XCTAssertEqual(try XCTUnwrap(AbletonNumberFieldLogic.parse("-1.2", configuration: negativeConfig)), -1, accuracy: 0.0001) + XCTAssertNil(AbletonNumberFieldLogic.parse("abc", configuration: negativeConfig)) + } + + func testJammValueSliderLogicClampsAndResetsDefault() { + let config = JammValueSliderConfiguration( + minValue: 0, + maxValue: 1, + defaultValue: 1.5, + step: 0.01, + precision: 2 + ) + + XCTAssertEqual(JammValueSliderLogic.clamp(-1, configuration: config), 0, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.clamp(2, configuration: config), 1, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.resetValue(configuration: config), 1, accuracy: 0.0001) + } + + func testJammValueSliderLogicNormalizesRanges() { + let volumeConfig = JammValueSliderConfiguration( + minValue: 0, + maxValue: 1, + defaultValue: 0.75, + step: 0.01, + precision: 2 + ) + let gainConfig = JammValueSliderConfiguration( + minValue: -60, + maxValue: 12, + defaultValue: 0, + step: 0.1, + precision: 1 + ) + let reversedConfig = JammValueSliderConfiguration( + minValue: 12, + maxValue: -60, + defaultValue: 0, + step: 0.1, + precision: 1 + ) + + XCTAssertEqual(JammValueSliderLogic.normalizedValue(0.25, configuration: volumeConfig), 0.25, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.normalizedValue(-60, configuration: gainConfig), 0, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.normalizedValue(12, configuration: gainConfig), 1, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.normalizedValue(-24, configuration: gainConfig), 0.5, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.normalizedValue(-24, configuration: reversedConfig), 0.5, accuracy: 0.0001) + } + + func testJammValueSliderLogicSnapsAndFormats() { + let integerConfig = JammValueSliderConfiguration( + minValue: 0, + maxValue: 10, + defaultValue: 5, + step: 1, + precision: 0 + ) + let fractionalConfig = JammValueSliderConfiguration( + minValue: -1, + maxValue: 1, + defaultValue: 0, + step: 0.25, + precision: 2 + ) + + XCTAssertEqual(JammValueSliderLogic.snapToStep(4.4, configuration: integerConfig), 4, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.snapToStep(4.6, configuration: integerConfig), 5, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.snapToStep(0.38, configuration: fractionalConfig), 0.5, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.format(0.38, configuration: fractionalConfig), "0.50") + } + + func testJammValueSliderLogicUsesDominantDragAxis() { + let config = JammValueSliderConfiguration( + minValue: 0, + maxValue: 1, + defaultValue: 0.5, + step: 0.01, + sensitivity: 1, + precision: 2 + ) + + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 10, deltaY: 2, configuration: config), 0.6, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 2, deltaY: 10, configuration: config), 0.6, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: -10, deltaY: 2, configuration: config), 0.4, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 2, deltaY: -10, configuration: config), 0.4, accuracy: 0.0001) + } + + func testJammValueSliderLogicSupportsSlowerIntegerPitchDrag() { + let config = JammValueSliderConfiguration( + minValue: -12, + maxValue: 12, + defaultValue: 0, + step: 1, + sensitivity: 0.08, + precision: 0 + ) + + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: 6, deltaY: 0, configuration: config), 0, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: 7, deltaY: 0, configuration: config), 1, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: -7, deltaY: 0, configuration: config), -1, accuracy: 0.0001) + } + + func testAppKitDragThresholdUsesVerticalDistanceForNumberField() { + XCTAssertFalse(AppKitDragThreshold.exceedsVerticalThreshold(deltaY: 2.9, threshold: 3)) + XCTAssertTrue(AppKitDragThreshold.exceedsVerticalThreshold(deltaY: 3, threshold: 3)) + XCTAssertTrue(AppKitDragThreshold.exceedsVerticalThreshold(deltaY: -3.1, threshold: 3)) + } + + func testAppKitDragThresholdUsesDominantAxisForValueSlider() { + XCTAssertFalse(AppKitDragThreshold.exceedsDominantAxisThreshold(deltaX: 2.9, deltaY: 1, threshold: 3)) + XCTAssertTrue(AppKitDragThreshold.exceedsDominantAxisThreshold(deltaX: 3, deltaY: 1, threshold: 3)) + XCTAssertTrue(AppKitDragThreshold.exceedsDominantAxisThreshold(deltaX: 1, deltaY: -3.1, threshold: 3)) + XCTAssertTrue(AppKitDragThreshold.exceedsDominantAxisThreshold(deltaX: -2, deltaY: 4, threshold: 3)) + } +} diff --git a/JammLabTests/SettingsAndControlLogicTests.swift b/JammLabTests/SettingsAndControlLogicTests.swift index 337ba2f..5a7ef90 100644 --- a/JammLabTests/SettingsAndControlLogicTests.swift +++ b/JammLabTests/SettingsAndControlLogicTests.swift @@ -974,211 +974,6 @@ final class SettingsAndControlLogicTests: XCTestCase { XCTAssertEqual(values.count, AppColorRole.allCases.count) } - func testAbletonNumberFieldLogicClampsAndResetsDefault() { - let config = AbletonNumberFieldConfiguration( - minValue: 40, - maxValue: 240, - defaultValue: 300, - step: 1, - precision: 0 - ) - - XCTAssertEqual(AbletonNumberFieldLogic.clamp(20, configuration: config), 40, accuracy: 0.0001) - XCTAssertEqual(AbletonNumberFieldLogic.clamp(300, configuration: config), 240, accuracy: 0.0001) - XCTAssertEqual(AbletonNumberFieldLogic.resetValue(configuration: config), 240, accuracy: 0.0001) - } - - func testAbletonNumberFieldLogicSnapsToIntegerAndFractionalSteps() { - let integerConfig = AbletonNumberFieldConfiguration( - minValue: 40, - maxValue: 240, - defaultValue: 120, - step: 1, - precision: 0 - ) - let fractionalConfig = AbletonNumberFieldConfiguration( - minValue: -1, - maxValue: 1, - defaultValue: 0, - step: 0.25, - precision: 2 - ) - - XCTAssertEqual(AbletonNumberFieldLogic.snapToStep(120.4, configuration: integerConfig), 120, accuracy: 0.0001) - XCTAssertEqual(AbletonNumberFieldLogic.snapToStep(120.6, configuration: integerConfig), 121, accuracy: 0.0001) - XCTAssertEqual(AbletonNumberFieldLogic.snapToStep(0.38, configuration: fractionalConfig), 0.5, accuracy: 0.0001) - } - - func testAbletonNumberFieldLogicFormatsPrecision() { - let integerConfig = AbletonNumberFieldConfiguration( - minValue: 0, - maxValue: 200, - defaultValue: 120, - step: 1, - precision: 0 - ) - let decimalConfig = AbletonNumberFieldConfiguration( - minValue: 0, - maxValue: 2, - defaultValue: 1, - step: 0.01, - precision: 2 - ) - let tempoConfig = AbletonNumberFieldConfiguration( - minValue: 40, - maxValue: 240, - defaultValue: 120, - step: 0.01, - precision: 2 - ) - - XCTAssertEqual(AbletonNumberFieldLogic.format(119.6, configuration: integerConfig), "120") - XCTAssertEqual(AbletonNumberFieldLogic.format(1.235, configuration: decimalConfig), "1.24") - XCTAssertEqual(AbletonNumberFieldLogic.format(120, configuration: tempoConfig), "120.00") - XCTAssertEqual(AbletonNumberFieldLogic.format(240, configuration: tempoConfig), "240.00") - } - - func testAbletonNumberFieldLogicParsesDecimalSeparatorsAndNegativeRules() { - let positiveConfig = AbletonNumberFieldConfiguration( - minValue: 0, - maxValue: 200, - defaultValue: 120, - step: 0.1, - precision: 1 - ) - let negativeConfig = AbletonNumberFieldConfiguration( - minValue: -12, - maxValue: 12, - defaultValue: 0, - step: 0.5, - precision: 1 - ) - let hundredthsConfig = AbletonNumberFieldConfiguration( - minValue: 40, - maxValue: 240, - defaultValue: 120, - step: 0.01, - precision: 2 - ) - - XCTAssertEqual(try XCTUnwrap(AbletonNumberFieldLogic.parse("123,4", configuration: positiveConfig)), 123.4, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(AbletonNumberFieldLogic.parse("123.45", configuration: positiveConfig)), 123.5, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(AbletonNumberFieldLogic.parse("123.45", configuration: hundredthsConfig)), 123.45, accuracy: 0.0001) - XCTAssertNil(AbletonNumberFieldLogic.parse("-1", configuration: positiveConfig)) - XCTAssertEqual(try XCTUnwrap(AbletonNumberFieldLogic.parse("-1.2", configuration: negativeConfig)), -1, accuracy: 0.0001) - XCTAssertNil(AbletonNumberFieldLogic.parse("abc", configuration: negativeConfig)) - } - - func testJammValueSliderLogicClampsAndResetsDefault() { - let config = JammValueSliderConfiguration( - minValue: 0, - maxValue: 1, - defaultValue: 1.5, - step: 0.01, - precision: 2 - ) - - XCTAssertEqual(JammValueSliderLogic.clamp(-1, configuration: config), 0, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.clamp(2, configuration: config), 1, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.resetValue(configuration: config), 1, accuracy: 0.0001) - } - - func testJammValueSliderLogicNormalizesRanges() { - let volumeConfig = JammValueSliderConfiguration( - minValue: 0, - maxValue: 1, - defaultValue: 0.75, - step: 0.01, - precision: 2 - ) - let gainConfig = JammValueSliderConfiguration( - minValue: -60, - maxValue: 12, - defaultValue: 0, - step: 0.1, - precision: 1 - ) - let reversedConfig = JammValueSliderConfiguration( - minValue: 12, - maxValue: -60, - defaultValue: 0, - step: 0.1, - precision: 1 - ) - - XCTAssertEqual(JammValueSliderLogic.normalizedValue(0.25, configuration: volumeConfig), 0.25, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.normalizedValue(-60, configuration: gainConfig), 0, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.normalizedValue(12, configuration: gainConfig), 1, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.normalizedValue(-24, configuration: gainConfig), 0.5, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.normalizedValue(-24, configuration: reversedConfig), 0.5, accuracy: 0.0001) - } - - func testJammValueSliderLogicSnapsAndFormats() { - let integerConfig = JammValueSliderConfiguration( - minValue: 0, - maxValue: 10, - defaultValue: 5, - step: 1, - precision: 0 - ) - let fractionalConfig = JammValueSliderConfiguration( - minValue: -1, - maxValue: 1, - defaultValue: 0, - step: 0.25, - precision: 2 - ) - - XCTAssertEqual(JammValueSliderLogic.snapToStep(4.4, configuration: integerConfig), 4, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.snapToStep(4.6, configuration: integerConfig), 5, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.snapToStep(0.38, configuration: fractionalConfig), 0.5, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.format(0.38, configuration: fractionalConfig), "0.50") - } - - func testJammValueSliderLogicUsesDominantDragAxis() { - let config = JammValueSliderConfiguration( - minValue: 0, - maxValue: 1, - defaultValue: 0.5, - step: 0.01, - sensitivity: 1, - precision: 2 - ) - - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 10, deltaY: 2, configuration: config), 0.6, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 2, deltaY: 10, configuration: config), 0.6, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: -10, deltaY: 2, configuration: config), 0.4, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 2, deltaY: -10, configuration: config), 0.4, accuracy: 0.0001) - } - - func testJammValueSliderLogicSupportsSlowerIntegerPitchDrag() { - let config = JammValueSliderConfiguration( - minValue: -12, - maxValue: 12, - defaultValue: 0, - step: 1, - sensitivity: 0.08, - precision: 0 - ) - - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: 6, deltaY: 0, configuration: config), 0, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: 7, deltaY: 0, configuration: config), 1, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: -7, deltaY: 0, configuration: config), -1, accuracy: 0.0001) - } - - func testAppKitDragThresholdUsesVerticalDistanceForNumberField() { - XCTAssertFalse(AppKitDragThreshold.exceedsVerticalThreshold(deltaY: 2.9, threshold: 3)) - XCTAssertTrue(AppKitDragThreshold.exceedsVerticalThreshold(deltaY: 3, threshold: 3)) - XCTAssertTrue(AppKitDragThreshold.exceedsVerticalThreshold(deltaY: -3.1, threshold: 3)) - } - - func testAppKitDragThresholdUsesDominantAxisForValueSlider() { - XCTAssertFalse(AppKitDragThreshold.exceedsDominantAxisThreshold(deltaX: 2.9, deltaY: 1, threshold: 3)) - XCTAssertTrue(AppKitDragThreshold.exceedsDominantAxisThreshold(deltaX: 3, deltaY: 1, threshold: 3)) - XCTAssertTrue(AppKitDragThreshold.exceedsDominantAxisThreshold(deltaX: 1, deltaY: -3.1, threshold: 3)) - XCTAssertTrue(AppKitDragThreshold.exceedsDominantAxisThreshold(deltaX: -2, deltaY: 4, threshold: 3)) - } - func testClickSoundSettingsDefaultsMatchCurrentGeneratedClick() { let defaults = ClickSoundSettings.defaultValue From 5257b1ba33789a83bf05295b4e66739755132278 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 7 Jul 2026 17:47:27 +0300 Subject: [PATCH 34/80] test: split recent projects store tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/RecentProjectsStoreTests.swift | 86 +++++++++++++++++++ .../SettingsAndControlLogicTests.swift | 82 ------------------ 3 files changed, 90 insertions(+), 82 deletions(-) create mode 100644 JammLabTests/RecentProjectsStoreTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index c97d74d..1ef6170 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; + 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; @@ -331,6 +332,7 @@ 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; + 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStoreTests.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; @@ -623,6 +625,7 @@ 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, + 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, @@ -1036,6 +1039,7 @@ 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, + 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, diff --git a/JammLabTests/RecentProjectsStoreTests.swift b/JammLabTests/RecentProjectsStoreTests.swift new file mode 100644 index 0000000..5e6101e --- /dev/null +++ b/JammLabTests/RecentProjectsStoreTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import JammLab + +final class RecentProjectsStoreTests: XCTestCase { + @MainActor + func testRecentProjectsStoreLoadsValidProjectEntries() throws { + let defaults = try temporaryUserDefaults() + let projectURL = try temporaryFile(name: "valid.jammlab", contents: "{}") + defer { try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) } + let entry = RecentProjectEntry( + displayName: "Valid", + bookmarkData: try ProjectDocumentService().bookmarkData(for: projectURL) + ) + + defaults.set(try JSONEncoder().encode([entry]), forKey: RecentProjectsStore.defaultsKey) + let store = RecentProjectsStore(defaults: defaults) + + XCTAssertEqual(store.entries.map(\.displayName), ["Valid"]) + } + + @MainActor + func testRecentProjectsStorePrunesMissingProjectEntriesOnLoad() throws { + let defaults = try temporaryUserDefaults() + let projectURL = try temporaryFile(name: "missing.jammlab", contents: "{}") + let projectDirectory = projectURL.deletingLastPathComponent() + let entry = RecentProjectEntry( + displayName: "Missing", + bookmarkData: try ProjectDocumentService().bookmarkData(for: projectURL) + ) + try FileManager.default.removeItem(at: projectDirectory) + + defaults.set(try JSONEncoder().encode([entry]), forKey: RecentProjectsStore.defaultsKey) + let store = RecentProjectsStore(defaults: defaults) + + XCTAssertTrue(store.entries.isEmpty) + XCTAssertTrue(RecentProjectsStore(defaults: defaults).entries.isEmpty) + } + + @MainActor + func testRecentProjectsStorePrunesUnsupportedExtensionsOnLoad() throws { + let defaults = try temporaryUserDefaults() + let textURL = try temporaryFile(name: "notes.txt", contents: "not a project") + defer { try? FileManager.default.removeItem(at: textURL.deletingLastPathComponent()) } + let entry = RecentProjectEntry( + displayName: "Notes", + bookmarkData: try ProjectDocumentService().bookmarkData(for: textURL) + ) + + defaults.set(try JSONEncoder().encode([entry]), forKey: RecentProjectsStore.defaultsKey) + let store = RecentProjectsStore(defaults: defaults) + + XCTAssertTrue(store.entries.isEmpty) + } + + @MainActor + func testRecentProjectsStoreDeduplicatesProjectsWhenAdding() throws { + let defaults = try temporaryUserDefaults() + let projectURL = try temporaryFile(name: "dedupe.jammlab", contents: "{}") + defer { try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) } + let projectService = ProjectDocumentService() + let store = RecentProjectsStore(defaults: defaults) + + store.addProject(url: projectURL, bookmarkData: try projectService.bookmarkData(for: projectURL)) + store.addProject(url: projectURL, bookmarkData: try projectService.bookmarkData(for: projectURL)) + + XCTAssertEqual(store.entries.count, 1) + XCTAssertEqual(store.entries.first?.displayName, "dedupe") + } + + @MainActor + func testRecentProjectsStoreClearPersistsEmptyList() throws { + let defaults = try temporaryUserDefaults() + let projectURL = try temporaryFile(name: "clear.jammlab", contents: "{}") + defer { try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) } + let projectService = ProjectDocumentService() + let store = RecentProjectsStore(defaults: defaults) + + store.addProject(url: projectURL, bookmarkData: try projectService.bookmarkData(for: projectURL)) + XCTAssertFalse(store.entries.isEmpty) + + store.clear() + + XCTAssertTrue(store.entries.isEmpty) + XCTAssertTrue(RecentProjectsStore(defaults: defaults).entries.isEmpty) + } +} diff --git a/JammLabTests/SettingsAndControlLogicTests.swift b/JammLabTests/SettingsAndControlLogicTests.swift index 5a7ef90..7ce2a97 100644 --- a/JammLabTests/SettingsAndControlLogicTests.swift +++ b/JammLabTests/SettingsAndControlLogicTests.swift @@ -4,88 +4,6 @@ import XCTest @testable import JammLab final class SettingsAndControlLogicTests: XCTestCase { - @MainActor - func testRecentProjectsStoreLoadsValidProjectEntries() throws { - let defaults = try temporaryUserDefaults() - let projectURL = try temporaryFile(name: "valid.jammlab", contents: "{}") - defer { try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) } - let entry = RecentProjectEntry( - displayName: "Valid", - bookmarkData: try ProjectDocumentService().bookmarkData(for: projectURL) - ) - - defaults.set(try JSONEncoder().encode([entry]), forKey: RecentProjectsStore.defaultsKey) - let store = RecentProjectsStore(defaults: defaults) - - XCTAssertEqual(store.entries.map(\.displayName), ["Valid"]) - } - - @MainActor - func testRecentProjectsStorePrunesMissingProjectEntriesOnLoad() throws { - let defaults = try temporaryUserDefaults() - let projectURL = try temporaryFile(name: "missing.jammlab", contents: "{}") - let projectDirectory = projectURL.deletingLastPathComponent() - let entry = RecentProjectEntry( - displayName: "Missing", - bookmarkData: try ProjectDocumentService().bookmarkData(for: projectURL) - ) - try FileManager.default.removeItem(at: projectDirectory) - - defaults.set(try JSONEncoder().encode([entry]), forKey: RecentProjectsStore.defaultsKey) - let store = RecentProjectsStore(defaults: defaults) - - XCTAssertTrue(store.entries.isEmpty) - XCTAssertTrue(RecentProjectsStore(defaults: defaults).entries.isEmpty) - } - - @MainActor - func testRecentProjectsStorePrunesUnsupportedExtensionsOnLoad() throws { - let defaults = try temporaryUserDefaults() - let textURL = try temporaryFile(name: "notes.txt", contents: "not a project") - defer { try? FileManager.default.removeItem(at: textURL.deletingLastPathComponent()) } - let entry = RecentProjectEntry( - displayName: "Notes", - bookmarkData: try ProjectDocumentService().bookmarkData(for: textURL) - ) - - defaults.set(try JSONEncoder().encode([entry]), forKey: RecentProjectsStore.defaultsKey) - let store = RecentProjectsStore(defaults: defaults) - - XCTAssertTrue(store.entries.isEmpty) - } - - @MainActor - func testRecentProjectsStoreDeduplicatesProjectsWhenAdding() throws { - let defaults = try temporaryUserDefaults() - let projectURL = try temporaryFile(name: "dedupe.jammlab", contents: "{}") - defer { try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) } - let projectService = ProjectDocumentService() - let store = RecentProjectsStore(defaults: defaults) - - store.addProject(url: projectURL, bookmarkData: try projectService.bookmarkData(for: projectURL)) - store.addProject(url: projectURL, bookmarkData: try projectService.bookmarkData(for: projectURL)) - - XCTAssertEqual(store.entries.count, 1) - XCTAssertEqual(store.entries.first?.displayName, "dedupe") - } - - @MainActor - func testRecentProjectsStoreClearPersistsEmptyList() throws { - let defaults = try temporaryUserDefaults() - let projectURL = try temporaryFile(name: "clear.jammlab", contents: "{}") - defer { try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) } - let projectService = ProjectDocumentService() - let store = RecentProjectsStore(defaults: defaults) - - store.addProject(url: projectURL, bookmarkData: try projectService.bookmarkData(for: projectURL)) - XCTAssertFalse(store.entries.isEmpty) - - store.clear() - - XCTAssertTrue(store.entries.isEmpty) - XCTAssertTrue(RecentProjectsStore(defaults: defaults).entries.isEmpty) - } - func testAppSettingsStoreDefaultsAndPersistsStemBackendComputeMode() throws { let defaults = try temporaryUserDefaults() let store = AppSettingsStore(defaults: defaults) From 62f1c7ebd48d9e7d05a5981d3f93a7b692c78b01 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 06:44:55 +0300 Subject: [PATCH 35/80] test: split color palette tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ColorPaletteTests.swift | 105 ++++++++++++++++++ .../SettingsAndControlLogicTests.swift | 101 ----------------- 3 files changed, 109 insertions(+), 101 deletions(-) create mode 100644 JammLabTests/ColorPaletteTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 1ef6170..3a0d255 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */; }; + 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; @@ -333,6 +334,7 @@ 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStoreTests.swift; sourceTree = ""; }; + 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTests.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; @@ -626,6 +628,7 @@ 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, + 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, @@ -1040,6 +1043,7 @@ 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */, + 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, diff --git a/JammLabTests/ColorPaletteTests.swift b/JammLabTests/ColorPaletteTests.swift new file mode 100644 index 0000000..c781a7a --- /dev/null +++ b/JammLabTests/ColorPaletteTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import JammLab + +final class ColorPaletteTests: XCTestCase { + func testDefaultColorPaletteMatchesAppDefaults() { + let palette = AppColorPalette.defaultValue + + for role in AppColorRole.allCases { + XCTAssertEqual(palette.hex(for: role), role.defaultHex) + } + XCTAssertEqual(palette.hex(for: .controlActive), "#878787") + XCTAssertEqual(palette.hex(for: .loopButtonActive), "#3CAF96") + XCTAssertEqual(palette.hex(for: .statusButtonFill), "#202020") + XCTAssertEqual(palette.hex(for: .statusButtonCriticalFill), "#D00000") + XCTAssertEqual(palette.hex(for: .statusButtonAttentionFill), "#C8D300") + XCTAssertEqual(palette.hex(for: .valueSliderFill), "#00AFC8") + XCTAssertEqual(palette.hex(for: .waveformBackground), "#A9A9A9") + XCTAssertEqual(palette.hex(for: .waveformColor), "#212121") + XCTAssertEqual(palette.hex(for: .waveformDisabledBackground), "#5C5C5C") + XCTAssertEqual(palette.hex(for: .waveformDisabledColor), "#2F2F2F") + XCTAssertEqual(palette.hex(for: .notationTrackBackground), "#303030") + XCTAssertEqual(palette.hex(for: .notationSymbolsAndLines), "#F2F2F2") + XCTAssertEqual(palette.hex(for: .timeTrackAccentBeatLine), "#747474") + XCTAssertEqual(palette.hex(for: .timeTrackBeatLine), "#AEAEAE") + XCTAssertEqual(palette.hex(for: .waveformAccentBeatLine), "#0C0C0C") + XCTAssertEqual(palette.hex(for: .waveformBeatLine), "#0C0C0C") + } + + func testThemeColorGroupsCoverEveryRoleOnce() { + let groupedRoles = AppColorRoleGroup.allCases.flatMap(\.roles) + + XCTAssertEqual(groupedRoles.count, AppColorRole.allCases.count) + XCTAssertEqual(Set(groupedRoles), Set(AppColorRole.allCases)) + } + + func testAppSettingsStorePersistsRestoresAndResetsColorPalette() throws { + let defaults = try temporaryUserDefaults() + let store = AppSettingsStore(defaults: defaults) + + store.updateColor(.accent, hex: "#123456") + store.updateColor(.appBackground, hex: "abcdef") + + let restored = AppSettingsStore(defaults: defaults) + XCTAssertEqual(restored.colorPalette.hex(for: .accent), "#123456") + XCTAssertEqual(restored.colorPalette.hex(for: .appBackground), "#ABCDEF") + + store.resetColorPaletteToDefaults() + + XCTAssertEqual(store.colorPalette, .defaultValue) + XCTAssertEqual(AppSettingsStore(defaults: defaults).colorPalette, .defaultValue) + } + + func testColorPaletteFallsBackForInvalidHexValues() throws { + let defaults = try temporaryUserDefaults() + let invalidPalette = AppColorPalette(values: [ + AppColorRole.accent.rawValue: "not-a-color", + AppColorRole.primaryText.rawValue: "#FFFF", + AppColorRole.notationSymbolsAndLines.rawValue: "#12GG34" + ]) + defaults.set(try JSONEncoder().encode(invalidPalette), forKey: AppSettingsStore.colorPaletteKey) + + let store = AppSettingsStore(defaults: defaults) + + XCTAssertEqual(store.colorPalette.hex(for: .accent), AppColorRole.accent.defaultHex) + XCTAssertEqual(store.colorPalette.hex(for: .primaryText), AppColorRole.primaryText.defaultHex) + XCTAssertEqual(store.colorPalette.hex(for: .notationSymbolsAndLines), AppColorRole.notationSymbolsAndLines.defaultHex) + } + + func testColorPaletteMergesPartialSavedValuesWithDefaults() throws { + let defaults = try temporaryUserDefaults() + let partialPalette = AppColorPalette(values: [ + AppColorRole.accent.rawValue: "#010203" + ]) + defaults.set(try JSONEncoder().encode(partialPalette), forKey: AppSettingsStore.colorPaletteKey) + + let store = AppSettingsStore(defaults: defaults) + + XCTAssertEqual(store.colorPalette.hex(for: .accent), "#010203") + XCTAssertEqual(store.colorPalette.hex(for: .panelBackground), AppColorRole.panelBackground.defaultHex) + XCTAssertEqual(store.colorPalette.hex(for: .timeTrackAccentBeatLine), AppColorRole.timeTrackAccentBeatLine.defaultHex) + XCTAssertEqual(store.colorPalette.hex(for: .waveformBeatLine), AppColorRole.waveformBeatLine.defaultHex) + XCTAssertEqual(store.colorPalette.hex(for: .notationTrackBackground), AppColorRole.notationTrackBackground.defaultHex) + XCTAssertEqual(store.colorPalette.hex(for: .notationSymbolsAndLines), AppColorRole.notationSymbolsAndLines.defaultHex) + } + + func testColorPaletteDropsRemovedSavedKeysAfterNormalization() throws { + let defaults = try temporaryUserDefaults() + let savedPalette = AppColorPalette(values: [ + AppColorRole.accent.rawValue: "#010203", + "accentText": "#112233" + ]) + defaults.set(try JSONEncoder().encode(savedPalette), forKey: AppSettingsStore.colorPaletteKey) + + let store = AppSettingsStore(defaults: defaults) + + XCTAssertEqual(store.colorPalette.hex(for: .accent), "#010203") + + let restoredData = try JSONEncoder().encode(store.colorPalette) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: restoredData) as? [String: Any]) + let values = try XCTUnwrap(object["values"] as? [String: String]) + + XCTAssertNil(values["accentText"]) + XCTAssertEqual(values.count, AppColorRole.allCases.count) + } +} diff --git a/JammLabTests/SettingsAndControlLogicTests.swift b/JammLabTests/SettingsAndControlLogicTests.swift index 7ce2a97..77ea354 100644 --- a/JammLabTests/SettingsAndControlLogicTests.swift +++ b/JammLabTests/SettingsAndControlLogicTests.swift @@ -791,107 +791,6 @@ final class SettingsAndControlLogicTests: XCTestCase { return buffer } - func testDefaultColorPaletteMatchesAppDefaults() { - let palette = AppColorPalette.defaultValue - - for role in AppColorRole.allCases { - XCTAssertEqual(palette.hex(for: role), role.defaultHex) - } - XCTAssertEqual(palette.hex(for: .controlActive), "#878787") - XCTAssertEqual(palette.hex(for: .loopButtonActive), "#3CAF96") - XCTAssertEqual(palette.hex(for: .statusButtonFill), "#202020") - XCTAssertEqual(palette.hex(for: .statusButtonCriticalFill), "#D00000") - XCTAssertEqual(palette.hex(for: .statusButtonAttentionFill), "#C8D300") - XCTAssertEqual(palette.hex(for: .valueSliderFill), "#00AFC8") - XCTAssertEqual(palette.hex(for: .waveformBackground), "#A9A9A9") - XCTAssertEqual(palette.hex(for: .waveformColor), "#212121") - XCTAssertEqual(palette.hex(for: .waveformDisabledBackground), "#5C5C5C") - XCTAssertEqual(palette.hex(for: .waveformDisabledColor), "#2F2F2F") - XCTAssertEqual(palette.hex(for: .notationTrackBackground), "#303030") - XCTAssertEqual(palette.hex(for: .notationSymbolsAndLines), "#F2F2F2") - XCTAssertEqual(palette.hex(for: .timeTrackAccentBeatLine), "#747474") - XCTAssertEqual(palette.hex(for: .timeTrackBeatLine), "#AEAEAE") - XCTAssertEqual(palette.hex(for: .waveformAccentBeatLine), "#0C0C0C") - XCTAssertEqual(palette.hex(for: .waveformBeatLine), "#0C0C0C") - } - - func testThemeColorGroupsCoverEveryRoleOnce() { - let groupedRoles = AppColorRoleGroup.allCases.flatMap(\.roles) - - XCTAssertEqual(groupedRoles.count, AppColorRole.allCases.count) - XCTAssertEqual(Set(groupedRoles), Set(AppColorRole.allCases)) - } - - func testAppSettingsStorePersistsRestoresAndResetsColorPalette() throws { - let defaults = try temporaryUserDefaults() - let store = AppSettingsStore(defaults: defaults) - - store.updateColor(.accent, hex: "#123456") - store.updateColor(.appBackground, hex: "abcdef") - - let restored = AppSettingsStore(defaults: defaults) - XCTAssertEqual(restored.colorPalette.hex(for: .accent), "#123456") - XCTAssertEqual(restored.colorPalette.hex(for: .appBackground), "#ABCDEF") - - store.resetColorPaletteToDefaults() - - XCTAssertEqual(store.colorPalette, .defaultValue) - XCTAssertEqual(AppSettingsStore(defaults: defaults).colorPalette, .defaultValue) - } - - func testColorPaletteFallsBackForInvalidHexValues() throws { - let defaults = try temporaryUserDefaults() - let invalidPalette = AppColorPalette(values: [ - AppColorRole.accent.rawValue: "not-a-color", - AppColorRole.primaryText.rawValue: "#FFFF", - AppColorRole.notationSymbolsAndLines.rawValue: "#12GG34" - ]) - defaults.set(try JSONEncoder().encode(invalidPalette), forKey: AppSettingsStore.colorPaletteKey) - - let store = AppSettingsStore(defaults: defaults) - - XCTAssertEqual(store.colorPalette.hex(for: .accent), AppColorRole.accent.defaultHex) - XCTAssertEqual(store.colorPalette.hex(for: .primaryText), AppColorRole.primaryText.defaultHex) - XCTAssertEqual(store.colorPalette.hex(for: .notationSymbolsAndLines), AppColorRole.notationSymbolsAndLines.defaultHex) - } - - func testColorPaletteMergesPartialSavedValuesWithDefaults() throws { - let defaults = try temporaryUserDefaults() - let partialPalette = AppColorPalette(values: [ - AppColorRole.accent.rawValue: "#010203" - ]) - defaults.set(try JSONEncoder().encode(partialPalette), forKey: AppSettingsStore.colorPaletteKey) - - let store = AppSettingsStore(defaults: defaults) - - XCTAssertEqual(store.colorPalette.hex(for: .accent), "#010203") - XCTAssertEqual(store.colorPalette.hex(for: .panelBackground), AppColorRole.panelBackground.defaultHex) - XCTAssertEqual(store.colorPalette.hex(for: .timeTrackAccentBeatLine), AppColorRole.timeTrackAccentBeatLine.defaultHex) - XCTAssertEqual(store.colorPalette.hex(for: .waveformBeatLine), AppColorRole.waveformBeatLine.defaultHex) - XCTAssertEqual(store.colorPalette.hex(for: .notationTrackBackground), AppColorRole.notationTrackBackground.defaultHex) - XCTAssertEqual(store.colorPalette.hex(for: .notationSymbolsAndLines), AppColorRole.notationSymbolsAndLines.defaultHex) - } - - func testColorPaletteDropsRemovedSavedKeysAfterNormalization() throws { - let defaults = try temporaryUserDefaults() - let savedPalette = AppColorPalette(values: [ - AppColorRole.accent.rawValue: "#010203", - "accentText": "#112233" - ]) - defaults.set(try JSONEncoder().encode(savedPalette), forKey: AppSettingsStore.colorPaletteKey) - - let store = AppSettingsStore(defaults: defaults) - - XCTAssertEqual(store.colorPalette.hex(for: .accent), "#010203") - - let restoredData = try JSONEncoder().encode(store.colorPalette) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: restoredData) as? [String: Any]) - let values = try XCTUnwrap(object["values"] as? [String: String]) - - XCTAssertNil(values["accentText"]) - XCTAssertEqual(values.count, AppColorRole.allCases.count) - } - func testClickSoundSettingsDefaultsMatchCurrentGeneratedClick() { let defaults = ClickSoundSettings.defaultValue From 143f0eb576ec83ee464d9fa0a2d01a74b46759c3 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 06:55:00 +0300 Subject: [PATCH 36/80] test: split settings store tests --- JammLab.xcodeproj/project.pbxproj | 8 ++ JammLabTests/AppSettingsStoreTests.swift | 58 ++++++++++ JammLabTests/ClickSoundSettingsTests.swift | 50 +++++++++ .../SettingsAndControlLogicTests.swift | 100 ------------------ 4 files changed, 116 insertions(+), 100 deletions(-) create mode 100644 JammLabTests/AppSettingsStoreTests.swift create mode 100644 JammLabTests/ClickSoundSettingsTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 3a0d255..2116c5a 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -126,6 +126,8 @@ 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */; }; 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */; }; + 9FAB01302CE0000100112233 /* ClickSoundSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */; }; + 9FAB01312CE0000100112233 /* AppSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; @@ -335,6 +337,8 @@ 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStoreTests.swift; sourceTree = ""; }; 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTests.swift; sourceTree = ""; }; + 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickSoundSettingsTests.swift; sourceTree = ""; }; + 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStoreTests.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; @@ -629,6 +633,8 @@ 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */, + 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */, + 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, @@ -1044,6 +1050,8 @@ 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */, 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */, + 9FAB01302CE0000100112233 /* ClickSoundSettingsTests.swift in Sources */, + 9FAB01312CE0000100112233 /* AppSettingsStoreTests.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, diff --git a/JammLabTests/AppSettingsStoreTests.swift b/JammLabTests/AppSettingsStoreTests.swift new file mode 100644 index 0000000..825ce1b --- /dev/null +++ b/JammLabTests/AppSettingsStoreTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import JammLab + +final class AppSettingsStoreTests: XCTestCase { + func testAppSettingsStoreDefaultsAndPersistsStemBackendComputeMode() throws { + let defaults = try temporaryUserDefaults() + let store = AppSettingsStore(defaults: defaults) + + XCTAssertEqual(store.stemBackendComputeMode, .cpuOnly) + + store.updateStemBackendComputeMode(.auto) + + XCTAssertEqual(AppSettingsStore(defaults: defaults).stemBackendComputeMode, .auto) + } + + func testAppSettingsStoreFallsBackForInvalidStemBackendComputeMode() throws { + let defaults = try temporaryUserDefaults() + defaults.set("mps", forKey: AppSettingsStore.stemBackendComputeModeKey) + + let store = AppSettingsStore(defaults: defaults) + + XCTAssertEqual(store.stemBackendComputeMode, .cpuOnly) + } + + func testAudioDeviceSettingsDefaultIsSystemDefault() { + XCTAssertNil(AudioDeviceSettings.defaultValue.inputDeviceUID) + XCTAssertNil(AudioDeviceSettings.defaultValue.outputDeviceUID) + } + + func testAppSettingsStorePersistsRestoresAndResetsAudioDeviceSettings() throws { + let defaults = try temporaryUserDefaults() + let store = AppSettingsStore(defaults: defaults) + + XCTAssertEqual(store.audioDeviceSettings, .defaultValue) + + store.updateAudioInputDeviceUID("input-device") + store.updateAudioOutputDeviceUID("output-device") + + let restored = AppSettingsStore(defaults: defaults) + XCTAssertEqual(restored.audioDeviceSettings.inputDeviceUID, "input-device") + XCTAssertEqual(restored.audioDeviceSettings.outputDeviceUID, "output-device") + + restored.resetAudioDevicesToSystemDefault() + + XCTAssertEqual(restored.audioDeviceSettings, .defaultValue) + XCTAssertEqual(AppSettingsStore(defaults: defaults).audioDeviceSettings, .defaultValue) + } + + func testAppSettingsStoreNormalizesEmptyAudioDeviceUIDs() throws { + let defaults = try temporaryUserDefaults() + let settings = AudioDeviceSettings(inputDeviceUID: " ", outputDeviceUID: "\n") + defaults.set(try JSONEncoder().encode(settings), forKey: AppSettingsStore.audioDeviceSettingsKey) + + let store = AppSettingsStore(defaults: defaults) + + XCTAssertEqual(store.audioDeviceSettings, .defaultValue) + } +} diff --git a/JammLabTests/ClickSoundSettingsTests.swift b/JammLabTests/ClickSoundSettingsTests.swift new file mode 100644 index 0000000..ff9f826 --- /dev/null +++ b/JammLabTests/ClickSoundSettingsTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import JammLab + +final class ClickSoundSettingsTests: XCTestCase { + func testClickSoundSettingsDefaultsMatchCurrentGeneratedClick() { + let defaults = ClickSoundSettings.defaultValue + + XCTAssertEqual(defaults.accentFrequencyHz, 1_760, accuracy: 0.0001) + XCTAssertEqual(defaults.regularFrequencyHz, 1_120, accuracy: 0.0001) + XCTAssertEqual(defaults.accentLengthMs, 36, accuracy: 0.0001) + XCTAssertEqual(defaults.regularLengthMs, 26, accuracy: 0.0001) + } + + func testAppSettingsStorePersistsRestoresAndResetsClickSoundSettings() throws { + let defaults = try temporaryUserDefaults() + let store = AppSettingsStore(defaults: defaults) + let custom = ClickSoundSettings( + accentFrequencyHz: 2_000, + regularFrequencyHz: 900, + accentLengthMs: 40, + regularLengthMs: 20 + ) + + store.updateClickSoundSettings(custom) + + XCTAssertEqual(AppSettingsStore(defaults: defaults).clickSoundSettings, custom) + + store.resetClickSoundSettingsToDefaults() + + XCTAssertEqual(store.clickSoundSettings, .defaultValue) + XCTAssertEqual(AppSettingsStore(defaults: defaults).clickSoundSettings, .defaultValue) + } + + func testAppSettingsStoreClampsInvalidClickSoundSettings() throws { + let defaults = try temporaryUserDefaults() + let store = AppSettingsStore(defaults: defaults) + + store.updateClickSoundSettings(ClickSoundSettings( + accentFrequencyHz: 20, + regularFrequencyHz: 10_000, + accentLengthMs: -4, + regularLengthMs: 400 + )) + + XCTAssertEqual(store.clickSoundSettings.accentFrequencyHz, 100, accuracy: 0.0001) + XCTAssertEqual(store.clickSoundSettings.regularFrequencyHz, 8_000, accuracy: 0.0001) + XCTAssertEqual(store.clickSoundSettings.accentLengthMs, 1, accuracy: 0.0001) + XCTAssertEqual(store.clickSoundSettings.regularLengthMs, 200, accuracy: 0.0001) + } +} diff --git a/JammLabTests/SettingsAndControlLogicTests.swift b/JammLabTests/SettingsAndControlLogicTests.swift index 77ea354..211f621 100644 --- a/JammLabTests/SettingsAndControlLogicTests.swift +++ b/JammLabTests/SettingsAndControlLogicTests.swift @@ -4,60 +4,6 @@ import XCTest @testable import JammLab final class SettingsAndControlLogicTests: XCTestCase { - func testAppSettingsStoreDefaultsAndPersistsStemBackendComputeMode() throws { - let defaults = try temporaryUserDefaults() - let store = AppSettingsStore(defaults: defaults) - - XCTAssertEqual(store.stemBackendComputeMode, .cpuOnly) - - store.updateStemBackendComputeMode(.auto) - - XCTAssertEqual(AppSettingsStore(defaults: defaults).stemBackendComputeMode, .auto) - } - - func testAppSettingsStoreFallsBackForInvalidStemBackendComputeMode() throws { - let defaults = try temporaryUserDefaults() - defaults.set("mps", forKey: AppSettingsStore.stemBackendComputeModeKey) - - let store = AppSettingsStore(defaults: defaults) - - XCTAssertEqual(store.stemBackendComputeMode, .cpuOnly) - } - - func testAudioDeviceSettingsDefaultIsSystemDefault() { - XCTAssertNil(AudioDeviceSettings.defaultValue.inputDeviceUID) - XCTAssertNil(AudioDeviceSettings.defaultValue.outputDeviceUID) - } - - func testAppSettingsStorePersistsRestoresAndResetsAudioDeviceSettings() throws { - let defaults = try temporaryUserDefaults() - let store = AppSettingsStore(defaults: defaults) - - XCTAssertEqual(store.audioDeviceSettings, .defaultValue) - - store.updateAudioInputDeviceUID("input-device") - store.updateAudioOutputDeviceUID("output-device") - - let restored = AppSettingsStore(defaults: defaults) - XCTAssertEqual(restored.audioDeviceSettings.inputDeviceUID, "input-device") - XCTAssertEqual(restored.audioDeviceSettings.outputDeviceUID, "output-device") - - restored.resetAudioDevicesToSystemDefault() - - XCTAssertEqual(restored.audioDeviceSettings, .defaultValue) - XCTAssertEqual(AppSettingsStore(defaults: defaults).audioDeviceSettings, .defaultValue) - } - - func testAppSettingsStoreNormalizesEmptyAudioDeviceUIDs() throws { - let defaults = try temporaryUserDefaults() - let settings = AudioDeviceSettings(inputDeviceUID: " ", outputDeviceUID: "\n") - defaults.set(try JSONEncoder().encode(settings), forKey: AppSettingsStore.audioDeviceSettingsKey) - - let store = AppSettingsStore(defaults: defaults) - - XCTAssertEqual(store.audioDeviceSettings, .defaultValue) - } - func testAudioSettingsDeviceLoaderDoesNotReadInputDevicesBeforePermission() async { let provider = MockAudioDeviceProvider() provider.inputDevicesResult = [ @@ -791,52 +737,6 @@ final class SettingsAndControlLogicTests: XCTestCase { return buffer } - func testClickSoundSettingsDefaultsMatchCurrentGeneratedClick() { - let defaults = ClickSoundSettings.defaultValue - - XCTAssertEqual(defaults.accentFrequencyHz, 1_760, accuracy: 0.0001) - XCTAssertEqual(defaults.regularFrequencyHz, 1_120, accuracy: 0.0001) - XCTAssertEqual(defaults.accentLengthMs, 36, accuracy: 0.0001) - XCTAssertEqual(defaults.regularLengthMs, 26, accuracy: 0.0001) - } - - func testAppSettingsStorePersistsRestoresAndResetsClickSoundSettings() throws { - let defaults = try temporaryUserDefaults() - let store = AppSettingsStore(defaults: defaults) - let custom = ClickSoundSettings( - accentFrequencyHz: 2_000, - regularFrequencyHz: 900, - accentLengthMs: 40, - regularLengthMs: 20 - ) - - store.updateClickSoundSettings(custom) - - XCTAssertEqual(AppSettingsStore(defaults: defaults).clickSoundSettings, custom) - - store.resetClickSoundSettingsToDefaults() - - XCTAssertEqual(store.clickSoundSettings, .defaultValue) - XCTAssertEqual(AppSettingsStore(defaults: defaults).clickSoundSettings, .defaultValue) - } - - func testAppSettingsStoreClampsInvalidClickSoundSettings() throws { - let defaults = try temporaryUserDefaults() - let store = AppSettingsStore(defaults: defaults) - - store.updateClickSoundSettings(ClickSoundSettings( - accentFrequencyHz: 20, - regularFrequencyHz: 10_000, - accentLengthMs: -4, - regularLengthMs: 400 - )) - - XCTAssertEqual(store.clickSoundSettings.accentFrequencyHz, 100, accuracy: 0.0001) - XCTAssertEqual(store.clickSoundSettings.regularFrequencyHz, 8_000, accuracy: 0.0001) - XCTAssertEqual(store.clickSoundSettings.accentLengthMs, 1, accuracy: 0.0001) - XCTAssertEqual(store.clickSoundSettings.regularLengthMs, 200, accuracy: 0.0001) - } - } private final class MockAudioDeviceProvider: AudioDeviceProviding { From ac75e573d43d93cca2f60aca6999922420c5cede Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 07:01:06 +0300 Subject: [PATCH 37/80] test: split audio settings device tests --- JammLab.xcodeproj/project.pbxproj | 8 ++ JammLabTests/AudioDeviceTestDoubles.swift | 57 +++++++++ JammLabTests/AudioSettingsDeviceTests.swift | 67 ++++++++++ .../SettingsAndControlLogicTests.swift | 118 ------------------ 4 files changed, 132 insertions(+), 118 deletions(-) create mode 100644 JammLabTests/AudioDeviceTestDoubles.swift create mode 100644 JammLabTests/AudioSettingsDeviceTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 2116c5a..70633ea 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -128,6 +128,8 @@ 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */; }; 9FAB01302CE0000100112233 /* ClickSoundSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */; }; 9FAB01312CE0000100112233 /* AppSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */; }; + 9FAB01322CE0000100112233 /* AudioDeviceTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */; }; + 9FAB01332CE0000100112233 /* AudioSettingsDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; @@ -339,6 +341,8 @@ 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTests.swift; sourceTree = ""; }; 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickSoundSettingsTests.swift; sourceTree = ""; }; 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStoreTests.swift; sourceTree = ""; }; + 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceTestDoubles.swift; sourceTree = ""; }; + 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSettingsDeviceTests.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; @@ -635,6 +639,8 @@ 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */, 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */, 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */, + 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */, + 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, @@ -1052,6 +1058,8 @@ 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */, 9FAB01302CE0000100112233 /* ClickSoundSettingsTests.swift in Sources */, 9FAB01312CE0000100112233 /* AppSettingsStoreTests.swift in Sources */, + 9FAB01322CE0000100112233 /* AudioDeviceTestDoubles.swift in Sources */, + 9FAB01332CE0000100112233 /* AudioSettingsDeviceTests.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, diff --git a/JammLabTests/AudioDeviceTestDoubles.swift b/JammLabTests/AudioDeviceTestDoubles.swift new file mode 100644 index 0000000..729a0a0 --- /dev/null +++ b/JammLabTests/AudioDeviceTestDoubles.swift @@ -0,0 +1,57 @@ +import CoreAudio +@testable import JammLab + +final class MockAudioDeviceProvider: AudioDeviceProviding { + var inputDevicesResult: [AudioDeviceInfo] = [] + var outputDevicesResult: [AudioDeviceInfo] = [] + var deviceIDs: [String: AudioDeviceID] = [:] + var defaultInputDeviceID = AudioDeviceID(1) + var defaultOutputDeviceID = AudioDeviceID(2) + var inputDevicesCallCount = 0 + var outputDevicesCallCount = 0 + var defaultDeviceCallKinds: [AudioDeviceKind] = [] + + func inputDevices() throws -> [AudioDeviceInfo] { + inputDevicesCallCount += 1 + return inputDevicesResult + } + + func outputDevices() throws -> [AudioDeviceInfo] { + outputDevicesCallCount += 1 + return outputDevicesResult + } + + func deviceID(forUID uid: String, kind: AudioDeviceKind) throws -> AudioDeviceID { + guard let deviceID = deviceIDs[uid] else { + throw AudioDeviceServiceError.deviceNotFound(uid) + } + return deviceID + } + + func defaultDeviceID(kind: AudioDeviceKind) throws -> AudioDeviceID { + defaultDeviceCallKinds.append(kind) + switch kind { + case .input: + return defaultInputDeviceID + case .output: + return defaultOutputDeviceID + } + } +} + +final class MockAudioInputPermissionProvider: AudioInputPermissionProviding { + var authorizationStatus: AudioInputPermissionStatus + var requestAccessCount = 0 + var requestResult: Bool + + init(status: AudioInputPermissionStatus, requestResult: Bool = false) { + self.authorizationStatus = status + self.requestResult = requestResult + } + + func requestAccess() async -> Bool { + requestAccessCount += 1 + authorizationStatus = requestResult ? .authorized : .denied + return requestResult + } +} diff --git a/JammLabTests/AudioSettingsDeviceTests.swift b/JammLabTests/AudioSettingsDeviceTests.swift new file mode 100644 index 0000000..a49c7f1 --- /dev/null +++ b/JammLabTests/AudioSettingsDeviceTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import JammLab + +final class AudioSettingsDeviceTests: XCTestCase { + func testAudioSettingsDeviceLoaderDoesNotReadInputDevicesBeforePermission() async { + let provider = MockAudioDeviceProvider() + provider.inputDevicesResult = [ + AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: true) + ] + provider.outputDevicesResult = [ + AudioDeviceInfo(uid: "output-1", name: "Output 1", kind: .output, isDefault: true) + ] + let permission = MockAudioInputPermissionProvider(status: .notDetermined) + let loader = AudioSettingsDeviceLoader(deviceProvider: provider, inputPermissionProvider: permission) + + let result = await loader.refreshDevices() + + XCTAssertEqual(provider.inputDevicesCallCount, 0) + XCTAssertEqual(provider.outputDevicesCallCount, 1) + XCTAssertEqual(result.inputDevices, []) + XCTAssertEqual(result.outputDevices.map(\.uid), ["output-1"]) + XCTAssertEqual(result.inputPermissionStatus, .notDetermined) + XCTAssertEqual(permission.requestAccessCount, 0) + } + + func testAudioSettingsDeviceLoaderReadsInputDevicesAfterPermission() async { + let provider = MockAudioDeviceProvider() + provider.inputDevicesResult = [ + AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: true) + ] + provider.outputDevicesResult = [ + AudioDeviceInfo(uid: "output-1", name: "Output 1", kind: .output, isDefault: true) + ] + let permission = MockAudioInputPermissionProvider(status: .authorized) + let loader = AudioSettingsDeviceLoader(deviceProvider: provider, inputPermissionProvider: permission) + + let result = await loader.refreshDevices() + + XCTAssertEqual(provider.inputDevicesCallCount, 1) + XCTAssertEqual(provider.outputDevicesCallCount, 1) + XCTAssertEqual(result.inputDevices.map(\.uid), ["input-1"]) + XCTAssertEqual(result.outputDevices.map(\.uid), ["output-1"]) + XCTAssertEqual(permission.requestAccessCount, 0) + } + + func testAudioSettingsDevicePickerShowsSystemDefaultForUnavailableSavedUID() { + let devices = [ + AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: true) + ] + + XCTAssertNil(AudioSettingsDevicePickerSelection.visibleUID( + selectedUID: "missing-input", + devices: devices + )) + } + + func testAudioSettingsDevicePickerUsesSavedUIDWhenAvailable() { + let devices = [ + AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: true) + ] + + XCTAssertEqual( + AudioSettingsDevicePickerSelection.visibleUID(selectedUID: "input-1", devices: devices), + "input-1" + ) + } +} diff --git a/JammLabTests/SettingsAndControlLogicTests.swift b/JammLabTests/SettingsAndControlLogicTests.swift index 211f621..42f7b8a 100644 --- a/JammLabTests/SettingsAndControlLogicTests.swift +++ b/JammLabTests/SettingsAndControlLogicTests.swift @@ -4,69 +4,6 @@ import XCTest @testable import JammLab final class SettingsAndControlLogicTests: XCTestCase { - func testAudioSettingsDeviceLoaderDoesNotReadInputDevicesBeforePermission() async { - let provider = MockAudioDeviceProvider() - provider.inputDevicesResult = [ - AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: true) - ] - provider.outputDevicesResult = [ - AudioDeviceInfo(uid: "output-1", name: "Output 1", kind: .output, isDefault: true) - ] - let permission = MockAudioInputPermissionProvider(status: .notDetermined) - let loader = AudioSettingsDeviceLoader(deviceProvider: provider, inputPermissionProvider: permission) - - let result = await loader.refreshDevices() - - XCTAssertEqual(provider.inputDevicesCallCount, 0) - XCTAssertEqual(provider.outputDevicesCallCount, 1) - XCTAssertEqual(result.inputDevices, []) - XCTAssertEqual(result.outputDevices.map(\.uid), ["output-1"]) - XCTAssertEqual(result.inputPermissionStatus, .notDetermined) - XCTAssertEqual(permission.requestAccessCount, 0) - } - - func testAudioSettingsDeviceLoaderReadsInputDevicesAfterPermission() async { - let provider = MockAudioDeviceProvider() - provider.inputDevicesResult = [ - AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: true) - ] - provider.outputDevicesResult = [ - AudioDeviceInfo(uid: "output-1", name: "Output 1", kind: .output, isDefault: true) - ] - let permission = MockAudioInputPermissionProvider(status: .authorized) - let loader = AudioSettingsDeviceLoader(deviceProvider: provider, inputPermissionProvider: permission) - - let result = await loader.refreshDevices() - - XCTAssertEqual(provider.inputDevicesCallCount, 1) - XCTAssertEqual(provider.outputDevicesCallCount, 1) - XCTAssertEqual(result.inputDevices.map(\.uid), ["input-1"]) - XCTAssertEqual(result.outputDevices.map(\.uid), ["output-1"]) - XCTAssertEqual(permission.requestAccessCount, 0) - } - - func testAudioSettingsDevicePickerShowsSystemDefaultForUnavailableSavedUID() { - let devices = [ - AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: true) - ] - - XCTAssertNil(AudioSettingsDevicePickerSelection.visibleUID( - selectedUID: "missing-input", - devices: devices - )) - } - - func testAudioSettingsDevicePickerUsesSavedUIDWhenAvailable() { - let devices = [ - AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: true) - ] - - XCTAssertEqual( - AudioSettingsDevicePickerSelection.visibleUID(selectedUID: "input-1", devices: devices), - "input-1" - ) - } - func testTunerInputDeviceResolverUsesSavedInputDevice() throws { let provider = MockAudioDeviceProvider() provider.inputDevicesResult = [ @@ -739,61 +676,6 @@ final class SettingsAndControlLogicTests: XCTestCase { } -private final class MockAudioDeviceProvider: AudioDeviceProviding { - var inputDevicesResult: [AudioDeviceInfo] = [] - var outputDevicesResult: [AudioDeviceInfo] = [] - var deviceIDs: [String: AudioDeviceID] = [:] - var defaultInputDeviceID = AudioDeviceID(1) - var defaultOutputDeviceID = AudioDeviceID(2) - var inputDevicesCallCount = 0 - var outputDevicesCallCount = 0 - var defaultDeviceCallKinds: [AudioDeviceKind] = [] - - func inputDevices() throws -> [AudioDeviceInfo] { - inputDevicesCallCount += 1 - return inputDevicesResult - } - - func outputDevices() throws -> [AudioDeviceInfo] { - outputDevicesCallCount += 1 - return outputDevicesResult - } - - func deviceID(forUID uid: String, kind: AudioDeviceKind) throws -> AudioDeviceID { - guard let deviceID = deviceIDs[uid] else { - throw AudioDeviceServiceError.deviceNotFound(uid) - } - return deviceID - } - - func defaultDeviceID(kind: AudioDeviceKind) throws -> AudioDeviceID { - defaultDeviceCallKinds.append(kind) - switch kind { - case .input: - return defaultInputDeviceID - case .output: - return defaultOutputDeviceID - } - } -} - -private final class MockAudioInputPermissionProvider: AudioInputPermissionProviding { - var authorizationStatus: AudioInputPermissionStatus - var requestAccessCount = 0 - var requestResult: Bool - - init(status: AudioInputPermissionStatus, requestResult: Bool = false) { - self.authorizationStatus = status - self.requestResult = requestResult - } - - func requestAccess() async -> Bool { - requestAccessCount += 1 - authorizationStatus = requestResult ? .authorized : .denied - return requestResult - } -} - private struct MockAudioBuffer { let samples: [Float] let sampleRate: Double From f1e6c2bdf9b562f9c05ae0c5a2b4b6ae488dc119 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 07:06:30 +0300 Subject: [PATCH 38/80] test: split tuner input resolver tests --- JammLab.xcodeproj/project.pbxproj | 4 ++ .../SettingsAndControlLogicTests.swift | 41 ----------------- .../TunerInputDeviceResolverTests.swift | 45 +++++++++++++++++++ 3 files changed, 49 insertions(+), 41 deletions(-) create mode 100644 JammLabTests/TunerInputDeviceResolverTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 70633ea..dfe2645 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -130,6 +130,7 @@ 9FAB01312CE0000100112233 /* AppSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */; }; 9FAB01322CE0000100112233 /* AudioDeviceTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */; }; 9FAB01332CE0000100112233 /* AudioSettingsDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */; }; + 9FAB01342CE0000100112233 /* TunerInputDeviceResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; @@ -343,6 +344,7 @@ 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStoreTests.swift; sourceTree = ""; }; 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceTestDoubles.swift; sourceTree = ""; }; 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSettingsDeviceTests.swift; sourceTree = ""; }; + 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputDeviceResolverTests.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; @@ -641,6 +643,7 @@ 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */, 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */, 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */, + 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, @@ -1060,6 +1063,7 @@ 9FAB01312CE0000100112233 /* AppSettingsStoreTests.swift in Sources */, 9FAB01322CE0000100112233 /* AudioDeviceTestDoubles.swift in Sources */, 9FAB01332CE0000100112233 /* AudioSettingsDeviceTests.swift in Sources */, + 9FAB01342CE0000100112233 /* TunerInputDeviceResolverTests.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, diff --git a/JammLabTests/SettingsAndControlLogicTests.swift b/JammLabTests/SettingsAndControlLogicTests.swift index 42f7b8a..33793e0 100644 --- a/JammLabTests/SettingsAndControlLogicTests.swift +++ b/JammLabTests/SettingsAndControlLogicTests.swift @@ -4,47 +4,6 @@ import XCTest @testable import JammLab final class SettingsAndControlLogicTests: XCTestCase { - func testTunerInputDeviceResolverUsesSavedInputDevice() throws { - let provider = MockAudioDeviceProvider() - provider.inputDevicesResult = [ - AudioDeviceInfo(uid: "input-1", name: "Interface Input", kind: .input, isDefault: false) - ] - provider.deviceIDs["input-1"] = 42 - provider.defaultInputDeviceID = 7 - - let selection = try TunerInputDeviceResolver(audioDeviceProvider: provider) - .resolveInputDevice(selectedUID: "input-1") - - XCTAssertEqual(selection.id, 42) - XCTAssertEqual(selection.name, "Interface Input") - XCTAssertEqual(provider.defaultDeviceCallKinds, []) - } - - func testTunerInputDeviceResolverUsesDefaultWhenInputSelectionIsNil() throws { - let provider = MockAudioDeviceProvider() - provider.defaultInputDeviceID = 7 - provider.inputDevicesResult = [ - AudioDeviceInfo(uid: "default-input", name: "Default Input", kind: .input, isDefault: true) - ] - - let selection = try TunerInputDeviceResolver(audioDeviceProvider: provider) - .resolveInputDevice(selectedUID: nil) - - XCTAssertEqual(selection.id, 7) - XCTAssertEqual(selection.name, "Default Input") - } - - func testTunerInputDeviceResolverDoesNotFallbackWhenSavedInputDeviceIsMissing() { - let provider = MockAudioDeviceProvider() - provider.defaultInputDeviceID = 7 - - XCTAssertThrowsError( - try TunerInputDeviceResolver(audioDeviceProvider: provider) - .resolveInputDevice(selectedUID: "missing-input") - ) - XCTAssertEqual(provider.defaultDeviceCallKinds, []) - } - func testTunerInputServiceErrorNamesInvalidElementStatusForUsers() { XCTAssertEqual( TunerInputServiceError.inputDeviceSwitchFailed(-10877).localizedDescription, diff --git a/JammLabTests/TunerInputDeviceResolverTests.swift b/JammLabTests/TunerInputDeviceResolverTests.swift new file mode 100644 index 0000000..be43ca6 --- /dev/null +++ b/JammLabTests/TunerInputDeviceResolverTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import JammLab + +final class TunerInputDeviceResolverTests: XCTestCase { + func testTunerInputDeviceResolverUsesSavedInputDevice() throws { + let provider = MockAudioDeviceProvider() + provider.inputDevicesResult = [ + AudioDeviceInfo(uid: "input-1", name: "Interface Input", kind: .input, isDefault: false) + ] + provider.deviceIDs["input-1"] = 42 + provider.defaultInputDeviceID = 7 + + let selection = try TunerInputDeviceResolver(audioDeviceProvider: provider) + .resolveInputDevice(selectedUID: "input-1") + + XCTAssertEqual(selection.id, 42) + XCTAssertEqual(selection.name, "Interface Input") + XCTAssertEqual(provider.defaultDeviceCallKinds, []) + } + + func testTunerInputDeviceResolverUsesDefaultWhenInputSelectionIsNil() throws { + let provider = MockAudioDeviceProvider() + provider.defaultInputDeviceID = 7 + provider.inputDevicesResult = [ + AudioDeviceInfo(uid: "default-input", name: "Default Input", kind: .input, isDefault: true) + ] + + let selection = try TunerInputDeviceResolver(audioDeviceProvider: provider) + .resolveInputDevice(selectedUID: nil) + + XCTAssertEqual(selection.id, 7) + XCTAssertEqual(selection.name, "Default Input") + } + + func testTunerInputDeviceResolverDoesNotFallbackWhenSavedInputDeviceIsMissing() { + let provider = MockAudioDeviceProvider() + provider.defaultInputDeviceID = 7 + + XCTAssertThrowsError( + try TunerInputDeviceResolver(audioDeviceProvider: provider) + .resolveInputDevice(selectedUID: "missing-input") + ) + XCTAssertEqual(provider.defaultDeviceCallKinds, []) + } +} From 5a884ef70e9541a233ba679ab85315aa74458250 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 07:11:19 +0300 Subject: [PATCH 39/80] test: rename tuner input service tests --- JammLab.xcodeproj/project.pbxproj | 8 ++++---- ...ntrolLogicTests.swift => TunerInputServiceTests.swift} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename JammLabTests/{SettingsAndControlLogicTests.swift => TunerInputServiceTests.swift} (99%) diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index dfe2645..5d16baf 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -122,7 +122,7 @@ 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; - 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */; }; + 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */; }; 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */; }; @@ -336,7 +336,7 @@ 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSaveCloseTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; - 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.swift; sourceTree = ""; }; + 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStoreTests.swift; sourceTree = ""; }; 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTests.swift; sourceTree = ""; }; @@ -635,7 +635,7 @@ 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, - 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, + 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */, @@ -1055,7 +1055,7 @@ 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, - 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.swift in Sources */, + 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */, 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */, diff --git a/JammLabTests/SettingsAndControlLogicTests.swift b/JammLabTests/TunerInputServiceTests.swift similarity index 99% rename from JammLabTests/SettingsAndControlLogicTests.swift rename to JammLabTests/TunerInputServiceTests.swift index 33793e0..e816e98 100644 --- a/JammLabTests/SettingsAndControlLogicTests.swift +++ b/JammLabTests/TunerInputServiceTests.swift @@ -3,7 +3,7 @@ import CoreAudio import XCTest @testable import JammLab -final class SettingsAndControlLogicTests: XCTestCase { +final class TunerInputServiceTests: XCTestCase { func testTunerInputServiceErrorNamesInvalidElementStatusForUsers() { XCTAssertEqual( TunerInputServiceError.inputDeviceSwitchFailed(-10877).localizedDescription, From c32c3a9735d454a262e40399d943a08e4f056aa0 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 07:16:00 +0300 Subject: [PATCH 40/80] test: split tuner input logic tests --- JammLab.xcodeproj/project.pbxproj | 4 ++++ JammLabTests/TunerInputLogicTests.swift | 20 ++++++++++++++++++++ JammLabTests/TunerInputServiceTests.swift | 16 ---------------- 3 files changed, 24 insertions(+), 16 deletions(-) create mode 100644 JammLabTests/TunerInputLogicTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 5d16baf..49b02ce 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -131,6 +131,7 @@ 9FAB01322CE0000100112233 /* AudioDeviceTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */; }; 9FAB01332CE0000100112233 /* AudioSettingsDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */; }; 9FAB01342CE0000100112233 /* TunerInputDeviceResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */; }; + 9FAB01352CE0000100112233 /* TunerInputLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00352CE0000100112233 /* TunerInputLogicTests.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; @@ -345,6 +346,7 @@ 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceTestDoubles.swift; sourceTree = ""; }; 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSettingsDeviceTests.swift; sourceTree = ""; }; 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputDeviceResolverTests.swift; sourceTree = ""; }; + 9FAB00352CE0000100112233 /* TunerInputLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputLogicTests.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; @@ -644,6 +646,7 @@ 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */, 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */, 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */, + 9FAB00352CE0000100112233 /* TunerInputLogicTests.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, @@ -1064,6 +1067,7 @@ 9FAB01322CE0000100112233 /* AudioDeviceTestDoubles.swift in Sources */, 9FAB01332CE0000100112233 /* AudioSettingsDeviceTests.swift in Sources */, 9FAB01342CE0000100112233 /* TunerInputDeviceResolverTests.swift in Sources */, + 9FAB01352CE0000100112233 /* TunerInputLogicTests.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, diff --git a/JammLabTests/TunerInputLogicTests.swift b/JammLabTests/TunerInputLogicTests.swift new file mode 100644 index 0000000..8740a21 --- /dev/null +++ b/JammLabTests/TunerInputLogicTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import JammLab + +final class TunerInputLogicTests: XCTestCase { + func testTunerInputServiceErrorNamesInvalidElementStatusForUsers() { + XCTAssertEqual( + TunerInputServiceError.inputDeviceSwitchFailed(-10877).localizedDescription, + "Audio input device switch failed with status -10877 (kAudioUnitErr_InvalidElement)." + ) + } + + func testTunerInputSignalLevelNormalizesRMSAsDBFS() { + XCTAssertEqual(TunerInputSignalLevel.normalized(rms: 0), 0) + XCTAssertEqual(TunerInputSignalLevel.normalized(rms: pow(10, -70.0 / 20.0)), 0) + XCTAssertEqual(TunerInputSignalLevel.normalized(rms: pow(10, -60.0 / 20.0)), 0) + XCTAssertEqual(TunerInputSignalLevel.normalized(rms: pow(10, -36.0 / 20.0)), 0.5, accuracy: 0.0001) + XCTAssertEqual(TunerInputSignalLevel.normalized(rms: pow(10, -12.0 / 20.0)), 1) + XCTAssertEqual(TunerInputSignalLevel.normalized(rms: 1), 1) + } +} diff --git a/JammLabTests/TunerInputServiceTests.swift b/JammLabTests/TunerInputServiceTests.swift index e816e98..3d5a3d3 100644 --- a/JammLabTests/TunerInputServiceTests.swift +++ b/JammLabTests/TunerInputServiceTests.swift @@ -4,22 +4,6 @@ import XCTest @testable import JammLab final class TunerInputServiceTests: XCTestCase { - func testTunerInputServiceErrorNamesInvalidElementStatusForUsers() { - XCTAssertEqual( - TunerInputServiceError.inputDeviceSwitchFailed(-10877).localizedDescription, - "Audio input device switch failed with status -10877 (kAudioUnitErr_InvalidElement)." - ) - } - - func testTunerInputSignalLevelNormalizesRMSAsDBFS() { - XCTAssertEqual(TunerInputSignalLevel.normalized(rms: 0), 0) - XCTAssertEqual(TunerInputSignalLevel.normalized(rms: pow(10, -70.0 / 20.0)), 0) - XCTAssertEqual(TunerInputSignalLevel.normalized(rms: pow(10, -60.0 / 20.0)), 0) - XCTAssertEqual(TunerInputSignalLevel.normalized(rms: pow(10, -36.0 / 20.0)), 0.5, accuracy: 0.0001) - XCTAssertEqual(TunerInputSignalLevel.normalized(rms: pow(10, -12.0 / 20.0)), 1) - XCTAssertEqual(TunerInputSignalLevel.normalized(rms: 1), 1) - } - @MainActor func testTunerInputServiceRequestsPermissionOnlyWhenStarted() async throws { let defaults = try temporaryUserDefaults() From 1633e984863bb0dc40e639c412cf1b42c75e896a Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 07:22:02 +0300 Subject: [PATCH 41/80] test: extract tuner input service test doubles --- JammLab.xcodeproj/project.pbxproj | 4 + .../TunerInputServiceTestDoubles.swift | 187 ++++++++++++++++++ JammLabTests/TunerInputServiceTests.swift | 183 ----------------- 3 files changed, 191 insertions(+), 183 deletions(-) create mode 100644 JammLabTests/TunerInputServiceTestDoubles.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 49b02ce..da953a4 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -132,6 +132,7 @@ 9FAB01332CE0000100112233 /* AudioSettingsDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */; }; 9FAB01342CE0000100112233 /* TunerInputDeviceResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */; }; 9FAB01352CE0000100112233 /* TunerInputLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00352CE0000100112233 /* TunerInputLogicTests.swift */; }; + 9FAB01362CE0000100112233 /* TunerInputServiceTestDoubles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00362CE0000100112233 /* TunerInputServiceTestDoubles.swift */; }; 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */; }; 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; }; 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; }; @@ -347,6 +348,7 @@ 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSettingsDeviceTests.swift; sourceTree = ""; }; 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputDeviceResolverTests.swift; sourceTree = ""; }; 9FAB00352CE0000100112233 /* TunerInputLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputLogicTests.swift; sourceTree = ""; }; + 9FAB00362CE0000100112233 /* TunerInputServiceTestDoubles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTestDoubles.swift; sourceTree = ""; }; 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickRenderStateTests.swift; sourceTree = ""; }; 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; @@ -647,6 +649,7 @@ 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */, 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */, 9FAB00352CE0000100112233 /* TunerInputLogicTests.swift */, + 9FAB00362CE0000100112233 /* TunerInputServiceTestDoubles.swift */, 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, @@ -1068,6 +1071,7 @@ 9FAB01332CE0000100112233 /* AudioSettingsDeviceTests.swift in Sources */, 9FAB01342CE0000100112233 /* TunerInputDeviceResolverTests.swift in Sources */, 9FAB01352CE0000100112233 /* TunerInputLogicTests.swift in Sources */, + 9FAB01362CE0000100112233 /* TunerInputServiceTestDoubles.swift in Sources */, 9FAB01082CE0000100112233 /* ClickRenderStateTests.swift in Sources */, 9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */, 9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */, diff --git a/JammLabTests/TunerInputServiceTestDoubles.swift b/JammLabTests/TunerInputServiceTestDoubles.swift new file mode 100644 index 0000000..32586fe --- /dev/null +++ b/JammLabTests/TunerInputServiceTestDoubles.swift @@ -0,0 +1,187 @@ +import AVFoundation +import CoreAudio +import XCTest +@testable import JammLab + +struct MockAudioBuffer { + let samples: [Float] + let sampleRate: Double +} + +final class MockTunerInputEngine: TunerInputEngineControlling { + var startDeviceIDs: [AudioDeviceID] = [] + var stopCallCount = 0 + var startErrors: [Error] = [] + var debugEvents: [TunerInputEngineDebugEvent] = [] + var audioBuffers: [MockAudioBuffer] = [] + private var audioBufferHandlers: [((AVAudioPCMBuffer, Double) -> Void)] = [] + private var debugHandlers: [((TunerInputEngineDebugEvent) -> Void)] = [] + + func start( + deviceID: AudioDeviceID, + bufferSize: AVAudioFrameCount, + onDebug: @escaping (TunerInputEngineDebugEvent) -> Void, + onAudioBuffer: @escaping (AVAudioPCMBuffer, Double) -> Void + ) throws { + startDeviceIDs.append(deviceID) + if !startErrors.isEmpty { + throw startErrors.removeFirst() + } + debugHandlers.append(onDebug) + audioBufferHandlers.append(onAudioBuffer) + for event in debugEvents { + onDebug(event) + } + for audioBuffer in audioBuffers { + sendAudioBuffer(samples: audioBuffer.samples, sampleRate: audioBuffer.sampleRate) + } + } + + func stop() { + stopCallCount += 1 + } + + func sendAudioBuffer(samples: [Float], sampleRate: Double = 44_100, toStartAt startIndex: Int? = nil) { + sendAudioBuffer(Self.makeBuffer(samples: samples, sampleRate: sampleRate), sampleRate: sampleRate, toStartAt: startIndex) + } + + func sendAudioBuffer(_ buffer: AVAudioPCMBuffer, sampleRate: Double = 44_100, toStartAt startIndex: Int? = nil) { + let targetIndex: Int? + if let startIndex { + targetIndex = startIndex + } else { + targetIndex = audioBufferHandlers.indices.last + } + guard let index = targetIndex, audioBufferHandlers.indices.contains(index) else { return } + if debugHandlers.indices.contains(index) { + debugHandlers[index](.tap(frameLength: buffer.frameLength, sampleRate: sampleRate)) + } + audioBufferHandlers[index](buffer, sampleRate) + } + + private static func makeBuffer(samples: [Float], sampleRate: Double) -> AVAudioPCMBuffer { + let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)! + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(max(samples.count, 1)))! + buffer.frameLength = AVAudioFrameCount(samples.count) + guard let channel = buffer.floatChannelData?[0] else { return buffer } + for (index, sample) in samples.enumerated() { + channel[index] = sample + } + return buffer + } +} + +final class RecordingPitchDetector: PitchDetecting { + private let lock = NSLock() + private var recordedSampleRates: [Double] = [] + + var sampleRates: [Double] { + lock.lock() + defer { lock.unlock() } + return recordedSampleRates + } + + func detect(samples: [Float], sampleRate: Double) -> PitchDetectionResult? { + lock.lock() + recordedSampleRates.append(sampleRate) + lock.unlock() + return nil + } +} + +final class BlockingPitchDetector: PitchDetecting { + let firstDetectionStarted = XCTestExpectation(description: "First tuner pitch detection started") + + private let lock = NSLock() + private let firstDetectionSemaphore = DispatchSemaphore(value: 0) + private var detectionCount = 0 + private var markers: [Int] = [] + + var detectedMarkers: [Int] { + lock.lock() + defer { lock.unlock() } + return markers + } + + func detect(samples: [Float], sampleRate: Double) -> PitchDetectionResult? { + let marker = Int(samples.first ?? 0) + let index: Int + + lock.lock() + detectionCount += 1 + index = detectionCount + markers.append(marker) + lock.unlock() + + if index == 1 { + firstDetectionStarted.fulfill() + _ = firstDetectionSemaphore.wait(timeout: .now() + 2) + } + + return Self.result(for: marker) + } + + func releaseFirstDetection() { + firstDetectionSemaphore.signal() + } + + private static func result(for marker: Int) -> PitchDetectionResult { + switch marker { + case 3: + return PitchDetectionResult( + frequencyHz: 523.25, + midiNote: 72, + noteName: "C", + octave: 5, + centsOffset: 0, + confidence: 1, + rms: 1 + ) + case 2: + return PitchDetectionResult( + frequencyHz: 493.88, + midiNote: 71, + noteName: "B", + octave: 4, + centsOffset: 0, + confidence: 1, + rms: 1 + ) + default: + return PitchDetectionResult( + frequencyHz: 440, + midiNote: 69, + noteName: "A", + octave: 4, + centsOffset: 0, + confidence: 1, + rms: 1 + ) + } + } +} + +final class QueuedPitchDetector: PitchDetecting { + private let lock = NSLock() + private var results: [PitchDetectionResult?] + private var callCount = 0 + + var detectCallCount: Int { + lock.lock() + defer { lock.unlock() } + return callCount + } + + init(results: [PitchDetectionResult?]) { + self.results = results + } + + func detect(samples: [Float], sampleRate: Double) -> PitchDetectionResult? { + lock.lock() + defer { lock.unlock() } + + callCount += 1 + guard !results.isEmpty else { return nil } + return results.removeFirst() + } +} diff --git a/JammLabTests/TunerInputServiceTests.swift b/JammLabTests/TunerInputServiceTests.swift index 3d5a3d3..b0f54f3 100644 --- a/JammLabTests/TunerInputServiceTests.swift +++ b/JammLabTests/TunerInputServiceTests.swift @@ -619,189 +619,6 @@ final class TunerInputServiceTests: XCTestCase { } -private struct MockAudioBuffer { - let samples: [Float] - let sampleRate: Double -} - -private final class MockTunerInputEngine: TunerInputEngineControlling { - var startDeviceIDs: [AudioDeviceID] = [] - var stopCallCount = 0 - var startErrors: [Error] = [] - var debugEvents: [TunerInputEngineDebugEvent] = [] - var audioBuffers: [MockAudioBuffer] = [] - private var audioBufferHandlers: [((AVAudioPCMBuffer, Double) -> Void)] = [] - private var debugHandlers: [((TunerInputEngineDebugEvent) -> Void)] = [] - - func start( - deviceID: AudioDeviceID, - bufferSize: AVAudioFrameCount, - onDebug: @escaping (TunerInputEngineDebugEvent) -> Void, - onAudioBuffer: @escaping (AVAudioPCMBuffer, Double) -> Void - ) throws { - startDeviceIDs.append(deviceID) - if !startErrors.isEmpty { - throw startErrors.removeFirst() - } - debugHandlers.append(onDebug) - audioBufferHandlers.append(onAudioBuffer) - for event in debugEvents { - onDebug(event) - } - for audioBuffer in audioBuffers { - sendAudioBuffer(samples: audioBuffer.samples, sampleRate: audioBuffer.sampleRate) - } - } - - func stop() { - stopCallCount += 1 - } - - func sendAudioBuffer(samples: [Float], sampleRate: Double = 44_100, toStartAt startIndex: Int? = nil) { - sendAudioBuffer(Self.makeBuffer(samples: samples, sampleRate: sampleRate), sampleRate: sampleRate, toStartAt: startIndex) - } - - func sendAudioBuffer(_ buffer: AVAudioPCMBuffer, sampleRate: Double = 44_100, toStartAt startIndex: Int? = nil) { - let targetIndex: Int? - if let startIndex { - targetIndex = startIndex - } else { - targetIndex = audioBufferHandlers.indices.last - } - guard let index = targetIndex, audioBufferHandlers.indices.contains(index) else { return } - if debugHandlers.indices.contains(index) { - debugHandlers[index](.tap(frameLength: buffer.frameLength, sampleRate: sampleRate)) - } - audioBufferHandlers[index](buffer, sampleRate) - } - - private static func makeBuffer(samples: [Float], sampleRate: Double) -> AVAudioPCMBuffer { - let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)! - let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(max(samples.count, 1)))! - buffer.frameLength = AVAudioFrameCount(samples.count) - guard let channel = buffer.floatChannelData?[0] else { return buffer } - for (index, sample) in samples.enumerated() { - channel[index] = sample - } - return buffer - } -} - -private final class RecordingPitchDetector: PitchDetecting { - private let lock = NSLock() - private var recordedSampleRates: [Double] = [] - - var sampleRates: [Double] { - lock.lock() - defer { lock.unlock() } - return recordedSampleRates - } - - func detect(samples: [Float], sampleRate: Double) -> PitchDetectionResult? { - lock.lock() - recordedSampleRates.append(sampleRate) - lock.unlock() - return nil - } -} - -private final class BlockingPitchDetector: PitchDetecting { - let firstDetectionStarted = XCTestExpectation(description: "First tuner pitch detection started") - - private let lock = NSLock() - private let firstDetectionSemaphore = DispatchSemaphore(value: 0) - private var detectionCount = 0 - private var markers: [Int] = [] - - var detectedMarkers: [Int] { - lock.lock() - defer { lock.unlock() } - return markers - } - - func detect(samples: [Float], sampleRate: Double) -> PitchDetectionResult? { - let marker = Int(samples.first ?? 0) - let index: Int - - lock.lock() - detectionCount += 1 - index = detectionCount - markers.append(marker) - lock.unlock() - - if index == 1 { - firstDetectionStarted.fulfill() - _ = firstDetectionSemaphore.wait(timeout: .now() + 2) - } - - return Self.result(for: marker) - } - - func releaseFirstDetection() { - firstDetectionSemaphore.signal() - } - - private static func result(for marker: Int) -> PitchDetectionResult { - switch marker { - case 3: - return PitchDetectionResult( - frequencyHz: 523.25, - midiNote: 72, - noteName: "C", - octave: 5, - centsOffset: 0, - confidence: 1, - rms: 1 - ) - case 2: - return PitchDetectionResult( - frequencyHz: 493.88, - midiNote: 71, - noteName: "B", - octave: 4, - centsOffset: 0, - confidence: 1, - rms: 1 - ) - default: - return PitchDetectionResult( - frequencyHz: 440, - midiNote: 69, - noteName: "A", - octave: 4, - centsOffset: 0, - confidence: 1, - rms: 1 - ) - } - } -} - -private final class QueuedPitchDetector: PitchDetecting { - private let lock = NSLock() - private var results: [PitchDetectionResult?] - private var callCount = 0 - - var detectCallCount: Int { - lock.lock() - defer { lock.unlock() } - return callCount - } - - init(results: [PitchDetectionResult?]) { - self.results = results - } - - func detect(samples: [Float], sampleRate: Double) -> PitchDetectionResult? { - lock.lock() - defer { lock.unlock() } - - callCount += 1 - guard !results.isEmpty else { return nil } - return results.removeFirst() - } -} - private extension PitchDetectionResult { static func result( noteName: String, From b1f73343cf74ab0868f465b2364171b1db8f2c98 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 07:26:52 +0300 Subject: [PATCH 42/80] test: extract tuner input service helpers --- .../TunerInputServiceTestDoubles.swift | 81 +++++++++ JammLabTests/TunerInputServiceTests.swift | 160 +++++------------- 2 files changed, 121 insertions(+), 120 deletions(-) diff --git a/JammLabTests/TunerInputServiceTestDoubles.swift b/JammLabTests/TunerInputServiceTestDoubles.swift index 32586fe..9fe00cc 100644 --- a/JammLabTests/TunerInputServiceTestDoubles.swift +++ b/JammLabTests/TunerInputServiceTestDoubles.swift @@ -185,3 +185,84 @@ final class QueuedPitchDetector: PitchDetecting { return results.removeFirst() } } + +extension XCTestCase { + func tunerDrainMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } + + func tunerSineWave( + frequency: Double, + duration: Double, + sampleRate: Double = 44_100, + amplitude: Float = 0.5 + ) -> [Float] { + let count = Int(duration * sampleRate) + return (0.. [Float] { + Array(repeating: marker, count: 128) + } + + @MainActor + func waitForTunerMainActorCondition( + timeout: TimeInterval = 2, + condition: @escaping () -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { + return true + } + try? await Task.sleep(nanoseconds: 20_000_000) + } + return condition() + } + + func makeTunerInt16Buffer(samples: [Int16], sampleRate: Double = 44_100) throws -> AVAudioPCMBuffer { + let format = try XCTUnwrap(AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: sampleRate, + channels: 1, + interleaved: false + )) + let buffer = try XCTUnwrap(AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(max(samples.count, 1)) + )) + buffer.frameLength = AVAudioFrameCount(samples.count) + if let channel = buffer.int16ChannelData?[0] { + for (index, sample) in samples.enumerated() { + channel[index] = sample + } + } + return buffer + } +} + +extension PitchDetectionResult { + static func result( + noteName: String, + octave: Int, + frequencyHz: Double, + midiNote: Int + ) -> PitchDetectionResult { + PitchDetectionResult( + frequencyHz: frequencyHz, + midiNote: midiNote, + noteName: noteName, + octave: octave, + centsOffset: 0, + confidence: 1, + rms: 1 + ) + } +} diff --git a/JammLabTests/TunerInputServiceTests.swift b/JammLabTests/TunerInputServiceTests.swift index b0f54f3..e19ee6c 100644 --- a/JammLabTests/TunerInputServiceTests.swift +++ b/JammLabTests/TunerInputServiceTests.swift @@ -1,4 +1,3 @@ -import AVFoundation import CoreAudio import XCTest @testable import JammLab @@ -105,7 +104,7 @@ final class TunerInputServiceTests: XCTestCase { XCTAssertEqual(engine.startDeviceIDs, [42]) settingsStore.updateAudioOutputDeviceUID("output-1") - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertEqual(engine.startDeviceIDs, [42]) } @@ -127,9 +126,9 @@ final class TunerInputServiceTests: XCTestCase { await service.start() settingsStore.updateAudioInputDeviceUID("input-2") - await drainMainQueue() + await tunerDrainMainQueue() await Task.yield() - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertEqual(engine.startDeviceIDs, [42, 84]) XCTAssertGreaterThanOrEqual(engine.stopCallCount, 2) @@ -208,9 +207,9 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - await drainMainQueue() + await tunerDrainMainQueue() await Task.yield() - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertGreaterThan(service.inputSignalLevel, 0) XCTAssertNil(service.currentResult) @@ -240,7 +239,7 @@ final class TunerInputServiceTests: XCTestCase { await service.start() service.stop() engine.sendAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5]) - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertEqual(service.inputSignalLevel, 0) } @@ -261,7 +260,7 @@ final class TunerInputServiceTests: XCTestCase { await service.start() engine.sendAudioBuffer(samples: []) - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertEqual(service.inputSignalLevel, 0) XCTAssertNil(service.currentResult) @@ -283,8 +282,8 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - engine.sendAudioBuffer(try makeInt16Buffer(samples: [1000, -1000, 1000, -1000])) - await drainMainQueue() + engine.sendAudioBuffer(try makeTunerInt16Buffer(samples: [1000, -1000, 1000, -1000])) + await tunerDrainMainQueue() XCTAssertEqual(service.inputSignalLevel, 0) XCTAssertNil(service.currentResult) @@ -308,14 +307,14 @@ final class TunerInputServiceTests: XCTestCase { await service.start() settingsStore.updateAudioInputDeviceUID("input-2") - await drainMainQueue() + await tunerDrainMainQueue() await Task.yield() - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertEqual(engine.startDeviceIDs, [42, 84]) engine.sendAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5], toStartAt: 0) - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertEqual(service.inputSignalLevel, 0) XCTAssertEqual(service.inputDebugSnapshot.tapCallbackCount, 0) @@ -329,7 +328,7 @@ final class TunerInputServiceTests: XCTestCase { provider.deviceIDs["input-1"] = 42 let engine = MockTunerInputEngine() engine.audioBuffers = [ - MockAudioBuffer(samples: sineWave(frequency: 440, duration: 0.4), sampleRate: 44_100) + MockAudioBuffer(samples: tunerSineWave(frequency: 440, duration: 0.4), sampleRate: 44_100) ] let service = TunerInputService( appSettingsStore: settingsStore, @@ -339,7 +338,7 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertGreaterThan(service.inputSignalLevel, 0) XCTAssertNil(service.errorMessage) @@ -362,14 +361,14 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - engine.sendAudioBuffer(samples: markerSamples(1)) + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) - engine.sendAudioBuffer(samples: markerSamples(2)) - engine.sendAudioBuffer(samples: markerSamples(3)) + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + engine.sendAudioBuffer(samples: tunerMarkerSamples(3)) detector.releaseFirstDetection() - let didPublishLatestResult = await waitForMainActorCondition { service.currentResult?.noteName == "C" } + let didPublishLatestResult = await waitForTunerMainActorCondition { service.currentResult?.noteName == "C" } XCTAssertTrue(didPublishLatestResult) XCTAssertEqual(detector.detectedMarkers, [1, 3]) } @@ -391,14 +390,14 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - engine.sendAudioBuffer(samples: markerSamples(1)) + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) service.stop() detector.releaseFirstDetection() - await drainMainQueue() + await tunerDrainMainQueue() await Task.yield() - await drainMainQueue() + await tunerDrainMainQueue() XCTAssertNil(service.currentResult) } @@ -420,9 +419,9 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - engine.sendAudioBuffer(samples: markerSamples(1), sampleRate: 48_000) + engine.sendAudioBuffer(samples: tunerMarkerSamples(1), sampleRate: 48_000) - let didRecordSampleRate = await waitForMainActorCondition { detector.sampleRates == [48_000] } + let didRecordSampleRate = await waitForTunerMainActorCondition { detector.sampleRates == [48_000] } XCTAssertTrue(didRecordSampleRate) } @@ -447,12 +446,12 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - engine.sendAudioBuffer(samples: markerSamples(1)) - let didPublishDetectedNote = await waitForMainActorCondition { service.currentResult?.noteName == "A" } + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } XCTAssertTrue(didPublishDetectedNote) - engine.sendAudioBuffer(samples: markerSamples(2)) - let didProcessMissingResult = await waitForMainActorCondition { detector.detectCallCount == 2 } + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } XCTAssertTrue(didProcessMissingResult) XCTAssertEqual(service.currentResult?.noteName, "A") @@ -480,14 +479,14 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - engine.sendAudioBuffer(samples: markerSamples(1)) - let didPublishDetectedNote = await waitForMainActorCondition { service.currentResult?.noteName == "A" } + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } XCTAssertTrue(didPublishDetectedNote) - engine.sendAudioBuffer(samples: markerSamples(2)) - let didProcessMissingResult = await waitForMainActorCondition { detector.detectCallCount == 2 } + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } XCTAssertTrue(didProcessMissingResult) - let didClearHeldNote = await waitForMainActorCondition { service.currentResult == nil } + let didClearHeldNote = await waitForTunerMainActorCondition { service.currentResult == nil } XCTAssertTrue(didClearHeldNote) } @@ -512,12 +511,12 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - engine.sendAudioBuffer(samples: markerSamples(1)) - let didPublishFirstNote = await waitForMainActorCondition { service.currentResult?.noteName == "A" } + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + let didPublishFirstNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } XCTAssertTrue(didPublishFirstNote) - engine.sendAudioBuffer(samples: markerSamples(2)) - let didPublishReplacementNote = await waitForMainActorCondition { service.currentResult?.noteName == "C" } + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + let didPublishReplacementNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "C" } XCTAssertTrue(didPublishReplacementNote) XCTAssertEqual(service.currentResult?.octave, 5) } @@ -543,12 +542,12 @@ final class TunerInputServiceTests: XCTestCase { ) await service.start() - engine.sendAudioBuffer(samples: markerSamples(1)) - let didPublishDetectedNote = await waitForMainActorCondition { service.currentResult?.noteName == "A" } + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } XCTAssertTrue(didPublishDetectedNote) - engine.sendAudioBuffer(samples: markerSamples(2)) - let didProcessMissingResult = await waitForMainActorCondition { detector.detectCallCount == 2 } + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } XCTAssertTrue(didProcessMissingResult) service.stop() @@ -557,83 +556,4 @@ final class TunerInputServiceTests: XCTestCase { XCTAssertNil(service.currentResult) } - private func drainMainQueue() async { - await withCheckedContinuation { continuation in - DispatchQueue.main.async { - continuation.resume() - } - } - } - - private func sineWave( - frequency: Double, - duration: Double, - sampleRate: Double = 44_100, - amplitude: Float = 0.5 - ) -> [Float] { - let count = Int(duration * sampleRate) - return (0.. [Float] { - Array(repeating: marker, count: 128) - } - - @MainActor - private func waitForMainActorCondition( - timeout: TimeInterval = 2, - condition: @escaping () -> Bool - ) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { - return true - } - try? await Task.sleep(nanoseconds: 20_000_000) - } - return condition() - } - - private func makeInt16Buffer(samples: [Int16], sampleRate: Double = 44_100) throws -> AVAudioPCMBuffer { - let format = try XCTUnwrap(AVAudioFormat( - commonFormat: .pcmFormatInt16, - sampleRate: sampleRate, - channels: 1, - interleaved: false - )) - let buffer = try XCTUnwrap(AVAudioPCMBuffer( - pcmFormat: format, - frameCapacity: AVAudioFrameCount(max(samples.count, 1)) - )) - buffer.frameLength = AVAudioFrameCount(samples.count) - if let channel = buffer.int16ChannelData?[0] { - for (index, sample) in samples.enumerated() { - channel[index] = sample - } - } - return buffer - } - -} - -private extension PitchDetectionResult { - static func result( - noteName: String, - octave: Int, - frequencyHz: Double, - midiNote: Int - ) -> PitchDetectionResult { - PitchDetectionResult( - frequencyHz: frequencyHz, - midiNote: midiNote, - noteName: noteName, - octave: octave, - centsOffset: 0, - confidence: 1, - rms: 1 - ) - } } From 8418c2ab629ff5e9a3d13216b4f481d18646745e Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 07:38:32 +0300 Subject: [PATCH 43/80] test: split timeline project logic tests --- JammLab.xcodeproj/project.pbxproj | 8 + JammLabTests/AppHotkeyTests.swift | 592 +++++++++++++++ JammLabTests/TimelineProjectLogicTests.swift | 714 ------------------ JammLabTests/TimelineViewportLogicTests.swift | 129 ++++ 4 files changed, 729 insertions(+), 714 deletions(-) create mode 100644 JammLabTests/AppHotkeyTests.swift create mode 100644 JammLabTests/TimelineViewportLogicTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index da953a4..5116fe8 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -106,6 +106,8 @@ 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */; }; 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */; }; 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */; }; + 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */; }; + 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */; }; 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; @@ -322,6 +324,8 @@ 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileImporterDurationTests.swift; sourceTree = ""; }; 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeakformLogicTests.swift; sourceTree = ""; }; 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProjectLogicTests.swift; sourceTree = ""; }; + 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewportLogicTests.swift; sourceTree = ""; }; + 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyTests.swift; sourceTree = ""; }; 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; @@ -623,6 +627,8 @@ 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */, 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, + 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, + 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */, 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, @@ -1045,6 +1051,8 @@ 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */, 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, + 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */, + 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */, 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, diff --git a/JammLabTests/AppHotkeyTests.swift b/JammLabTests/AppHotkeyTests.swift new file mode 100644 index 0000000..d8c78e4 --- /dev/null +++ b/JammLabTests/AppHotkeyTests.swift @@ -0,0 +1,592 @@ +import AppKit +import XCTest +@testable import JammLab + +final class AppHotkeyTests: XCTestCase { + func testAppHotkeyRecognizesTabForPlaybackModeToggle() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\t", + charactersIgnoringModifiers: "\t", + isARepeat: false, + keyCode: 48 + )) + + XCTAssertEqual(AppHotkey(event: event), .togglePlaybackMode) + XCTAssertEqual(AppHotkey.togglePlaybackMode.key, "Tab") + } + + func testAppHotkeyRecognizesSpaceForPlayStop() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: false, + keyCode: 49 + )) + + XCTAssertEqual(AppHotkey(event: event), .playPause) + XCTAssertEqual(AppHotkey.playPause.key, "Space") + XCTAssertEqual(AppHotkey.playPause.title, "Play / Stop") + XCTAssertEqual(AppHotkey.playPause.detail, "Start playback from the position marker or stop and return to it.") + } + + func testAppHotkeyEventFilterScopesAllowedHotkeysToAttachedWindow() throws { + let spaceEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: false, + keyCode: 49 + )) + let repeatSpaceEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: true, + keyCode: 49 + )) + let tabEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "\t", + charactersIgnoringModifiers: "\t", + isARepeat: false, + keyCode: 48 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ), + .playPause + ) + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 42, + firstResponder: NSView(), + allowedHotkeys: [.playPause] + ), + .playPause + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 7, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatSpaceEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: tabEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + } + + func testAppHotkeyExposesSelectedNotationItemHarmonyShortcutMetadata() { + XCTAssertTrue(AppHotkey.allCases.contains(.editHarmonyAtSelectedNotationItem)) + XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.key, "Cmd+K") + XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.title, "Edit Harmony") + XCTAssertEqual( + AppHotkey.editHarmonyAtSelectedNotationItem.detail, + "Open harmony entry for the selected notation item." + ) + } + + func testAppHotkeyRecognizesNotationDurationNumberKeys() throws { + let expectations: [(key: String, keyCode: UInt16, hotkey: AppHotkey, denominator: Int)] = [ + ("4", 21, .setNotationDurationEighth, 8), + ("5", 23, .setNotationDurationQuarter, 4), + ("6", 22, .setNotationDurationHalf, 2), + ("7", 26, .setNotationDurationWhole, 1) + ] + + for expectation in expectations { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: expectation.key, + charactersIgnoringModifiers: expectation.key, + isARepeat: false, + keyCode: expectation.keyCode + )) + + XCTAssertEqual(AppHotkey(event: event), expectation.hotkey) + XCTAssertEqual(expectation.hotkey.key, expectation.key) + XCTAssertEqual(expectation.hotkey.notationDurationDenominator, expectation.denominator) + XCTAssertTrue(AppHotkey.notationDurationHotkeys.contains(expectation.hotkey)) + } + + XCTAssertEqual(AppHotkey.setNotationDurationEighth.title, "Set Eighth Note Duration") + XCTAssertEqual( + AppHotkey.setNotationDurationEighth.detail, + "Set notation duration to eighth notes for the selected notation item." + ) + XCTAssertTrue(AppHotkey.allCases.contains(.setNotationDurationWhole)) + } + + func testAppHotkeyMappingsDoNotContainDuplicateKeyLabels() { + let keys = AppHotkey.allCases.map(\.key) + XCTAssertEqual(keys.count, Set(keys).count) + } + + func testAppHotkeyRecognizesCmdKButNotOldHarmonyKeys() throws { + let aEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "a", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + )) + let hEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "h", + charactersIgnoringModifiers: "h", + isARepeat: false, + keyCode: 4 + )) + let commandAEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "a", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + )) + let commandKEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: false, + keyCode: 40 + )) + let shiftAEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.shift], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "A", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + )) + + XCTAssertNil(AppHotkey(event: aEvent)) + XCTAssertNil(AppHotkey(event: hEvent)) + XCTAssertNil(AppHotkey(event: commandAEvent)) + XCTAssertEqual(AppHotkey(event: commandKEvent), .editHarmonyAtSelectedNotationItem) + XCTAssertNil(AppHotkey(event: shiftAEvent)) + } + + func testAppHotkeyRecognizesOptionVForVideoWindowToggle() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.option], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "v", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + )) + + XCTAssertEqual(AppHotkey(event: event), .toggleVideoWindow) + XCTAssertEqual(AppHotkey.toggleVideoWindow.key, "Opt+V") + XCTAssertEqual(AppHotkey.toggleVideoWindow.title, "Video Window") + XCTAssertEqual( + AppHotkey.toggleVideoWindow.detail, + "Open or close the sidecar video window for the current video project." + ) + } + + func testAppHotkeyRecognizesShiftCForTempoTimeSignatureMarker() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.shift], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "C", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 8 + )) + + XCTAssertEqual(AppHotkey(event: event), .addTempoTimeSignatureMarker) + XCTAssertEqual(AppHotkey.addTempoTimeSignatureMarker.key, "Shift+C") + XCTAssertEqual(AppHotkey.addTempoTimeSignatureMarker.title, "Add Tempo / Time Signature Marker") + } + + func testAppHotkeyRecognizesCommandCAndVForNotationMeasureCopyPaste() throws { + let copyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 8 + )) + let pasteEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "v", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + )) + + XCTAssertEqual(AppHotkey(event: copyEvent), .copyMeasure) + XCTAssertEqual(AppHotkey.copyMeasure.key, "Cmd+C") + XCTAssertEqual(AppHotkey.copyMeasure.title, "Copy Measure") + XCTAssertEqual(AppHotkey(event: pasteEvent), .pasteMeasure) + XCTAssertEqual(AppHotkey.pasteMeasure.key, "Cmd+V") + XCTAssertEqual(AppHotkey.pasteMeasure.title, "Paste Measure") + } + + func testAppHotkeyRecognizesEscapeForNotationMeasureSelectionClear() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + )) + + XCTAssertEqual(AppHotkey(event: event), .clearNotationMeasureSelection) + XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.key, "Esc") + XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.title, "Clear Measure Selection") + } + + func testAppHotkeyEventFilterDoesNotStealMeasureCopyPasteFromTextRespondersOrUnavailableScopes() throws { + let copyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 8 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.copyMeasure] + ), + .copyMeasure + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.copyMeasure] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.copyMeasure] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealEscapeFromTextRespondersOrUnavailableScopes() throws { + let escapeEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.clearNotationMeasureSelection] + ), + .clearNotationMeasureSelection + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.clearNotationMeasureSelection] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.clearNotationMeasureSelection] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealEditHarmonyFromTextRespondersOrUnavailableScopes() throws { + let editHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: false, + keyCode: 40 + )) + let repeatEditHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: true, + keyCode: 40 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ), + .editHarmonyAtSelectedNotationItem + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatEditHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { + let durationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: false, + keyCode: 21 + )) + let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: true, + keyCode: 21 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ), + .setNotationDurationEighth + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatDurationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + } +} diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index cd6c44b..f8730ac 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -1,133 +1,7 @@ -import AppKit import XCTest @testable import JammLab final class TimelineProjectLogicTests: XCTestCase { - func testTimelineViewportMapsAndBoundsVisibleRange() { - let viewport = TimelineViewport(duration: 100, visibleRange: 20...60) - - XCTAssertEqual(viewport.clampedRange.lowerBound, 20) - XCTAssertEqual(viewport.clampedRange.upperBound, 60) - XCTAssertEqual(viewport.xPosition(for: 40, width: 200), 100, accuracy: 0.0001) - XCTAssertEqual(viewport.time(forX: 50, width: 200), 30, accuracy: 0.0001) - XCTAssertEqual(viewport.intersection(start: 10, end: 30)?.lowerBound, 20) - XCTAssertEqual(viewport.intersection(start: 10, end: 30)?.upperBound, 30) - } - - func testTimelineViewportZoomAndPanStayInBounds() { - let viewport = TimelineViewport(duration: 100, visibleRange: 20...60) - - let zoomed = viewport.zoomed(to: 20, anchoredAt: 30) - XCTAssertEqual(zoomed.visibleDuration, 20, accuracy: 0.0001) - XCTAssertGreaterThanOrEqual(zoomed.clampedRange.lowerBound, 0) - XCTAssertLessThanOrEqual(zoomed.clampedRange.upperBound, 100) - - let pannedToStart = viewport.panned(by: -1000) - XCTAssertEqual(pannedToStart.clampedRange.lowerBound, 0, accuracy: 0.0001) - - let pannedToEnd = viewport.panned(by: 1000) - XCTAssertEqual(pannedToEnd.clampedRange.upperBound, 100, accuracy: 0.0001) - } - - func testTimelineViewportPositionsTimeNearLeadingEdgePreservingZoom() { - let viewport = TimelineViewport(duration: 100, visibleRange: 20...40) - - let followed = viewport.positionedWithTimeNearLeadingEdge(30) - - XCTAssertEqual(followed.visibleDuration, 20, accuracy: 0.0001) - XCTAssertEqual(followed.clampedRange.lowerBound, 28.4, accuracy: 0.0001) - XCTAssertEqual(followed.clampedRange.upperBound, 48.4, accuracy: 0.0001) - XCTAssertEqual(followed.xPosition(for: 30, width: 100), 8, accuracy: 0.0001) - } - - func testTimelineViewportFollowPositionClampsAtTrackEdges() { - let viewport = TimelineViewport(duration: 100, visibleRange: 20...40) - - let start = viewport.positionedWithTimeNearLeadingEdge(1) - let end = viewport.positionedWithTimeNearLeadingEdge(98) - - XCTAssertEqual(start.clampedRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(start.clampedRange.upperBound, 20, accuracy: 0.0001) - XCTAssertEqual(end.clampedRange.lowerBound, 80, accuracy: 0.0001) - XCTAssertEqual(end.clampedRange.upperBound, 100, accuracy: 0.0001) - } - - func testTimelineViewportFollowIsNoOpForFullRange() { - let viewport = TimelineViewport(duration: 100, visibleRange: 0...100) - - let followed = viewport.positionedWithTimeNearLeadingEdge(90) - - XCTAssertEqual(followed.clampedRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(followed.clampedRange.upperBound, 100, accuracy: 0.0001) - XCTAssertFalse(viewport.shouldFollowPlaybackTime(90)) - } - - func testTimelineViewportShouldFollowNearRightEdgeOnlyWhenZoomed() { - let viewport = TimelineViewport(duration: 100, visibleRange: 0...20) - - XCTAssertFalse(viewport.shouldFollowPlaybackTime(18.39)) - XCTAssertTrue(viewport.shouldFollowPlaybackTime(18.4)) - XCTAssertTrue(viewport.shouldFollowPlaybackTime(21)) - XCTAssertTrue(viewport.shouldFollowPlaybackTime(-1)) - } - - func testTimelineViewportScrollerMetricsMapsVisibleRangeToThumb() { - let metrics = TimelineViewportScrollerMetrics( - duration: 100, - visibleRange: 25...75, - trackWidth: 200, - minimumThumbWidth: 24 - ) - - XCTAssertEqual(metrics.thumbWidth, 100, accuracy: 0.0001) - XCTAssertEqual(metrics.thumbX, 50, accuracy: 0.0001) - } - - func testTimelineViewportScrollerDragPreservesVisibleDuration() { - let metrics = TimelineViewportScrollerMetrics( - duration: 100, - visibleRange: 20...60, - trackWidth: 200, - minimumThumbWidth: 24 - ) - - let range = metrics.range(draggedBy: 50) - - XCTAssertEqual(range.upperBound - range.lowerBound, 40, accuracy: 0.0001) - XCTAssertGreaterThan(range.lowerBound, 20) - } - - func testTimelineViewportScrollerDragClampsAtTrackEdges() { - let metrics = TimelineViewportScrollerMetrics( - duration: 100, - visibleRange: 20...60, - trackWidth: 200, - minimumThumbWidth: 24 - ) - - let startRange = metrics.range(draggedBy: -1_000) - let endRange = metrics.range(draggedBy: 1_000) - - XCTAssertEqual(startRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(startRange.upperBound, 40, accuracy: 0.0001) - XCTAssertEqual(endRange.lowerBound, 60, accuracy: 0.0001) - XCTAssertEqual(endRange.upperBound, 100, accuracy: 0.0001) - } - - func testTimelineViewportScrollerHandlesZeroDuration() { - let metrics = TimelineViewportScrollerMetrics( - duration: 0, - visibleRange: 0...0, - trackWidth: 200, - minimumThumbWidth: 24 - ) - - XCTAssertEqual(metrics.thumbWidth, 200, accuracy: 0.0001) - XCTAssertEqual(metrics.thumbX, 0, accuracy: 0.0001) - XCTAssertEqual(metrics.range(draggedBy: 50).lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(metrics.range(draggedBy: 50).upperBound, 0, accuracy: 0.0001) - } - func testProjectSaveDestinationCreatesProjectSubdirectory() { let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song") let destination = ProjectSaveDestination.projectFolder(selectedURL) @@ -473,592 +347,4 @@ final class TimelineProjectLogicTests: XCTestCase { accuracy: 0.0001 ) } - - func testAppHotkeyRecognizesTabForPlaybackModeToggle() throws { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "\t", - charactersIgnoringModifiers: "\t", - isARepeat: false, - keyCode: 48 - )) - - XCTAssertEqual(AppHotkey(event: event), .togglePlaybackMode) - XCTAssertEqual(AppHotkey.togglePlaybackMode.key, "Tab") - } - - func testAppHotkeyRecognizesSpaceForPlayStop() throws { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: " ", - charactersIgnoringModifiers: " ", - isARepeat: false, - keyCode: 49 - )) - - XCTAssertEqual(AppHotkey(event: event), .playPause) - XCTAssertEqual(AppHotkey.playPause.key, "Space") - XCTAssertEqual(AppHotkey.playPause.title, "Play / Stop") - XCTAssertEqual(AppHotkey.playPause.detail, "Start playback from the position marker or stop and return to it.") - } - - func testAppHotkeyEventFilterScopesAllowedHotkeysToAttachedWindow() throws { - let spaceEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: " ", - charactersIgnoringModifiers: " ", - isARepeat: false, - keyCode: 49 - )) - let repeatSpaceEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: " ", - charactersIgnoringModifiers: " ", - isARepeat: true, - keyCode: 49 - )) - let tabEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "\t", - charactersIgnoringModifiers: "\t", - isARepeat: false, - keyCode: 48 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ), - .playPause - ) - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 42, - firstResponder: NSView(), - allowedHotkeys: [.playPause] - ), - .playPause - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 7, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatSpaceEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: tabEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - } - - func testAppHotkeyExposesSelectedNotationItemHarmonyShortcutMetadata() { - XCTAssertTrue(AppHotkey.allCases.contains(.editHarmonyAtSelectedNotationItem)) - XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.key, "Cmd+K") - XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.title, "Edit Harmony") - XCTAssertEqual( - AppHotkey.editHarmonyAtSelectedNotationItem.detail, - "Open harmony entry for the selected notation item." - ) - } - - func testAppHotkeyRecognizesNotationDurationNumberKeys() throws { - let expectations: [(key: String, keyCode: UInt16, hotkey: AppHotkey, denominator: Int)] = [ - ("4", 21, .setNotationDurationEighth, 8), - ("5", 23, .setNotationDurationQuarter, 4), - ("6", 22, .setNotationDurationHalf, 2), - ("7", 26, .setNotationDurationWhole, 1) - ] - - for expectation in expectations { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: expectation.key, - charactersIgnoringModifiers: expectation.key, - isARepeat: false, - keyCode: expectation.keyCode - )) - - XCTAssertEqual(AppHotkey(event: event), expectation.hotkey) - XCTAssertEqual(expectation.hotkey.key, expectation.key) - XCTAssertEqual(expectation.hotkey.notationDurationDenominator, expectation.denominator) - XCTAssertTrue(AppHotkey.notationDurationHotkeys.contains(expectation.hotkey)) - } - - XCTAssertEqual(AppHotkey.setNotationDurationEighth.title, "Set Eighth Note Duration") - XCTAssertEqual( - AppHotkey.setNotationDurationEighth.detail, - "Set notation duration to eighth notes for the selected notation item." - ) - XCTAssertTrue(AppHotkey.allCases.contains(.setNotationDurationWhole)) - } - - func testAppHotkeyMappingsDoNotContainDuplicateKeyLabels() { - let keys = AppHotkey.allCases.map(\.key) - XCTAssertEqual(keys.count, Set(keys).count) - } - - func testAppHotkeyRecognizesCmdKButNotOldHarmonyKeys() throws { - let aEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "a", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - )) - let hEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "h", - charactersIgnoringModifiers: "h", - isARepeat: false, - keyCode: 4 - )) - let commandAEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "a", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - )) - let commandKEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "k", - charactersIgnoringModifiers: "k", - isARepeat: false, - keyCode: 40 - )) - let shiftAEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.shift], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "A", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - )) - - XCTAssertNil(AppHotkey(event: aEvent)) - XCTAssertNil(AppHotkey(event: hEvent)) - XCTAssertNil(AppHotkey(event: commandAEvent)) - XCTAssertEqual(AppHotkey(event: commandKEvent), .editHarmonyAtSelectedNotationItem) - XCTAssertNil(AppHotkey(event: shiftAEvent)) - } - - func testAppHotkeyRecognizesOptionVForVideoWindowToggle() throws { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.option], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "v", - charactersIgnoringModifiers: "v", - isARepeat: false, - keyCode: 9 - )) - - XCTAssertEqual(AppHotkey(event: event), .toggleVideoWindow) - XCTAssertEqual(AppHotkey.toggleVideoWindow.key, "Opt+V") - XCTAssertEqual(AppHotkey.toggleVideoWindow.title, "Video Window") - XCTAssertEqual( - AppHotkey.toggleVideoWindow.detail, - "Open or close the sidecar video window for the current video project." - ) - } - - func testAppHotkeyRecognizesShiftCForTempoTimeSignatureMarker() throws { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.shift], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "C", - charactersIgnoringModifiers: "c", - isARepeat: false, - keyCode: 8 - )) - - XCTAssertEqual(AppHotkey(event: event), .addTempoTimeSignatureMarker) - XCTAssertEqual(AppHotkey.addTempoTimeSignatureMarker.key, "Shift+C") - XCTAssertEqual(AppHotkey.addTempoTimeSignatureMarker.title, "Add Tempo / Time Signature Marker") - } - - func testAppHotkeyRecognizesCommandCAndVForNotationMeasureCopyPaste() throws { - let copyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "c", - charactersIgnoringModifiers: "c", - isARepeat: false, - keyCode: 8 - )) - let pasteEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "v", - charactersIgnoringModifiers: "v", - isARepeat: false, - keyCode: 9 - )) - - XCTAssertEqual(AppHotkey(event: copyEvent), .copyMeasure) - XCTAssertEqual(AppHotkey.copyMeasure.key, "Cmd+C") - XCTAssertEqual(AppHotkey.copyMeasure.title, "Copy Measure") - XCTAssertEqual(AppHotkey(event: pasteEvent), .pasteMeasure) - XCTAssertEqual(AppHotkey.pasteMeasure.key, "Cmd+V") - XCTAssertEqual(AppHotkey.pasteMeasure.title, "Paste Measure") - } - - func testAppHotkeyRecognizesEscapeForNotationMeasureSelectionClear() throws { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "\u{1b}", - charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, - keyCode: 53 - )) - - XCTAssertEqual(AppHotkey(event: event), .clearNotationMeasureSelection) - XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.key, "Esc") - XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.title, "Clear Measure Selection") - } - - func testAppHotkeyEventFilterDoesNotStealMeasureCopyPasteFromTextRespondersOrUnavailableScopes() throws { - let copyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "c", - charactersIgnoringModifiers: "c", - isARepeat: false, - keyCode: 8 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.copyMeasure] - ), - .copyMeasure - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.copyMeasure] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.copyMeasure] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealEscapeFromTextRespondersOrUnavailableScopes() throws { - let escapeEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "\u{1b}", - charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, - keyCode: 53 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.clearNotationMeasureSelection] - ), - .clearNotationMeasureSelection - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.clearNotationMeasureSelection] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.clearNotationMeasureSelection] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealEditHarmonyFromTextRespondersOrUnavailableScopes() throws { - let editHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "k", - charactersIgnoringModifiers: "k", - isARepeat: false, - keyCode: 40 - )) - let repeatEditHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "k", - charactersIgnoringModifiers: "k", - isARepeat: true, - keyCode: 40 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ), - .editHarmonyAtSelectedNotationItem - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatEditHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { - let durationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: false, - keyCode: 21 - )) - let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: true, - keyCode: 21 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: AppHotkey.notationDurationHotkeys - ), - .setNotationDurationEighth - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatDurationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - } - } diff --git a/JammLabTests/TimelineViewportLogicTests.swift b/JammLabTests/TimelineViewportLogicTests.swift new file mode 100644 index 0000000..9b24ae2 --- /dev/null +++ b/JammLabTests/TimelineViewportLogicTests.swift @@ -0,0 +1,129 @@ +import XCTest +@testable import JammLab + +final class TimelineViewportLogicTests: XCTestCase { + func testTimelineViewportMapsAndBoundsVisibleRange() { + let viewport = TimelineViewport(duration: 100, visibleRange: 20...60) + + XCTAssertEqual(viewport.clampedRange.lowerBound, 20) + XCTAssertEqual(viewport.clampedRange.upperBound, 60) + XCTAssertEqual(viewport.xPosition(for: 40, width: 200), 100, accuracy: 0.0001) + XCTAssertEqual(viewport.time(forX: 50, width: 200), 30, accuracy: 0.0001) + XCTAssertEqual(viewport.intersection(start: 10, end: 30)?.lowerBound, 20) + XCTAssertEqual(viewport.intersection(start: 10, end: 30)?.upperBound, 30) + } + + func testTimelineViewportZoomAndPanStayInBounds() { + let viewport = TimelineViewport(duration: 100, visibleRange: 20...60) + + let zoomed = viewport.zoomed(to: 20, anchoredAt: 30) + XCTAssertEqual(zoomed.visibleDuration, 20, accuracy: 0.0001) + XCTAssertGreaterThanOrEqual(zoomed.clampedRange.lowerBound, 0) + XCTAssertLessThanOrEqual(zoomed.clampedRange.upperBound, 100) + + let pannedToStart = viewport.panned(by: -1000) + XCTAssertEqual(pannedToStart.clampedRange.lowerBound, 0, accuracy: 0.0001) + + let pannedToEnd = viewport.panned(by: 1000) + XCTAssertEqual(pannedToEnd.clampedRange.upperBound, 100, accuracy: 0.0001) + } + + func testTimelineViewportPositionsTimeNearLeadingEdgePreservingZoom() { + let viewport = TimelineViewport(duration: 100, visibleRange: 20...40) + + let followed = viewport.positionedWithTimeNearLeadingEdge(30) + + XCTAssertEqual(followed.visibleDuration, 20, accuracy: 0.0001) + XCTAssertEqual(followed.clampedRange.lowerBound, 28.4, accuracy: 0.0001) + XCTAssertEqual(followed.clampedRange.upperBound, 48.4, accuracy: 0.0001) + XCTAssertEqual(followed.xPosition(for: 30, width: 100), 8, accuracy: 0.0001) + } + + func testTimelineViewportFollowPositionClampsAtTrackEdges() { + let viewport = TimelineViewport(duration: 100, visibleRange: 20...40) + + let start = viewport.positionedWithTimeNearLeadingEdge(1) + let end = viewport.positionedWithTimeNearLeadingEdge(98) + + XCTAssertEqual(start.clampedRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(start.clampedRange.upperBound, 20, accuracy: 0.0001) + XCTAssertEqual(end.clampedRange.lowerBound, 80, accuracy: 0.0001) + XCTAssertEqual(end.clampedRange.upperBound, 100, accuracy: 0.0001) + } + + func testTimelineViewportFollowIsNoOpForFullRange() { + let viewport = TimelineViewport(duration: 100, visibleRange: 0...100) + + let followed = viewport.positionedWithTimeNearLeadingEdge(90) + + XCTAssertEqual(followed.clampedRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(followed.clampedRange.upperBound, 100, accuracy: 0.0001) + XCTAssertFalse(viewport.shouldFollowPlaybackTime(90)) + } + + func testTimelineViewportShouldFollowNearRightEdgeOnlyWhenZoomed() { + let viewport = TimelineViewport(duration: 100, visibleRange: 0...20) + + XCTAssertFalse(viewport.shouldFollowPlaybackTime(18.39)) + XCTAssertTrue(viewport.shouldFollowPlaybackTime(18.4)) + XCTAssertTrue(viewport.shouldFollowPlaybackTime(21)) + XCTAssertTrue(viewport.shouldFollowPlaybackTime(-1)) + } + + func testTimelineViewportScrollerMetricsMapsVisibleRangeToThumb() { + let metrics = TimelineViewportScrollerMetrics( + duration: 100, + visibleRange: 25...75, + trackWidth: 200, + minimumThumbWidth: 24 + ) + + XCTAssertEqual(metrics.thumbWidth, 100, accuracy: 0.0001) + XCTAssertEqual(metrics.thumbX, 50, accuracy: 0.0001) + } + + func testTimelineViewportScrollerDragPreservesVisibleDuration() { + let metrics = TimelineViewportScrollerMetrics( + duration: 100, + visibleRange: 20...60, + trackWidth: 200, + minimumThumbWidth: 24 + ) + + let range = metrics.range(draggedBy: 50) + + XCTAssertEqual(range.upperBound - range.lowerBound, 40, accuracy: 0.0001) + XCTAssertGreaterThan(range.lowerBound, 20) + } + + func testTimelineViewportScrollerDragClampsAtTrackEdges() { + let metrics = TimelineViewportScrollerMetrics( + duration: 100, + visibleRange: 20...60, + trackWidth: 200, + minimumThumbWidth: 24 + ) + + let startRange = metrics.range(draggedBy: -1_000) + let endRange = metrics.range(draggedBy: 1_000) + + XCTAssertEqual(startRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(startRange.upperBound, 40, accuracy: 0.0001) + XCTAssertEqual(endRange.lowerBound, 60, accuracy: 0.0001) + XCTAssertEqual(endRange.upperBound, 100, accuracy: 0.0001) + } + + func testTimelineViewportScrollerHandlesZeroDuration() { + let metrics = TimelineViewportScrollerMetrics( + duration: 0, + visibleRange: 0...0, + trackWidth: 200, + minimumThumbWidth: 24 + ) + + XCTAssertEqual(metrics.thumbWidth, 200, accuracy: 0.0001) + XCTAssertEqual(metrics.thumbX, 0, accuracy: 0.0001) + XCTAssertEqual(metrics.range(draggedBy: 50).lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(metrics.range(draggedBy: 50).upperBound, 0, accuracy: 0.0001) + } +} From 3dd3105ace885489d8e70f0502f07d1414240743 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 07:47:07 +0300 Subject: [PATCH 44/80] test: split project artifact store tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ProjectArtifactStoreTests.swift | 357 +++++++++++++++++++ JammLabTests/StemWorkflowLogicTests.swift | 353 ------------------ 3 files changed, 361 insertions(+), 353 deletions(-) create mode 100644 JammLabTests/ProjectArtifactStoreTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 5116fe8..f2b27e1 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; + 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; @@ -341,6 +342,7 @@ 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSaveCloseTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; + 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStoreTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; @@ -644,6 +646,7 @@ 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, + 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, @@ -1068,6 +1071,7 @@ 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, + 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, diff --git a/JammLabTests/ProjectArtifactStoreTests.swift b/JammLabTests/ProjectArtifactStoreTests.swift new file mode 100644 index 0000000..b3cb600 --- /dev/null +++ b/JammLabTests/ProjectArtifactStoreTests.swift @@ -0,0 +1,357 @@ +import XCTest +@testable import JammLab + +final class ProjectArtifactStoreTests: XCTestCase { + func testProjectArtifactStoreRoundTripsStemMetadataAndFiles() throws { + let directory = temporaryDirectory() + let sourceDirectory = directory.appendingPathComponent("source", isDirectory: true) + try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let sourceFingerprint = StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 42, modificationTime: 123) + let metadata = StemCacheMetadata( + cacheKey: "cache-key", + sourceFingerprint: sourceFingerprint, + backendIdentifier: "JammLabSeparatorHelper/test", + separationMethodID: StemSeparationMethod.fourStem.id, + modelName: StemSeparationMethod.fourStem.modelName, + settingsVersion: 2, + createdAt: Date(timeIntervalSince1970: 100), + stems: try StemSeparationMethod.fourStem.stemTypes.map { type in + let url = try temporaryFile(in: sourceDirectory, name: "\(type.rawValue)-source.wav", contents: type.rawValue) + return StemFile(type: type, url: url, displayName: type.title) + } + ) + let projectURL = directory.appendingPathComponent("Song.jammlab") + let store = ProjectArtifactStore() + + let localMetadata = try store.writeStemMetadata(metadata, projectURL: projectURL) + let restored = try XCTUnwrap(store.readStemMetadata( + projectURL: projectURL, + expectedFingerprint: sourceFingerprint + )) + + XCTAssertEqual(localMetadata.cacheKey, metadata.cacheKey) + XCTAssertEqual(restored.cacheKey, metadata.cacheKey) + XCTAssertEqual(restored.sourceFingerprint, sourceFingerprint) + XCTAssertEqual(restored.separationMethodID, StemSeparationMethod.fourStem.id) + XCTAssertEqual(Set(restored.stems.map(\.type)), Set(StemSeparationMethod.fourStem.stemTypes)) + for stem in restored.stems { + XCTAssertEqual(stem.url.deletingLastPathComponent(), store.stemsDirectory(for: projectURL)) + XCTAssertEqual(stem.url.lastPathComponent, stem.type.canonicalStemFilename) + XCTAssertEqual(try String(contentsOf: stem.url, encoding: .utf8), stem.type.rawValue) + } + } + + func testProjectArtifactStoreRoundTripsVocalInstrumentalStemMetadataAndFiles() throws { + let directory = temporaryDirectory() + let sourceDirectory = directory.appendingPathComponent("source", isDirectory: true) + try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let sourceFingerprint = StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 42, modificationTime: 123) + let metadata = StemCacheMetadata( + cacheKey: "cache-key-2", + sourceFingerprint: sourceFingerprint, + backendIdentifier: "JammLabSeparatorHelper/test", + separationMethodID: StemSeparationMethod.vocalInstrumental.id, + modelName: StemSeparationMethod.vocalInstrumental.modelName, + settingsVersion: 2, + createdAt: Date(timeIntervalSince1970: 100), + stems: try StemSeparationMethod.vocalInstrumental.stemTypes.map { type in + let url = try temporaryFile(in: sourceDirectory, name: "\(type.rawValue)-source.wav", contents: type.rawValue) + return StemFile(type: type, url: url, displayName: type.title) + } + ) + let projectURL = directory.appendingPathComponent("Song.jammlab") + let store = ProjectArtifactStore() + + _ = try store.writeStemMetadata(metadata, projectURL: projectURL) + let restored = try XCTUnwrap(store.readStemMetadata( + projectURL: projectURL, + expectedFingerprint: sourceFingerprint + )) + + XCTAssertEqual(restored.separationMethodID, StemSeparationMethod.vocalInstrumental.id) + XCTAssertEqual(restored.stems.map(\.type), [.vocals, .instrumental]) + XCTAssertEqual(restored.stems.map(\.displayName), ["Vocals", "Instrumental"]) + } + + func testProjectArtifactStoreRoundTripsSixStemMetadataAndFiles() throws { + let directory = temporaryDirectory() + let sourceDirectory = directory.appendingPathComponent("source", isDirectory: true) + try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let sourceFingerprint = StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 42, modificationTime: 123) + let metadata = StemCacheMetadata( + cacheKey: "cache-key-6", + sourceFingerprint: sourceFingerprint, + backendIdentifier: "JammLabSeparatorHelper/test", + separationMethodID: StemSeparationMethod.sixStem.id, + modelName: StemSeparationMethod.sixStem.modelName, + settingsVersion: 2, + createdAt: Date(timeIntervalSince1970: 100), + stems: try StemSeparationMethod.sixStem.stemTypes.map { type in + let url = try temporaryFile(in: sourceDirectory, name: "\(type.rawValue)-source.wav", contents: type.rawValue) + return StemFile(type: type, url: url, displayName: type.title) + } + ) + let projectURL = directory.appendingPathComponent("Song.jammlab") + let store = ProjectArtifactStore() + + _ = try store.writeStemMetadata(metadata, projectURL: projectURL) + let restored = try XCTUnwrap(store.readStemMetadata( + projectURL: projectURL, + expectedFingerprint: sourceFingerprint + )) + + XCTAssertEqual(restored.separationMethodID, StemSeparationMethod.sixStem.id) + XCTAssertEqual(restored.modelName, StemSeparationMethod.sixStem.modelName) + XCTAssertEqual(restored.stems.map(\.type), StemSeparationMethod.sixStem.stemTypes) + XCTAssertEqual(restored.stems.map(\.displayName), StemSeparationMethod.sixStem.stemTypes.map(\.title)) + for stem in restored.stems { + XCTAssertEqual(stem.url.lastPathComponent, stem.type.canonicalStemFilename) + XCTAssertEqual(try String(contentsOf: stem.url, encoding: .utf8), stem.type.rawValue) + } + } + + func testProjectArtifactStorePersistsVideoAudioBesideProject() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let extractedAudioURL = try temporaryFile(in: directory, name: "cached-audio.m4a", contents: "audio") + let videoURL = directory.appendingPathComponent("lesson.mp4") + let file = ImportedAudioFile( + url: extractedAudioURL, + sourceMediaURL: videoURL, + displayName: "lesson.mp4", + duration: 12, + mediaKind: .video + ) + let projectURL = directory.appendingPathComponent("Song.jammlab") + let store = ProjectArtifactStore() + + let persisted = try store.persistVideoAudioIfNeeded(file, projectURL: projectURL) + + XCTAssertEqual(persisted.url, store.videoAudioURL(for: projectURL)) + XCTAssertEqual(persisted.sourceMediaURL, videoURL) + XCTAssertEqual(persisted.mediaKind, .video) + XCTAssertEqual(try String(contentsOf: persisted.url, encoding: .utf8), "audio") + XCTAssertEqual(store.existingVideoAudioURL(for: projectURL), persisted.url) + } + + func testProjectPersistenceCoordinatorPersistsVideoAudioAndReturnsTemporaryCleanupURL() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let cachedAudioURL = try temporaryFile(in: directory, name: "cached-audio.m4a", contents: "audio") + let videoURL = directory.appendingPathComponent("lesson.mov") + let projectURL = directory.appendingPathComponent("Song.jammlab") + let store = ProjectArtifactStore() + let coordinator = try makeProjectPersistenceCoordinator(projectArtifactStore: store) + let input = ProjectSaveArtifactsInput( + importedFile: ImportedAudioFile( + url: cachedAudioURL, + sourceMediaURL: videoURL, + displayName: "lesson.mov", + duration: 12, + mediaKind: .video + ), + projectURL: projectURL, + peakformData: nil, + stemPeakforms: [:], + stemFiles: [], + stemCacheMetadata: nil + ) + + let result = try await coordinator.prepareSaveArtifacts(input) + + XCTAssertEqual(result.importedFile?.url, store.videoAudioURL(for: projectURL)) + XCTAssertEqual(result.temporaryVideoAudioURLToRemove, cachedAudioURL) + XCTAssertEqual(try String(contentsOf: store.videoAudioURL(for: projectURL), encoding: .utf8), "audio") + } + + func testProjectPersistenceCoordinatorWritesPeakformsAndStemMetadata() async throws { + let directory = temporaryDirectory() + let stemSourceDirectory = directory.appendingPathComponent("stem-source", isDirectory: true) + try FileManager.default.createDirectory(at: stemSourceDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let audioURL = try temporaryFile(in: directory, name: "song.wav", contents: "audio") + let projectURL = directory.appendingPathComponent("Song.jammlab") + let store = ProjectArtifactStore() + let coordinator = try makeProjectPersistenceCoordinator(projectArtifactStore: store) + let peakform = PeakformData( + duration: 1, + sampleRate: 44_100, + levels: [PeakformLevel(samplesPerPeak: 512, peaks: [PeakPoint(min: -0.5, max: 0.5, rms: 0.2)])] + ) + let stems = try StemSeparationMethod.fourStem.stemTypes.map { type in + StemFile( + type: type, + url: try temporaryFile(in: stemSourceDirectory, name: "\(type.rawValue).wav", contents: type.rawValue), + displayName: type.title + ) + } + let metadata = StemCacheMetadata( + cacheKey: "cache-key", + sourceFingerprint: StemSourceFingerprint(path: audioURL.path, fileSize: 5, modificationTime: 10), + backendIdentifier: "JammLabSeparatorHelper/test", + separationMethodID: StemSeparationMethod.fourStem.id, + modelName: StemSeparationMethod.fourStem.modelName, + settingsVersion: 2, + createdAt: Date(timeIntervalSince1970: 100), + stems: stems + ) + let input = ProjectSaveArtifactsInput( + importedFile: ImportedAudioFile(url: audioURL, displayName: "song.wav", duration: 1), + projectURL: projectURL, + peakformData: peakform, + stemPeakforms: [.vocals: peakform], + stemFiles: stems, + stemCacheMetadata: metadata + ) + + let result = try await coordinator.prepareSaveArtifacts(input) + + XCTAssertNotNil(try store.readMainPeakform(projectURL: projectURL)) + XCTAssertNotNil(try store.readStemPeakform(type: .vocals, projectURL: projectURL)) + XCTAssertEqual(result.peakformURLsToRemove, [audioURL] + stems.map(\.url)) + XCTAssertEqual(result.stemMetadata?.cacheKey, metadata.cacheKey) + XCTAssertEqual(result.stemCacheKeyToRemove, metadata.cacheKey) + XCTAssertEqual(result.stemMetadata?.stems.map { $0.url.deletingLastPathComponent() }, Array(repeating: store.stemsDirectory(for: projectURL), count: StemSeparationMethod.fourStem.stemTypes.count)) + } + + func testProjectPersistenceCoordinatorOpenMediaPrefersProjectLocalVideoAudio() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let projectService = ProjectDocumentService() + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") + try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) + let localAudioURL = store.videoAudioURL(for: projectURL) + try Data("local-audio".utf8).write(to: localAudioURL) + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + decodedDuration: { url in + XCTAssertEqual(url, localAudioURL) + return 9 + } + ) + let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, localAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, videoURL) + XCTAssertEqual(result.file.mediaKind, .video) + XCTAssertEqual(result.projectDuration, 9) + XCTAssertFalse(result.shouldAnalyzeTempo) + XCTAssertNil(result.warningMessage) + } + + func testProjectPersistenceCoordinatorOpenVideoWithoutLocalAudioUsesRuntimeCacheOnly() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let projectService = ProjectDocumentService() + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") + let extractedAudioURL = try temporaryFile(in: directory, name: "runtime-audio.m4a", contents: "audio") + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + importFileFromURL: { url in + XCTAssertEqual(url, videoURL) + return ImportedAudioFile( + url: extractedAudioURL, + sourceMediaURL: url, + displayName: url.lastPathComponent, + duration: 7, + mediaKind: .video + ) + } + ) + let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, extractedAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, videoURL) + XCTAssertEqual(result.file.mediaKind, .video) + XCTAssertEqual(result.projectDuration, 7) + XCTAssertFalse(FileManager.default.fileExists(atPath: store.mediaDirectory(for: projectURL).path)) + } + + func testProjectPersistenceCoordinatorMissingVideoSourceFallsBackToLocalAudioWithWarning() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) + let localAudioURL = store.videoAudioURL(for: projectURL) + try Data("local-audio".utf8).write(to: localAudioURL) + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + decodedDuration: { url in + XCTAssertEqual(url, localAudioURL) + return 6 + } + ) + let project = videoProject(bookmarkData: Data("invalid-bookmark".utf8), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, localAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, localAudioURL) + XCTAssertEqual(result.file.mediaKind, .audio) + XCTAssertNil(result.file.videoURL) + XCTAssertEqual(result.projectDuration, 6) + XCTAssertNotNil(result.warningMessage) + } +} + +private extension ProjectArtifactStoreTests { + func makeProjectPersistenceCoordinator( + projectArtifactStore: ProjectArtifactStore, + importFileFromURL: ((URL) async throws -> ImportedAudioFile)? = nil, + decodedDuration: @escaping (URL) throws -> TimeInterval = { _ in 1 } + ) throws -> ProjectPersistenceCoordinator { + ProjectPersistenceCoordinator( + projectArtifactStore: projectArtifactStore, + projectDocumentService: ProjectDocumentService(), + peakformProvider: MockPeakformProvider(), + stemSeparationService: StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + applicationSupportDirectory: temporaryDirectory() + ), + importFileFromURL: importFileFromURL, + decodedDuration: decodedDuration + ) + } + + func videoProject(bookmarkData: Data, duration: TimeInterval) -> JammLabProject { + JammLabProject( + audioBookmarkData: bookmarkData, + audioDisplayName: "lesson.mov", + audioDuration: duration, + mediaKind: .video, + notes: [], + loopStart: 0, + loopEnd: duration, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + } +} diff --git a/JammLabTests/StemWorkflowLogicTests.swift b/JammLabTests/StemWorkflowLogicTests.swift index 0e3b0e9..07ef691 100644 --- a/JammLabTests/StemWorkflowLogicTests.swift +++ b/JammLabTests/StemWorkflowLogicTests.swift @@ -561,357 +561,4 @@ final class StemWorkflowLogicTests: XCTestCase { XCTAssertFalse(service.isInputPermissionFailure(error, originalAudioPath: "/other/song.mp3")) } - func testProjectArtifactStoreRoundTripsStemMetadataAndFiles() throws { - let directory = temporaryDirectory() - let sourceDirectory = directory.appendingPathComponent("source", isDirectory: true) - try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let sourceFingerprint = StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 42, modificationTime: 123) - let metadata = StemCacheMetadata( - cacheKey: "cache-key", - sourceFingerprint: sourceFingerprint, - backendIdentifier: "JammLabSeparatorHelper/test", - separationMethodID: StemSeparationMethod.fourStem.id, - modelName: StemSeparationMethod.fourStem.modelName, - settingsVersion: 2, - createdAt: Date(timeIntervalSince1970: 100), - stems: try StemSeparationMethod.fourStem.stemTypes.map { type in - let url = try temporaryFile(in: sourceDirectory, name: "\(type.rawValue)-source.wav", contents: type.rawValue) - return StemFile(type: type, url: url, displayName: type.title) - } - ) - let projectURL = directory.appendingPathComponent("Song.jammlab") - let store = ProjectArtifactStore() - - let localMetadata = try store.writeStemMetadata(metadata, projectURL: projectURL) - let restored = try XCTUnwrap(store.readStemMetadata( - projectURL: projectURL, - expectedFingerprint: sourceFingerprint - )) - - XCTAssertEqual(localMetadata.cacheKey, metadata.cacheKey) - XCTAssertEqual(restored.cacheKey, metadata.cacheKey) - XCTAssertEqual(restored.sourceFingerprint, sourceFingerprint) - XCTAssertEqual(restored.separationMethodID, StemSeparationMethod.fourStem.id) - XCTAssertEqual(Set(restored.stems.map(\.type)), Set(StemSeparationMethod.fourStem.stemTypes)) - for stem in restored.stems { - XCTAssertEqual(stem.url.deletingLastPathComponent(), store.stemsDirectory(for: projectURL)) - XCTAssertEqual(stem.url.lastPathComponent, stem.type.canonicalStemFilename) - XCTAssertEqual(try String(contentsOf: stem.url, encoding: .utf8), stem.type.rawValue) - } - } - - func testProjectArtifactStoreRoundTripsVocalInstrumentalStemMetadataAndFiles() throws { - let directory = temporaryDirectory() - let sourceDirectory = directory.appendingPathComponent("source", isDirectory: true) - try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let sourceFingerprint = StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 42, modificationTime: 123) - let metadata = StemCacheMetadata( - cacheKey: "cache-key-2", - sourceFingerprint: sourceFingerprint, - backendIdentifier: "JammLabSeparatorHelper/test", - separationMethodID: StemSeparationMethod.vocalInstrumental.id, - modelName: StemSeparationMethod.vocalInstrumental.modelName, - settingsVersion: 2, - createdAt: Date(timeIntervalSince1970: 100), - stems: try StemSeparationMethod.vocalInstrumental.stemTypes.map { type in - let url = try temporaryFile(in: sourceDirectory, name: "\(type.rawValue)-source.wav", contents: type.rawValue) - return StemFile(type: type, url: url, displayName: type.title) - } - ) - let projectURL = directory.appendingPathComponent("Song.jammlab") - let store = ProjectArtifactStore() - - _ = try store.writeStemMetadata(metadata, projectURL: projectURL) - let restored = try XCTUnwrap(store.readStemMetadata( - projectURL: projectURL, - expectedFingerprint: sourceFingerprint - )) - - XCTAssertEqual(restored.separationMethodID, StemSeparationMethod.vocalInstrumental.id) - XCTAssertEqual(restored.stems.map(\.type), [.vocals, .instrumental]) - XCTAssertEqual(restored.stems.map(\.displayName), ["Vocals", "Instrumental"]) - } - - func testProjectArtifactStoreRoundTripsSixStemMetadataAndFiles() throws { - let directory = temporaryDirectory() - let sourceDirectory = directory.appendingPathComponent("source", isDirectory: true) - try FileManager.default.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let sourceFingerprint = StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 42, modificationTime: 123) - let metadata = StemCacheMetadata( - cacheKey: "cache-key-6", - sourceFingerprint: sourceFingerprint, - backendIdentifier: "JammLabSeparatorHelper/test", - separationMethodID: StemSeparationMethod.sixStem.id, - modelName: StemSeparationMethod.sixStem.modelName, - settingsVersion: 2, - createdAt: Date(timeIntervalSince1970: 100), - stems: try StemSeparationMethod.sixStem.stemTypes.map { type in - let url = try temporaryFile(in: sourceDirectory, name: "\(type.rawValue)-source.wav", contents: type.rawValue) - return StemFile(type: type, url: url, displayName: type.title) - } - ) - let projectURL = directory.appendingPathComponent("Song.jammlab") - let store = ProjectArtifactStore() - - _ = try store.writeStemMetadata(metadata, projectURL: projectURL) - let restored = try XCTUnwrap(store.readStemMetadata( - projectURL: projectURL, - expectedFingerprint: sourceFingerprint - )) - - XCTAssertEqual(restored.separationMethodID, StemSeparationMethod.sixStem.id) - XCTAssertEqual(restored.modelName, StemSeparationMethod.sixStem.modelName) - XCTAssertEqual(restored.stems.map(\.type), StemSeparationMethod.sixStem.stemTypes) - XCTAssertEqual(restored.stems.map(\.displayName), StemSeparationMethod.sixStem.stemTypes.map(\.title)) - for stem in restored.stems { - XCTAssertEqual(stem.url.lastPathComponent, stem.type.canonicalStemFilename) - XCTAssertEqual(try String(contentsOf: stem.url, encoding: .utf8), stem.type.rawValue) - } - } - - func testProjectArtifactStorePersistsVideoAudioBesideProject() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let extractedAudioURL = try temporaryFile(in: directory, name: "cached-audio.m4a", contents: "audio") - let videoURL = directory.appendingPathComponent("lesson.mp4") - let file = ImportedAudioFile( - url: extractedAudioURL, - sourceMediaURL: videoURL, - displayName: "lesson.mp4", - duration: 12, - mediaKind: .video - ) - let projectURL = directory.appendingPathComponent("Song.jammlab") - let store = ProjectArtifactStore() - - let persisted = try store.persistVideoAudioIfNeeded(file, projectURL: projectURL) - - XCTAssertEqual(persisted.url, store.videoAudioURL(for: projectURL)) - XCTAssertEqual(persisted.sourceMediaURL, videoURL) - XCTAssertEqual(persisted.mediaKind, .video) - XCTAssertEqual(try String(contentsOf: persisted.url, encoding: .utf8), "audio") - XCTAssertEqual(store.existingVideoAudioURL(for: projectURL), persisted.url) - } - - func testProjectPersistenceCoordinatorPersistsVideoAudioAndReturnsTemporaryCleanupURL() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let cachedAudioURL = try temporaryFile(in: directory, name: "cached-audio.m4a", contents: "audio") - let videoURL = directory.appendingPathComponent("lesson.mov") - let projectURL = directory.appendingPathComponent("Song.jammlab") - let store = ProjectArtifactStore() - let coordinator = try makeProjectPersistenceCoordinator(projectArtifactStore: store) - let input = ProjectSaveArtifactsInput( - importedFile: ImportedAudioFile( - url: cachedAudioURL, - sourceMediaURL: videoURL, - displayName: "lesson.mov", - duration: 12, - mediaKind: .video - ), - projectURL: projectURL, - peakformData: nil, - stemPeakforms: [:], - stemFiles: [], - stemCacheMetadata: nil - ) - - let result = try await coordinator.prepareSaveArtifacts(input) - - XCTAssertEqual(result.importedFile?.url, store.videoAudioURL(for: projectURL)) - XCTAssertEqual(result.temporaryVideoAudioURLToRemove, cachedAudioURL) - XCTAssertEqual(try String(contentsOf: store.videoAudioURL(for: projectURL), encoding: .utf8), "audio") - } - - func testProjectPersistenceCoordinatorWritesPeakformsAndStemMetadata() async throws { - let directory = temporaryDirectory() - let stemSourceDirectory = directory.appendingPathComponent("stem-source", isDirectory: true) - try FileManager.default.createDirectory(at: stemSourceDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let audioURL = try temporaryFile(in: directory, name: "song.wav", contents: "audio") - let projectURL = directory.appendingPathComponent("Song.jammlab") - let store = ProjectArtifactStore() - let coordinator = try makeProjectPersistenceCoordinator(projectArtifactStore: store) - let peakform = PeakformData( - duration: 1, - sampleRate: 44_100, - levels: [PeakformLevel(samplesPerPeak: 512, peaks: [PeakPoint(min: -0.5, max: 0.5, rms: 0.2)])] - ) - let stems = try StemSeparationMethod.fourStem.stemTypes.map { type in - StemFile( - type: type, - url: try temporaryFile(in: stemSourceDirectory, name: "\(type.rawValue).wav", contents: type.rawValue), - displayName: type.title - ) - } - let metadata = StemCacheMetadata( - cacheKey: "cache-key", - sourceFingerprint: StemSourceFingerprint(path: audioURL.path, fileSize: 5, modificationTime: 10), - backendIdentifier: "JammLabSeparatorHelper/test", - separationMethodID: StemSeparationMethod.fourStem.id, - modelName: StemSeparationMethod.fourStem.modelName, - settingsVersion: 2, - createdAt: Date(timeIntervalSince1970: 100), - stems: stems - ) - let input = ProjectSaveArtifactsInput( - importedFile: ImportedAudioFile(url: audioURL, displayName: "song.wav", duration: 1), - projectURL: projectURL, - peakformData: peakform, - stemPeakforms: [.vocals: peakform], - stemFiles: stems, - stemCacheMetadata: metadata - ) - - let result = try await coordinator.prepareSaveArtifacts(input) - - XCTAssertNotNil(try store.readMainPeakform(projectURL: projectURL)) - XCTAssertNotNil(try store.readStemPeakform(type: .vocals, projectURL: projectURL)) - XCTAssertEqual(result.peakformURLsToRemove, [audioURL] + stems.map(\.url)) - XCTAssertEqual(result.stemMetadata?.cacheKey, metadata.cacheKey) - XCTAssertEqual(result.stemCacheKeyToRemove, metadata.cacheKey) - XCTAssertEqual(result.stemMetadata?.stems.map { $0.url.deletingLastPathComponent() }, Array(repeating: store.stemsDirectory(for: projectURL), count: StemSeparationMethod.fourStem.stemTypes.count)) - } - - func testProjectPersistenceCoordinatorOpenMediaPrefersProjectLocalVideoAudio() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let projectService = ProjectDocumentService() - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") - try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) - let localAudioURL = store.videoAudioURL(for: projectURL) - try Data("local-audio".utf8).write(to: localAudioURL) - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - decodedDuration: { url in - XCTAssertEqual(url, localAudioURL) - return 9 - } - ) - let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, localAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, videoURL) - XCTAssertEqual(result.file.mediaKind, .video) - XCTAssertEqual(result.projectDuration, 9) - XCTAssertFalse(result.shouldAnalyzeTempo) - XCTAssertNil(result.warningMessage) - } - - func testProjectPersistenceCoordinatorOpenVideoWithoutLocalAudioUsesRuntimeCacheOnly() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let projectService = ProjectDocumentService() - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") - let extractedAudioURL = try temporaryFile(in: directory, name: "runtime-audio.m4a", contents: "audio") - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - importFileFromURL: { url in - XCTAssertEqual(url, videoURL) - return ImportedAudioFile( - url: extractedAudioURL, - sourceMediaURL: url, - displayName: url.lastPathComponent, - duration: 7, - mediaKind: .video - ) - } - ) - let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, extractedAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, videoURL) - XCTAssertEqual(result.file.mediaKind, .video) - XCTAssertEqual(result.projectDuration, 7) - XCTAssertFalse(FileManager.default.fileExists(atPath: store.mediaDirectory(for: projectURL).path)) - } - - func testProjectPersistenceCoordinatorMissingVideoSourceFallsBackToLocalAudioWithWarning() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) - let localAudioURL = store.videoAudioURL(for: projectURL) - try Data("local-audio".utf8).write(to: localAudioURL) - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - decodedDuration: { url in - XCTAssertEqual(url, localAudioURL) - return 6 - } - ) - let project = videoProject(bookmarkData: Data("invalid-bookmark".utf8), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, localAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, localAudioURL) - XCTAssertEqual(result.file.mediaKind, .audio) - XCTAssertNil(result.file.videoURL) - XCTAssertEqual(result.projectDuration, 6) - XCTAssertNotNil(result.warningMessage) - } - -} - -private extension StemWorkflowLogicTests { - func makeProjectPersistenceCoordinator( - projectArtifactStore: ProjectArtifactStore, - importFileFromURL: ((URL) async throws -> ImportedAudioFile)? = nil, - decodedDuration: @escaping (URL) throws -> TimeInterval = { _ in 1 } - ) throws -> ProjectPersistenceCoordinator { - ProjectPersistenceCoordinator( - projectArtifactStore: projectArtifactStore, - projectDocumentService: ProjectDocumentService(), - peakformProvider: MockPeakformProvider(), - stemSeparationService: StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - applicationSupportDirectory: temporaryDirectory() - ), - importFileFromURL: importFileFromURL, - decodedDuration: decodedDuration - ) - } - - func videoProject(bookmarkData: Data, duration: TimeInterval) -> JammLabProject { - JammLabProject( - audioBookmarkData: bookmarkData, - audioDisplayName: "lesson.mov", - audioDuration: duration, - mediaKind: .video, - notes: [], - loopStart: 0, - loopEnd: duration, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - } } From 710ae25c8ce90cd6a33ca1193ad4a476a10bd27a Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 08:01:48 +0300 Subject: [PATCH 45/80] test: split large hotkey and tuner test files --- JammLab.xcodeproj/project.pbxproj | 8 + JammLabTests/AppHotkeyEventFilterTests.swift | 339 ++++++++++++++++++ JammLabTests/AppHotkeyTests.swift | 334 ----------------- .../TunerInputServiceNoteHoldTests.swift | 136 +++++++ JammLabTests/TunerInputServiceTests.swift | 131 ------- 5 files changed, 483 insertions(+), 465 deletions(-) create mode 100644 JammLabTests/AppHotkeyEventFilterTests.swift create mode 100644 JammLabTests/TunerInputServiceNoteHoldTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index f2b27e1..6335055 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */; }; 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */; }; 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */; }; + 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */; }; 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; @@ -126,6 +127,7 @@ 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; + 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */; }; 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */; }; 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */; }; @@ -327,6 +329,7 @@ 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProjectLogicTests.swift; sourceTree = ""; }; 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewportLogicTests.swift; sourceTree = ""; }; 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyTests.swift; sourceTree = ""; }; + 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyEventFilterTests.swift; sourceTree = ""; }; 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; @@ -345,6 +348,7 @@ 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStoreTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; + 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceNoteHoldTests.swift; sourceTree = ""; }; 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStoreTests.swift; sourceTree = ""; }; 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTests.swift; sourceTree = ""; }; @@ -631,6 +635,7 @@ 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */, + 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */, 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, @@ -649,6 +654,7 @@ 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, + 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */, 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */, @@ -1056,6 +1062,7 @@ 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */, 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */, + 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */, 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, @@ -1074,6 +1081,7 @@ 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, + 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */, 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */, 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */, diff --git a/JammLabTests/AppHotkeyEventFilterTests.swift b/JammLabTests/AppHotkeyEventFilterTests.swift new file mode 100644 index 0000000..eab8c81 --- /dev/null +++ b/JammLabTests/AppHotkeyEventFilterTests.swift @@ -0,0 +1,339 @@ +import AppKit +import XCTest +@testable import JammLab + +final class AppHotkeyEventFilterTests: XCTestCase { + func testAppHotkeyEventFilterScopesAllowedHotkeysToAttachedWindow() throws { + let spaceEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: false, + keyCode: 49 + )) + let repeatSpaceEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: " ", + charactersIgnoringModifiers: " ", + isARepeat: true, + keyCode: 49 + )) + let tabEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "\t", + charactersIgnoringModifiers: "\t", + isARepeat: false, + keyCode: 48 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ), + .playPause + ) + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 42, + firstResponder: NSView(), + allowedHotkeys: [.playPause] + ), + .playPause + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: spaceEvent, + attachedWindowNumber: 7, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatSpaceEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: tabEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealMeasureCopyPasteFromTextRespondersOrUnavailableScopes() throws { + let copyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 8 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.copyMeasure] + ), + .copyMeasure + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.copyMeasure] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.copyMeasure] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealEscapeFromTextRespondersOrUnavailableScopes() throws { + let escapeEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.clearNotationMeasureSelection] + ), + .clearNotationMeasureSelection + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.clearNotationMeasureSelection] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.clearNotationMeasureSelection] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealEditHarmonyFromTextRespondersOrUnavailableScopes() throws { + let editHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: false, + keyCode: 40 + )) + let repeatEditHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: true, + keyCode: 40 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ), + .editHarmonyAtSelectedNotationItem + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatEditHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { + let durationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: false, + keyCode: 21 + )) + let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: true, + keyCode: 21 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ), + .setNotationDurationEighth + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatDurationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + } +} diff --git a/JammLabTests/AppHotkeyTests.swift b/JammLabTests/AppHotkeyTests.swift index d8c78e4..c829169 100644 --- a/JammLabTests/AppHotkeyTests.swift +++ b/JammLabTests/AppHotkeyTests.swift @@ -41,104 +41,6 @@ final class AppHotkeyTests: XCTestCase { XCTAssertEqual(AppHotkey.playPause.detail, "Start playback from the position marker or stop and return to it.") } - func testAppHotkeyEventFilterScopesAllowedHotkeysToAttachedWindow() throws { - let spaceEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: " ", - charactersIgnoringModifiers: " ", - isARepeat: false, - keyCode: 49 - )) - let repeatSpaceEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: " ", - charactersIgnoringModifiers: " ", - isARepeat: true, - keyCode: 49 - )) - let tabEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "\t", - charactersIgnoringModifiers: "\t", - isARepeat: false, - keyCode: 48 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ), - .playPause - ) - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 42, - firstResponder: NSView(), - allowedHotkeys: [.playPause] - ), - .playPause - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: spaceEvent, - attachedWindowNumber: 7, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatSpaceEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: tabEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - } - func testAppHotkeyExposesSelectedNotationItemHarmonyShortcutMetadata() { XCTAssertTrue(AppHotkey.allCases.contains(.editHarmonyAtSelectedNotationItem)) XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.key, "Cmd+K") @@ -353,240 +255,4 @@ final class AppHotkeyTests: XCTestCase { XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.key, "Esc") XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.title, "Clear Measure Selection") } - - func testAppHotkeyEventFilterDoesNotStealMeasureCopyPasteFromTextRespondersOrUnavailableScopes() throws { - let copyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "c", - charactersIgnoringModifiers: "c", - isARepeat: false, - keyCode: 8 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.copyMeasure] - ), - .copyMeasure - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.copyMeasure] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.copyMeasure] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealEscapeFromTextRespondersOrUnavailableScopes() throws { - let escapeEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "\u{1b}", - charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, - keyCode: 53 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.clearNotationMeasureSelection] - ), - .clearNotationMeasureSelection - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.clearNotationMeasureSelection] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.clearNotationMeasureSelection] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealEditHarmonyFromTextRespondersOrUnavailableScopes() throws { - let editHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "k", - charactersIgnoringModifiers: "k", - isARepeat: false, - keyCode: 40 - )) - let repeatEditHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "k", - charactersIgnoringModifiers: "k", - isARepeat: true, - keyCode: 40 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ), - .editHarmonyAtSelectedNotationItem - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatEditHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { - let durationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: false, - keyCode: 21 - )) - let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: true, - keyCode: 21 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: AppHotkey.notationDurationHotkeys - ), - .setNotationDurationEighth - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatDurationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - } } diff --git a/JammLabTests/TunerInputServiceNoteHoldTests.swift b/JammLabTests/TunerInputServiceNoteHoldTests.swift new file mode 100644 index 0000000..20f57f5 --- /dev/null +++ b/JammLabTests/TunerInputServiceNoteHoldTests.swift @@ -0,0 +1,136 @@ +import CoreAudio +import XCTest +@testable import JammLab + +final class TunerInputServiceNoteHoldTests: XCTestCase { + @MainActor + func testTunerInputServiceKeepsDetectedNoteDuringHoldWindow() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = QueuedPitchDetector(results: [ + .result(noteName: "A", octave: 4, frequencyHz: 440, midiNote: 69), + nil + ]) + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector, + noteHoldDuration: 0.2 + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } + XCTAssertTrue(didPublishDetectedNote) + + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } + XCTAssertTrue(didProcessMissingResult) + + XCTAssertEqual(service.currentResult?.noteName, "A") + XCTAssertEqual(service.currentResult?.octave, 4) + } + + @MainActor + func testTunerInputServiceClearsHeldNoteAfterHoldWindowWithoutAnotherBuffer() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = QueuedPitchDetector(results: [ + .result(noteName: "A", octave: 4, frequencyHz: 440, midiNote: 69), + nil + ]) + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector, + noteHoldDuration: 0.03 + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } + XCTAssertTrue(didPublishDetectedNote) + + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } + XCTAssertTrue(didProcessMissingResult) + let didClearHeldNote = await waitForTunerMainActorCondition { service.currentResult == nil } + XCTAssertTrue(didClearHeldNote) + } + + @MainActor + func testTunerInputServiceReplacesHeldNoteWithNewDetectionImmediately() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = QueuedPitchDetector(results: [ + .result(noteName: "A", octave: 4, frequencyHz: 440, midiNote: 69), + .result(noteName: "C", octave: 5, frequencyHz: 523.25, midiNote: 72) + ]) + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector, + noteHoldDuration: 1.0 + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + let didPublishFirstNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } + XCTAssertTrue(didPublishFirstNote) + + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + let didPublishReplacementNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "C" } + XCTAssertTrue(didPublishReplacementNote) + XCTAssertEqual(service.currentResult?.octave, 5) + } + + @MainActor + func testTunerInputServiceClearsHeldNoteOnStopAndCancelsHoldClear() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = QueuedPitchDetector(results: [ + .result(noteName: "A", octave: 4, frequencyHz: 440, midiNote: 69), + nil + ]) + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector, + noteHoldDuration: 0.2 + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } + XCTAssertTrue(didPublishDetectedNote) + + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } + XCTAssertTrue(didProcessMissingResult) + service.stop() + + XCTAssertNil(service.currentResult) + try? await Task.sleep(nanoseconds: 250_000_000) + XCTAssertNil(service.currentResult) + } +} diff --git a/JammLabTests/TunerInputServiceTests.swift b/JammLabTests/TunerInputServiceTests.swift index e19ee6c..2b22b7a 100644 --- a/JammLabTests/TunerInputServiceTests.swift +++ b/JammLabTests/TunerInputServiceTests.swift @@ -425,135 +425,4 @@ final class TunerInputServiceTests: XCTestCase { XCTAssertTrue(didRecordSampleRate) } - @MainActor - func testTunerInputServiceKeepsDetectedNoteDuringHoldWindow() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = QueuedPitchDetector(results: [ - .result(noteName: "A", octave: 4, frequencyHz: 440, midiNote: 69), - nil - ]) - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector, - noteHoldDuration: 0.2 - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) - let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } - XCTAssertTrue(didPublishDetectedNote) - - engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) - let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } - XCTAssertTrue(didProcessMissingResult) - - XCTAssertEqual(service.currentResult?.noteName, "A") - XCTAssertEqual(service.currentResult?.octave, 4) - } - - @MainActor - func testTunerInputServiceClearsHeldNoteAfterHoldWindowWithoutAnotherBuffer() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = QueuedPitchDetector(results: [ - .result(noteName: "A", octave: 4, frequencyHz: 440, midiNote: 69), - nil - ]) - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector, - noteHoldDuration: 0.03 - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) - let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } - XCTAssertTrue(didPublishDetectedNote) - - engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) - let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } - XCTAssertTrue(didProcessMissingResult) - let didClearHeldNote = await waitForTunerMainActorCondition { service.currentResult == nil } - XCTAssertTrue(didClearHeldNote) - } - - @MainActor - func testTunerInputServiceReplacesHeldNoteWithNewDetectionImmediately() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = QueuedPitchDetector(results: [ - .result(noteName: "A", octave: 4, frequencyHz: 440, midiNote: 69), - .result(noteName: "C", octave: 5, frequencyHz: 523.25, midiNote: 72) - ]) - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector, - noteHoldDuration: 1.0 - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) - let didPublishFirstNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } - XCTAssertTrue(didPublishFirstNote) - - engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) - let didPublishReplacementNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "C" } - XCTAssertTrue(didPublishReplacementNote) - XCTAssertEqual(service.currentResult?.octave, 5) - } - - @MainActor - func testTunerInputServiceClearsHeldNoteOnStopAndCancelsHoldClear() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = QueuedPitchDetector(results: [ - .result(noteName: "A", octave: 4, frequencyHz: 440, midiNote: 69), - nil - ]) - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector, - noteHoldDuration: 0.2 - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) - let didPublishDetectedNote = await waitForTunerMainActorCondition { service.currentResult?.noteName == "A" } - XCTAssertTrue(didPublishDetectedNote) - - engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) - let didProcessMissingResult = await waitForTunerMainActorCondition { detector.detectCallCount == 2 } - XCTAssertTrue(didProcessMissingResult) - service.stop() - - XCTAssertNil(service.currentResult) - try? await Task.sleep(nanoseconds: 250_000_000) - XCTAssertNil(service.currentResult) - } - } From 52694fd6bfcf4d7869ea335414ba51489e2f7c05 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 08:06:32 +0300 Subject: [PATCH 46/80] test: split stem job workflow tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/StemJobWorkflowTests.swift | 275 ++++++++++++++++++++++ JammLabTests/StemWorkflowLogicTests.swift | 271 --------------------- 3 files changed, 279 insertions(+), 271 deletions(-) create mode 100644 JammLabTests/StemJobWorkflowTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 6335055..368873c 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; + 9FAB013C2CE0000100112233 /* StemJobWorkflowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */; }; 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; @@ -345,6 +346,7 @@ 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSaveCloseTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; + 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemJobWorkflowTests.swift; sourceTree = ""; }; 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStoreTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; @@ -651,6 +653,7 @@ 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, + 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */, 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, @@ -1078,6 +1081,7 @@ 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, + 9FAB013C2CE0000100112233 /* StemJobWorkflowTests.swift in Sources */, 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, diff --git a/JammLabTests/StemJobWorkflowTests.swift b/JammLabTests/StemJobWorkflowTests.swift new file mode 100644 index 0000000..f9e9cae --- /dev/null +++ b/JammLabTests/StemJobWorkflowTests.swift @@ -0,0 +1,275 @@ +import XCTest +@testable import JammLab + +final class StemJobWorkflowTests: XCTestCase { + func testStemJobModelsRoundTrip() throws { + let request = StemJobRequest( + jobID: "job-1", + audioPath: "/tmp/song.mp3", + cacheKey: "cache", + cacheDirectoryPath: "/tmp/cache", + modelDirectoryPath: "/tmp/models", + sourceFingerprint: StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 10, modificationTime: 20), + separationMethodID: StemSeparationMethod.vocalInstrumental.id, + expectedStemTypes: StemSeparationMethod.vocalInstrumental.stemTypes, + modelName: StemSeparationMethod.vocalInstrumental.modelName, + settingsVersion: 2, + audioSeparatorPath: nil, + audioSeparatorBookmarkData: nil, + computeMode: "auto", + createdAt: Date(timeIntervalSince1970: 100) + ) + let status = StemJobStatus( + jobID: request.jobID, + phase: .processing, + progress: 0.5, + message: "Separating stems", + diagnostics: "stdout tail", + backendCommand: "JammLabSeparatorHelper song.mp3", + updatedAt: Date(timeIntervalSince1970: 101) + ) + let metadata = StemCacheMetadata( + cacheKey: request.cacheKey, + sourceFingerprint: request.sourceFingerprint, + backendIdentifier: "audio-separator", + separationMethodID: request.separationMethodID, + modelName: request.modelName, + settingsVersion: request.settingsVersion, + createdAt: Date(timeIntervalSince1970: 102), + stems: [StemFile(type: .vocals, url: URL(fileURLWithPath: "/tmp/vocals.wav"), displayName: "Vocals")] + ) + let result = StemJobResult(jobID: request.jobID, cacheKey: request.cacheKey, metadata: metadata, completedAt: Date(timeIntervalSince1970: 103)) + + XCTAssertEqual(try JSONDecoder().decode(StemJobRequest.self, from: JSONEncoder().encode(request)), request) + XCTAssertEqual(try JSONDecoder().decode(StemJobStatus.self, from: JSONEncoder().encode(status)), status) + XCTAssertEqual(try JSONDecoder().decode(StemJobResult.self, from: JSONEncoder().encode(result)), result) + } + + func testLegacyStemJobRequestWithoutAudioSeparatorPathDecodes() throws { + let json = """ + { + "jobID": "job-legacy", + "audioPath": "/tmp/song.mp3", + "cacheKey": "cache", + "cacheDirectoryPath": "/tmp/cache", + "modelDirectoryPath": "/tmp/models", + "sourceFingerprint": { + "path": "/tmp/song.mp3", + "fileSize": 10, + "modificationTime": 20 + }, + "modelName": "htdemucs.yaml", + "settingsVersion": 2, + "createdAt": 100 + } + """ + + let request = try JSONDecoder().decode(StemJobRequest.self, from: Data(json.utf8)) + + XCTAssertEqual(request.jobID, "job-legacy") + XCTAssertNil(request.audioSeparatorPath) + XCTAssertNil(request.audioSeparatorBookmarkData) + XCTAssertNil(request.computeMode) + XCTAssertNil(request.separationMethodID) + XCTAssertNil(request.expectedStemTypes) + } + + func testStemJobFilesUseVersionedCurrentJobsDirectory() { + let appSupport = URL(fileURLWithPath: "/tmp/JammLab", isDirectory: true) + let jobsDirectory = StemJobFiles.currentJobsDirectory(in: appSupport) + + XCTAssertEqual(StemJobFiles.helperVersion, 5) + XCTAssertEqual(jobsDirectory.path, "/tmp/JammLab/\(StemJobFiles.jobsDirectoryName)/v5") + XCTAssertEqual( + jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename).path, + "/tmp/JammLab/\(StemJobFiles.jobsDirectoryName)/v5/\(StemJobFiles.heartbeatFilename)" + ) + } + + func testStemCacheDirectoryNameIsUnversioned() { + XCTAssertEqual(StemJobFiles.cacheDirectoryName, "StemCache") + XCTAssertEqual(StemJobFiles.modelDirectoryName, "StemModels") + } + + func testStemHelperHeartbeatFreshness() { + let fresh = StemHelperHeartbeat(helperVersion: StemJobFiles.helperVersion, updatedAt: Date(), activeJobID: nil) + let stale = StemHelperHeartbeat(helperVersion: StemJobFiles.helperVersion, updatedAt: Date().addingTimeInterval(-30), activeJobID: "job") + + XCTAssertTrue(fresh.isFresh) + XCTAssertFalse(stale.isFresh) + } + + func testStemBackendResolverUsesBundledSeparatorOnly() throws { + let resolver = StemBackendResolver( + helperExecutableURL: URL(fileURLWithPath: "/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper") + ) + + let commands = resolver.bundledSeparatorCandidates.map { $0.commandDescription(extraArguments: ["--env_info"]) } + + XCTAssertEqual(commands, ["/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper --env_info"]) + XCTAssertFalse(commands.contains { $0.contains("/usr/bin/env") }) + XCTAssertFalse(commands.contains { $0.contains("/opt/homebrew") }) + XCTAssertFalse(commands.contains { $0.contains("demucs") }) + } + + func testBundledSeparatorDefaultPathResolvesBesideStemHelper() { + let currentExecutable = URL(fileURLWithPath: "/App/JammLab.app/Contents/Helpers/JammLabStemHelper") + let helperURL = StemBackendResolver.defaultBundledSeparatorExecutableURL(currentExecutableURL: currentExecutable) + + XCTAssertEqual( + helperURL.path, + "/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper" + ) + } + + func testStemBackendResolverBuildsBundledSeparationCommand() { + let candidate = StemBackendCandidate( + executableURL: URL(fileURLWithPath: "/App/Helpers/JammLabSeparatorHelper/JammLabSeparatorHelper"), + argumentsPrefix: [], + displayName: "JammLabSeparatorHelper/1" + ) + let command = candidate.commandDescription(extraArguments: [ + "/tmp/song.mp3", + "-m", + "htdemucs.yaml", + "--output_format", + "WAV" + ]) + + XCTAssertEqual(command, "/App/Helpers/JammLabSeparatorHelper/JammLabSeparatorHelper /tmp/song.mp3 -m htdemucs.yaml --output_format WAV") + } + + func testStemBackendComputeModeHelperArguments() { + XCTAssertEqual(StemBackendComputeMode.cpuOnly.helperArgument, "cpu") + XCTAssertEqual(StemBackendComputeMode.auto.helperArgument, "auto") + } + + func testStemJobStatusMapsToViewState() { + XCTAssertEqual(StemJobPhase.pending.viewPhase.title, StemSeparationPhase.checkingBackend.title) + XCTAssertEqual(StemJobPhase.processing.viewPhase.title, StemSeparationPhase.processing.title) + XCTAssertEqual(StemJobPhase.completed.viewPhase.title, StemSeparationPhase.completed.title) + XCTAssertEqual(StemJobPhase.cancelled.viewPhase.title, StemSeparationPhase.cancelled.title) + } + + func testAudioSeparatorOutputFilenameMatching() { + XCTAssertTrue(StemType.vocals.matchesOutputFilename("song_(Vocals)_htdemucs.wav")) + XCTAssertTrue(StemType.instrumental.matchesOutputFilename("song_(Instrumental)_UVR-MDX-NET-Inst_HQ_5.wav")) + XCTAssertTrue(StemType.instrumental.matchesOutputFilename("song_no_vocals.wav")) + XCTAssertTrue(StemType.drums.matchesOutputFilename("track_drums.flac")) + XCTAssertTrue(StemType.bass.matchesOutputFilename("bass.wav")) + XCTAssertTrue(StemType.guitar.matchesOutputFilename("song_(Guitar)_htdemucs_6s.wav")) + XCTAssertTrue(StemType.piano.matchesOutputFilename("song_piano.flac")) + XCTAssertFalse(StemType.other.matchesOutputFilename("song_vocals.txt")) + XCTAssertFalse(StemType.bass.matchesOutputFilename("drums.wav")) + } + + func testStemTypesExposeCanonicalStemFilenames() { + XCTAssertEqual(StemType.vocals.canonicalStemFilename, "vocals.wav") + XCTAssertEqual(StemType.instrumental.canonicalStemFilename, "instrumental.wav") + XCTAssertEqual(StemType.drums.canonicalStemFilename, "drums.wav") + XCTAssertEqual(StemType.bass.canonicalStemFilename, "bass.wav") + XCTAssertEqual(StemType.other.canonicalStemFilename, "other.wav") + XCTAssertEqual(StemType.guitar.canonicalStemFilename, "guitar.wav") + XCTAssertEqual(StemType.piano.canonicalStemFilename, "piano.wav") + } + + func testHelperJobFailureDiagnosticsIncludesDetails() { + let error = StemSeparationError.helperJobFailed( + """ + job: /tmp/job + command: audio-separator song.mp3 + stderr: + backend failed + """ + ) + + XCTAssertTrue(error.diagnostics.contains("audio-separator song.mp3")) + XCTAssertTrue(error.diagnostics.contains("backend failed")) + } + + func testStemJobInputUsesDirectOriginalPathWhenNotSandboxed() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false }, + applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) + ) + + let input = try service.jobInput( + for: audioURL, + jobDirectory: directory.appendingPathComponent("job", isDirectory: true), + mode: StemJobInputMode.direct + ) + + XCTAssertEqual(input.audioPath, audioURL.path) + XCTAssertNil(input.stagedInputDirectory) + } + + func testStemJobInputStagesAudioInsideJobDirectoryWhenSandboxed() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") + let jobDirectory = directory.appendingPathComponent("job", isDirectory: true) + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { true }, + applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) + ) + + let input = try service.jobInput(for: audioURL, jobDirectory: jobDirectory, mode: StemJobInputMode.staged) + + XCTAssertEqual(input.stagedInputDirectory, jobDirectory.appendingPathComponent("input", isDirectory: true)) + XCTAssertEqual(input.audioPath, jobDirectory.appendingPathComponent("input/song.mp3").path) + XCTAssertEqual(try String(contentsOfFile: input.audioPath, encoding: .utf8), "audio") + } + + func testStemJobRequestUsesStagedAudioPathButKeepsOriginalFingerprint() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") + let jobDirectory = directory.appendingPathComponent("job", isDirectory: true) + let cacheDirectory = directory.appendingPathComponent("cache", isDirectory: true) + let fingerprint = StemSourceFingerprint(path: audioURL.path, fileSize: 5, modificationTime: 123) + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { true }, + applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) + ) + + try service.createJobForTesting( + audioURL: audioURL, + fingerprint: fingerprint, + cacheKey: "cache-key", + cacheDirectory: cacheDirectory, + jobDirectory: jobDirectory, + inputMode: StemJobInputMode.staged, + method: .sixStem + ) + let requestData = try Data(contentsOf: jobDirectory.appendingPathComponent(StemJobFiles.requestFilename)) + let request = try JSONDecoder().decode(StemJobRequest.self, from: requestData) + + XCTAssertEqual(request.audioPath, jobDirectory.appendingPathComponent("input/song.mp3").path) + XCTAssertEqual(request.sourceFingerprint, fingerprint) + XCTAssertEqual(request.separationMethodID, StemSeparationMethod.sixStem.id) + XCTAssertEqual(request.modelName, StemSeparationMethod.sixStem.modelName) + XCTAssertEqual(request.expectedStemTypes, StemSeparationMethod.sixStem.stemTypes) + } + + func testStemInputPermissionFailureClassification() throws { + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false }, + applicationSupportDirectory: temporaryDirectory() + ) + let path = "/Users/example/Music/song.mp3" + let error = StemSeparationError.helperJobFailed("Failed: Operation not permitted: '\(path)'") + + XCTAssertTrue(service.isInputPermissionFailure(error, originalAudioPath: path)) + XCTAssertFalse(service.isInputPermissionFailure(error, originalAudioPath: "/other/song.mp3")) + } +} diff --git a/JammLabTests/StemWorkflowLogicTests.swift b/JammLabTests/StemWorkflowLogicTests.swift index 07ef691..ac3c3c9 100644 --- a/JammLabTests/StemWorkflowLogicTests.swift +++ b/JammLabTests/StemWorkflowLogicTests.swift @@ -290,275 +290,4 @@ final class StemWorkflowLogicTests: XCTestCase { XCTAssertNotEqual(VideoAudioExtractionService.cacheKey(for: changedURL), firstKey) } - func testStemJobModelsRoundTrip() throws { - let request = StemJobRequest( - jobID: "job-1", - audioPath: "/tmp/song.mp3", - cacheKey: "cache", - cacheDirectoryPath: "/tmp/cache", - modelDirectoryPath: "/tmp/models", - sourceFingerprint: StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 10, modificationTime: 20), - separationMethodID: StemSeparationMethod.vocalInstrumental.id, - expectedStemTypes: StemSeparationMethod.vocalInstrumental.stemTypes, - modelName: StemSeparationMethod.vocalInstrumental.modelName, - settingsVersion: 2, - audioSeparatorPath: nil, - audioSeparatorBookmarkData: nil, - computeMode: "auto", - createdAt: Date(timeIntervalSince1970: 100) - ) - let status = StemJobStatus( - jobID: request.jobID, - phase: .processing, - progress: 0.5, - message: "Separating stems", - diagnostics: "stdout tail", - backendCommand: "JammLabSeparatorHelper song.mp3", - updatedAt: Date(timeIntervalSince1970: 101) - ) - let metadata = StemCacheMetadata( - cacheKey: request.cacheKey, - sourceFingerprint: request.sourceFingerprint, - backendIdentifier: "audio-separator", - separationMethodID: request.separationMethodID, - modelName: request.modelName, - settingsVersion: request.settingsVersion, - createdAt: Date(timeIntervalSince1970: 102), - stems: [StemFile(type: .vocals, url: URL(fileURLWithPath: "/tmp/vocals.wav"), displayName: "Vocals")] - ) - let result = StemJobResult(jobID: request.jobID, cacheKey: request.cacheKey, metadata: metadata, completedAt: Date(timeIntervalSince1970: 103)) - - XCTAssertEqual(try JSONDecoder().decode(StemJobRequest.self, from: JSONEncoder().encode(request)), request) - XCTAssertEqual(try JSONDecoder().decode(StemJobStatus.self, from: JSONEncoder().encode(status)), status) - XCTAssertEqual(try JSONDecoder().decode(StemJobResult.self, from: JSONEncoder().encode(result)), result) - } - - func testLegacyStemJobRequestWithoutAudioSeparatorPathDecodes() throws { - let json = """ - { - "jobID": "job-legacy", - "audioPath": "/tmp/song.mp3", - "cacheKey": "cache", - "cacheDirectoryPath": "/tmp/cache", - "modelDirectoryPath": "/tmp/models", - "sourceFingerprint": { - "path": "/tmp/song.mp3", - "fileSize": 10, - "modificationTime": 20 - }, - "modelName": "htdemucs.yaml", - "settingsVersion": 2, - "createdAt": 100 - } - """ - - let request = try JSONDecoder().decode(StemJobRequest.self, from: Data(json.utf8)) - - XCTAssertEqual(request.jobID, "job-legacy") - XCTAssertNil(request.audioSeparatorPath) - XCTAssertNil(request.audioSeparatorBookmarkData) - XCTAssertNil(request.computeMode) - XCTAssertNil(request.separationMethodID) - XCTAssertNil(request.expectedStemTypes) - } - - func testStemJobFilesUseVersionedCurrentJobsDirectory() { - let appSupport = URL(fileURLWithPath: "/tmp/JammLab", isDirectory: true) - let jobsDirectory = StemJobFiles.currentJobsDirectory(in: appSupport) - - XCTAssertEqual(StemJobFiles.helperVersion, 5) - XCTAssertEqual(jobsDirectory.path, "/tmp/JammLab/\(StemJobFiles.jobsDirectoryName)/v5") - XCTAssertEqual( - jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename).path, - "/tmp/JammLab/\(StemJobFiles.jobsDirectoryName)/v5/\(StemJobFiles.heartbeatFilename)" - ) - } - - func testStemCacheDirectoryNameIsUnversioned() { - XCTAssertEqual(StemJobFiles.cacheDirectoryName, "StemCache") - XCTAssertEqual(StemJobFiles.modelDirectoryName, "StemModels") - } - - func testStemHelperHeartbeatFreshness() { - let fresh = StemHelperHeartbeat(helperVersion: StemJobFiles.helperVersion, updatedAt: Date(), activeJobID: nil) - let stale = StemHelperHeartbeat(helperVersion: StemJobFiles.helperVersion, updatedAt: Date().addingTimeInterval(-30), activeJobID: "job") - - XCTAssertTrue(fresh.isFresh) - XCTAssertFalse(stale.isFresh) - } - - func testStemBackendResolverUsesBundledSeparatorOnly() throws { - let resolver = StemBackendResolver( - helperExecutableURL: URL(fileURLWithPath: "/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper") - ) - - let commands = resolver.bundledSeparatorCandidates.map { $0.commandDescription(extraArguments: ["--env_info"]) } - - XCTAssertEqual(commands, ["/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper --env_info"]) - XCTAssertFalse(commands.contains { $0.contains("/usr/bin/env") }) - XCTAssertFalse(commands.contains { $0.contains("/opt/homebrew") }) - XCTAssertFalse(commands.contains { $0.contains("demucs") }) - } - - func testBundledSeparatorDefaultPathResolvesBesideStemHelper() { - let currentExecutable = URL(fileURLWithPath: "/App/JammLab.app/Contents/Helpers/JammLabStemHelper") - let helperURL = StemBackendResolver.defaultBundledSeparatorExecutableURL(currentExecutableURL: currentExecutable) - - XCTAssertEqual( - helperURL.path, - "/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper" - ) - } - - func testStemBackendResolverBuildsBundledSeparationCommand() { - let candidate = StemBackendCandidate( - executableURL: URL(fileURLWithPath: "/App/Helpers/JammLabSeparatorHelper/JammLabSeparatorHelper"), - argumentsPrefix: [], - displayName: "JammLabSeparatorHelper/1" - ) - let command = candidate.commandDescription(extraArguments: [ - "/tmp/song.mp3", - "-m", - "htdemucs.yaml", - "--output_format", - "WAV" - ]) - - XCTAssertEqual(command, "/App/Helpers/JammLabSeparatorHelper/JammLabSeparatorHelper /tmp/song.mp3 -m htdemucs.yaml --output_format WAV") - } - - func testStemBackendComputeModeHelperArguments() { - XCTAssertEqual(StemBackendComputeMode.cpuOnly.helperArgument, "cpu") - XCTAssertEqual(StemBackendComputeMode.auto.helperArgument, "auto") - } - - func testStemJobStatusMapsToViewState() { - XCTAssertEqual(StemJobPhase.pending.viewPhase.title, StemSeparationPhase.checkingBackend.title) - XCTAssertEqual(StemJobPhase.processing.viewPhase.title, StemSeparationPhase.processing.title) - XCTAssertEqual(StemJobPhase.completed.viewPhase.title, StemSeparationPhase.completed.title) - XCTAssertEqual(StemJobPhase.cancelled.viewPhase.title, StemSeparationPhase.cancelled.title) - } - - func testAudioSeparatorOutputFilenameMatching() { - XCTAssertTrue(StemType.vocals.matchesOutputFilename("song_(Vocals)_htdemucs.wav")) - XCTAssertTrue(StemType.instrumental.matchesOutputFilename("song_(Instrumental)_UVR-MDX-NET-Inst_HQ_5.wav")) - XCTAssertTrue(StemType.instrumental.matchesOutputFilename("song_no_vocals.wav")) - XCTAssertTrue(StemType.drums.matchesOutputFilename("track_drums.flac")) - XCTAssertTrue(StemType.bass.matchesOutputFilename("bass.wav")) - XCTAssertTrue(StemType.guitar.matchesOutputFilename("song_(Guitar)_htdemucs_6s.wav")) - XCTAssertTrue(StemType.piano.matchesOutputFilename("song_piano.flac")) - XCTAssertFalse(StemType.other.matchesOutputFilename("song_vocals.txt")) - XCTAssertFalse(StemType.bass.matchesOutputFilename("drums.wav")) - } - - func testStemTypesExposeCanonicalStemFilenames() { - XCTAssertEqual(StemType.vocals.canonicalStemFilename, "vocals.wav") - XCTAssertEqual(StemType.instrumental.canonicalStemFilename, "instrumental.wav") - XCTAssertEqual(StemType.drums.canonicalStemFilename, "drums.wav") - XCTAssertEqual(StemType.bass.canonicalStemFilename, "bass.wav") - XCTAssertEqual(StemType.other.canonicalStemFilename, "other.wav") - XCTAssertEqual(StemType.guitar.canonicalStemFilename, "guitar.wav") - XCTAssertEqual(StemType.piano.canonicalStemFilename, "piano.wav") - } - - func testHelperJobFailureDiagnosticsIncludesDetails() { - let error = StemSeparationError.helperJobFailed( - """ - job: /tmp/job - command: audio-separator song.mp3 - stderr: - backend failed - """ - ) - - XCTAssertTrue(error.diagnostics.contains("audio-separator song.mp3")) - XCTAssertTrue(error.diagnostics.contains("backend failed")) - } - - func testStemJobInputUsesDirectOriginalPathWhenNotSandboxed() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false }, - applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) - ) - - let input = try service.jobInput( - for: audioURL, - jobDirectory: directory.appendingPathComponent("job", isDirectory: true), - mode: StemJobInputMode.direct - ) - - XCTAssertEqual(input.audioPath, audioURL.path) - XCTAssertNil(input.stagedInputDirectory) - } - - func testStemJobInputStagesAudioInsideJobDirectoryWhenSandboxed() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") - let jobDirectory = directory.appendingPathComponent("job", isDirectory: true) - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { true }, - applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) - ) - - let input = try service.jobInput(for: audioURL, jobDirectory: jobDirectory, mode: StemJobInputMode.staged) - - XCTAssertEqual(input.stagedInputDirectory, jobDirectory.appendingPathComponent("input", isDirectory: true)) - XCTAssertEqual(input.audioPath, jobDirectory.appendingPathComponent("input/song.mp3").path) - XCTAssertEqual(try String(contentsOfFile: input.audioPath, encoding: .utf8), "audio") - } - - func testStemJobRequestUsesStagedAudioPathButKeepsOriginalFingerprint() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") - let jobDirectory = directory.appendingPathComponent("job", isDirectory: true) - let cacheDirectory = directory.appendingPathComponent("cache", isDirectory: true) - let fingerprint = StemSourceFingerprint(path: audioURL.path, fileSize: 5, modificationTime: 123) - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { true }, - applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) - ) - - try service.createJobForTesting( - audioURL: audioURL, - fingerprint: fingerprint, - cacheKey: "cache-key", - cacheDirectory: cacheDirectory, - jobDirectory: jobDirectory, - inputMode: StemJobInputMode.staged, - method: .sixStem - ) - let requestData = try Data(contentsOf: jobDirectory.appendingPathComponent(StemJobFiles.requestFilename)) - let request = try JSONDecoder().decode(StemJobRequest.self, from: requestData) - - XCTAssertEqual(request.audioPath, jobDirectory.appendingPathComponent("input/song.mp3").path) - XCTAssertEqual(request.sourceFingerprint, fingerprint) - XCTAssertEqual(request.separationMethodID, StemSeparationMethod.sixStem.id) - XCTAssertEqual(request.modelName, StemSeparationMethod.sixStem.modelName) - XCTAssertEqual(request.expectedStemTypes, StemSeparationMethod.sixStem.stemTypes) - } - - func testStemInputPermissionFailureClassification() throws { - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false }, - applicationSupportDirectory: temporaryDirectory() - ) - let path = "/Users/example/Music/song.mp3" - let error = StemSeparationError.helperJobFailed("Failed: Operation not permitted: '\(path)'") - - XCTAssertTrue(service.isInputPermissionFailure(error, originalAudioPath: path)) - XCTAssertFalse(service.isInputPermissionFailure(error, originalAudioPath: "/other/song.mp3")) - } - } From bc0b060ee0c96b18be054be03420fcd3656146aa Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 11:45:21 +0300 Subject: [PATCH 47/80] test: split notation clipboard view model tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../ViewModelNotationClipboardTests.swift | 263 +++++++++++++++++ .../ViewModelNotationSelectionTests.swift | 265 +----------------- 3 files changed, 271 insertions(+), 261 deletions(-) create mode 100644 JammLabTests/ViewModelNotationClipboardTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 368873c..37abbaa 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */; }; 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; + 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */; }; 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */; }; @@ -342,6 +343,7 @@ 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTempoMapTests.swift; sourceTree = ""; }; 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; + 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationClipboardTests.swift; sourceTree = ""; }; 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSaveCloseTests.swift; sourceTree = ""; }; @@ -649,6 +651,7 @@ 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, + 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */, 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, @@ -1077,6 +1080,7 @@ 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, + 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */, 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */, diff --git a/JammLabTests/ViewModelNotationClipboardTests.swift b/JammLabTests/ViewModelNotationClipboardTests.swift new file mode 100644 index 0000000..ee89c47 --- /dev/null +++ b/JammLabTests/ViewModelNotationClipboardTests.swift @@ -0,0 +1,263 @@ +import XCTest +@testable import JammLab + +final class ViewModelNotationClipboardTests: XCTestCase { + @MainActor + func testCopyNotationMeasureCopiesOnlyHarmonies() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + viewModel.notes = [ + TimecodedNote(kind: .region, time: measure.startTime, duration: 1, title: "Intro") + ] + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0.5, measureNumber: 99, offsetInQuarterNotes: 99, rawText: "F"), + HarmonySymbol(time: measure.endTime, measureNumber: 1, offsetInQuarterNotes: 4, rawText: "G") + ] + + viewModel.selectNotationMeasure(measure) + + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [[ + NotationMeasureClipboardItem(offsetInQuarterNotes: 1, rawText: "F") + ]]) + } + + @MainActor + func testCopyNotationMeasureRangePreservesOrderAndEmptyMeasures() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(firstMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [ + [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "C")], + [], + [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "Am")] + ]) + } + + @MainActor + func testPasteNotationMeasureReplacesTargetAndSupportsUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + let sourceA = HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + let sourceB = HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "F") + let targetExisting = HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") + viewModel.harmonySymbols = [sourceA, sourceB, targetExisting] + viewModel.markProjectClean() + + viewModel.selectNotationMeasure(sourceMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + let beforePaste = viewModel.harmonySymbols + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let targetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(targetSymbols.map(\.rawText), ["C", "F"]) + XCTAssertEqual(targetSymbols.map(\.id).contains(sourceA.id), false) + XCTAssertEqual(targetSymbols.map(\.id).contains(sourceB.id), false) + XCTAssertEqual(targetSymbols[0].time, 2, accuracy: 0.0001) + XCTAssertEqual(targetSymbols[1].time, 3, accuracy: 0.0001) + XCTAssertNil(viewModel.selectedHarmonySymbolID) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [targetMeasure.number]) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.harmonySymbols, beforePaste) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.redoLastEdit() + + let redoneTargetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(redoneTargetSymbols.map(\.rawText), ["C", "F"]) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let thirdMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) + } + + @MainActor + func testPastingEmptyNotationMeasureClearsTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let emptyMeasure = try notationMeasure(3, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") + ] + + viewModel.selectNotationMeasure(emptyMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertEqual(viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }.map(\.rawText), ["C"]) + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(fourthMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) + } + + @MainActor + func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 120, beatsPerBar: 3) + viewModel.markProjectClean() + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "D") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let targetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(targetSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(try XCTUnwrap(targetSymbols.first).time, 2, accuracy: 0.0001) + } + + @MainActor + func testCopyRejectsPartialStaleNotationMeasureSelection() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let firstMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + + viewModel.selectedNotationMeasures = [ + NotationMeasureSelection(measure: firstMeasure), + NotationMeasureSelection( + measure: ScoreMeasure( + number: secondMeasure.number, + startTime: secondMeasure.startTime, + endTime: secondMeasure.endTime + 0.25, + attributes: secondMeasure.attributes + ) + ) + ] + + XCTAssertFalse(viewModel.copySelectedNotationMeasure()) + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + } + + @MainActor + func testClearingNotationMeasureSelectionDoesNotClearClipboardOrMarkDirty() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + ] + viewModel.selectNotationMeasure(measure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.markProjectClean() + + viewModel.clearNotationMeasureSelection() + + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + XCTAssertNotNil(viewModel.notationMeasureClipboard) + XCTAssertFalse(viewModel.isProjectModified) + } +} diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift index 54c17d5..8a6ad4e 100644 --- a/JammLabTests/ViewModelNotationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -149,225 +149,6 @@ final class ViewModelNotationSelectionTests: XCTestCase { XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [2]) } - @MainActor - func testCopyNotationMeasureCopiesOnlyHarmonies() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - viewModel.notes = [ - TimecodedNote(kind: .region, time: measure.startTime, duration: 1, title: "Intro") - ] - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0.5, measureNumber: 99, offsetInQuarterNotes: 99, rawText: "F"), - HarmonySymbol(time: measure.endTime, measureNumber: 1, offsetInQuarterNotes: 4, rawText: "G") - ] - - viewModel.selectNotationMeasure(measure) - - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [[ - NotationMeasureClipboardItem(offsetInQuarterNotes: 1, rawText: "F") - ]]) - } - - @MainActor - func testCopyNotationMeasureRangePreservesOrderAndEmptyMeasures() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let firstMeasure = try notationMeasure(1, in: viewModel) - let thirdMeasure = try notationMeasure(3, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(firstMeasure) - viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) - - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - XCTAssertEqual(viewModel.notationMeasureClipboard?.measures.map(\.items), [ - [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "C")], - [], - [NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "Am")] - ]) - } - - @MainActor - func testPasteNotationMeasureReplacesTargetAndSupportsUndoRedo() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let undoManager = UndoManager() - viewModel.undoManager = undoManager - let sourceMeasure = try notationMeasure(1, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - let sourceA = HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") - let sourceB = HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "F") - let targetExisting = HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") - viewModel.harmonySymbols = [sourceA, sourceB, targetExisting] - viewModel.markProjectClean() - - viewModel.selectNotationMeasure(sourceMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - let beforePaste = viewModel.harmonySymbols - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let targetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(targetSymbols.map(\.rawText), ["C", "F"]) - XCTAssertEqual(targetSymbols.map(\.id).contains(sourceA.id), false) - XCTAssertEqual(targetSymbols.map(\.id).contains(sourceB.id), false) - XCTAssertEqual(targetSymbols[0].time, 2, accuracy: 0.0001) - XCTAssertEqual(targetSymbols[1].time, 3, accuracy: 0.0001) - XCTAssertNil(viewModel.selectedHarmonySymbolID) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [targetMeasure.number]) - XCTAssertTrue(viewModel.isProjectModified) - - viewModel.undoLastEdit() - - XCTAssertEqual(viewModel.harmonySymbols, beforePaste) - XCTAssertFalse(viewModel.isProjectModified) - - viewModel.redoLastEdit() - - let redoneTargetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(redoneTargetSymbols.map(\.rawText), ["C", "F"]) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - let targetMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let thirdMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - let fourthMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - } - XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) - } - - @MainActor - func testPastingEmptyNotationMeasureClearsTarget() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let emptyMeasure = try notationMeasure(3, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") - ] - - viewModel.selectNotationMeasure(emptyMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - XCTAssertFalse(viewModel.harmonySymbols.contains { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - }) - } - - @MainActor - func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - let targetMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - XCTAssertEqual(viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - }.map(\.rawText), ["C"]) - XCTAssertFalse(viewModel.harmonySymbols.contains { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - }) - } - - @MainActor - func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let thirdMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(fourthMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let fourthMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - } - XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) - } - - @MainActor - func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 120, beatsPerBar: 3) - viewModel.markProjectClean() - let sourceMeasure = try notationMeasure(1, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "D") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let targetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(targetSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(try XCTUnwrap(targetSymbols.first).time, 2, accuracy: 0.0001) - } - @MainActor func testTempoMapChangesClearSelectedNotationMeasure() throws { let viewModel = try loadedNotationViewModel(duration: 8) @@ -379,46 +160,6 @@ final class ViewModelNotationSelectionTests: XCTestCase { XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) } - @MainActor - func testCopyRejectsPartialStaleNotationMeasureSelection() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let firstMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - - viewModel.selectedNotationMeasures = [ - NotationMeasureSelection(measure: firstMeasure), - NotationMeasureSelection( - measure: ScoreMeasure( - number: secondMeasure.number, - startTime: secondMeasure.startTime, - endTime: secondMeasure.endTime + 0.25, - attributes: secondMeasure.attributes - ) - ) - ] - - XCTAssertFalse(viewModel.copySelectedNotationMeasure()) - XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) - } - - @MainActor - func testClearingNotationMeasureSelectionDoesNotClearClipboardOrMarkDirty() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") - ] - viewModel.selectNotationMeasure(measure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.markProjectClean() - - viewModel.clearNotationMeasureSelection() - - XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) - XCTAssertNotNil(viewModel.notationMeasureClipboard) - XCTAssertFalse(viewModel.isProjectModified) - } - @MainActor func testExportNotationWritesMusicXMLWithoutChangingDirtyOrUndoState() async throws { let emptyViewModel = AudioPlayerViewModel( @@ -466,9 +207,11 @@ final class ViewModelNotationSelectionTests: XCTestCase { XCTAssertTrue(viewModel.canUndo) XCTAssertNil(viewModel.errorMessage) } +} +extension XCTestCase { @MainActor - private func loadedNotationViewModel(duration: TimeInterval) throws -> AudioPlayerViewModel { + func loadedNotationViewModel(duration: TimeInterval) throws -> AudioPlayerViewModel { let audioURL = try temporaryAudioFile(duration: duration) let viewModel = AudioPlayerViewModel( analyzer: MockAnalyzer(), @@ -485,7 +228,7 @@ final class ViewModelNotationSelectionTests: XCTestCase { } @MainActor - private func notationMeasure(_ number: Int, in viewModel: AudioPlayerViewModel) throws -> ScoreMeasure { + func notationMeasure(_ number: Int, in viewModel: AudioPlayerViewModel) throws -> ScoreMeasure { let score = NotationViewportFactory().scoreState( tempoMap: viewModel.tempoMap, duration: viewModel.duration, From 1f7de7e47f67d9604bc4887c37908e26f0b14a92 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 11:50:14 +0300 Subject: [PATCH 48/80] test: split tuner signal service tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../TunerInputServiceSignalTests.swift | 245 ++++++++++++++++++ JammLabTests/TunerInputServiceTests.swift | 240 ----------------- 3 files changed, 249 insertions(+), 240 deletions(-) create mode 100644 JammLabTests/TunerInputServiceSignalTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 37abbaa..e22276f 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; + 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */; }; 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */; }; 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */; }; @@ -352,6 +353,7 @@ 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStoreTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; + 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceSignalTests.swift; sourceTree = ""; }; 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceNoteHoldTests.swift; sourceTree = ""; }; 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStoreTests.swift; sourceTree = ""; }; @@ -660,6 +662,7 @@ 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, + 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */, 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */, 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, @@ -1089,6 +1092,7 @@ 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, + 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */, 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */, 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */, diff --git a/JammLabTests/TunerInputServiceSignalTests.swift b/JammLabTests/TunerInputServiceSignalTests.swift new file mode 100644 index 0000000..9bc4e8b --- /dev/null +++ b/JammLabTests/TunerInputServiceSignalTests.swift @@ -0,0 +1,245 @@ +import CoreAudio +import XCTest +@testable import JammLab + +final class TunerInputServiceSignalTests: XCTestCase { + @MainActor + func testTunerInputServicePublishesSignalLevelWhenPitchIsUnavailable() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + engine.debugEvents = [ + .deviceSwitch(status: noErr), + .format(sampleRate: 44_100, channelCount: 1, commonFormat: .pcmFormatFloat32, isInterleaved: false) + ] + engine.audioBuffers = [ + MockAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5], sampleRate: 44_100) + ] + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + await tunerDrainMainQueue() + await Task.yield() + await tunerDrainMainQueue() + + XCTAssertGreaterThan(service.inputSignalLevel, 0) + XCTAssertNil(service.currentResult) + XCTAssertEqual(service.inputDebugSnapshot.deviceSwitchStatus, noErr) + XCTAssertEqual(service.inputDebugSnapshot.engineSampleRate, 44_100) + XCTAssertEqual(service.inputDebugSnapshot.engineChannelCount, 1) + XCTAssertEqual(service.inputDebugSnapshot.engineCommonFormat, .pcmFormatFloat32) + XCTAssertEqual(service.inputDebugSnapshot.engineIsInterleaved, false) + XCTAssertEqual(service.inputDebugSnapshot.tapCallbackCount, 0) + XCTAssertEqual(service.inputDebugSnapshot.conversionStatus, .notStarted) + } + + @MainActor + func testTunerInputServiceIgnoresStaleSignalAfterStop() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + service.stop() + engine.sendAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5]) + await tunerDrainMainQueue() + + XCTAssertEqual(service.inputSignalLevel, 0) + } + + @MainActor + func testTunerInputServiceIgnoresEmptyInputBuffer() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + engine.sendAudioBuffer(samples: []) + await tunerDrainMainQueue() + + XCTAssertEqual(service.inputSignalLevel, 0) + XCTAssertNil(service.currentResult) + XCTAssertEqual(service.inputDebugSnapshot.conversionStatus, .notStarted) + } + + @MainActor + func testTunerInputServiceIgnoresUnsupportedInputBuffer() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + engine.sendAudioBuffer(try makeTunerInt16Buffer(samples: [1000, -1000, 1000, -1000])) + await tunerDrainMainQueue() + + XCTAssertEqual(service.inputSignalLevel, 0) + XCTAssertNil(service.currentResult) + XCTAssertEqual(service.inputDebugSnapshot.conversionStatus, .notStarted) + } + + @MainActor + func testTunerInputServiceIgnoresStaleSignalAfterInputDeviceRestart() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + provider.deviceIDs["input-2"] = 84 + let engine = MockTunerInputEngine() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + settingsStore.updateAudioInputDeviceUID("input-2") + await tunerDrainMainQueue() + await Task.yield() + await tunerDrainMainQueue() + + XCTAssertEqual(engine.startDeviceIDs, [42, 84]) + + engine.sendAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5], toStartAt: 0) + await tunerDrainMainQueue() + + XCTAssertEqual(service.inputSignalLevel, 0) + XCTAssertEqual(service.inputDebugSnapshot.tapCallbackCount, 0) + } + + @MainActor + func testTunerInputServiceKeepsRunningWhenSignalLevelUpdates() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + engine.audioBuffers = [ + MockAudioBuffer(samples: tunerSineWave(frequency: 440, duration: 0.4), sampleRate: 44_100) + ] + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + await tunerDrainMainQueue() + + XCTAssertGreaterThan(service.inputSignalLevel, 0) + XCTAssertNil(service.errorMessage) + } + + @MainActor + func testTunerInputServiceCoalescesPendingAnalysisToLatestBuffer() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = BlockingPitchDetector() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) + + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + engine.sendAudioBuffer(samples: tunerMarkerSamples(3)) + detector.releaseFirstDetection() + + let didPublishLatestResult = await waitForTunerMainActorCondition { service.currentResult?.noteName == "C" } + XCTAssertTrue(didPublishLatestResult) + XCTAssertEqual(detector.detectedMarkers, [1, 3]) + } + + @MainActor + func testTunerInputServiceIgnoresStalePitchAfterStop() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = BlockingPitchDetector() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) + + service.stop() + detector.releaseFirstDetection() + await tunerDrainMainQueue() + await Task.yield() + await tunerDrainMainQueue() + + XCTAssertNil(service.currentResult) + } + + @MainActor + func testTunerInputServicePassesEngineSampleRateToDetector() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = RecordingPitchDetector() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1), sampleRate: 48_000) + + let didRecordSampleRate = await waitForTunerMainActorCondition { detector.sampleRates == [48_000] } + XCTAssertTrue(didRecordSampleRate) + } +} diff --git a/JammLabTests/TunerInputServiceTests.swift b/JammLabTests/TunerInputServiceTests.swift index 2b22b7a..cb44b2a 100644 --- a/JammLabTests/TunerInputServiceTests.swift +++ b/JammLabTests/TunerInputServiceTests.swift @@ -185,244 +185,4 @@ final class TunerInputServiceTests: XCTestCase { XCTAssertEqual(service.inputSignalLevel, 0) } - @MainActor - func testTunerInputServicePublishesSignalLevelWhenPitchIsUnavailable() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - engine.debugEvents = [ - .deviceSwitch(status: noErr), - .format(sampleRate: 44_100, channelCount: 1, commonFormat: .pcmFormatFloat32, isInterleaved: false) - ] - engine.audioBuffers = [ - MockAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5], sampleRate: 44_100) - ] - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - await tunerDrainMainQueue() - await Task.yield() - await tunerDrainMainQueue() - - XCTAssertGreaterThan(service.inputSignalLevel, 0) - XCTAssertNil(service.currentResult) - XCTAssertEqual(service.inputDebugSnapshot.deviceSwitchStatus, noErr) - XCTAssertEqual(service.inputDebugSnapshot.engineSampleRate, 44_100) - XCTAssertEqual(service.inputDebugSnapshot.engineChannelCount, 1) - XCTAssertEqual(service.inputDebugSnapshot.engineCommonFormat, .pcmFormatFloat32) - XCTAssertEqual(service.inputDebugSnapshot.engineIsInterleaved, false) - XCTAssertEqual(service.inputDebugSnapshot.tapCallbackCount, 0) - XCTAssertEqual(service.inputDebugSnapshot.conversionStatus, .notStarted) - } - - @MainActor - func testTunerInputServiceIgnoresStaleSignalAfterStop() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - service.stop() - engine.sendAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5]) - await tunerDrainMainQueue() - - XCTAssertEqual(service.inputSignalLevel, 0) - } - - @MainActor - func testTunerInputServiceIgnoresEmptyInputBuffer() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - engine.sendAudioBuffer(samples: []) - await tunerDrainMainQueue() - - XCTAssertEqual(service.inputSignalLevel, 0) - XCTAssertNil(service.currentResult) - XCTAssertEqual(service.inputDebugSnapshot.conversionStatus, .notStarted) - } - - @MainActor - func testTunerInputServiceIgnoresUnsupportedInputBuffer() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - engine.sendAudioBuffer(try makeTunerInt16Buffer(samples: [1000, -1000, 1000, -1000])) - await tunerDrainMainQueue() - - XCTAssertEqual(service.inputSignalLevel, 0) - XCTAssertNil(service.currentResult) - XCTAssertEqual(service.inputDebugSnapshot.conversionStatus, .notStarted) - } - - @MainActor - func testTunerInputServiceIgnoresStaleSignalAfterInputDeviceRestart() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - provider.deviceIDs["input-2"] = 84 - let engine = MockTunerInputEngine() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - settingsStore.updateAudioInputDeviceUID("input-2") - await tunerDrainMainQueue() - await Task.yield() - await tunerDrainMainQueue() - - XCTAssertEqual(engine.startDeviceIDs, [42, 84]) - - engine.sendAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5], toStartAt: 0) - await tunerDrainMainQueue() - - XCTAssertEqual(service.inputSignalLevel, 0) - XCTAssertEqual(service.inputDebugSnapshot.tapCallbackCount, 0) - } - - @MainActor - func testTunerInputServiceKeepsRunningWhenSignalLevelUpdates() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - engine.audioBuffers = [ - MockAudioBuffer(samples: tunerSineWave(frequency: 440, duration: 0.4), sampleRate: 44_100) - ] - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - await tunerDrainMainQueue() - - XCTAssertGreaterThan(service.inputSignalLevel, 0) - XCTAssertNil(service.errorMessage) - } - - @MainActor - func testTunerInputServiceCoalescesPendingAnalysisToLatestBuffer() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = BlockingPitchDetector() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) - await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) - - engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) - engine.sendAudioBuffer(samples: tunerMarkerSamples(3)) - detector.releaseFirstDetection() - - let didPublishLatestResult = await waitForTunerMainActorCondition { service.currentResult?.noteName == "C" } - XCTAssertTrue(didPublishLatestResult) - XCTAssertEqual(detector.detectedMarkers, [1, 3]) - } - - @MainActor - func testTunerInputServiceIgnoresStalePitchAfterStop() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = BlockingPitchDetector() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) - await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) - - service.stop() - detector.releaseFirstDetection() - await tunerDrainMainQueue() - await Task.yield() - await tunerDrainMainQueue() - - XCTAssertNil(service.currentResult) - } - - @MainActor - func testTunerInputServicePassesEngineSampleRateToDetector() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = RecordingPitchDetector() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1), sampleRate: 48_000) - - let didRecordSampleRate = await waitForTunerMainActorCondition { detector.sampleRates == [48_000] } - XCTAssertTrue(didRecordSampleRate) - } - } From e2b9b9a684f1f004ea2a7b3d752ad734eb49c1c7 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 11:53:32 +0300 Subject: [PATCH 49/80] test: split notation fallback geometry tests --- JammLab.xcodeproj/project.pbxproj | 4 ++ .../NotationFallbackGeometryTests.swift | 63 +++++++++++++++++++ .../NotationMeasureGeometryTests.swift | 58 ----------------- 3 files changed, 67 insertions(+), 58 deletions(-) create mode 100644 JammLabTests/NotationFallbackGeometryTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index e22276f..d0f917f 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -156,6 +156,7 @@ 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */; }; 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00152CE0000100112233 /* NotationViewportTests.swift */; }; 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */; }; + 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */; }; 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */; }; 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */; }; 9FAB01192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */; }; @@ -380,6 +381,7 @@ 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureTimingTests.swift; sourceTree = ""; }; 9FAB00152CE0000100112233 /* NotationViewportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTests.swift; sourceTree = ""; }; 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureGeometryTests.swift; sourceTree = ""; }; + 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationFallbackGeometryTests.swift; sourceTree = ""; }; 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSelectionOverlayLayoutTests.swift; sourceTree = ""; }; 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSlashBeatLayoutTests.swift; sourceTree = ""; }; 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyLabelLayoutTests.swift; sourceTree = ""; }; @@ -689,6 +691,7 @@ 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */, 9FAB00152CE0000100112233 /* NotationViewportTests.swift */, 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, + 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */, 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */, 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */, 9FAB00192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift */, @@ -1119,6 +1122,7 @@ 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */, 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */, 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */, + 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */, 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */, 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */, 9FAB01192CE0000100112233 /* NotationHarmonyLabelLayoutTests.swift in Sources */, diff --git a/JammLabTests/NotationFallbackGeometryTests.swift b/JammLabTests/NotationFallbackGeometryTests.swift new file mode 100644 index 0000000..01ca68d --- /dev/null +++ b/JammLabTests/NotationFallbackGeometryTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import JammLab + +final class NotationFallbackGeometryTests: XCTestCase { + func testNotationMeasureLayoutFallbackGeometryPreservesSymmetricOuterInsets() { + let totalWidth: CGFloat = 296 + + let geometries = NotationMeasureLayout.fallbackCanvasGeometries( + measureCount: 0, + totalWidth: totalWidth + ) + let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) + + XCTAssertEqual(geometries.count, 1) + XCTAssertEqual(geometries[0].contentStartX, 0, accuracy: 0.0001) + XCTAssertEqual(geometries[0].staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertEqual(geometries[0].staffEndX, totalWidth - AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertTrue(geometries[0].includesRawStartBarline) + XCTAssertFalse(geometries[0].contentStartsAfterCellBoundary) + XCTAssertEqual(geometries[0].leadingBarlineX ?? -1, geometries[0].staffStartX, accuracy: 0.0001) + XCTAssertEqual(barlines.last?.x ?? -1, geometries[0].staffEndX, accuracy: 0.0001) + XCTAssertEqual( + NotationMeasureLayout.playheadX(geometry: geometries[0], progress: 0), + geometries[0].contentStartX, + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.playheadIndicatorX( + geometry: geometries[0], + progress: 0, + indicatorWidth: AppTheme.Stroke.thick + ), + geometries[0].staffStartX, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutClampsSymmetricOuterInsetsForNarrowWidth() { + let inset = AppTheme.Timeline.notationStaffHorizontalInset + let totalWidth = inset * 1.5 + + let geometries = NotationMeasureLayout.fallbackCanvasGeometries( + measureCount: 1, + totalWidth: totalWidth + ) + let geometry = geometries[0] + let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) + + XCTAssertEqual(geometry.staffStartX, inset, accuracy: 0.0001) + XCTAssertEqual(geometry.staffEndX, geometry.staffStartX, accuracy: 0.0001) + XCTAssertEqual(barlines.first?.x ?? -1, geometry.staffStartX, accuracy: 0.0001) + XCTAssertEqual(barlines.last?.x ?? -1, geometry.staffEndX, accuracy: 0.0001) + XCTAssertEqual( + NotationMeasureLayout.playheadIndicatorX( + geometry: geometry, + progress: 1, + indicatorWidth: AppTheme.Stroke.thick + ), + geometry.staffStartX, + accuracy: 0.0001 + ) + } +} diff --git a/JammLabTests/NotationMeasureGeometryTests.swift b/JammLabTests/NotationMeasureGeometryTests.swift index 37d06cb..526c60f 100644 --- a/JammLabTests/NotationMeasureGeometryTests.swift +++ b/JammLabTests/NotationMeasureGeometryTests.swift @@ -349,62 +349,4 @@ final class NotationMeasureGeometryTests: XCTestCase { XCTAssertFalse(barlines.contains { abs($0.x - geometries[2].contentStartX) < 0.0001 }) } - func testNotationMeasureLayoutFallbackGeometryPreservesSymmetricOuterInsets() { - let totalWidth: CGFloat = 296 - - let geometries = NotationMeasureLayout.fallbackCanvasGeometries( - measureCount: 0, - totalWidth: totalWidth - ) - let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) - - XCTAssertEqual(geometries.count, 1) - XCTAssertEqual(geometries[0].contentStartX, 0, accuracy: 0.0001) - XCTAssertEqual(geometries[0].staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertEqual(geometries[0].staffEndX, totalWidth - AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertTrue(geometries[0].includesRawStartBarline) - XCTAssertFalse(geometries[0].contentStartsAfterCellBoundary) - XCTAssertEqual(geometries[0].leadingBarlineX ?? -1, geometries[0].staffStartX, accuracy: 0.0001) - XCTAssertEqual(barlines.last?.x ?? -1, geometries[0].staffEndX, accuracy: 0.0001) - XCTAssertEqual( - NotationMeasureLayout.playheadX(geometry: geometries[0], progress: 0), - geometries[0].contentStartX, - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.playheadIndicatorX( - geometry: geometries[0], - progress: 0, - indicatorWidth: AppTheme.Stroke.thick - ), - geometries[0].staffStartX, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutClampsSymmetricOuterInsetsForNarrowWidth() { - let inset = AppTheme.Timeline.notationStaffHorizontalInset - let totalWidth = inset * 1.5 - - let geometries = NotationMeasureLayout.fallbackCanvasGeometries( - measureCount: 1, - totalWidth: totalWidth - ) - let geometry = geometries[0] - let barlines = NotationMeasureLayout.barlineGeometries(for: geometries) - - XCTAssertEqual(geometry.staffStartX, inset, accuracy: 0.0001) - XCTAssertEqual(geometry.staffEndX, geometry.staffStartX, accuracy: 0.0001) - XCTAssertEqual(barlines.first?.x ?? -1, geometry.staffStartX, accuracy: 0.0001) - XCTAssertEqual(barlines.last?.x ?? -1, geometry.staffEndX, accuracy: 0.0001) - XCTAssertEqual( - NotationMeasureLayout.playheadIndicatorX( - geometry: geometry, - progress: 1, - indicatorWidth: AppTheme.Stroke.thick - ), - geometry.staffStartX, - accuracy: 0.0001 - ) - } } From f5ef2fc64e13f0d741ac66446164a67412465ce0 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 12:05:07 +0300 Subject: [PATCH 50/80] test: split focused model and artifact tests --- JammLab.xcodeproj/project.pbxproj | 12 + .../NotationMusicXMLChordParserTests.swift | 40 ++++ JammLabTests/NotationMusicXMLTests.swift | 36 --- JammLabTests/ProjectArtifactStoreTests.swift | 212 ----------------- ...tPersistenceCoordinatorArtifactTests.swift | 217 ++++++++++++++++++ JammLabTests/TimecodedNoteColorTests.swift | 134 +++++++++++ JammLabTests/TimelineProjectLogicTests.swift | 130 ----------- 7 files changed, 403 insertions(+), 378 deletions(-) create mode 100644 JammLabTests/NotationMusicXMLChordParserTests.swift create mode 100644 JammLabTests/ProjectPersistenceCoordinatorArtifactTests.swift create mode 100644 JammLabTests/TimecodedNoteColorTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index d0f917f..53bb597 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */; }; 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */; }; 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */; }; + 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */; }; 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */; }; 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */; }; 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */; }; @@ -127,6 +128,7 @@ 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB013C2CE0000100112233 /* StemJobWorkflowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */; }; 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */; }; + 9FAB01412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */; }; @@ -147,6 +149,7 @@ 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */; }; 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */; }; 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */; }; + 9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */; }; 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */; }; 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */; }; 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */; }; @@ -331,6 +334,7 @@ 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileImporterDurationTests.swift; sourceTree = ""; }; 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeakformLogicTests.swift; sourceTree = ""; }; 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProjectLogicTests.swift; sourceTree = ""; }; + 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimecodedNoteColorTests.swift; sourceTree = ""; }; 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewportLogicTests.swift; sourceTree = ""; }; 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyTests.swift; sourceTree = ""; }; 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyEventFilterTests.swift; sourceTree = ""; }; @@ -352,6 +356,7 @@ 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemJobWorkflowTests.swift; sourceTree = ""; }; 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStoreTests.swift; sourceTree = ""; }; + 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectPersistenceCoordinatorArtifactTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceSignalTests.swift; sourceTree = ""; }; @@ -372,6 +377,7 @@ 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridAndTempoMapTests.swift; sourceTree = ""; }; 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationPrimitivesTests.swift; sourceTree = ""; }; 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = ""; }; + 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLChordParserTests.swift; sourceTree = ""; }; 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationKeySignatureTests.swift; sourceTree = ""; }; 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeDisplayTests.swift; sourceTree = ""; }; 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRegionLabelTests.swift; sourceTree = ""; }; @@ -641,6 +647,7 @@ 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */, 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, + 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */, 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */, 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */, @@ -662,6 +669,7 @@ 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */, 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, + 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */, @@ -682,6 +690,7 @@ 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */, 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */, 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */, + 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */, 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */, 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */, 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */, @@ -1072,6 +1081,7 @@ 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */, 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, + 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */, 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */, 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */, 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */, @@ -1093,6 +1103,7 @@ 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB013C2CE0000100112233 /* StemJobWorkflowTests.swift in Sources */, 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */, + 9FAB01412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */, @@ -1113,6 +1124,7 @@ 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */, 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */, 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */, + 9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */, 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */, 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */, 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */, diff --git a/JammLabTests/NotationMusicXMLChordParserTests.swift b/JammLabTests/NotationMusicXMLChordParserTests.swift new file mode 100644 index 0000000..9231282 --- /dev/null +++ b/JammLabTests/NotationMusicXMLChordParserTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import JammLab + +final class NotationMusicXMLChordParserTests: XCTestCase { + func testMusicXMLChordParserSupportsSemanticChords() throws { + let plain = try MusicXMLChordParser.parse("C", measureNumber: 1) + XCTAssertEqual(plain.root, MusicXMLPitchStep(step: "C", alter: 0)) + XCTAssertEqual(plain.kindValue, "major") + + let minor = try MusicXMLChordParser.parse("Am", measureNumber: 1) + XCTAssertEqual(minor.root, MusicXMLPitchStep(step: "A", alter: 0)) + XCTAssertEqual(minor.kindValue, "minor") + + let altered = try MusicXMLChordParser.parse("Bb13(#11)/D", measureNumber: 2) + XCTAssertEqual(altered.root, MusicXMLPitchStep(step: "B", alter: -1)) + XCTAssertEqual(altered.kindValue, "dominant-13th") + XCTAssertEqual(altered.bass, MusicXMLPitchStep(step: "D", alter: 0)) + XCTAssertEqual(altered.degrees, [ + MusicXMLChordDegree(value: 11, alter: 1, type: .alter) + ]) + + let halfDiminished = try MusicXMLChordParser.parse("C#m7b5", measureNumber: 3) + XCTAssertEqual(halfDiminished.root, MusicXMLPitchStep(step: "C", alter: 1)) + XCTAssertEqual(halfDiminished.kindValue, "half-diminished") + + let added = try MusicXMLChordParser.parse("Aadd9", measureNumber: 4) + XCTAssertEqual(added.kindValue, "major") + XCTAssertEqual(added.degrees, [ + MusicXMLChordDegree(value: 9, alter: 0, type: .add) + ]) + } + + func testMusicXMLChordParserRejectsUnsupportedChords() { + XCTAssertThrowsError(try MusicXMLChordParser.parse("", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("H7", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("G7alt", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("C(foo)", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("C/G/B", measureNumber: 1)) + } +} diff --git a/JammLabTests/NotationMusicXMLTests.swift b/JammLabTests/NotationMusicXMLTests.swift index fc90de5..0daec13 100644 --- a/JammLabTests/NotationMusicXMLTests.swift +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -3,42 +3,6 @@ import XCTest @testable import JammLab final class NotationMusicXMLTests: XCTestCase { - func testMusicXMLChordParserSupportsSemanticChords() throws { - let plain = try MusicXMLChordParser.parse("C", measureNumber: 1) - XCTAssertEqual(plain.root, MusicXMLPitchStep(step: "C", alter: 0)) - XCTAssertEqual(plain.kindValue, "major") - - let minor = try MusicXMLChordParser.parse("Am", measureNumber: 1) - XCTAssertEqual(minor.root, MusicXMLPitchStep(step: "A", alter: 0)) - XCTAssertEqual(minor.kindValue, "minor") - - let altered = try MusicXMLChordParser.parse("Bb13(#11)/D", measureNumber: 2) - XCTAssertEqual(altered.root, MusicXMLPitchStep(step: "B", alter: -1)) - XCTAssertEqual(altered.kindValue, "dominant-13th") - XCTAssertEqual(altered.bass, MusicXMLPitchStep(step: "D", alter: 0)) - XCTAssertEqual(altered.degrees, [ - MusicXMLChordDegree(value: 11, alter: 1, type: .alter) - ]) - - let halfDiminished = try MusicXMLChordParser.parse("C#m7b5", measureNumber: 3) - XCTAssertEqual(halfDiminished.root, MusicXMLPitchStep(step: "C", alter: 1)) - XCTAssertEqual(halfDiminished.kindValue, "half-diminished") - - let added = try MusicXMLChordParser.parse("Aadd9", measureNumber: 4) - XCTAssertEqual(added.kindValue, "major") - XCTAssertEqual(added.degrees, [ - MusicXMLChordDegree(value: 9, alter: 0, type: .add) - ]) - } - - func testMusicXMLChordParserRejectsUnsupportedChords() { - XCTAssertThrowsError(try MusicXMLChordParser.parse("", measureNumber: 1)) - XCTAssertThrowsError(try MusicXMLChordParser.parse("H7", measureNumber: 1)) - XCTAssertThrowsError(try MusicXMLChordParser.parse("G7alt", measureNumber: 1)) - XCTAssertThrowsError(try MusicXMLChordParser.parse("C(foo)", measureNumber: 1)) - XCTAssertThrowsError(try MusicXMLChordParser.parse("C/G/B", measureNumber: 1)) - } - func testMusicXMLExportIncludesMeasuresAttributesHarmonyAndRegionDirections() throws { let regionID = UUID(uuidString: "00000000-0000-0000-0000-000000000401")! let state = NotationViewportFactory().scoreState( diff --git a/JammLabTests/ProjectArtifactStoreTests.swift b/JammLabTests/ProjectArtifactStoreTests.swift index b3cb600..ce76e89 100644 --- a/JammLabTests/ProjectArtifactStoreTests.swift +++ b/JammLabTests/ProjectArtifactStoreTests.swift @@ -142,216 +142,4 @@ final class ProjectArtifactStoreTests: XCTestCase { XCTAssertEqual(store.existingVideoAudioURL(for: projectURL), persisted.url) } - func testProjectPersistenceCoordinatorPersistsVideoAudioAndReturnsTemporaryCleanupURL() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let cachedAudioURL = try temporaryFile(in: directory, name: "cached-audio.m4a", contents: "audio") - let videoURL = directory.appendingPathComponent("lesson.mov") - let projectURL = directory.appendingPathComponent("Song.jammlab") - let store = ProjectArtifactStore() - let coordinator = try makeProjectPersistenceCoordinator(projectArtifactStore: store) - let input = ProjectSaveArtifactsInput( - importedFile: ImportedAudioFile( - url: cachedAudioURL, - sourceMediaURL: videoURL, - displayName: "lesson.mov", - duration: 12, - mediaKind: .video - ), - projectURL: projectURL, - peakformData: nil, - stemPeakforms: [:], - stemFiles: [], - stemCacheMetadata: nil - ) - - let result = try await coordinator.prepareSaveArtifacts(input) - - XCTAssertEqual(result.importedFile?.url, store.videoAudioURL(for: projectURL)) - XCTAssertEqual(result.temporaryVideoAudioURLToRemove, cachedAudioURL) - XCTAssertEqual(try String(contentsOf: store.videoAudioURL(for: projectURL), encoding: .utf8), "audio") - } - - func testProjectPersistenceCoordinatorWritesPeakformsAndStemMetadata() async throws { - let directory = temporaryDirectory() - let stemSourceDirectory = directory.appendingPathComponent("stem-source", isDirectory: true) - try FileManager.default.createDirectory(at: stemSourceDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let audioURL = try temporaryFile(in: directory, name: "song.wav", contents: "audio") - let projectURL = directory.appendingPathComponent("Song.jammlab") - let store = ProjectArtifactStore() - let coordinator = try makeProjectPersistenceCoordinator(projectArtifactStore: store) - let peakform = PeakformData( - duration: 1, - sampleRate: 44_100, - levels: [PeakformLevel(samplesPerPeak: 512, peaks: [PeakPoint(min: -0.5, max: 0.5, rms: 0.2)])] - ) - let stems = try StemSeparationMethod.fourStem.stemTypes.map { type in - StemFile( - type: type, - url: try temporaryFile(in: stemSourceDirectory, name: "\(type.rawValue).wav", contents: type.rawValue), - displayName: type.title - ) - } - let metadata = StemCacheMetadata( - cacheKey: "cache-key", - sourceFingerprint: StemSourceFingerprint(path: audioURL.path, fileSize: 5, modificationTime: 10), - backendIdentifier: "JammLabSeparatorHelper/test", - separationMethodID: StemSeparationMethod.fourStem.id, - modelName: StemSeparationMethod.fourStem.modelName, - settingsVersion: 2, - createdAt: Date(timeIntervalSince1970: 100), - stems: stems - ) - let input = ProjectSaveArtifactsInput( - importedFile: ImportedAudioFile(url: audioURL, displayName: "song.wav", duration: 1), - projectURL: projectURL, - peakformData: peakform, - stemPeakforms: [.vocals: peakform], - stemFiles: stems, - stemCacheMetadata: metadata - ) - - let result = try await coordinator.prepareSaveArtifacts(input) - - XCTAssertNotNil(try store.readMainPeakform(projectURL: projectURL)) - XCTAssertNotNil(try store.readStemPeakform(type: .vocals, projectURL: projectURL)) - XCTAssertEqual(result.peakformURLsToRemove, [audioURL] + stems.map(\.url)) - XCTAssertEqual(result.stemMetadata?.cacheKey, metadata.cacheKey) - XCTAssertEqual(result.stemCacheKeyToRemove, metadata.cacheKey) - XCTAssertEqual(result.stemMetadata?.stems.map { $0.url.deletingLastPathComponent() }, Array(repeating: store.stemsDirectory(for: projectURL), count: StemSeparationMethod.fourStem.stemTypes.count)) - } - - func testProjectPersistenceCoordinatorOpenMediaPrefersProjectLocalVideoAudio() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let projectService = ProjectDocumentService() - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") - try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) - let localAudioURL = store.videoAudioURL(for: projectURL) - try Data("local-audio".utf8).write(to: localAudioURL) - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - decodedDuration: { url in - XCTAssertEqual(url, localAudioURL) - return 9 - } - ) - let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, localAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, videoURL) - XCTAssertEqual(result.file.mediaKind, .video) - XCTAssertEqual(result.projectDuration, 9) - XCTAssertFalse(result.shouldAnalyzeTempo) - XCTAssertNil(result.warningMessage) - } - - func testProjectPersistenceCoordinatorOpenVideoWithoutLocalAudioUsesRuntimeCacheOnly() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let projectService = ProjectDocumentService() - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") - let extractedAudioURL = try temporaryFile(in: directory, name: "runtime-audio.m4a", contents: "audio") - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - importFileFromURL: { url in - XCTAssertEqual(url, videoURL) - return ImportedAudioFile( - url: extractedAudioURL, - sourceMediaURL: url, - displayName: url.lastPathComponent, - duration: 7, - mediaKind: .video - ) - } - ) - let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, extractedAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, videoURL) - XCTAssertEqual(result.file.mediaKind, .video) - XCTAssertEqual(result.projectDuration, 7) - XCTAssertFalse(FileManager.default.fileExists(atPath: store.mediaDirectory(for: projectURL).path)) - } - - func testProjectPersistenceCoordinatorMissingVideoSourceFallsBackToLocalAudioWithWarning() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) - let localAudioURL = store.videoAudioURL(for: projectURL) - try Data("local-audio".utf8).write(to: localAudioURL) - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - decodedDuration: { url in - XCTAssertEqual(url, localAudioURL) - return 6 - } - ) - let project = videoProject(bookmarkData: Data("invalid-bookmark".utf8), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, localAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, localAudioURL) - XCTAssertEqual(result.file.mediaKind, .audio) - XCTAssertNil(result.file.videoURL) - XCTAssertEqual(result.projectDuration, 6) - XCTAssertNotNil(result.warningMessage) - } -} - -private extension ProjectArtifactStoreTests { - func makeProjectPersistenceCoordinator( - projectArtifactStore: ProjectArtifactStore, - importFileFromURL: ((URL) async throws -> ImportedAudioFile)? = nil, - decodedDuration: @escaping (URL) throws -> TimeInterval = { _ in 1 } - ) throws -> ProjectPersistenceCoordinator { - ProjectPersistenceCoordinator( - projectArtifactStore: projectArtifactStore, - projectDocumentService: ProjectDocumentService(), - peakformProvider: MockPeakformProvider(), - stemSeparationService: StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - applicationSupportDirectory: temporaryDirectory() - ), - importFileFromURL: importFileFromURL, - decodedDuration: decodedDuration - ) - } - - func videoProject(bookmarkData: Data, duration: TimeInterval) -> JammLabProject { - JammLabProject( - audioBookmarkData: bookmarkData, - audioDisplayName: "lesson.mov", - audioDuration: duration, - mediaKind: .video, - notes: [], - loopStart: 0, - loopEnd: duration, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - } } diff --git a/JammLabTests/ProjectPersistenceCoordinatorArtifactTests.swift b/JammLabTests/ProjectPersistenceCoordinatorArtifactTests.swift new file mode 100644 index 0000000..2d962bd --- /dev/null +++ b/JammLabTests/ProjectPersistenceCoordinatorArtifactTests.swift @@ -0,0 +1,217 @@ +import XCTest +@testable import JammLab + +final class ProjectPersistenceCoordinatorArtifactTests: XCTestCase { + func testProjectPersistenceCoordinatorPersistsVideoAudioAndReturnsTemporaryCleanupURL() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let cachedAudioURL = try temporaryFile(in: directory, name: "cached-audio.m4a", contents: "audio") + let videoURL = directory.appendingPathComponent("lesson.mov") + let projectURL = directory.appendingPathComponent("Song.jammlab") + let store = ProjectArtifactStore() + let coordinator = try makeProjectPersistenceCoordinator(projectArtifactStore: store) + let input = ProjectSaveArtifactsInput( + importedFile: ImportedAudioFile( + url: cachedAudioURL, + sourceMediaURL: videoURL, + displayName: "lesson.mov", + duration: 12, + mediaKind: .video + ), + projectURL: projectURL, + peakformData: nil, + stemPeakforms: [:], + stemFiles: [], + stemCacheMetadata: nil + ) + + let result = try await coordinator.prepareSaveArtifacts(input) + + XCTAssertEqual(result.importedFile?.url, store.videoAudioURL(for: projectURL)) + XCTAssertEqual(result.temporaryVideoAudioURLToRemove, cachedAudioURL) + XCTAssertEqual(try String(contentsOf: store.videoAudioURL(for: projectURL), encoding: .utf8), "audio") + } + + func testProjectPersistenceCoordinatorWritesPeakformsAndStemMetadata() async throws { + let directory = temporaryDirectory() + let stemSourceDirectory = directory.appendingPathComponent("stem-source", isDirectory: true) + try FileManager.default.createDirectory(at: stemSourceDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let audioURL = try temporaryFile(in: directory, name: "song.wav", contents: "audio") + let projectURL = directory.appendingPathComponent("Song.jammlab") + let store = ProjectArtifactStore() + let coordinator = try makeProjectPersistenceCoordinator(projectArtifactStore: store) + let peakform = PeakformData( + duration: 1, + sampleRate: 44_100, + levels: [PeakformLevel(samplesPerPeak: 512, peaks: [PeakPoint(min: -0.5, max: 0.5, rms: 0.2)])] + ) + let stems = try StemSeparationMethod.fourStem.stemTypes.map { type in + StemFile( + type: type, + url: try temporaryFile(in: stemSourceDirectory, name: "\(type.rawValue).wav", contents: type.rawValue), + displayName: type.title + ) + } + let metadata = StemCacheMetadata( + cacheKey: "cache-key", + sourceFingerprint: StemSourceFingerprint(path: audioURL.path, fileSize: 5, modificationTime: 10), + backendIdentifier: "JammLabSeparatorHelper/test", + separationMethodID: StemSeparationMethod.fourStem.id, + modelName: StemSeparationMethod.fourStem.modelName, + settingsVersion: 2, + createdAt: Date(timeIntervalSince1970: 100), + stems: stems + ) + let input = ProjectSaveArtifactsInput( + importedFile: ImportedAudioFile(url: audioURL, displayName: "song.wav", duration: 1), + projectURL: projectURL, + peakformData: peakform, + stemPeakforms: [.vocals: peakform], + stemFiles: stems, + stemCacheMetadata: metadata + ) + + let result = try await coordinator.prepareSaveArtifacts(input) + + XCTAssertNotNil(try store.readMainPeakform(projectURL: projectURL)) + XCTAssertNotNil(try store.readStemPeakform(type: .vocals, projectURL: projectURL)) + XCTAssertEqual(result.peakformURLsToRemove, [audioURL] + stems.map(\.url)) + XCTAssertEqual(result.stemMetadata?.cacheKey, metadata.cacheKey) + XCTAssertEqual(result.stemCacheKeyToRemove, metadata.cacheKey) + XCTAssertEqual(result.stemMetadata?.stems.map { $0.url.deletingLastPathComponent() }, Array(repeating: store.stemsDirectory(for: projectURL), count: StemSeparationMethod.fourStem.stemTypes.count)) + } + + func testProjectPersistenceCoordinatorOpenMediaPrefersProjectLocalVideoAudio() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let projectService = ProjectDocumentService() + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") + try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) + let localAudioURL = store.videoAudioURL(for: projectURL) + try Data("local-audio".utf8).write(to: localAudioURL) + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + decodedDuration: { url in + XCTAssertEqual(url, localAudioURL) + return 9 + } + ) + let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, localAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, videoURL) + XCTAssertEqual(result.file.mediaKind, .video) + XCTAssertEqual(result.projectDuration, 9) + XCTAssertFalse(result.shouldAnalyzeTempo) + XCTAssertNil(result.warningMessage) + } + + func testProjectPersistenceCoordinatorOpenVideoWithoutLocalAudioUsesRuntimeCacheOnly() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let projectService = ProjectDocumentService() + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") + let extractedAudioURL = try temporaryFile(in: directory, name: "runtime-audio.m4a", contents: "audio") + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + importFileFromURL: { url in + XCTAssertEqual(url, videoURL) + return ImportedAudioFile( + url: extractedAudioURL, + sourceMediaURL: url, + displayName: url.lastPathComponent, + duration: 7, + mediaKind: .video + ) + } + ) + let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, extractedAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, videoURL) + XCTAssertEqual(result.file.mediaKind, .video) + XCTAssertEqual(result.projectDuration, 7) + XCTAssertFalse(FileManager.default.fileExists(atPath: store.mediaDirectory(for: projectURL).path)) + } + + func testProjectPersistenceCoordinatorMissingVideoSourceFallsBackToLocalAudioWithWarning() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) + let localAudioURL = store.videoAudioURL(for: projectURL) + try Data("local-audio".utf8).write(to: localAudioURL) + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + decodedDuration: { url in + XCTAssertEqual(url, localAudioURL) + return 6 + } + ) + let project = videoProject(bookmarkData: Data("invalid-bookmark".utf8), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, localAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, localAudioURL) + XCTAssertEqual(result.file.mediaKind, .audio) + XCTAssertNil(result.file.videoURL) + XCTAssertEqual(result.projectDuration, 6) + XCTAssertNotNil(result.warningMessage) + } +} + +private extension ProjectPersistenceCoordinatorArtifactTests { + func makeProjectPersistenceCoordinator( + projectArtifactStore: ProjectArtifactStore, + importFileFromURL: ((URL) async throws -> ImportedAudioFile)? = nil, + decodedDuration: @escaping (URL) throws -> TimeInterval = { _ in 1 } + ) throws -> ProjectPersistenceCoordinator { + ProjectPersistenceCoordinator( + projectArtifactStore: projectArtifactStore, + projectDocumentService: ProjectDocumentService(), + peakformProvider: MockPeakformProvider(), + stemSeparationService: StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + applicationSupportDirectory: temporaryDirectory() + ), + importFileFromURL: importFileFromURL, + decodedDuration: decodedDuration + ) + } + + func videoProject(bookmarkData: Data, duration: TimeInterval) -> JammLabProject { + JammLabProject( + audioBookmarkData: bookmarkData, + audioDisplayName: "lesson.mov", + audioDuration: duration, + mediaKind: .video, + notes: [], + loopStart: 0, + loopEnd: duration, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + } +} diff --git a/JammLabTests/TimecodedNoteColorTests.swift b/JammLabTests/TimecodedNoteColorTests.swift new file mode 100644 index 0000000..5db30d4 --- /dev/null +++ b/JammLabTests/TimecodedNoteColorTests.swift @@ -0,0 +1,134 @@ +import XCTest +@testable import JammLab + +final class TimecodedNoteColorTests: XCTestCase { + func testDefaultNoteColorsResolveByKind() { + let marker = TimecodedNote(time: 1, title: "Marker") + let region = TimecodedNote(kind: .region, time: 2, duration: 3, title: "Region") + + XCTAssertEqual(marker.color, .markerDefault) + XCTAssertEqual(marker.resolvedColorHex, "#A00000") + XCTAssertEqual(region.color, .regionDefault) + XCTAssertEqual(region.resolvedColorHex, "#567272") + } + + func testNoteColorPresetMenusUseKindSpecificPalettes() { + let marker = TimecodedNote(time: 1, title: "Marker") + let region = TimecodedNote(kind: .region, time: 2, duration: 3, title: "Region") + + let markerPresets = NoteColorPreset.presets(for: marker) + let regionPresets = NoteColorPreset.presets(for: region) + + XCTAssertEqual(markerPresets.first?.title, "Default") + XCTAssertEqual(markerPresets.first?.id, .markerDefault) + XCTAssertEqual(markerPresets.map(\.title), ["Default", "Orange", "Yellow", "Blue", "Purple"]) + XCTAssertEqual(markerPresets.map(\.id), [.markerDefault, .markerOrange, .markerYellow, .markerBlue, .markerPurple]) + XCTAssertEqual(markerPresets.map(\.hex), ["#A00000", "#B85A00", "#A88A00", "#1F6FA8", "#7A3FA0"]) + XCTAssertFalse(markerPresets.map(\.title).contains("Marker Default")) + XCTAssertFalse(markerPresets.map(\.title).contains("Region Default")) + + XCTAssertEqual(regionPresets.map(\.title), ["Default", "Green", "Amber", "Blue", "Plum"]) + XCTAssertEqual(regionPresets.map(\.id), [.regionDefault, .regionGreen, .regionAmber, .regionBlue, .regionPlum]) + XCTAssertEqual(regionPresets.map(\.hex), ["#567272", "#66805A", "#9A8048", "#5B7188", "#7A617E"]) + XCTAssertFalse(regionPresets.map(\.title).contains("Marker Default")) + XCTAssertFalse(regionPresets.map(\.title).contains("Region Default")) + } + + func testCustomNoteColorPersistsThroughRoundTrip() throws { + let note = TimecodedNote( + kind: .region, + time: 12.3, + duration: 4.5, + title: "Chorus", + color: .regionBlue, + customColorHex: "12ab34" + ) + let data = try JSONEncoder().encode(note) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(try XCTUnwrap(object["color"] as? String), "regionBlue") + XCTAssertEqual(try XCTUnwrap(object["customColorHex"] as? String), "#12AB34") + + let decoded = try JSONDecoder().decode(TimecodedNote.self, from: data) + XCTAssertEqual(decoded.color, .regionBlue) + XCTAssertEqual(decoded.customColorHex, "#12AB34") + XCTAssertEqual(decoded.resolvedColorHex, "#12AB34") + } + + func testInvalidCustomNoteColorFallsBackToPreset() throws { + let json = """ + { + "id": "00000000-0000-0000-0000-000000000010", + "kind": "marker", + "time": 3.0, + "title": "Bad Color", + "color": "blue", + "customColorHex": "bad-color" + } + """ + + let decoded = try JSONDecoder().decode(TimecodedNote.self, from: Data(json.utf8)) + + XCTAssertNil(decoded.customColorHex) + XCTAssertEqual(decoded.color, .markerDefault) + XCTAssertEqual(decoded.resolvedColorHex, MarkerColor.markerDefault.defaultHex) + } + + func testUnknownOldColorRawValuesDefaultByKind() throws { + let markerJSON = """ + { + "id": "00000000-0000-0000-0000-000000000013", + "kind": "marker", + "time": 3.0, + "title": "Old Marker", + "color": "blue" + } + """ + let regionJSON = """ + { + "id": "00000000-0000-0000-0000-000000000014", + "kind": "region", + "time": 4.0, + "duration": 2.0, + "title": "Old Region", + "color": "green" + } + """ + + let marker = try JSONDecoder().decode(TimecodedNote.self, from: Data(markerJSON.utf8)) + let region = try JSONDecoder().decode(TimecodedNote.self, from: Data(regionJSON.utf8)) + + XCTAssertEqual(marker.color, .markerDefault) + XCTAssertEqual(marker.resolvedColorHex, "#A00000") + XCTAssertEqual(region.color, .regionDefault) + XCTAssertEqual(region.resolvedColorHex, "#567272") + } + + func testMissingColorDefaultsByDecodedKind() throws { + let markerJSON = """ + { + "id": "00000000-0000-0000-0000-000000000011", + "kind": "marker", + "time": 3.0, + "title": "Marker" + } + """ + let regionJSON = """ + { + "id": "00000000-0000-0000-0000-000000000012", + "kind": "region", + "time": 4.0, + "duration": 2.0, + "title": "Region" + } + """ + + let marker = try JSONDecoder().decode(TimecodedNote.self, from: Data(markerJSON.utf8)) + let region = try JSONDecoder().decode(TimecodedNote.self, from: Data(regionJSON.utf8)) + + XCTAssertEqual(marker.color, .markerDefault) + XCTAssertEqual(marker.resolvedColorHex, "#A00000") + XCTAssertEqual(region.color, .regionDefault) + XCTAssertEqual(region.resolvedColorHex, "#567272") + } +} diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index f8730ac..dcfc762 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -70,136 +70,6 @@ final class TimelineProjectLogicTests: XCTestCase { XCTAssertEqual(decoded.regionEndTime, 16.8, accuracy: 0.0001) } - func testDefaultNoteColorsResolveByKind() { - let marker = TimecodedNote(time: 1, title: "Marker") - let region = TimecodedNote(kind: .region, time: 2, duration: 3, title: "Region") - - XCTAssertEqual(marker.color, .markerDefault) - XCTAssertEqual(marker.resolvedColorHex, "#A00000") - XCTAssertEqual(region.color, .regionDefault) - XCTAssertEqual(region.resolvedColorHex, "#567272") - } - - func testNoteColorPresetMenusUseKindSpecificPalettes() { - let marker = TimecodedNote(time: 1, title: "Marker") - let region = TimecodedNote(kind: .region, time: 2, duration: 3, title: "Region") - - let markerPresets = NoteColorPreset.presets(for: marker) - let regionPresets = NoteColorPreset.presets(for: region) - - XCTAssertEqual(markerPresets.first?.title, "Default") - XCTAssertEqual(markerPresets.first?.id, .markerDefault) - XCTAssertEqual(markerPresets.map(\.title), ["Default", "Orange", "Yellow", "Blue", "Purple"]) - XCTAssertEqual(markerPresets.map(\.id), [.markerDefault, .markerOrange, .markerYellow, .markerBlue, .markerPurple]) - XCTAssertEqual(markerPresets.map(\.hex), ["#A00000", "#B85A00", "#A88A00", "#1F6FA8", "#7A3FA0"]) - XCTAssertFalse(markerPresets.map(\.title).contains("Marker Default")) - XCTAssertFalse(markerPresets.map(\.title).contains("Region Default")) - - XCTAssertEqual(regionPresets.map(\.title), ["Default", "Green", "Amber", "Blue", "Plum"]) - XCTAssertEqual(regionPresets.map(\.id), [.regionDefault, .regionGreen, .regionAmber, .regionBlue, .regionPlum]) - XCTAssertEqual(regionPresets.map(\.hex), ["#567272", "#66805A", "#9A8048", "#5B7188", "#7A617E"]) - XCTAssertFalse(regionPresets.map(\.title).contains("Marker Default")) - XCTAssertFalse(regionPresets.map(\.title).contains("Region Default")) - } - - func testCustomNoteColorPersistsThroughRoundTrip() throws { - let note = TimecodedNote( - kind: .region, - time: 12.3, - duration: 4.5, - title: "Chorus", - color: .regionBlue, - customColorHex: "12ab34" - ) - let data = try JSONEncoder().encode(note) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - - XCTAssertEqual(try XCTUnwrap(object["color"] as? String), "regionBlue") - XCTAssertEqual(try XCTUnwrap(object["customColorHex"] as? String), "#12AB34") - - let decoded = try JSONDecoder().decode(TimecodedNote.self, from: data) - XCTAssertEqual(decoded.color, .regionBlue) - XCTAssertEqual(decoded.customColorHex, "#12AB34") - XCTAssertEqual(decoded.resolvedColorHex, "#12AB34") - } - - func testInvalidCustomNoteColorFallsBackToPreset() throws { - let json = """ - { - "id": "00000000-0000-0000-0000-000000000010", - "kind": "marker", - "time": 3.0, - "title": "Bad Color", - "color": "blue", - "customColorHex": "bad-color" - } - """ - - let decoded = try JSONDecoder().decode(TimecodedNote.self, from: Data(json.utf8)) - - XCTAssertNil(decoded.customColorHex) - XCTAssertEqual(decoded.color, .markerDefault) - XCTAssertEqual(decoded.resolvedColorHex, MarkerColor.markerDefault.defaultHex) - } - - func testUnknownOldColorRawValuesDefaultByKind() throws { - let markerJSON = """ - { - "id": "00000000-0000-0000-0000-000000000013", - "kind": "marker", - "time": 3.0, - "title": "Old Marker", - "color": "blue" - } - """ - let regionJSON = """ - { - "id": "00000000-0000-0000-0000-000000000014", - "kind": "region", - "time": 4.0, - "duration": 2.0, - "title": "Old Region", - "color": "green" - } - """ - - let marker = try JSONDecoder().decode(TimecodedNote.self, from: Data(markerJSON.utf8)) - let region = try JSONDecoder().decode(TimecodedNote.self, from: Data(regionJSON.utf8)) - - XCTAssertEqual(marker.color, .markerDefault) - XCTAssertEqual(marker.resolvedColorHex, "#A00000") - XCTAssertEqual(region.color, .regionDefault) - XCTAssertEqual(region.resolvedColorHex, "#567272") - } - - func testMissingColorDefaultsByDecodedKind() throws { - let markerJSON = """ - { - "id": "00000000-0000-0000-0000-000000000011", - "kind": "marker", - "time": 3.0, - "title": "Marker" - } - """ - let regionJSON = """ - { - "id": "00000000-0000-0000-0000-000000000012", - "kind": "region", - "time": 4.0, - "duration": 2.0, - "title": "Region" - } - """ - - let marker = try JSONDecoder().decode(TimecodedNote.self, from: Data(markerJSON.utf8)) - let region = try JSONDecoder().decode(TimecodedNote.self, from: Data(regionJSON.utf8)) - - XCTAssertEqual(marker.color, .markerDefault) - XCTAssertEqual(marker.resolvedColorHex, "#A00000") - XCTAssertEqual(region.color, .regionDefault) - XCTAssertEqual(region.resolvedColorHex, "#567272") - } - func testLegacyRegionEndTimeDecodesToDuration() throws { let json = """ { From 675f33bfee560690ffc55757fbeb83b7cfbd07bd Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 12:13:01 +0300 Subject: [PATCH 51/80] test: split notation geometry and timeline persistence tests --- JammLab.xcodeproj/project.pbxproj | 8 + .../NotationAttributeGeometryTests.swift | 216 ++++++++++++++++++ .../NotationMeasureGeometryTests.swift | 212 ----------------- .../ViewModelProjectRestoreTests.swift | 130 ----------- ...ewModelTimelineRangePersistenceTests.swift | 134 +++++++++++ 5 files changed, 358 insertions(+), 342 deletions(-) create mode 100644 JammLabTests/NotationAttributeGeometryTests.swift create mode 100644 JammLabTests/ViewModelTimelineRangePersistenceTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 53bb597..50201c9 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -117,6 +117,7 @@ 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */; }; 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */; }; + 9FAB01442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */; }; 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */; }; 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */; }; 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; @@ -159,6 +160,7 @@ 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */; }; 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00152CE0000100112233 /* NotationViewportTests.swift */; }; 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */; }; + 9FAB01432CE0000100112233 /* NotationAttributeGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */; }; 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */; }; 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */; }; 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */; }; @@ -345,6 +347,7 @@ 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectKeyPersistenceTests.swift; sourceTree = ""; }; 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectRestoreTests.swift; sourceTree = ""; }; + 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTimelineRangePersistenceTests.swift; sourceTree = ""; }; 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelEditingStateTests.swift; sourceTree = ""; }; 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTempoMapTests.swift; sourceTree = ""; }; 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; @@ -387,6 +390,7 @@ 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureTimingTests.swift; sourceTree = ""; }; 9FAB00152CE0000100112233 /* NotationViewportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTests.swift; sourceTree = ""; }; 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureGeometryTests.swift; sourceTree = ""; }; + 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeGeometryTests.swift; sourceTree = ""; }; 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationFallbackGeometryTests.swift; sourceTree = ""; }; 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSelectionOverlayLayoutTests.swift; sourceTree = ""; }; 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSlashBeatLayoutTests.swift; sourceTree = ""; }; @@ -658,6 +662,7 @@ 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, + 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */, 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, @@ -700,6 +705,7 @@ 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */, 9FAB00152CE0000100112233 /* NotationViewportTests.swift */, 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, + 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */, 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */, 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */, 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */, @@ -1092,6 +1098,7 @@ 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */, 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */, + 9FAB01442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift in Sources */, 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */, 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, @@ -1134,6 +1141,7 @@ 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */, 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */, 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */, + 9FAB01432CE0000100112233 /* NotationAttributeGeometryTests.swift in Sources */, 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */, 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */, 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */, diff --git a/JammLabTests/NotationAttributeGeometryTests.swift b/JammLabTests/NotationAttributeGeometryTests.swift new file mode 100644 index 0000000..bcecb66 --- /dev/null +++ b/JammLabTests/NotationAttributeGeometryTests.swift @@ -0,0 +1,216 @@ +import XCTest +@testable import JammLab + +final class NotationAttributeGeometryTests: XCTestCase { + func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { + let cMajor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "C major"), + timeSignature: .fourFour, + clef: .treble + ) + let aMinor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "A minor"), + timeSignature: .fourFour, + clef: .treble + ) + let fMajor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + + XCTAssertTrue(cMajor.keySignature.notationAccidentalGlyphs(for: cMajor.clef).isEmpty) + XCTAssertTrue(aMinor.keySignature.notationAccidentalGlyphs(for: aMinor.clef).isEmpty) + XCTAssertFalse(fMajor.keySignature.notationAccidentalGlyphs(for: fMajor.clef).isEmpty) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .full), + AppTheme.Timeline.notationAttributeStaffTopInset, + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: aMinor, display: .full), + NotationMeasureLayout.attributeStaffTopInset(for: fMajor, display: .full), + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .none), + 0, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForPartialAttributeBlocks() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "Bb major"), + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + let partialDisplays = [ + NotationAttributeDisplay(showsClef: true, showsKeySignature: false, showsTimeSignature: false), + NotationAttributeDisplay(showsClef: false, showsKeySignature: true, showsTimeSignature: false), + NotationAttributeDisplay(showsClef: false, showsKeySignature: false, showsTimeSignature: true) + ] + + for display in partialDisplays { + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: attributes, display: display), + AppTheme.Timeline.notationAttributeStaffTopInset, + accuracy: 0.0001 + ) + } + } + + func testNotationMeasureLayoutOffsetsAttributedMeasurePlayheadAfterAttributes() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4), + clef: .treble + ) + let cellWidth: CGFloat = 148 + let display = NotationAttributeDisplay.full + let attributeReserveWidth = NotationMeasureLayout.attributeReserveWidth( + for: attributes, + display: display + ) + + let attributedStart = NotationMeasureLayout.playheadX( + measureIndex: 0, + cellWidth: cellWidth, + progress: 0, + attributes: attributes, + display: display + ) + let attributedEnd = NotationMeasureLayout.playheadX( + measureIndex: 0, + cellWidth: cellWidth, + progress: 1, + attributes: attributes, + display: display + ) + let ordinaryStart = NotationMeasureLayout.playheadX( + measureIndex: 1, + cellWidth: cellWidth, + progress: 0, + attributes: attributes, + display: .none + ) + let contentStart = NotationMeasureLayout.contentStartX( + measureIndex: 0, + cellWidth: cellWidth, + attributes: attributes, + display: display + ) + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 0, + measureCount: 4, + cellWidth: cellWidth, + attributes: attributes, + display: display, + totalWidth: cellWidth * 4 + ) + let barlines = NotationMeasureLayout.barlineGeometries(for: [geometry]) + + XCTAssertGreaterThan(attributedStart, AppTheme.Spacing.md) + XCTAssertEqual(attributedStart, contentStart, accuracy: 0.0001) + XCTAssertEqual(attributedStart, attributeReserveWidth, accuracy: 0.0001) + XCTAssertEqual(attributedEnd, contentStart + cellWidth, accuracy: 0.0001) + XCTAssertEqual(ordinaryStart, cellWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.contentStartX, attributedStart, accuracy: 0.0001) + XCTAssertEqual(geometry.contentEndX, contentStart + cellWidth, accuracy: 0.0001) + XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, cellWidth, accuracy: 0.0001) + XCTAssertEqual( + NotationMeasureLayout.playheadX(geometry: geometry, progress: 1), + geometry.contentEndX, + accuracy: 0.0001 + ) + XCTAssertEqual(geometry.staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) + XCTAssertFalse(geometry.includesRawStartBarline) + XCTAssertTrue(geometry.contentStartsAfterCellBoundary) + XCTAssertEqual(geometry.leadingBarlineX ?? -1, geometry.staffStartX, accuracy: 0.0001) + XCTAssertTrue(barlines.contains { abs($0.x - geometry.staffStartX) < 0.0001 }) + XCTAssertFalse(barlines.contains { abs($0.x - geometry.contentStartX) < 0.0001 }) + XCTAssertEqual(barlines.count, 2) + XCTAssertEqual(barlines[1].x, geometry.cellEndX, accuracy: 0.0001) + XCTAssertTrue(barlines[0].isOuterBoundary) + XCTAssertTrue(barlines[1].isOuterBoundary) + } + + func testNotationMeasureLayoutUsesTimeOnlyWidthAtTimeSignatureChange() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + let display = NotationAttributeDisplay( + showsClef: false, + showsKeySignature: false, + showsTimeSignature: true + ) + let cellWidth: CGFloat = 148 + + let blockWidth = NotationMeasureLayout.attributeBlockWidth( + for: attributes, + display: display, + cellWidth: cellWidth + ) + let contentStart = NotationMeasureLayout.contentStartX( + measureIndex: 2, + cellWidth: cellWidth, + attributes: attributes, + display: display + ) + let playheadStart = NotationMeasureLayout.playheadX( + measureIndex: 2, + cellWidth: cellWidth, + progress: 0, + attributes: attributes, + display: display + ) + let geometry = NotationMeasureLayout.canvasGeometry( + measureIndex: 2, + measureCount: 4, + cellWidth: cellWidth, + attributes: attributes, + display: display, + totalWidth: cellWidth * 4 + ) + let barlines = NotationMeasureLayout.barlineGeometries(for: [geometry]) + + XCTAssertEqual(blockWidth, AppTheme.Timeline.notationTimeSignatureWidth, accuracy: 0.0001) + XCTAssertEqual(contentStart, playheadStart, accuracy: 0.0001) + XCTAssertEqual(geometry.staffStartX, geometry.cellStartX, accuracy: 0.0001) + XCTAssertEqual(geometry.contentStartX, contentStart, accuracy: 0.0001) + XCTAssertTrue(geometry.includesRawStartBarline) + XCTAssertTrue(geometry.contentStartsAfterCellBoundary) + XCTAssertTrue(barlines.contains { abs($0.x - geometry.cellStartX) < 0.0001 }) + XCTAssertFalse(barlines.contains { abs($0.x - geometry.contentStartX) < 0.0001 }) + } + + func testNotationMeasureLayoutUsesThemeClefWidthInFullAttributeReserve() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + let blockWidth = NotationMeasureLayout.attributeBlockWidth( + for: attributes, + display: .full, + cellWidth: AppTheme.Timeline.notationMeasureMinWidth + ) + let reserveWidth = NotationMeasureLayout.attributeReserveWidth( + for: attributes, + display: .full + ) + let expectedBlockWidth = AppTheme.Timeline.notationClefWidth + + NotationMeasureLayout.keySignatureWidth(for: attributes) + + AppTheme.Timeline.notationTimeSignatureWidth + + NotationMeasureLayout.spacingWidth(forVisibleComponentCount: 3) + + XCTAssertEqual(blockWidth, expectedBlockWidth, accuracy: 0.0001) + XCTAssertEqual( + reserveWidth, + AppTheme.Spacing.md + expectedBlockWidth + AppTheme.Spacing.xs, + accuracy: 0.0001 + ) + } +} diff --git a/JammLabTests/NotationMeasureGeometryTests.swift b/JammLabTests/NotationMeasureGeometryTests.swift index 526c60f..9bd78fa 100644 --- a/JammLabTests/NotationMeasureGeometryTests.swift +++ b/JammLabTests/NotationMeasureGeometryTests.swift @@ -2,218 +2,6 @@ import XCTest @testable import JammLab final class NotationMeasureGeometryTests: XCTestCase { - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { - let cMajor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "C major"), - timeSignature: .fourFour, - clef: .treble - ) - let aMinor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "A minor"), - timeSignature: .fourFour, - clef: .treble - ) - let fMajor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - - XCTAssertTrue(cMajor.keySignature.notationAccidentalGlyphs(for: cMajor.clef).isEmpty) - XCTAssertTrue(aMinor.keySignature.notationAccidentalGlyphs(for: aMinor.clef).isEmpty) - XCTAssertFalse(fMajor.keySignature.notationAccidentalGlyphs(for: fMajor.clef).isEmpty) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .full), - AppTheme.Timeline.notationAttributeStaffTopInset, - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: aMinor, display: .full), - NotationMeasureLayout.attributeStaffTopInset(for: fMajor, display: .full), - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .none), - 0, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForPartialAttributeBlocks() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "Bb major"), - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - let partialDisplays = [ - NotationAttributeDisplay(showsClef: true, showsKeySignature: false, showsTimeSignature: false), - NotationAttributeDisplay(showsClef: false, showsKeySignature: true, showsTimeSignature: false), - NotationAttributeDisplay(showsClef: false, showsKeySignature: false, showsTimeSignature: true) - ] - - for display in partialDisplays { - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: attributes, display: display), - AppTheme.Timeline.notationAttributeStaffTopInset, - accuracy: 0.0001 - ) - } - } - - func testNotationMeasureLayoutOffsetsAttributedMeasurePlayheadAfterAttributes() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4), - clef: .treble - ) - let cellWidth: CGFloat = 148 - let display = NotationAttributeDisplay.full - let attributeReserveWidth = NotationMeasureLayout.attributeReserveWidth( - for: attributes, - display: display - ) - - let attributedStart = NotationMeasureLayout.playheadX( - measureIndex: 0, - cellWidth: cellWidth, - progress: 0, - attributes: attributes, - display: display - ) - let attributedEnd = NotationMeasureLayout.playheadX( - measureIndex: 0, - cellWidth: cellWidth, - progress: 1, - attributes: attributes, - display: display - ) - let ordinaryStart = NotationMeasureLayout.playheadX( - measureIndex: 1, - cellWidth: cellWidth, - progress: 0, - attributes: attributes, - display: .none - ) - let contentStart = NotationMeasureLayout.contentStartX( - measureIndex: 0, - cellWidth: cellWidth, - attributes: attributes, - display: display - ) - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 0, - measureCount: 4, - cellWidth: cellWidth, - attributes: attributes, - display: display, - totalWidth: cellWidth * 4 - ) - let barlines = NotationMeasureLayout.barlineGeometries(for: [geometry]) - - XCTAssertGreaterThan(attributedStart, AppTheme.Spacing.md) - XCTAssertEqual(attributedStart, contentStart, accuracy: 0.0001) - XCTAssertEqual(attributedStart, attributeReserveWidth, accuracy: 0.0001) - XCTAssertEqual(attributedEnd, contentStart + cellWidth, accuracy: 0.0001) - XCTAssertEqual(ordinaryStart, cellWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.contentStartX, attributedStart, accuracy: 0.0001) - XCTAssertEqual(geometry.contentEndX, contentStart + cellWidth, accuracy: 0.0001) - XCTAssertEqual(geometry.contentEndX - geometry.contentStartX, cellWidth, accuracy: 0.0001) - XCTAssertEqual( - NotationMeasureLayout.playheadX(geometry: geometry, progress: 1), - geometry.contentEndX, - accuracy: 0.0001 - ) - XCTAssertEqual(geometry.staffStartX, AppTheme.Timeline.notationStaffHorizontalInset, accuracy: 0.0001) - XCTAssertFalse(geometry.includesRawStartBarline) - XCTAssertTrue(geometry.contentStartsAfterCellBoundary) - XCTAssertEqual(geometry.leadingBarlineX ?? -1, geometry.staffStartX, accuracy: 0.0001) - XCTAssertTrue(barlines.contains { abs($0.x - geometry.staffStartX) < 0.0001 }) - XCTAssertFalse(barlines.contains { abs($0.x - geometry.contentStartX) < 0.0001 }) - XCTAssertEqual(barlines.count, 2) - XCTAssertEqual(barlines[1].x, geometry.cellEndX, accuracy: 0.0001) - XCTAssertTrue(barlines[0].isOuterBoundary) - XCTAssertTrue(barlines[1].isOuterBoundary) - } - - func testNotationMeasureLayoutUsesTimeOnlyWidthAtTimeSignatureChange() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - let display = NotationAttributeDisplay( - showsClef: false, - showsKeySignature: false, - showsTimeSignature: true - ) - let cellWidth: CGFloat = 148 - - let blockWidth = NotationMeasureLayout.attributeBlockWidth( - for: attributes, - display: display, - cellWidth: cellWidth - ) - let contentStart = NotationMeasureLayout.contentStartX( - measureIndex: 2, - cellWidth: cellWidth, - attributes: attributes, - display: display - ) - let playheadStart = NotationMeasureLayout.playheadX( - measureIndex: 2, - cellWidth: cellWidth, - progress: 0, - attributes: attributes, - display: display - ) - let geometry = NotationMeasureLayout.canvasGeometry( - measureIndex: 2, - measureCount: 4, - cellWidth: cellWidth, - attributes: attributes, - display: display, - totalWidth: cellWidth * 4 - ) - let barlines = NotationMeasureLayout.barlineGeometries(for: [geometry]) - - XCTAssertEqual(blockWidth, AppTheme.Timeline.notationTimeSignatureWidth, accuracy: 0.0001) - XCTAssertEqual(contentStart, playheadStart, accuracy: 0.0001) - XCTAssertEqual(geometry.staffStartX, geometry.cellStartX, accuracy: 0.0001) - XCTAssertEqual(geometry.contentStartX, contentStart, accuracy: 0.0001) - XCTAssertTrue(geometry.includesRawStartBarline) - XCTAssertTrue(geometry.contentStartsAfterCellBoundary) - XCTAssertTrue(barlines.contains { abs($0.x - geometry.cellStartX) < 0.0001 }) - XCTAssertFalse(barlines.contains { abs($0.x - geometry.contentStartX) < 0.0001 }) - } - - func testNotationMeasureLayoutUsesThemeClefWidthInFullAttributeReserve() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - let blockWidth = NotationMeasureLayout.attributeBlockWidth( - for: attributes, - display: .full, - cellWidth: AppTheme.Timeline.notationMeasureMinWidth - ) - let reserveWidth = NotationMeasureLayout.attributeReserveWidth( - for: attributes, - display: .full - ) - let expectedBlockWidth = AppTheme.Timeline.notationClefWidth - + NotationMeasureLayout.keySignatureWidth(for: attributes) - + AppTheme.Timeline.notationTimeSignatureWidth - + NotationMeasureLayout.spacingWidth(forVisibleComponentCount: 3) - - XCTAssertEqual(blockWidth, expectedBlockWidth, accuracy: 0.0001) - XCTAssertEqual( - reserveWidth, - AppTheme.Spacing.md + expectedBlockWidth + AppTheme.Spacing.xs, - accuracy: 0.0001 - ) - } - func testNotationMeasureLayoutKeepsOrdinaryMeasureAtRawBoundary() { let attributes = MeasureAttributes( keySignature: KeySignature.normalized(from: "C major"), diff --git a/JammLabTests/ViewModelProjectRestoreTests.swift b/JammLabTests/ViewModelProjectRestoreTests.swift index 9be4aa4..13249c7 100644 --- a/JammLabTests/ViewModelProjectRestoreTests.swift +++ b/JammLabTests/ViewModelProjectRestoreTests.swift @@ -136,136 +136,6 @@ final class ViewModelProjectRestoreTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(savedProject.playbackMarkerTime), 1.25, accuracy: 0.0001) } - @MainActor - func testManualTimelineRangeChangesDirtyStateAndPersistsOnSave() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-save.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - let media = ImportedAudioFile(url: audioURL, displayName: "viewport.wav", duration: 4) - try viewModel.loadImportedAudio(media) - - viewModel.setTimelineVisibleRange(1...2.5) - - XCTAssertTrue(viewModel.isProjectModified) - - let didSave = await viewModel.saveProject(to: projectURL) - - XCTAssertTrue(didSave) - XCTAssertFalse(viewModel.isProjectModified) - let savedProject = try projectService.load(from: projectURL) - let savedRange = try XCTUnwrap(savedProject.timelineVisibleRange) - XCTAssertEqual(savedRange.start, 1, accuracy: 0.0001) - XCTAssertEqual(savedRange.end, 2.5, accuracy: 0.0001) - } - - @MainActor - func testProjectOpenRestoresTimelineVisibleRange() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-open.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - timelineVisibleRange: ProjectTimelineVisibleRange(start: 1, end: 2.5) - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "timeline-range-open", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openRecentProject(entry) - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 1, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 2.5, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 1, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 2.5, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testProjectOpenDefaultsInvalidTimelineVisibleRangeToFullDuration() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-invalid.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - timelineVisibleRange: ProjectTimelineVisibleRange(start: -1, end: 99) - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "timeline-range-invalid", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openRecentProject(entry) - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 4, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 4, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - @MainActor func testLegacyProjectOpenDefaultsLoopClickAndSnapToOff() async throws { let audioURL = try temporaryAudioFile() diff --git a/JammLabTests/ViewModelTimelineRangePersistenceTests.swift b/JammLabTests/ViewModelTimelineRangePersistenceTests.swift new file mode 100644 index 0000000..7f309b8 --- /dev/null +++ b/JammLabTests/ViewModelTimelineRangePersistenceTests.swift @@ -0,0 +1,134 @@ +import XCTest +@testable import JammLab + +final class ViewModelTimelineRangePersistenceTests: XCTestCase { + @MainActor + func testManualTimelineRangeChangesDirtyStateAndPersistsOnSave() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + let media = ImportedAudioFile(url: audioURL, displayName: "viewport.wav", duration: 4) + try viewModel.loadImportedAudio(media) + + viewModel.setTimelineVisibleRange(1...2.5) + + XCTAssertTrue(viewModel.isProjectModified) + + let didSave = await viewModel.saveProject(to: projectURL) + + XCTAssertTrue(didSave) + XCTAssertFalse(viewModel.isProjectModified) + let savedProject = try projectService.load(from: projectURL) + let savedRange = try XCTUnwrap(savedProject.timelineVisibleRange) + XCTAssertEqual(savedRange.start, 1, accuracy: 0.0001) + XCTAssertEqual(savedRange.end, 2.5, accuracy: 0.0001) + } + + @MainActor + func testProjectOpenRestoresTimelineVisibleRange() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-open.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + timelineVisibleRange: ProjectTimelineVisibleRange(start: 1, end: 2.5) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "timeline-range-open", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 1, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 2.5, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 1, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 2.5, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testProjectOpenDefaultsInvalidTimelineVisibleRangeToFullDuration() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("timeline-range-invalid.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + timelineVisibleRange: ProjectTimelineVisibleRange(start: -1, end: 99) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "timeline-range-invalid", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 4, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 4, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } +} From 60aeadb9fe9f11c3715035ced1034fd777ef1d00 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 12:20:10 +0300 Subject: [PATCH 52/80] test: split hotkey filter and viewport tempo map tests --- JammLab.xcodeproj/project.pbxproj | 8 + JammLabTests/AppHotkeyEventFilterTests.swift | 235 ----------------- .../AppHotkeyNotationEventFilterTests.swift | 241 ++++++++++++++++++ .../NotationViewportTempoMapTests.swift | 177 +++++++++++++ JammLabTests/NotationViewportTests.swift | 133 ---------- 5 files changed, 426 insertions(+), 368 deletions(-) create mode 100644 JammLabTests/AppHotkeyNotationEventFilterTests.swift create mode 100644 JammLabTests/NotationViewportTempoMapTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 50201c9..a0cd540 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -110,6 +110,7 @@ 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */; }; 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */; }; 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */; }; + 9FAB01452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */; }; 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; @@ -159,6 +160,7 @@ 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */; }; 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */; }; 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00152CE0000100112233 /* NotationViewportTests.swift */; }; + 9FAB01462CE0000100112233 /* NotationViewportTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00462CE0000100112233 /* NotationViewportTempoMapTests.swift */; }; 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */; }; 9FAB01432CE0000100112233 /* NotationAttributeGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */; }; 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */; }; @@ -340,6 +342,7 @@ 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewportLogicTests.swift; sourceTree = ""; }; 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyTests.swift; sourceTree = ""; }; 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyEventFilterTests.swift; sourceTree = ""; }; + 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyNotationEventFilterTests.swift; sourceTree = ""; }; 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; @@ -389,6 +392,7 @@ 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyPlacementTests.swift; sourceTree = ""; }; 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureTimingTests.swift; sourceTree = ""; }; 9FAB00152CE0000100112233 /* NotationViewportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTests.swift; sourceTree = ""; }; + 9FAB00462CE0000100112233 /* NotationViewportTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTempoMapTests.swift; sourceTree = ""; }; 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureGeometryTests.swift; sourceTree = ""; }; 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeGeometryTests.swift; sourceTree = ""; }; 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationFallbackGeometryTests.swift; sourceTree = ""; }; @@ -655,6 +659,7 @@ 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */, 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */, + 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */, 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, @@ -704,6 +709,7 @@ 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */, 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */, 9FAB00152CE0000100112233 /* NotationViewportTests.swift */, + 9FAB00462CE0000100112233 /* NotationViewportTempoMapTests.swift */, 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */, 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */, @@ -1091,6 +1097,7 @@ 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */, 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */, 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */, + 9FAB01452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift in Sources */, 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, @@ -1140,6 +1147,7 @@ 9FAB01132CE0000100112233 /* NotationHarmonyPlacementTests.swift in Sources */, 9FAB01142CE0000100112233 /* NotationMeasureTimingTests.swift in Sources */, 9FAB01152CE0000100112233 /* NotationViewportTests.swift in Sources */, + 9FAB01462CE0000100112233 /* NotationViewportTempoMapTests.swift in Sources */, 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */, 9FAB01432CE0000100112233 /* NotationAttributeGeometryTests.swift in Sources */, 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */, diff --git a/JammLabTests/AppHotkeyEventFilterTests.swift b/JammLabTests/AppHotkeyEventFilterTests.swift index eab8c81..3bbca38 100644 --- a/JammLabTests/AppHotkeyEventFilterTests.swift +++ b/JammLabTests/AppHotkeyEventFilterTests.swift @@ -101,239 +101,4 @@ final class AppHotkeyEventFilterTests: XCTestCase { ) } - func testAppHotkeyEventFilterDoesNotStealMeasureCopyPasteFromTextRespondersOrUnavailableScopes() throws { - let copyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "c", - charactersIgnoringModifiers: "c", - isARepeat: false, - keyCode: 8 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.copyMeasure] - ), - .copyMeasure - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.copyMeasure] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: copyEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.copyMeasure] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealEscapeFromTextRespondersOrUnavailableScopes() throws { - let escapeEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "\u{1b}", - charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, - keyCode: 53 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.clearNotationMeasureSelection] - ), - .clearNotationMeasureSelection - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.clearNotationMeasureSelection] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: escapeEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.clearNotationMeasureSelection] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealEditHarmonyFromTextRespondersOrUnavailableScopes() throws { - let editHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "k", - charactersIgnoringModifiers: "k", - isARepeat: false, - keyCode: 40 - )) - let repeatEditHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "k", - charactersIgnoringModifiers: "k", - isARepeat: true, - keyCode: 40 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ), - .editHarmonyAtSelectedNotationItem - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: editHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatEditHarmonyEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.editHarmonyAtSelectedNotationItem] - ) - ) - } - - func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { - let durationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: false, - keyCode: 21 - )) - let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: true, - keyCode: 21 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: AppHotkey.notationDurationHotkeys - ), - .setNotationDurationEighth - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatDurationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - } } diff --git a/JammLabTests/AppHotkeyNotationEventFilterTests.swift b/JammLabTests/AppHotkeyNotationEventFilterTests.swift new file mode 100644 index 0000000..dd9c664 --- /dev/null +++ b/JammLabTests/AppHotkeyNotationEventFilterTests.swift @@ -0,0 +1,241 @@ +import AppKit +import XCTest +@testable import JammLab + +final class AppHotkeyNotationEventFilterTests: XCTestCase { + func testAppHotkeyEventFilterDoesNotStealMeasureCopyPasteFromTextRespondersOrUnavailableScopes() throws { + let copyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 8 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.copyMeasure] + ), + .copyMeasure + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.copyMeasure] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: copyEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.copyMeasure] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealEscapeFromTextRespondersOrUnavailableScopes() throws { + let escapeEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.clearNotationMeasureSelection] + ), + .clearNotationMeasureSelection + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.clearNotationMeasureSelection] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: escapeEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.clearNotationMeasureSelection] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealEditHarmonyFromTextRespondersOrUnavailableScopes() throws { + let editHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: false, + keyCode: 40 + )) + let repeatEditHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: true, + keyCode: 40 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ), + .editHarmonyAtSelectedNotationItem + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: editHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatEditHarmonyEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.editHarmonyAtSelectedNotationItem] + ) + ) + } + + func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { + let durationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: false, + keyCode: 21 + )) + let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: true, + keyCode: 21 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ), + .setNotationDurationEighth + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatDurationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + } +} diff --git a/JammLabTests/NotationViewportTempoMapTests.swift b/JammLabTests/NotationViewportTempoMapTests.swift new file mode 100644 index 0000000..5d35c4d --- /dev/null +++ b/JammLabTests/NotationViewportTempoMapTests.swift @@ -0,0 +1,177 @@ +import XCTest +@testable import JammLab + +final class NotationViewportTempoMapTests: XCTestCase { + func testNotationViewportStartsAtMeasureOneBeforeDelayedFirstBeat() throws { + let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 0, + playbackMarkerTime: 0, + visibleMeasureCount: 8 + ) + let firstMeasure = try XCTUnwrap(state.visibleMeasures.first) + + XCTAssertTrue(state.isReady) + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 1) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + XCTAssertEqual(state.anchorTime, 0.78, accuracy: 0.0001) + XCTAssertEqual(firstMeasure.startTime, 0.78, accuracy: 0.0001) + XCTAssertGreaterThan(firstMeasure.duration, 0) + } + + func testNotationViewportKeepsMeasureOneAtDelayedFirstBeat() { + let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 0.78, + playbackMarkerTime: 0, + visibleMeasureCount: 8 + ) + + XCTAssertTrue(state.isReady) + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 1) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + XCTAssertEqual(state.anchorTime, 0.78, accuracy: 0.0001) + } + + func testNotationViewportKeepsFirstPageInsideSecondDelayedMeasure() { + let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 3.0, + playbackMarkerTime: 0, + visibleMeasureCount: 8 + ) + + XCTAssertTrue(state.isReady) + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 2) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + } + + func testNotationViewportCarriesMeasureAttributesAcrossTimeSignatureMarker() { + let tempoMap = fourFourTempoMap( + duration: 12, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] + ) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 2.1, + visibleMeasureCount: 4 + ) + + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4]) + XCTAssertEqual(state.visibleMeasures[0].attributes.timeSignature, .fourFour) + XCTAssertEqual(state.visibleMeasures[2].attributes.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) + } + + func testNotationViewportDoesNotRestartPageAtTimeSignatureMarkerInsideVisibleWindow() { + let tempoMap = fourFourTempoMap( + duration: 18, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] + ) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 5.1, + visibleMeasureCount: 8 + ) + + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 3) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) + } + + func testNotationViewportHonorsBarNumberResetAtTimeSignatureMarker() { + let tempoMap = fourFourTempoMap( + duration: 12, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3, setsNewFirstBeat: true)] + ) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 2.1, + visibleMeasureCount: 4 + ) + + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2]) + } + + func testNotationViewportKeepsGlobalPageAcrossBarNumberReset() { + let tempoMap = fourFourTempoMap( + duration: 18, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3, setsNewFirstBeat: true)] + ) + + let state = notationViewportState( + tempoMap: tempoMap, + currentTime: 5.1, + visibleMeasureCount: 8 + ) + + XCTAssertEqual(state.firstVisibleMeasureNumber, 1) + XCTAssertEqual(state.activeMeasureNumber, 1) + XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2, 3, 4, 5, 6]) + } + + private func fourFourTempoMap( + duration: TimeInterval, + firstBeatTime: TimeInterval = 0, + markers: [TimecodedNote] = [] + ) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: firstBeatTime, + timeSignature: .fourFour + ), + markers: markers, + duration: duration + ) + } + + private func notationViewportState( + tempoMap: TempoMap, + currentTime: TimeInterval, + playbackMarkerTime: TimeInterval = 0, + isPlaying: Bool = true, + keyName: String? = "C major", + visibleMeasureCount: Int = 8, + harmonySymbols: [HarmonySymbol] = [], + notes: [TimecodedNote] = [] + ) -> NotationViewportState { + NotationViewportFactory().viewportState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: currentTime, + playbackMarkerTime: playbackMarkerTime, + isPlaying: isPlaying, + keyName: keyName, + visibleMeasureCount: visibleMeasureCount, + harmonySymbols: harmonySymbols, + notes: notes + ) + } + + private func timeSignatureMarker( + time: TimeInterval, + beatsPerBar: Int, + setsNewFirstBeat: Bool = false + ) -> TimecodedNote { + TimecodedNote( + time: time, + title: "\(beatsPerBar)/4", + metadata: TempoTimeSignatureMarkerPayload( + beatsPerBar: beatsPerBar, + setsNewFirstBeat: setsNewFirstBeat + ).metadata + ) + } +} diff --git a/JammLabTests/NotationViewportTests.swift b/JammLabTests/NotationViewportTests.swift index ceddffe..bcd4b63 100644 --- a/JammLabTests/NotationViewportTests.swift +++ b/JammLabTests/NotationViewportTests.swift @@ -76,125 +76,6 @@ final class NotationViewportTests: XCTestCase { XCTAssertGreaterThan(firstMeasure.duration, 0) } - func testNotationViewportStartsAtMeasureOneBeforeDelayedFirstBeat() throws { - let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 0, - playbackMarkerTime: 0, - visibleMeasureCount: 8 - ) - let firstMeasure = try XCTUnwrap(state.visibleMeasures.first) - - XCTAssertTrue(state.isReady) - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 1) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - XCTAssertEqual(state.anchorTime, 0.78, accuracy: 0.0001) - XCTAssertEqual(firstMeasure.startTime, 0.78, accuracy: 0.0001) - XCTAssertGreaterThan(firstMeasure.duration, 0) - } - - func testNotationViewportKeepsMeasureOneAtDelayedFirstBeat() { - let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 0.78, - playbackMarkerTime: 0, - visibleMeasureCount: 8 - ) - - XCTAssertTrue(state.isReady) - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 1) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - XCTAssertEqual(state.anchorTime, 0.78, accuracy: 0.0001) - } - - func testNotationViewportKeepsFirstPageInsideSecondDelayedMeasure() { - let tempoMap = fourFourTempoMap(duration: 120, firstBeatTime: 0.78) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 3.0, - playbackMarkerTime: 0, - visibleMeasureCount: 8 - ) - - XCTAssertTrue(state.isReady) - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 2) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - } - - func testNotationViewportCarriesMeasureAttributesAcrossTimeSignatureMarker() { - let tempoMap = fourFourTempoMap( - duration: 12, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] - ) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 2.1, - visibleMeasureCount: 4 - ) - - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4]) - XCTAssertEqual(state.visibleMeasures[0].attributes.timeSignature, .fourFour) - XCTAssertEqual(state.visibleMeasures[2].attributes.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) - } - - func testNotationViewportDoesNotRestartPageAtTimeSignatureMarkerInsideVisibleWindow() { - let tempoMap = fourFourTempoMap( - duration: 18, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] - ) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 5.1, - visibleMeasureCount: 8 - ) - - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 3) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 3, 4, 5, 6, 7, 8]) - } - - func testNotationViewportHonorsBarNumberResetAtTimeSignatureMarker() { - let tempoMap = fourFourTempoMap( - duration: 12, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3, setsNewFirstBeat: true)] - ) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 2.1, - visibleMeasureCount: 4 - ) - - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2]) - } - - func testNotationViewportKeepsGlobalPageAcrossBarNumberReset() { - let tempoMap = fourFourTempoMap( - duration: 18, - markers: [timeSignatureMarker(time: 4, beatsPerBar: 3, setsNewFirstBeat: true)] - ) - - let state = notationViewportState( - tempoMap: tempoMap, - currentTime: 5.1, - visibleMeasureCount: 8 - ) - - XCTAssertEqual(state.firstVisibleMeasureNumber, 1) - XCTAssertEqual(state.activeMeasureNumber, 1) - XCTAssertEqual(state.visibleMeasures.map(\.number), [1, 2, 1, 2, 3, 4, 5, 6]) - } - func testNotationViewportReturnsPendingStateWhenTempoIsUnavailable() { let tempoMap = TempoMap(baseSettings: BeatGridSettings(), markers: [], duration: 12) @@ -279,18 +160,4 @@ final class NotationViewportTests: XCTestCase { ) } - private func timeSignatureMarker( - time: TimeInterval, - beatsPerBar: Int, - setsNewFirstBeat: Bool = false - ) -> TimecodedNote { - TimecodedNote( - time: time, - title: "\(beatsPerBar)/4", - metadata: TempoTimeSignatureMarkerPayload( - beatsPerBar: beatsPerBar, - setsNewFirstBeat: setsNewFirstBeat - ).metadata - ) - } } From 5cb49e337c5309dc62d1837b310dfbfba78a32a9 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 12:26:28 +0300 Subject: [PATCH 53/80] test: split musicxml rest export and region activation tests --- JammLab.xcodeproj/project.pbxproj | 8 + .../NotationMusicXMLRestExportTests.swift | 134 ++++++++++++++++ JammLabTests/NotationMusicXMLTests.swift | 91 ----------- .../ViewModelRegionActivationTests.swift | 144 ++++++++++++++++++ JammLabTests/ViewModelRegionLoopTests.swift | 140 ----------------- 5 files changed, 286 insertions(+), 231 deletions(-) create mode 100644 JammLabTests/NotationMusicXMLRestExportTests.swift create mode 100644 JammLabTests/ViewModelRegionActivationTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index a0cd540..6727d21 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -122,6 +122,7 @@ 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */; }; 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */; }; 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; + 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */; }; 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */; }; 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; @@ -152,6 +153,7 @@ 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */; }; 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */; }; 9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */; }; + 9FAB01472CE0000100112233 /* NotationMusicXMLRestExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */; }; 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */; }; 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */; }; 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */; }; @@ -354,6 +356,7 @@ 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelEditingStateTests.swift; sourceTree = ""; }; 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTempoMapTests.swift; sourceTree = ""; }; 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; + 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionActivationTests.swift; sourceTree = ""; }; 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationClipboardTests.swift; sourceTree = ""; }; 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; @@ -384,6 +387,7 @@ 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationPrimitivesTests.swift; sourceTree = ""; }; 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = ""; }; 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLChordParserTests.swift; sourceTree = ""; }; + 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLRestExportTests.swift; sourceTree = ""; }; 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationKeySignatureTests.swift; sourceTree = ""; }; 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeDisplayTests.swift; sourceTree = ""; }; 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRegionLabelTests.swift; sourceTree = ""; }; @@ -671,6 +675,7 @@ 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, + 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */, 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */, 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, @@ -701,6 +706,7 @@ 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */, 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */, 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */, + 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */, 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */, 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */, 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */, @@ -1109,6 +1115,7 @@ 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */, 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, + 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */, 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */, 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, @@ -1139,6 +1146,7 @@ 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */, 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */, 9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */, + 9FAB01472CE0000100112233 /* NotationMusicXMLRestExportTests.swift in Sources */, 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */, 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */, 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */, diff --git a/JammLabTests/NotationMusicXMLRestExportTests.swift b/JammLabTests/NotationMusicXMLRestExportTests.swift new file mode 100644 index 0000000..11d7435 --- /dev/null +++ b/JammLabTests/NotationMusicXMLRestExportTests.swift @@ -0,0 +1,134 @@ +import Foundation +import XCTest +@testable import JammLab + +final class NotationMusicXMLRestExportTests: XCTestCase { + func testMusicXMLExportIncludesSplitNotationRests() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + notationItems: splitQuarterQuarterHalfNotationItems(), + harmonySymbols: [ + HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "Fmaj7") + ] + ) + + let document = try exportedMusicXMLDocument(for: state) + let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) + let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) + let notes = firstMeasure.elements(forName: "note") + let harmonies = firstMeasure.elements(forName: "harmony") + let halfRest = try XCTUnwrap(notes.last) + let harmony = try XCTUnwrap(harmonies.first) + + XCTAssertEqual(notes.count, 3) + XCTAssertEqual(notes.map { $0.elements(forName: "duration").first?.stringValue }, ["480", "480", "960"]) + XCTAssertEqual(notes.map { $0.elements(forName: "type").first?.stringValue }, ["quarter", "quarter", "half"]) + XCTAssertEqual(try firstXMLChild(named: "offset", in: harmony).stringValue, "480") + try assertXMLChild(harmony, precedes: halfRest, in: firstMeasure) + XCTAssertTrue(notes.allSatisfy { note in + note.elements(forName: "rest").first?.attribute(forName: "measure") == nil + }) + } + + func testMusicXMLHarmonyAtNotationItemBoundaryUsesNextItemCursor() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + notationItems: splitQuarterQuarterHalfNotationItems(), + harmonySymbols: [ + HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "Dm7") + ] + ) + + let document = try exportedMusicXMLDocument(for: state) + let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) + let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) + let notes = firstMeasure.elements(forName: "note") + let harmony = try XCTUnwrap(firstMeasure.elements(forName: "harmony").first) + let thirdRest = try XCTUnwrap(notes.last) + + XCTAssertEqual(try firstXMLChild(named: "offset", in: harmony).stringValue, "0") + try assertXMLChild(harmony, precedes: thirdRest, in: firstMeasure) + } + + private func exportedMusicXMLDocument(for state: NotationScoreState) throws -> XMLDocument { + let data = try NotationExportService().export( + NotationExportRequest(displayName: "Song", score: state), + format: .musicXML + ) + return try XMLDocument(data: data) + } + + private func childElements(in element: XMLElement) -> [XMLElement] { + (element.children ?? []).compactMap { $0 as? XMLElement } + } + + private func assertXMLChild( + _ firstElement: XMLElement, + precedes secondElement: XMLElement, + in parentElement: XMLElement, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let elements = childElements(in: parentElement) + let firstIndex = try XCTUnwrap(elements.firstIndex { $0 === firstElement }, file: file, line: line) + let secondIndex = try XCTUnwrap(elements.firstIndex { $0 === secondElement }, file: file, line: line) + XCTAssertLessThan(firstIndex, secondIndex, file: file, line: line) + } + + private func firstXMLChild( + named name: String, + in element: XMLElement, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> XMLElement { + try XCTUnwrap(element.elements(forName: name).first, file: file, line: line) + } + + private func splitQuarterQuarterHalfNotationItems() -> [NotationMeasureItem] { + [ + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + } + + private func fourFourTempoMap(duration: TimeInterval) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ), + markers: [], + duration: duration + ) + } +} diff --git a/JammLabTests/NotationMusicXMLTests.swift b/JammLabTests/NotationMusicXMLTests.swift index 0daec13..f7d370d 100644 --- a/JammLabTests/NotationMusicXMLTests.swift +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -172,71 +172,6 @@ final class NotationMusicXMLTests: XCTestCase { } } - func testMusicXMLExportIncludesSplitNotationRests() throws { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 4), - duration: 4, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "C major", - notationItems: splitQuarterQuarterHalfNotationItems(), - harmonySymbols: [ - HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "Fmaj7") - ] - ) - - let document = try exportedMusicXMLDocument(for: state) - let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) - let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) - let notes = firstMeasure.elements(forName: "note") - let harmonies = firstMeasure.elements(forName: "harmony") - let halfRest = try XCTUnwrap(notes.last) - let harmony = try XCTUnwrap(harmonies.first) - - XCTAssertEqual(notes.count, 3) - XCTAssertEqual(notes.map { $0.elements(forName: "duration").first?.stringValue }, ["480", "480", "960"]) - XCTAssertEqual(notes.map { $0.elements(forName: "type").first?.stringValue }, ["quarter", "quarter", "half"]) - XCTAssertEqual(try firstXMLChild(named: "offset", in: harmony).stringValue, "480") - try assertXMLChild(harmony, precedes: halfRest, in: firstMeasure) - XCTAssertTrue(notes.allSatisfy { note in - note.elements(forName: "rest").first?.attribute(forName: "measure") == nil - }) - } - - func testMusicXMLHarmonyAtNotationItemBoundaryUsesNextItemCursor() throws { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 4), - duration: 4, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "C major", - notationItems: splitQuarterQuarterHalfNotationItems(), - harmonySymbols: [ - HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "Dm7") - ] - ) - - let document = try exportedMusicXMLDocument(for: state) - let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) - let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) - let notes = firstMeasure.elements(forName: "note") - let harmony = try XCTUnwrap(firstMeasure.elements(forName: "harmony").first) - let thirdRest = try XCTUnwrap(notes.last) - - XCTAssertEqual(try firstXMLChild(named: "offset", in: harmony).stringValue, "0") - try assertXMLChild(harmony, precedes: thirdRest, in: firstMeasure) - } - - private func exportedMusicXMLDocument(for state: NotationScoreState) throws -> XMLDocument { - let data = try NotationExportService().export( - NotationExportRequest(displayName: "Song", score: state), - format: .musicXML - ) - return try XMLDocument(data: data) - } - private func childElements(in element: XMLElement) -> [XMLElement] { (element.children ?? []).compactMap { $0 as? XMLElement } } @@ -263,32 +198,6 @@ final class NotationMusicXMLTests: XCTestCase { try XCTUnwrap(element.elements(forName: name).first, file: file, line: line) } - private func splitQuarterQuarterHalfNotationItems() -> [NotationMeasureItem] { - [ - NotationMeasureItem( - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 0, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ), - NotationMeasureItem( - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 1, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) - ), - NotationMeasureItem( - measureNumber: 1, - measureStartTime: 0, - offsetInQuarterNotes: 2, - durationInQuarterNotes: 2, - displayDuration: NotationDuration(denominator: 2) - ) - ] - } - private func fourFourTempoMap( duration: TimeInterval, firstBeatTime: TimeInterval = 0, diff --git a/JammLabTests/ViewModelRegionActivationTests.swift b/JammLabTests/ViewModelRegionActivationTests.swift new file mode 100644 index 0000000..94a4af5 --- /dev/null +++ b/JammLabTests/ViewModelRegionActivationTests.swift @@ -0,0 +1,144 @@ +import XCTest +@testable import JammLab + +final class ViewModelRegionActivationTests: XCTestCase { + @MainActor + func testActivateRegionAsLoopWithoutSeekingPreservesPlaybackPosition() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.loopRegion = LoopRegion(start: 0, end: 6) + viewModel.activeLoopRegionID = nil + viewModel.setPlaybackMarkerExactly(to: 1.1) + let initialSeekCount = engine.seekCount + viewModel.markProjectClean() + + viewModel.activateRegionAsLoop(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(engine.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 1.1, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 1.1, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 1.1, accuracy: 0.0001) + XCTAssertEqual(engine.seekCount, initialSeekCount) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testActivateRegionAsLoopAndMoveMarkerWhenStoppedMovesPlaybackPosition() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + videoFollower: videoFollower + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.setPlaybackMarkerExactly(to: 1.1) + viewModel.playbackState = .stopped + viewModel.markProjectClean() + + viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2.3, accuracy: 0.0001) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testActivateRegionAsLoopAndMoveMarkerWhenPausedMovesPlaybackPosition() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + videoFollower: videoFollower + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.setPlaybackMarkerExactly(to: 1.1) + viewModel.playbackState = .paused + viewModel.markProjectClean() + + viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2.3, accuracy: 0.0001) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testActivateRegionAsLoopAndMoveMarkerWhilePlayingDoesNotSeekPlayback() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + videoFollower: videoFollower + ) + let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) + try viewModel.loadImportedAudio(media) + let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") + viewModel.notes = [region] + viewModel.setPlaybackMarkerExactly(to: 1.1) + let seekCountAfterInitialPosition = engine.seekCount + let videoSeekCountAfterInitialPosition = videoFollower.seekTimes.count + viewModel.playbackState = .playing + engine.isPlaying = true + engine.currentTime = 4.5 + viewModel.currentTime = 4.5 + viewModel.markProjectClean() + + viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) + + XCTAssertEqual(viewModel.selectedRegionID, region.id) + XCTAssertEqual(viewModel.activeLoopRegionID, region.id) + XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) + XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 4.5, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 4.5, accuracy: 0.0001) + XCTAssertEqual(engine.seekCount, seekCountAfterInitialPosition) + XCTAssertEqual(videoFollower.seekTimes.count, videoSeekCountAfterInitialPosition) + XCTAssertEqual(viewModel.playbackState, .playing) + XCTAssertTrue(engine.isPlaying) + XCTAssertTrue(viewModel.isProjectModified) + } +} diff --git a/JammLabTests/ViewModelRegionLoopTests.swift b/JammLabTests/ViewModelRegionLoopTests.swift index 3ab7668..1bdd326 100644 --- a/JammLabTests/ViewModelRegionLoopTests.swift +++ b/JammLabTests/ViewModelRegionLoopTests.swift @@ -83,146 +83,6 @@ final class ViewModelRegionLoopTests: XCTestCase { XCTAssertTrue(viewModel.isProjectModified) } - @MainActor - func testActivateRegionAsLoopWithoutSeekingPreservesPlaybackPosition() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.loopRegion = LoopRegion(start: 0, end: 6) - viewModel.activeLoopRegionID = nil - viewModel.setPlaybackMarkerExactly(to: 1.1) - let initialSeekCount = engine.seekCount - viewModel.markProjectClean() - - viewModel.activateRegionAsLoop(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(engine.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 1.1, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 1.1, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 1.1, accuracy: 0.0001) - XCTAssertEqual(engine.seekCount, initialSeekCount) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testActivateRegionAsLoopAndMoveMarkerWhenStoppedMovesPlaybackPosition() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine, - videoFollower: videoFollower - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.setPlaybackMarkerExactly(to: 1.1) - viewModel.playbackState = .stopped - viewModel.markProjectClean() - - viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2.3, accuracy: 0.0001) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testActivateRegionAsLoopAndMoveMarkerWhenPausedMovesPlaybackPosition() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine, - videoFollower: videoFollower - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.setPlaybackMarkerExactly(to: 1.1) - viewModel.playbackState = .paused - viewModel.markProjectClean() - - viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2.3, accuracy: 0.0001) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testActivateRegionAsLoopAndMoveMarkerWhilePlayingDoesNotSeekPlayback() throws { - let audioURL = try temporaryAudioFile(duration: 6) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine, - videoFollower: videoFollower - ) - let media = ImportedAudioFile(url: audioURL, displayName: "region.wav", duration: 6) - try viewModel.loadImportedAudio(media) - let region = TimecodedNote(kind: .region, time: 2.3, duration: 1.4, title: "Region") - viewModel.notes = [region] - viewModel.setPlaybackMarkerExactly(to: 1.1) - let seekCountAfterInitialPosition = engine.seekCount - let videoSeekCountAfterInitialPosition = videoFollower.seekTimes.count - viewModel.playbackState = .playing - engine.isPlaying = true - engine.currentTime = 4.5 - viewModel.currentTime = 4.5 - viewModel.markProjectClean() - - viewModel.activateRegionAsLoopAndMoveMarker(id: region.id) - - XCTAssertEqual(viewModel.selectedRegionID, region.id) - XCTAssertEqual(viewModel.activeLoopRegionID, region.id) - XCTAssertEqual(viewModel.loopRegion.start, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.loopRegion.end, 3.7, accuracy: 0.0001) - XCTAssertEqual(viewModel.playbackMarkerTime, 2.3, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 4.5, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 4.5, accuracy: 0.0001) - XCTAssertEqual(engine.seekCount, seekCountAfterInitialPosition) - XCTAssertEqual(videoFollower.seekTimes.count, videoSeekCountAfterInitialPosition) - XCTAssertEqual(viewModel.playbackState, .playing) - XCTAssertTrue(engine.isPlaying) - XCTAssertTrue(viewModel.isProjectModified) - } - @MainActor func testActivateInspectorItemMovesMarkerForRegionsAndKeepsMarkerSeek() throws { let audioURL = try temporaryAudioFile(duration: 6) From 3d879c5fb17089270d853603adb3875cbf755166 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 13:05:33 +0300 Subject: [PATCH 54/80] refactor: split stem workflow tests by responsibility --- JammLab.xcodeproj/project.pbxproj | 12 ++ JammLabTests/MediaImportLogicTests.swift | 38 ++++++ JammLabTests/StemBackendResolverTests.swift | 49 +++++++ JammLabTests/StemJobWorkflowTests.swift | 45 ------ JammLabTests/StemWorkflowLogicTests.swift | 128 ------------------ .../StemWorkflowPersistenceTests.swift | 98 ++++++++++++++ 6 files changed, 197 insertions(+), 173 deletions(-) create mode 100644 JammLabTests/MediaImportLogicTests.swift create mode 100644 JammLabTests/StemBackendResolverTests.swift create mode 100644 JammLabTests/StemWorkflowPersistenceTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 6727d21..ea112c1 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -129,6 +129,9 @@ 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; + 9FAB01492CE0000100112233 /* StemWorkflowPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00492CE0000100112233 /* StemWorkflowPersistenceTests.swift */; }; + 9FAB014A2CE0000100112233 /* MediaImportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004A2CE0000100112233 /* MediaImportLogicTests.swift */; }; + 9FAB014B2CE0000100112233 /* StemBackendResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004B2CE0000100112233 /* StemBackendResolverTests.swift */; }; 9FAB013C2CE0000100112233 /* StemJobWorkflowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */; }; 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */; }; 9FAB01412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */; }; @@ -363,6 +366,9 @@ 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSaveCloseTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; + 9FAB00492CE0000100112233 /* StemWorkflowPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowPersistenceTests.swift; sourceTree = ""; }; + 9FAB004A2CE0000100112233 /* MediaImportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaImportLogicTests.swift; sourceTree = ""; }; + 9FAB004B2CE0000100112233 /* StemBackendResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemBackendResolverTests.swift; sourceTree = ""; }; 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemJobWorkflowTests.swift; sourceTree = ""; }; 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStoreTests.swift; sourceTree = ""; }; 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectPersistenceCoordinatorArtifactTests.swift; sourceTree = ""; }; @@ -682,6 +688,9 @@ 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, + 9FAB00492CE0000100112233 /* StemWorkflowPersistenceTests.swift */, + 9FAB004A2CE0000100112233 /* MediaImportLogicTests.swift */, + 9FAB004B2CE0000100112233 /* StemBackendResolverTests.swift */, 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */, 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */, @@ -1122,6 +1131,9 @@ 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, + 9FAB01492CE0000100112233 /* StemWorkflowPersistenceTests.swift in Sources */, + 9FAB014A2CE0000100112233 /* MediaImportLogicTests.swift in Sources */, + 9FAB014B2CE0000100112233 /* StemBackendResolverTests.swift in Sources */, 9FAB013C2CE0000100112233 /* StemJobWorkflowTests.swift in Sources */, 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */, 9FAB01412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift in Sources */, diff --git a/JammLabTests/MediaImportLogicTests.swift b/JammLabTests/MediaImportLogicTests.swift new file mode 100644 index 0000000..d0bc8c8 --- /dev/null +++ b/JammLabTests/MediaImportLogicTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import JammLab + +final class MediaImportLogicTests: XCTestCase { + func testMediaImporterClassifiesSupportedFormats() { + let importer = AudioFileImporter() + + XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/song.mp3")), .audio) + XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/song.wav")), .audio) + XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/lesson.mp4")), .video) + XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/lesson.mov")), .video) + XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/lesson.m4v")), .video) + XCTAssertNil(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/document.pdf"))) + } + + func testMediaCacheKeyIsStableForSameFileIdentity() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let url = try temporaryFile(in: directory, name: "lesson.mp4", contents: "video") + let modificationDate = Date(timeIntervalSince1970: 1234) + try FileManager.default.setAttributes([.modificationDate: modificationDate], ofItemAtPath: url.path) + + let firstKey = VideoAudioExtractionService.cacheKey(for: url) + let secondKey = VideoAudioExtractionService.cacheKey(for: url) + + XCTAssertEqual(firstKey, secondKey) + + let changedURL = try temporaryFile(in: directory, name: "changed-lesson.mp4", contents: "changed-video") + try FileManager.default.setAttributes( + [.modificationDate: modificationDate.addingTimeInterval(10)], + ofItemAtPath: changedURL.path + ) + + XCTAssertNotEqual(VideoAudioExtractionService.cacheKey(for: changedURL), firstKey) + } +} diff --git a/JammLabTests/StemBackendResolverTests.swift b/JammLabTests/StemBackendResolverTests.swift new file mode 100644 index 0000000..504796e --- /dev/null +++ b/JammLabTests/StemBackendResolverTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import JammLab + +final class StemBackendResolverTests: XCTestCase { + func testStemBackendResolverUsesBundledSeparatorOnly() throws { + let resolver = StemBackendResolver( + helperExecutableURL: URL(fileURLWithPath: "/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper") + ) + + let commands = resolver.bundledSeparatorCandidates.map { $0.commandDescription(extraArguments: ["--env_info"]) } + + XCTAssertEqual(commands, ["/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper --env_info"]) + XCTAssertFalse(commands.contains { $0.contains("/usr/bin/env") }) + XCTAssertFalse(commands.contains { $0.contains("/opt/homebrew") }) + XCTAssertFalse(commands.contains { $0.contains("demucs") }) + } + + func testBundledSeparatorDefaultPathResolvesBesideStemHelper() { + let currentExecutable = URL(fileURLWithPath: "/App/JammLab.app/Contents/Helpers/JammLabStemHelper") + let helperURL = StemBackendResolver.defaultBundledSeparatorExecutableURL(currentExecutableURL: currentExecutable) + + XCTAssertEqual( + helperURL.path, + "/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper" + ) + } + + func testStemBackendResolverBuildsBundledSeparationCommand() { + let candidate = StemBackendCandidate( + executableURL: URL(fileURLWithPath: "/App/Helpers/JammLabSeparatorHelper/JammLabSeparatorHelper"), + argumentsPrefix: [], + displayName: "JammLabSeparatorHelper/1" + ) + let command = candidate.commandDescription(extraArguments: [ + "/tmp/song.mp3", + "-m", + "htdemucs.yaml", + "--output_format", + "WAV" + ]) + + XCTAssertEqual(command, "/App/Helpers/JammLabSeparatorHelper/JammLabSeparatorHelper /tmp/song.mp3 -m htdemucs.yaml --output_format WAV") + } + + func testStemBackendComputeModeHelperArguments() { + XCTAssertEqual(StemBackendComputeMode.cpuOnly.helperArgument, "cpu") + XCTAssertEqual(StemBackendComputeMode.auto.helperArgument, "auto") + } +} diff --git a/JammLabTests/StemJobWorkflowTests.swift b/JammLabTests/StemJobWorkflowTests.swift index f9e9cae..061f5b9 100644 --- a/JammLabTests/StemJobWorkflowTests.swift +++ b/JammLabTests/StemJobWorkflowTests.swift @@ -99,51 +99,6 @@ final class StemJobWorkflowTests: XCTestCase { XCTAssertFalse(stale.isFresh) } - func testStemBackendResolverUsesBundledSeparatorOnly() throws { - let resolver = StemBackendResolver( - helperExecutableURL: URL(fileURLWithPath: "/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper") - ) - - let commands = resolver.bundledSeparatorCandidates.map { $0.commandDescription(extraArguments: ["--env_info"]) } - - XCTAssertEqual(commands, ["/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper --env_info"]) - XCTAssertFalse(commands.contains { $0.contains("/usr/bin/env") }) - XCTAssertFalse(commands.contains { $0.contains("/opt/homebrew") }) - XCTAssertFalse(commands.contains { $0.contains("demucs") }) - } - - func testBundledSeparatorDefaultPathResolvesBesideStemHelper() { - let currentExecutable = URL(fileURLWithPath: "/App/JammLab.app/Contents/Helpers/JammLabStemHelper") - let helperURL = StemBackendResolver.defaultBundledSeparatorExecutableURL(currentExecutableURL: currentExecutable) - - XCTAssertEqual( - helperURL.path, - "/App/JammLab.app/Contents/Resources/JammLabSeparatorHelper/JammLabSeparatorHelper" - ) - } - - func testStemBackendResolverBuildsBundledSeparationCommand() { - let candidate = StemBackendCandidate( - executableURL: URL(fileURLWithPath: "/App/Helpers/JammLabSeparatorHelper/JammLabSeparatorHelper"), - argumentsPrefix: [], - displayName: "JammLabSeparatorHelper/1" - ) - let command = candidate.commandDescription(extraArguments: [ - "/tmp/song.mp3", - "-m", - "htdemucs.yaml", - "--output_format", - "WAV" - ]) - - XCTAssertEqual(command, "/App/Helpers/JammLabSeparatorHelper/JammLabSeparatorHelper /tmp/song.mp3 -m htdemucs.yaml --output_format WAV") - } - - func testStemBackendComputeModeHelperArguments() { - XCTAssertEqual(StemBackendComputeMode.cpuOnly.helperArgument, "cpu") - XCTAssertEqual(StemBackendComputeMode.auto.helperArgument, "auto") - } - func testStemJobStatusMapsToViewState() { XCTAssertEqual(StemJobPhase.pending.viewPhase.title, StemSeparationPhase.checkingBackend.title) XCTAssertEqual(StemJobPhase.processing.viewPhase.title, StemSeparationPhase.processing.title) diff --git a/JammLabTests/StemWorkflowLogicTests.swift b/JammLabTests/StemWorkflowLogicTests.swift index ac3c3c9..435b414 100644 --- a/JammLabTests/StemWorkflowLogicTests.swift +++ b/JammLabTests/StemWorkflowLogicTests.swift @@ -104,66 +104,6 @@ final class StemWorkflowLogicTests: XCTestCase { XCTAssertFalse(mix.item(for: .instrumental).isAvailable) } - func testProjectVersionSevenPersistsProjectEditablePlaybackStateMediaKindArtifactRootBookmarkAndVideoWindowState() throws { - let artifactRootBookmarkData = Data("artifact-root-bookmark".utf8) - let metadata = StemProjectState( - cacheKey: "cache-123", - sourceFingerprint: StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 42, modificationTime: 1234), - backendIdentifier: "demucs:/opt/homebrew/bin/demucs", - modelName: "htdemucs", - settingsVersion: 1, - playbackMode: .stems, - mixState: StemMixState(items: [ - StemMixItem(type: .vocals, volume: 0, isMuted: true, isSoloed: false, isAvailable: true), - StemMixItem(type: .drums, volume: 0.8, isMuted: false, isSoloed: true, isAvailable: true) - ]) - ) - let project = JammLabProject( - audioBookmarkData: Data("bookmark".utf8), - artifactRootBookmarkData: artifactRootBookmarkData, - audioDisplayName: "lesson.mp4", - audioDuration: 120, - mediaKind: .video, - notes: [], - loopStart: 0, - loopEnd: 120, - isLoopEnabled: true, - playbackRate: 1, - pitchShiftSemitones: 0, - tempoBPM: 120, - beatGridSettings: BeatGridSettings(bpm: 120, timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4)), - mainTrackVolume: 0.64, - isClickEnabled: true, - clickVolume: 0.42, - isSnapEnabled: true, - playbackMode: .stems, - playbackMarkerTime: 12.5, - stemState: metadata, - isVideoWindowOpen: true, - isNotationTrackCollapsed: false - ) - - let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) - - XCTAssertEqual(decoded.formatVersion, 10) - XCTAssertEqual(decoded.artifactRootBookmarkData, artifactRootBookmarkData) - XCTAssertEqual(decoded.mediaKind, .video) - XCTAssertEqual(decoded.isLoopEnabled, true) - XCTAssertEqual(decoded.mainTrackVolume, 0.64) - XCTAssertEqual(decoded.beatGridSettings?.timeSignature, TimeSignature(beatsPerBar: 7, beatUnit: 4)) - XCTAssertEqual(decoded.isClickEnabled, true) - XCTAssertEqual(decoded.clickVolume, 0.42) - XCTAssertEqual(decoded.playbackMarkerTime, 12.5) - XCTAssertEqual(decoded.isSnapEnabled, true) - XCTAssertEqual(decoded.playbackMode, .stems) - XCTAssertEqual(decoded.isVideoWindowOpen, true) - XCTAssertEqual(decoded.isNotationTrackCollapsed, false) - XCTAssertEqual(decoded.stemState?.cacheKey, "cache-123") - XCTAssertEqual(decoded.stemState?.playbackMode, .stems) - XCTAssertEqual(try XCTUnwrap(decoded.stemState?.mixState.effectiveVolume(for: .vocals)), 0, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(decoded.stemState?.mixState.effectiveVolume(for: .drums)), 0.8, accuracy: 0.0001) - } - func testStemFingerprintCanMatchSameFileThroughDifferentPath() { let original = StemSourceFingerprint(path: "/Users/me/Music/song.mp3", fileSize: 42, modificationTime: 1234) let resolvedBookmark = StemSourceFingerprint(path: "/private/var/folders/song.mp3", fileSize: 42, modificationTime: 1234) @@ -222,72 +162,4 @@ final class StemWorkflowLogicTests: XCTestCase { XCTAssertGreaterThan(sixStemCollapsedHeight, collapsedHeight) } - func testLegacyProjectWithoutStemStateStillDecodes() throws { - let json = """ - { - "formatVersion": 1, - "audioBookmarkData": "Ym9va21hcms=", - "audioDisplayName": "legacy.mp3", - "audioDuration": 90, - "notes": [], - "loopStart": 0, - "loopEnd": 90, - "playbackRate": 1, - "pitchShiftSemitones": 0, - "tempoBPM": 120, - "beatGridSettings": null - } - """ - - let decoded = try JSONDecoder().decode(JammLabProject.self, from: Data(json.utf8)) - - XCTAssertEqual(decoded.formatVersion, 1) - XCTAssertEqual(decoded.audioDisplayName, "legacy.mp3") - XCTAssertNil(decoded.artifactRootBookmarkData) - XCTAssertNil(decoded.stemState) - XCTAssertNil(decoded.mainTrackVolume) - XCTAssertNil(decoded.isLoopEnabled) - XCTAssertNil(decoded.isClickEnabled) - XCTAssertNil(decoded.clickVolume) - XCTAssertNil(decoded.isSnapEnabled) - XCTAssertNil(decoded.playbackMode) - XCTAssertNil(decoded.mediaKind) - XCTAssertNil(decoded.isVideoWindowOpen) - XCTAssertNil(decoded.isNotationTrackCollapsed) - } - - func testMediaImporterClassifiesSupportedFormats() { - let importer = AudioFileImporter() - - XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/song.mp3")), .audio) - XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/song.wav")), .audio) - XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/lesson.mp4")), .video) - XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/lesson.mov")), .video) - XCTAssertEqual(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/lesson.m4v")), .video) - XCTAssertNil(importer.mediaKind(for: URL(fileURLWithPath: "/tmp/document.pdf"))) - } - - func testMediaCacheKeyIsStableForSameFileIdentity() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let url = try temporaryFile(in: directory, name: "lesson.mp4", contents: "video") - let modificationDate = Date(timeIntervalSince1970: 1234) - try FileManager.default.setAttributes([.modificationDate: modificationDate], ofItemAtPath: url.path) - - let firstKey = VideoAudioExtractionService.cacheKey(for: url) - let secondKey = VideoAudioExtractionService.cacheKey(for: url) - - XCTAssertEqual(firstKey, secondKey) - - let changedURL = try temporaryFile(in: directory, name: "changed-lesson.mp4", contents: "changed-video") - try FileManager.default.setAttributes( - [.modificationDate: modificationDate.addingTimeInterval(10)], - ofItemAtPath: changedURL.path - ) - - XCTAssertNotEqual(VideoAudioExtractionService.cacheKey(for: changedURL), firstKey) - } - } diff --git a/JammLabTests/StemWorkflowPersistenceTests.swift b/JammLabTests/StemWorkflowPersistenceTests.swift new file mode 100644 index 0000000..79cea73 --- /dev/null +++ b/JammLabTests/StemWorkflowPersistenceTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import JammLab + +final class StemWorkflowPersistenceTests: XCTestCase { + func testProjectVersionSevenPersistsProjectEditablePlaybackStateMediaKindArtifactRootBookmarkAndVideoWindowState() throws { + let artifactRootBookmarkData = Data("artifact-root-bookmark".utf8) + let metadata = StemProjectState( + cacheKey: "cache-123", + sourceFingerprint: StemSourceFingerprint(path: "/tmp/song.mp3", fileSize: 42, modificationTime: 1234), + backendIdentifier: "demucs:/opt/homebrew/bin/demucs", + modelName: "htdemucs", + settingsVersion: 1, + playbackMode: .stems, + mixState: StemMixState(items: [ + StemMixItem(type: .vocals, volume: 0, isMuted: true, isSoloed: false, isAvailable: true), + StemMixItem(type: .drums, volume: 0.8, isMuted: false, isSoloed: true, isAvailable: true) + ]) + ) + let project = JammLabProject( + audioBookmarkData: Data("bookmark".utf8), + artifactRootBookmarkData: artifactRootBookmarkData, + audioDisplayName: "lesson.mp4", + audioDuration: 120, + mediaKind: .video, + notes: [], + loopStart: 0, + loopEnd: 120, + isLoopEnabled: true, + playbackRate: 1, + pitchShiftSemitones: 0, + tempoBPM: 120, + beatGridSettings: BeatGridSettings(bpm: 120, timeSignature: TimeSignature(beatsPerBar: 7, beatUnit: 4)), + mainTrackVolume: 0.64, + isClickEnabled: true, + clickVolume: 0.42, + isSnapEnabled: true, + playbackMode: .stems, + playbackMarkerTime: 12.5, + stemState: metadata, + isVideoWindowOpen: true, + isNotationTrackCollapsed: false + ) + + let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) + + XCTAssertEqual(decoded.formatVersion, 10) + XCTAssertEqual(decoded.artifactRootBookmarkData, artifactRootBookmarkData) + XCTAssertEqual(decoded.mediaKind, .video) + XCTAssertEqual(decoded.isLoopEnabled, true) + XCTAssertEqual(decoded.mainTrackVolume, 0.64) + XCTAssertEqual(decoded.beatGridSettings?.timeSignature, TimeSignature(beatsPerBar: 7, beatUnit: 4)) + XCTAssertEqual(decoded.isClickEnabled, true) + XCTAssertEqual(decoded.clickVolume, 0.42) + XCTAssertEqual(decoded.playbackMarkerTime, 12.5) + XCTAssertEqual(decoded.isSnapEnabled, true) + XCTAssertEqual(decoded.playbackMode, .stems) + XCTAssertEqual(decoded.isVideoWindowOpen, true) + XCTAssertEqual(decoded.isNotationTrackCollapsed, false) + XCTAssertEqual(decoded.stemState?.cacheKey, "cache-123") + XCTAssertEqual(decoded.stemState?.playbackMode, .stems) + XCTAssertEqual(try XCTUnwrap(decoded.stemState?.mixState.effectiveVolume(for: .vocals)), 0, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(decoded.stemState?.mixState.effectiveVolume(for: .drums)), 0.8, accuracy: 0.0001) + } + + func testLegacyProjectWithoutStemStateStillDecodes() throws { + let json = """ + { + "formatVersion": 1, + "audioBookmarkData": "Ym9va21hcms=", + "audioDisplayName": "legacy.mp3", + "audioDuration": 90, + "notes": [], + "loopStart": 0, + "loopEnd": 90, + "playbackRate": 1, + "pitchShiftSemitones": 0, + "tempoBPM": 120, + "beatGridSettings": null + } + """ + + let decoded = try JSONDecoder().decode(JammLabProject.self, from: Data(json.utf8)) + + XCTAssertEqual(decoded.formatVersion, 1) + XCTAssertEqual(decoded.audioDisplayName, "legacy.mp3") + XCTAssertNil(decoded.artifactRootBookmarkData) + XCTAssertNil(decoded.stemState) + XCTAssertNil(decoded.mainTrackVolume) + XCTAssertNil(decoded.isLoopEnabled) + XCTAssertNil(decoded.isClickEnabled) + XCTAssertNil(decoded.clickVolume) + XCTAssertNil(decoded.isSnapEnabled) + XCTAssertNil(decoded.playbackMode) + XCTAssertNil(decoded.mediaKind) + XCTAssertNil(decoded.isVideoWindowOpen) + XCTAssertNil(decoded.isNotationTrackCollapsed) + } +} From d9329bc884f0fd628eba057dd64a54f2a198636e Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 13:33:38 +0300 Subject: [PATCH 55/80] refactor: split notation paste tests from clipboard tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../ViewModelNotationClipboardTests.swift | 178 ----------------- .../ViewModelNotationPasteTests.swift | 182 ++++++++++++++++++ 3 files changed, 186 insertions(+), 178 deletions(-) create mode 100644 JammLabTests/ViewModelNotationPasteTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index ea112c1..f6591fa 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */; }; 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */; }; + 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */; }; 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */; }; @@ -362,6 +363,7 @@ 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionActivationTests.swift; sourceTree = ""; }; 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationClipboardTests.swift; sourceTree = ""; }; + 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationPasteTests.swift; sourceTree = ""; }; 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSaveCloseTests.swift; sourceTree = ""; }; @@ -684,6 +686,7 @@ 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */, 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */, + 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */, 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, @@ -1127,6 +1130,7 @@ 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */, 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */, + 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */, 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */, diff --git a/JammLabTests/ViewModelNotationClipboardTests.swift b/JammLabTests/ViewModelNotationClipboardTests.swift index ee89c47..3c58bcd 100644 --- a/JammLabTests/ViewModelNotationClipboardTests.swift +++ b/JammLabTests/ViewModelNotationClipboardTests.swift @@ -43,184 +43,6 @@ final class ViewModelNotationClipboardTests: XCTestCase { ]) } - @MainActor - func testPasteNotationMeasureReplacesTargetAndSupportsUndoRedo() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let undoManager = UndoManager() - viewModel.undoManager = undoManager - let sourceMeasure = try notationMeasure(1, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - let sourceA = HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") - let sourceB = HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "F") - let targetExisting = HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") - viewModel.harmonySymbols = [sourceA, sourceB, targetExisting] - viewModel.markProjectClean() - - viewModel.selectNotationMeasure(sourceMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - let beforePaste = viewModel.harmonySymbols - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let targetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(targetSymbols.map(\.rawText), ["C", "F"]) - XCTAssertEqual(targetSymbols.map(\.id).contains(sourceA.id), false) - XCTAssertEqual(targetSymbols.map(\.id).contains(sourceB.id), false) - XCTAssertEqual(targetSymbols[0].time, 2, accuracy: 0.0001) - XCTAssertEqual(targetSymbols[1].time, 3, accuracy: 0.0001) - XCTAssertNil(viewModel.selectedHarmonySymbolID) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [targetMeasure.number]) - XCTAssertTrue(viewModel.isProjectModified) - - viewModel.undoLastEdit() - - XCTAssertEqual(viewModel.harmonySymbols, beforePaste) - XCTAssertFalse(viewModel.isProjectModified) - - viewModel.redoLastEdit() - - let redoneTargetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(redoneTargetSymbols.map(\.rawText), ["C", "F"]) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - let targetMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let thirdMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - let fourthMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - } - XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) - } - - @MainActor - func testPastingEmptyNotationMeasureClearsTarget() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let emptyMeasure = try notationMeasure(3, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") - ] - - viewModel.selectNotationMeasure(emptyMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - XCTAssertFalse(viewModel.harmonySymbols.contains { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - }) - } - - @MainActor - func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - let targetMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - XCTAssertEqual(viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - }.map(\.rawText), ["C"]) - XCTAssertFalse(viewModel.harmonySymbols.contains { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - }) - } - - @MainActor - func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let thirdMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(fourthMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let fourthMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - } - XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) - } - - @MainActor - func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 120, beatsPerBar: 3) - viewModel.markProjectClean() - let sourceMeasure = try notationMeasure(1, in: viewModel) - let targetMeasure = try notationMeasure(2, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "D") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let targetSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - XCTAssertEqual(targetSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(try XCTUnwrap(targetSymbols.first).time, 2, accuracy: 0.0001) - } - @MainActor func testCopyRejectsPartialStaleNotationMeasureSelection() throws { let viewModel = try loadedNotationViewModel(duration: 8) diff --git a/JammLabTests/ViewModelNotationPasteTests.swift b/JammLabTests/ViewModelNotationPasteTests.swift new file mode 100644 index 0000000..cd0986b --- /dev/null +++ b/JammLabTests/ViewModelNotationPasteTests.swift @@ -0,0 +1,182 @@ +import XCTest +@testable import JammLab + +final class ViewModelNotationPasteTests: XCTestCase { + @MainActor + func testPasteNotationMeasureReplacesTargetAndSupportsUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + let sourceA = HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + let sourceB = HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "F") + let targetExisting = HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") + viewModel.harmonySymbols = [sourceA, sourceB, targetExisting] + viewModel.markProjectClean() + + viewModel.selectNotationMeasure(sourceMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + let beforePaste = viewModel.harmonySymbols + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let targetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(targetSymbols.map(\.rawText), ["C", "F"]) + XCTAssertEqual(targetSymbols.map(\.id).contains(sourceA.id), false) + XCTAssertEqual(targetSymbols.map(\.id).contains(sourceB.id), false) + XCTAssertEqual(targetSymbols[0].time, 2, accuracy: 0.0001) + XCTAssertEqual(targetSymbols[1].time, 3, accuracy: 0.0001) + XCTAssertNil(viewModel.selectedHarmonySymbolID) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [targetMeasure.number]) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.harmonySymbols, beforePaste) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.redoLastEdit() + + let redoneTargetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(redoneTargetSymbols.map(\.rawText), ["C", "F"]) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let thirdMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) + } + + @MainActor + func testPastingEmptyNotationMeasureClearsTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let emptyMeasure = try notationMeasure(3, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 2.5, measureNumber: 2, offsetInQuarterNotes: 1, rawText: "G7") + ] + + viewModel.selectNotationMeasure(emptyMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertEqual(viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }.map(\.rawText), ["C"]) + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(fourthMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) + } + + @MainActor + func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.addTempoTimeSignatureMarker(at: 2, bpm: 120, beatsPerBar: 3) + viewModel.markProjectClean() + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "D") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let targetSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + XCTAssertEqual(targetSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(try XCTUnwrap(targetSymbols.first).time, 2, accuracy: 0.0001) + } +} From ad9daf3ad6b1f1911e7b737e528236da37270e94 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 13:38:09 +0300 Subject: [PATCH 56/80] refactor: split audio sample converter tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/AudioSampleConverterTests.swift | 118 +++++++++++++++++++ JammLabTests/PitchDetectionTests.swift | 112 ------------------ 3 files changed, 122 insertions(+), 112 deletions(-) create mode 100644 JammLabTests/AudioSampleConverterTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index f6591fa..d7d0eed 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -204,6 +204,7 @@ 9FBA01032D40000100112233 /* TunerInputService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00032D40000100112233 /* TunerInputService.swift */; }; 9FBA01042D40000100112233 /* TunerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00042D40000100112233 /* TunerView.swift */; }; 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00052D40000100112233 /* PitchDetectionTests.swift */; }; + 9FBA01062D40000100112233 /* AudioSampleConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */; }; 9FD001012D20000100112233 /* ControlHelpText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FD001002D20000100112233 /* ControlHelpText.swift */; }; /* End PBXBuildFile section */ @@ -442,6 +443,7 @@ 9FBA00032D40000100112233 /* TunerInputService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputService.swift; sourceTree = ""; }; 9FBA00042D40000100112233 /* TunerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerView.swift; sourceTree = ""; }; 9FBA00052D40000100112233 /* PitchDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchDetectionTests.swift; sourceTree = ""; }; + 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSampleConverterTests.swift; sourceTree = ""; }; 9FC100012D10000100112233 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; 9FC100022D10000100112233 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 9FC100032D10000100112233 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; @@ -666,6 +668,7 @@ 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */, 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */, 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, + 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */, 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, @@ -1110,6 +1113,7 @@ 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */, 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */, 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, + 9FBA01062D40000100112233 /* AudioSampleConverterTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */, 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */, diff --git a/JammLabTests/AudioSampleConverterTests.swift b/JammLabTests/AudioSampleConverterTests.swift new file mode 100644 index 0000000..c5c8401 --- /dev/null +++ b/JammLabTests/AudioSampleConverterTests.swift @@ -0,0 +1,118 @@ +import AVFoundation +import XCTest +@testable import JammLab + +final class AudioSampleConverterTests: XCTestCase { + func testAudioSampleConverterReadsMonoFloatNonInterleavedBuffers() throws { + let buffer = try makeFloatBuffer(samplesByChannel: [[0.25, -0.5, 0.75]], interleaved: false) + + XCTAssertEqual(AudioSampleConverter.monoFloatSamples(from: buffer), [0.25, -0.5, 0.75]) + } + + func testAudioSampleConverterDownmixesStereoFloatNonInterleavedBuffers() throws { + let buffer = try makeFloatBuffer( + samplesByChannel: [ + [0.2, 0.4, 0.6], + [0.4, 0.2, -0.2] + ], + interleaved: false + ) + + assertSamples(AudioSampleConverter.monoFloatSamples(from: buffer), equal: [0.3, 0.3, 0.2]) + } + + func testAudioSampleConverterDownmixesStereoFloatInterleavedBuffers() throws { + let buffer = try makeFloatBuffer( + samplesByChannel: [ + [0.2, 0.4, 0.6], + [0.4, 0.2, -0.2] + ], + interleaved: true + ) + + assertSamples(AudioSampleConverter.monoFloatSamples(from: buffer), equal: [0.3, 0.3, 0.2]) + } + + func testAudioSampleConverterReturnsEmptySamplesForEmptyBuffer() throws { + let format = try XCTUnwrap(AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 44_100, + channels: 1, + interleaved: false + )) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1)) + buffer.frameLength = 0 + + XCTAssertEqual(AudioSampleConverter.monoFloatSamples(from: buffer), []) + } + + func testAudioSampleConverterRejectsUnsupportedPCMFormat() throws { + let format = try XCTUnwrap(AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: 44_100, + channels: 1, + interleaved: false + )) + let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1)) + buffer.frameLength = 1 + + XCTAssertNil(AudioSampleConverter.monoFloatSamples(from: buffer)) + } + + private func assertSamples( + _ actual: [Float]?, + equal expected: [Float], + accuracy: Float = 0.0001, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard let actual else { + XCTFail("Expected samples, got nil", file: file, line: line) + return + } + XCTAssertEqual(actual.count, expected.count, file: file, line: line) + for (actualSample, expectedSample) in zip(actual, expected) { + XCTAssertEqual(actualSample, expectedSample, accuracy: accuracy, file: file, line: line) + } + } + + private func makeFloatBuffer(samplesByChannel: [[Float]], interleaved: Bool) throws -> AVAudioPCMBuffer { + let channelCount = samplesByChannel.count + XCTAssertGreaterThan(channelCount, 0) + let frameCount = try XCTUnwrap(samplesByChannel.first?.count) + XCTAssertTrue(samplesByChannel.allSatisfy { $0.count == frameCount }) + + let format = try XCTUnwrap(AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 44_100, + channels: AVAudioChannelCount(channelCount), + interleaved: interleaved + )) + let buffer = try XCTUnwrap(AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(frameCount) + )) + buffer.frameLength = AVAudioFrameCount(frameCount) + + if interleaved { + let audioBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList) + let audioBuffer = try XCTUnwrap(audioBuffers.first) + let data = try XCTUnwrap(audioBuffer.mData) + let samples = data.bindMemory(to: Float.self, capacity: frameCount * channelCount) + for frame in 0.. AVAudioPCMBuffer { - let channelCount = samplesByChannel.count - XCTAssertGreaterThan(channelCount, 0) - let frameCount = try XCTUnwrap(samplesByChannel.first?.count) - XCTAssertTrue(samplesByChannel.allSatisfy { $0.count == frameCount }) - - let format = try XCTUnwrap(AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 44_100, - channels: AVAudioChannelCount(channelCount), - interleaved: interleaved - )) - let buffer = try XCTUnwrap(AVAudioPCMBuffer( - pcmFormat: format, - frameCapacity: AVAudioFrameCount(frameCount) - )) - buffer.frameLength = AVAudioFrameCount(frameCount) - - if interleaved { - let audioBuffers = UnsafeMutableAudioBufferListPointer(buffer.mutableAudioBufferList) - let audioBuffer = try XCTUnwrap(audioBuffers.first) - let data = try XCTUnwrap(audioBuffer.mData) - let samples = data.bindMemory(to: Float.self, capacity: frameCount * channelCount) - for frame in 0.. Date: Wed, 8 Jul 2026 13:42:48 +0300 Subject: [PATCH 57/80] refactor: split notation hotkey mapping tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../AppHotkeyNotationMappingTests.swift | 173 ++++++++++++++++++ JammLabTests/AppHotkeyTests.swift | 167 ----------------- 3 files changed, 177 insertions(+), 167 deletions(-) create mode 100644 JammLabTests/AppHotkeyNotationMappingTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index d7d0eed..8497939 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */; }; 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */; }; 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */; }; + 9FAB014D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift */; }; 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */; }; 9FAB01452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */; }; 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; @@ -348,6 +349,7 @@ 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimecodedNoteColorTests.swift; sourceTree = ""; }; 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewportLogicTests.swift; sourceTree = ""; }; 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyTests.swift; sourceTree = ""; }; + 9FAB004D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyNotationMappingTests.swift; sourceTree = ""; }; 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyEventFilterTests.swift; sourceTree = ""; }; 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyNotationEventFilterTests.swift; sourceTree = ""; }; 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; @@ -673,6 +675,7 @@ 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */, 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */, + 9FAB004D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift */, 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */, 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */, 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, @@ -1118,6 +1121,7 @@ 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */, 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */, 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */, + 9FAB014D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift in Sources */, 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */, 9FAB01452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift in Sources */, 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, diff --git a/JammLabTests/AppHotkeyNotationMappingTests.swift b/JammLabTests/AppHotkeyNotationMappingTests.swift new file mode 100644 index 0000000..62f033d --- /dev/null +++ b/JammLabTests/AppHotkeyNotationMappingTests.swift @@ -0,0 +1,173 @@ +import AppKit +import XCTest +@testable import JammLab + +final class AppHotkeyNotationMappingTests: XCTestCase { + func testAppHotkeyExposesSelectedNotationItemHarmonyShortcutMetadata() { + XCTAssertTrue(AppHotkey.allCases.contains(.editHarmonyAtSelectedNotationItem)) + XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.key, "Cmd+K") + XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.title, "Edit Harmony") + XCTAssertEqual( + AppHotkey.editHarmonyAtSelectedNotationItem.detail, + "Open harmony entry for the selected notation item." + ) + } + + func testAppHotkeyRecognizesNotationDurationNumberKeys() throws { + let expectations: [(key: String, keyCode: UInt16, hotkey: AppHotkey, denominator: Int)] = [ + ("4", 21, .setNotationDurationEighth, 8), + ("5", 23, .setNotationDurationQuarter, 4), + ("6", 22, .setNotationDurationHalf, 2), + ("7", 26, .setNotationDurationWhole, 1) + ] + + for expectation in expectations { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: expectation.key, + charactersIgnoringModifiers: expectation.key, + isARepeat: false, + keyCode: expectation.keyCode + )) + + XCTAssertEqual(AppHotkey(event: event), expectation.hotkey) + XCTAssertEqual(expectation.hotkey.key, expectation.key) + XCTAssertEqual(expectation.hotkey.notationDurationDenominator, expectation.denominator) + XCTAssertTrue(AppHotkey.notationDurationHotkeys.contains(expectation.hotkey)) + } + + XCTAssertEqual(AppHotkey.setNotationDurationEighth.title, "Set Eighth Note Duration") + XCTAssertEqual( + AppHotkey.setNotationDurationEighth.detail, + "Set notation duration to eighth notes for the selected notation item." + ) + XCTAssertTrue(AppHotkey.allCases.contains(.setNotationDurationWhole)) + } + + func testAppHotkeyRecognizesCmdKButNotOldHarmonyKeys() throws { + let aEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "a", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + )) + let hEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "h", + charactersIgnoringModifiers: "h", + isARepeat: false, + keyCode: 4 + )) + let commandAEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "a", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + )) + let commandKEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "k", + charactersIgnoringModifiers: "k", + isARepeat: false, + keyCode: 40 + )) + let shiftAEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.shift], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "A", + charactersIgnoringModifiers: "a", + isARepeat: false, + keyCode: 0 + )) + + XCTAssertNil(AppHotkey(event: aEvent)) + XCTAssertNil(AppHotkey(event: hEvent)) + XCTAssertNil(AppHotkey(event: commandAEvent)) + XCTAssertEqual(AppHotkey(event: commandKEvent), .editHarmonyAtSelectedNotationItem) + XCTAssertNil(AppHotkey(event: shiftAEvent)) + } + + func testAppHotkeyRecognizesCommandCAndVForNotationMeasureCopyPaste() throws { + let copyEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "c", + charactersIgnoringModifiers: "c", + isARepeat: false, + keyCode: 8 + )) + let pasteEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "v", + charactersIgnoringModifiers: "v", + isARepeat: false, + keyCode: 9 + )) + + XCTAssertEqual(AppHotkey(event: copyEvent), .copyMeasure) + XCTAssertEqual(AppHotkey.copyMeasure.key, "Cmd+C") + XCTAssertEqual(AppHotkey.copyMeasure.title, "Copy Measure") + XCTAssertEqual(AppHotkey(event: pasteEvent), .pasteMeasure) + XCTAssertEqual(AppHotkey.pasteMeasure.key, "Cmd+V") + XCTAssertEqual(AppHotkey.pasteMeasure.title, "Paste Measure") + } + + func testAppHotkeyRecognizesEscapeForNotationMeasureSelectionClear() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + )) + + XCTAssertEqual(AppHotkey(event: event), .clearNotationMeasureSelection) + XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.key, "Esc") + XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.title, "Clear Measure Selection") + } +} diff --git a/JammLabTests/AppHotkeyTests.swift b/JammLabTests/AppHotkeyTests.swift index c829169..70c5361 100644 --- a/JammLabTests/AppHotkeyTests.swift +++ b/JammLabTests/AppHotkeyTests.swift @@ -41,126 +41,11 @@ final class AppHotkeyTests: XCTestCase { XCTAssertEqual(AppHotkey.playPause.detail, "Start playback from the position marker or stop and return to it.") } - func testAppHotkeyExposesSelectedNotationItemHarmonyShortcutMetadata() { - XCTAssertTrue(AppHotkey.allCases.contains(.editHarmonyAtSelectedNotationItem)) - XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.key, "Cmd+K") - XCTAssertEqual(AppHotkey.editHarmonyAtSelectedNotationItem.title, "Edit Harmony") - XCTAssertEqual( - AppHotkey.editHarmonyAtSelectedNotationItem.detail, - "Open harmony entry for the selected notation item." - ) - } - - func testAppHotkeyRecognizesNotationDurationNumberKeys() throws { - let expectations: [(key: String, keyCode: UInt16, hotkey: AppHotkey, denominator: Int)] = [ - ("4", 21, .setNotationDurationEighth, 8), - ("5", 23, .setNotationDurationQuarter, 4), - ("6", 22, .setNotationDurationHalf, 2), - ("7", 26, .setNotationDurationWhole, 1) - ] - - for expectation in expectations { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: expectation.key, - charactersIgnoringModifiers: expectation.key, - isARepeat: false, - keyCode: expectation.keyCode - )) - - XCTAssertEqual(AppHotkey(event: event), expectation.hotkey) - XCTAssertEqual(expectation.hotkey.key, expectation.key) - XCTAssertEqual(expectation.hotkey.notationDurationDenominator, expectation.denominator) - XCTAssertTrue(AppHotkey.notationDurationHotkeys.contains(expectation.hotkey)) - } - - XCTAssertEqual(AppHotkey.setNotationDurationEighth.title, "Set Eighth Note Duration") - XCTAssertEqual( - AppHotkey.setNotationDurationEighth.detail, - "Set notation duration to eighth notes for the selected notation item." - ) - XCTAssertTrue(AppHotkey.allCases.contains(.setNotationDurationWhole)) - } - func testAppHotkeyMappingsDoNotContainDuplicateKeyLabels() { let keys = AppHotkey.allCases.map(\.key) XCTAssertEqual(keys.count, Set(keys).count) } - func testAppHotkeyRecognizesCmdKButNotOldHarmonyKeys() throws { - let aEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "a", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - )) - let hEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "h", - charactersIgnoringModifiers: "h", - isARepeat: false, - keyCode: 4 - )) - let commandAEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "a", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - )) - let commandKEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "k", - charactersIgnoringModifiers: "k", - isARepeat: false, - keyCode: 40 - )) - let shiftAEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.shift], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "A", - charactersIgnoringModifiers: "a", - isARepeat: false, - keyCode: 0 - )) - - XCTAssertNil(AppHotkey(event: aEvent)) - XCTAssertNil(AppHotkey(event: hEvent)) - XCTAssertNil(AppHotkey(event: commandAEvent)) - XCTAssertEqual(AppHotkey(event: commandKEvent), .editHarmonyAtSelectedNotationItem) - XCTAssertNil(AppHotkey(event: shiftAEvent)) - } - func testAppHotkeyRecognizesOptionVForVideoWindowToggle() throws { let event = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, @@ -203,56 +88,4 @@ final class AppHotkeyTests: XCTestCase { XCTAssertEqual(AppHotkey.addTempoTimeSignatureMarker.title, "Add Tempo / Time Signature Marker") } - func testAppHotkeyRecognizesCommandCAndVForNotationMeasureCopyPaste() throws { - let copyEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "c", - charactersIgnoringModifiers: "c", - isARepeat: false, - keyCode: 8 - )) - let pasteEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "v", - charactersIgnoringModifiers: "v", - isARepeat: false, - keyCode: 9 - )) - - XCTAssertEqual(AppHotkey(event: copyEvent), .copyMeasure) - XCTAssertEqual(AppHotkey.copyMeasure.key, "Cmd+C") - XCTAssertEqual(AppHotkey.copyMeasure.title, "Copy Measure") - XCTAssertEqual(AppHotkey(event: pasteEvent), .pasteMeasure) - XCTAssertEqual(AppHotkey.pasteMeasure.key, "Cmd+V") - XCTAssertEqual(AppHotkey.pasteMeasure.title, "Paste Measure") - } - - func testAppHotkeyRecognizesEscapeForNotationMeasureSelectionClear() throws { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: "\u{1b}", - charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, - keyCode: 53 - )) - - XCTAssertEqual(AppHotkey(event: event), .clearNotationMeasureSelection) - XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.key, "Esc") - XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.title, "Clear Measure Selection") - } } From 9ea13a1cd4eb101ecdc11c2f4a658064630ab4f8 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 13:58:59 +0300 Subject: [PATCH 58/80] refactor: split notation export view model test --- JammLab.xcodeproj/project.pbxproj | 4 ++ .../ViewModelNotationExportTests.swift | 52 +++++++++++++++++++ .../ViewModelNotationSelectionTests.swift | 47 ----------------- 3 files changed, 56 insertions(+), 47 deletions(-) create mode 100644 JammLabTests/ViewModelNotationExportTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 8497939..8e060f5 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */; }; 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; + 9FAB014E2CE0000100112233 /* ViewModelNotationExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */; }; 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */; }; 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */; }; 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; @@ -365,6 +366,7 @@ 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionActivationTests.swift; sourceTree = ""; }; 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; + 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationExportTests.swift; sourceTree = ""; }; 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationClipboardTests.swift; sourceTree = ""; }; 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationPasteTests.swift; sourceTree = ""; }; 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; @@ -691,6 +693,7 @@ 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */, 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, + 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */, 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */, 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */, 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, @@ -1137,6 +1140,7 @@ 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */, 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, + 9FAB014E2CE0000100112233 /* ViewModelNotationExportTests.swift in Sources */, 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */, 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */, 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, diff --git a/JammLabTests/ViewModelNotationExportTests.swift b/JammLabTests/ViewModelNotationExportTests.swift new file mode 100644 index 0000000..633531c --- /dev/null +++ b/JammLabTests/ViewModelNotationExportTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import JammLab + +final class ViewModelNotationExportTests: XCTestCase { + @MainActor + func testExportNotationWritesMusicXMLWithoutChangingDirtyOrUndoState() async throws { + let emptyViewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine() + ) + XCTAssertFalse(emptyViewModel.canExportNotation) + + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.tempoBPM = 132.5 + viewModel.beatGridSettings.bpm = 132.5 + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "Cmaj7") + ] + viewModel.markProjectClean() + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("notation-export-\(UUID().uuidString)") + .appendingPathExtension("musicxml") + defer { try? FileManager.default.removeItem(at: outputURL) } + + XCTAssertTrue(viewModel.canExportNotation) + let didExportCleanProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) + + XCTAssertTrue(didExportCleanProject) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(viewModel.canUndo) + XCTAssertNil(viewModel.errorMessage) + let xml = try String(contentsOf: outputURL, encoding: .utf8) + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.contains("major-seventh")) + XCTAssertTrue(xml.contains("132.5")) + + viewModel.setLooping(true) + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertTrue(viewModel.canUndo) + + let didExportDirtyProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) + + XCTAssertTrue(didExportDirtyProject) + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertTrue(viewModel.canUndo) + XCTAssertNil(viewModel.errorMessage) + } +} diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift index 8a6ad4e..8806299 100644 --- a/JammLabTests/ViewModelNotationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -160,53 +160,6 @@ final class ViewModelNotationSelectionTests: XCTestCase { XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) } - @MainActor - func testExportNotationWritesMusicXMLWithoutChangingDirtyOrUndoState() async throws { - let emptyViewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine() - ) - XCTAssertFalse(emptyViewModel.canExportNotation) - - let viewModel = try loadedNotationViewModel(duration: 8) - let undoManager = UndoManager() - viewModel.undoManager = undoManager - viewModel.tempoBPM = 132.5 - viewModel.beatGridSettings.bpm = 132.5 - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "Cmaj7") - ] - viewModel.markProjectClean() - - let outputURL = FileManager.default.temporaryDirectory - .appendingPathComponent("notation-export-\(UUID().uuidString)") - .appendingPathExtension("musicxml") - defer { try? FileManager.default.removeItem(at: outputURL) } - - XCTAssertTrue(viewModel.canExportNotation) - let didExportCleanProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) - - XCTAssertTrue(didExportCleanProject) - XCTAssertFalse(viewModel.isProjectModified) - XCTAssertFalse(viewModel.canUndo) - XCTAssertNil(viewModel.errorMessage) - let xml = try String(contentsOf: outputURL, encoding: .utf8) - XCTAssertTrue(xml.contains("")) - XCTAssertTrue(xml.contains("major-seventh")) - XCTAssertTrue(xml.contains("132.5")) - - viewModel.setLooping(true) - XCTAssertTrue(viewModel.isProjectModified) - XCTAssertTrue(viewModel.canUndo) - - let didExportDirtyProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) - - XCTAssertTrue(didExportDirtyProject) - XCTAssertTrue(viewModel.isProjectModified) - XCTAssertTrue(viewModel.canUndo) - XCTAssertNil(viewModel.errorMessage) - } } extension XCTestCase { From 267765b79e3634b108cf39eddecf15150a75e01a Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 14:04:19 +0300 Subject: [PATCH 59/80] refactor: split tuner input analysis tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../TunerInputServiceAnalysisTests.swift | 86 +++++++++++++++++++ .../TunerInputServiceSignalTests.swift | 80 ----------------- 3 files changed, 90 insertions(+), 80 deletions(-) create mode 100644 JammLabTests/TunerInputServiceAnalysisTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 8e060f5..0c399fb 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -141,6 +141,7 @@ 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */; }; + 9FAB014F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */; }; 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */; }; 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */; }; @@ -382,6 +383,7 @@ 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceSignalTests.swift; sourceTree = ""; }; + 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceAnalysisTests.swift; sourceTree = ""; }; 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceNoteHoldTests.swift; sourceTree = ""; }; 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStoreTests.swift; sourceTree = ""; }; @@ -709,6 +711,7 @@ 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */, + 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */, 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */, 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, @@ -1156,6 +1159,7 @@ 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */, + 9FAB014F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift in Sources */, 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */, 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */, diff --git a/JammLabTests/TunerInputServiceAnalysisTests.swift b/JammLabTests/TunerInputServiceAnalysisTests.swift new file mode 100644 index 0000000..f1bee99 --- /dev/null +++ b/JammLabTests/TunerInputServiceAnalysisTests.swift @@ -0,0 +1,86 @@ +import CoreAudio +import XCTest +@testable import JammLab + +final class TunerInputServiceAnalysisTests: XCTestCase { + @MainActor + func testTunerInputServiceCoalescesPendingAnalysisToLatestBuffer() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = BlockingPitchDetector() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) + + engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) + engine.sendAudioBuffer(samples: tunerMarkerSamples(3)) + detector.releaseFirstDetection() + + let didPublishLatestResult = await waitForTunerMainActorCondition { service.currentResult?.noteName == "C" } + XCTAssertTrue(didPublishLatestResult) + XCTAssertEqual(detector.detectedMarkers, [1, 3]) + } + + @MainActor + func testTunerInputServiceIgnoresStalePitchAfterStop() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = BlockingPitchDetector() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) + await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) + + service.stop() + detector.releaseFirstDetection() + await tunerDrainMainQueue() + await Task.yield() + await tunerDrainMainQueue() + + XCTAssertNil(service.currentResult) + } + + @MainActor + func testTunerInputServicePassesEngineSampleRateToDetector() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + let detector = RecordingPitchDetector() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine, + detector: detector + ) + + await service.start() + engine.sendAudioBuffer(samples: tunerMarkerSamples(1), sampleRate: 48_000) + + let didRecordSampleRate = await waitForTunerMainActorCondition { detector.sampleRates == [48_000] } + XCTAssertTrue(didRecordSampleRate) + } +} diff --git a/JammLabTests/TunerInputServiceSignalTests.swift b/JammLabTests/TunerInputServiceSignalTests.swift index 9bc4e8b..1289e94 100644 --- a/JammLabTests/TunerInputServiceSignalTests.swift +++ b/JammLabTests/TunerInputServiceSignalTests.swift @@ -162,84 +162,4 @@ final class TunerInputServiceSignalTests: XCTestCase { XCTAssertNil(service.errorMessage) } - @MainActor - func testTunerInputServiceCoalescesPendingAnalysisToLatestBuffer() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = BlockingPitchDetector() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) - await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) - - engine.sendAudioBuffer(samples: tunerMarkerSamples(2)) - engine.sendAudioBuffer(samples: tunerMarkerSamples(3)) - detector.releaseFirstDetection() - - let didPublishLatestResult = await waitForTunerMainActorCondition { service.currentResult?.noteName == "C" } - XCTAssertTrue(didPublishLatestResult) - XCTAssertEqual(detector.detectedMarkers, [1, 3]) - } - - @MainActor - func testTunerInputServiceIgnoresStalePitchAfterStop() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = BlockingPitchDetector() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1)) - await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) - - service.stop() - detector.releaseFirstDetection() - await tunerDrainMainQueue() - await Task.yield() - await tunerDrainMainQueue() - - XCTAssertNil(service.currentResult) - } - - @MainActor - func testTunerInputServicePassesEngineSampleRateToDetector() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - let detector = RecordingPitchDetector() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine, - detector: detector - ) - - await service.start() - engine.sendAudioBuffer(samples: tunerMarkerSamples(1), sampleRate: 48_000) - - let didRecordSampleRate = await waitForTunerMainActorCondition { detector.sampleRates == [48_000] } - XCTAssertTrue(didRecordSampleRate) - } } From 6843e8c8cbdcf12598dfe13026e34046aba7cc7b Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 16:53:54 +0300 Subject: [PATCH 60/80] refactor: split video window project restore tests --- JammLab.xcodeproj/project.pbxproj | 4 + ...iewModelVideoProjectPersistenceTests.swift | 144 ----------------- ...wModelVideoWindowProjectRestoreTests.swift | 149 ++++++++++++++++++ 3 files changed, 153 insertions(+), 144 deletions(-) create mode 100644 JammLabTests/ViewModelVideoWindowProjectRestoreTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 0c399fb..76f7f0b 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -130,6 +130,7 @@ 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */; }; 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; + 9FAB01502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift */; }; 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */; }; 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */; }; 9FAB01492CE0000100112233 /* StemWorkflowPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00492CE0000100112233 /* StemWorkflowPersistenceTests.swift */; }; @@ -372,6 +373,7 @@ 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationPasteTests.swift; sourceTree = ""; }; 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; + 9FAB00502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowProjectRestoreTests.swift; sourceTree = ""; }; 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSaveCloseTests.swift; sourceTree = ""; }; 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowLogicTests.swift; sourceTree = ""; }; 9FAB00492CE0000100112233 /* StemWorkflowPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemWorkflowPersistenceTests.swift; sourceTree = ""; }; @@ -700,6 +702,7 @@ 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */, 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, + 9FAB00502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift */, 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, 9FAB00492CE0000100112233 /* StemWorkflowPersistenceTests.swift */, @@ -1148,6 +1151,7 @@ 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */, 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, + 9FAB01502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift in Sources */, 9FAB012C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift in Sources */, 9FAB01052CE0000100112233 /* StemWorkflowLogicTests.swift in Sources */, 9FAB01492CE0000100112233 /* StemWorkflowPersistenceTests.swift in Sources */, diff --git a/JammLabTests/ViewModelVideoProjectPersistenceTests.swift b/JammLabTests/ViewModelVideoProjectPersistenceTests.swift index 73919bf..4c50e3a 100644 --- a/JammLabTests/ViewModelVideoProjectPersistenceTests.swift +++ b/JammLabTests/ViewModelVideoProjectPersistenceTests.swift @@ -95,148 +95,4 @@ final class ViewModelVideoProjectPersistenceTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } - @MainActor - func testOpenProjectRestoresSavedVideoWindowOpenState() async throws { - let fixture = try makeVideoProjectFixture( - name: "video-window-open", - isVideoWindowOpen: true - ) - defer { try? FileManager.default.removeItem(at: fixture.directory) } - - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - videoFollower: videoFollower, - projectService: fixture.projectService, - projectArtifactStore: fixture.artifactStore, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: fixture.projectURL) - - XCTAssertNil(viewModel.errorMessage) - XCTAssertEqual(videoFollower.loadedVideoURL, fixture.videoURL) - XCTAssertTrue(viewModel.isVideoWindowOpen) - XCTAssertEqual(videoFollower.showWindowEvents.count, 1) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testOpenProjectClosesVideoWindowWhenSavedStateIsClosed() async throws { - let fixture = try makeVideoProjectFixture( - name: "video-window-closed", - isVideoWindowOpen: false - ) - defer { try? FileManager.default.removeItem(at: fixture.directory) } - - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - videoFollower: videoFollower, - projectService: fixture.projectService, - projectArtifactStore: fixture.artifactStore, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - videoFollower.showWindow(at: 0, isPlaying: false, rate: 1) - - await viewModel.openProject(at: fixture.projectURL) - - XCTAssertNil(viewModel.errorMessage) - XCTAssertFalse(viewModel.isVideoWindowOpen) - XCTAssertFalse(videoFollower.isWindowOpen) - XCTAssertEqual(videoFollower.showWindowEvents.count, 1) - XCTAssertGreaterThanOrEqual(videoFollower.closeWindowCount, 1) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testOpenLegacyVideoProjectWithoutWindowStateKeepsVideoWindowClosed() async throws { - let fixture = try makeVideoProjectFixture( - name: "video-window-legacy", - isVideoWindowOpen: nil - ) - defer { try? FileManager.default.removeItem(at: fixture.directory) } - - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - videoFollower: videoFollower, - projectService: fixture.projectService, - projectArtifactStore: fixture.artifactStore, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: fixture.projectURL) - - XCTAssertNil(viewModel.errorMessage) - XCTAssertFalse(viewModel.isVideoWindowOpen) - XCTAssertFalse(videoFollower.isWindowOpen) - XCTAssertTrue(videoFollower.showWindowEvents.isEmpty) - XCTAssertFalse(viewModel.isProjectModified) - } - - private struct VideoProjectFixture { - let directory: URL - let projectURL: URL - let videoURL: URL - let projectService: ProjectDocumentService - let artifactStore: ProjectArtifactStore - } - - private func makeVideoProjectFixture( - name: String, - isVideoWindowOpen: Bool? - ) throws -> VideoProjectFixture { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - - let projectURL = directory.appendingPathComponent("\(name).jammlab") - let videoURL = try temporaryFile(in: directory, name: "\(name).mov", contents: "video") - let localAudioURL = try temporaryAudioFile() - defer { try? FileManager.default.removeItem(at: localAudioURL) } - - let artifactStore = ProjectArtifactStore() - try FileManager.default.createDirectory( - at: artifactStore.mediaDirectory(for: projectURL), - withIntermediateDirectories: true - ) - try FileManager.default.copyItem( - at: localAudioURL, - to: artifactStore.videoAudioURL(for: projectURL) - ) - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: videoURL), - audioDisplayName: videoURL.lastPathComponent, - audioDuration: 0.5, - mediaKind: .video, - notes: [], - loopStart: 0, - loopEnd: 0.5, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - isVideoWindowOpen: isVideoWindowOpen - ) - try projectService.save(project, to: projectURL) - - return VideoProjectFixture( - directory: directory, - projectURL: projectURL, - videoURL: videoURL, - projectService: projectService, - artifactStore: artifactStore - ) - } } diff --git a/JammLabTests/ViewModelVideoWindowProjectRestoreTests.swift b/JammLabTests/ViewModelVideoWindowProjectRestoreTests.swift new file mode 100644 index 0000000..d0a5858 --- /dev/null +++ b/JammLabTests/ViewModelVideoWindowProjectRestoreTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import JammLab + +final class ViewModelVideoWindowProjectRestoreTests: XCTestCase { + @MainActor + func testOpenProjectRestoresSavedVideoWindowOpenState() async throws { + let fixture = try makeVideoProjectFixture( + name: "video-window-open", + isVideoWindowOpen: true + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + videoFollower: videoFollower, + projectService: fixture.projectService, + projectArtifactStore: fixture.artifactStore, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: fixture.projectURL) + + XCTAssertNil(viewModel.errorMessage) + XCTAssertEqual(videoFollower.loadedVideoURL, fixture.videoURL) + XCTAssertTrue(viewModel.isVideoWindowOpen) + XCTAssertEqual(videoFollower.showWindowEvents.count, 1) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testOpenProjectClosesVideoWindowWhenSavedStateIsClosed() async throws { + let fixture = try makeVideoProjectFixture( + name: "video-window-closed", + isVideoWindowOpen: false + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + videoFollower: videoFollower, + projectService: fixture.projectService, + projectArtifactStore: fixture.artifactStore, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + videoFollower.showWindow(at: 0, isPlaying: false, rate: 1) + + await viewModel.openProject(at: fixture.projectURL) + + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.isVideoWindowOpen) + XCTAssertFalse(videoFollower.isWindowOpen) + XCTAssertEqual(videoFollower.showWindowEvents.count, 1) + XCTAssertGreaterThanOrEqual(videoFollower.closeWindowCount, 1) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testOpenLegacyVideoProjectWithoutWindowStateKeepsVideoWindowClosed() async throws { + let fixture = try makeVideoProjectFixture( + name: "video-window-legacy", + isVideoWindowOpen: nil + ) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + videoFollower: videoFollower, + projectService: fixture.projectService, + projectArtifactStore: fixture.artifactStore, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: fixture.projectURL) + + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.isVideoWindowOpen) + XCTAssertFalse(videoFollower.isWindowOpen) + XCTAssertTrue(videoFollower.showWindowEvents.isEmpty) + XCTAssertFalse(viewModel.isProjectModified) + } + + private struct VideoProjectFixture { + let directory: URL + let projectURL: URL + let videoURL: URL + let projectService: ProjectDocumentService + let artifactStore: ProjectArtifactStore + } + + private func makeVideoProjectFixture( + name: String, + isVideoWindowOpen: Bool? + ) throws -> VideoProjectFixture { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let projectURL = directory.appendingPathComponent("\(name).jammlab") + let videoURL = try temporaryFile(in: directory, name: "\(name).mov", contents: "video") + let localAudioURL = try temporaryAudioFile() + defer { try? FileManager.default.removeItem(at: localAudioURL) } + + let artifactStore = ProjectArtifactStore() + try FileManager.default.createDirectory( + at: artifactStore.mediaDirectory(for: projectURL), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem( + at: localAudioURL, + to: artifactStore.videoAudioURL(for: projectURL) + ) + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: videoURL), + audioDisplayName: videoURL.lastPathComponent, + audioDuration: 0.5, + mediaKind: .video, + notes: [], + loopStart: 0, + loopEnd: 0.5, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + isVideoWindowOpen: isVideoWindowOpen + ) + try projectService.save(project, to: projectURL) + + return VideoProjectFixture( + directory: directory, + projectURL: projectURL, + videoURL: videoURL, + projectService: projectService, + artifactStore: artifactStore + ) + } +} From d56f1dbe76e4f70406c52e063c0504a0dca91b6f Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 16:57:07 +0300 Subject: [PATCH 61/80] refactor: split view model note editing tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelEditingStateTests.swift | 69 ----------------- JammLabTests/ViewModelNoteEditingTests.swift | 74 +++++++++++++++++++ 3 files changed, 78 insertions(+), 69 deletions(-) create mode 100644 JammLabTests/ViewModelNoteEditingTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 76f7f0b..bb87464 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */; }; 9FAB01442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */; }; 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */; }; + 9FAB01512CE0000100112233 /* ViewModelNoteEditingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00512CE0000100112233 /* ViewModelNoteEditingTests.swift */; }; 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */; }; 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */; }; @@ -364,6 +365,7 @@ 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectRestoreTests.swift; sourceTree = ""; }; 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTimelineRangePersistenceTests.swift; sourceTree = ""; }; 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelEditingStateTests.swift; sourceTree = ""; }; + 9FAB00512CE0000100112233 /* ViewModelNoteEditingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNoteEditingTests.swift; sourceTree = ""; }; 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTempoMapTests.swift; sourceTree = ""; }; 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionActivationTests.swift; sourceTree = ""; }; @@ -693,6 +695,7 @@ 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */, 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, + 9FAB00512CE0000100112233 /* ViewModelNoteEditingTests.swift */, 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */, @@ -1142,6 +1145,7 @@ 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */, 9FAB01442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift in Sources */, 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */, + 9FAB01512CE0000100112233 /* ViewModelNoteEditingTests.swift in Sources */, 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */, diff --git a/JammLabTests/ViewModelEditingStateTests.swift b/JammLabTests/ViewModelEditingStateTests.swift index 70e83e9..81646a6 100644 --- a/JammLabTests/ViewModelEditingStateTests.swift +++ b/JammLabTests/ViewModelEditingStateTests.swift @@ -164,73 +164,4 @@ final class ViewModelEditingStateTests: XCTestCase { XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) } - @MainActor - func testUndoRestoresNoteUpdateOrderAndTitle() { - let undoManager = UndoManager() - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - let markerA = TimecodedNote(time: 10, title: "A") - let markerB = TimecodedNote(time: 2, title: "B") - let state = ProjectEditableState( - notes: [markerA, markerB], - selectedRegionID: nil, - activeLoopRegionID: nil, - loopRegion: .empty, - isLooping: false, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - mainTrackVolume: AppSliderDefaults.mainTrackVolume, - stemMixState: StemMixState(), - playbackMode: .original, - isClickEnabled: false, - clickVolume: AppSliderDefaults.clickVolume, - isSnapEnabled: false - ) - viewModel.restoreEditableState(state) - viewModel.undoManager = undoManager - - viewModel.updateNoteTitle(id: markerA.id, title: "Renamed") - - XCTAssertEqual(viewModel.notes.map(\.title), ["Renamed", "B"]) - - viewModel.undoLastEdit() - - XCTAssertEqual(viewModel.notes.map(\.title), ["A", "B"]) - } - - @MainActor - func testViewModelUpdatesPresetAndCustomNoteColors() { - let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) - let marker = TimecodedNote(time: 2, title: "A", color: .markerBlue, customColorHex: "#123456") - let state = ProjectEditableState( - notes: [marker], - selectedRegionID: nil, - activeLoopRegionID: nil, - loopRegion: .empty, - isLooping: false, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - mainTrackVolume: AppSliderDefaults.mainTrackVolume, - stemMixState: StemMixState(), - playbackMode: .original, - isClickEnabled: false, - clickVolume: AppSliderDefaults.clickVolume, - isSnapEnabled: false - ) - viewModel.restoreEditableState(state) - - viewModel.updateNoteCustomColor(id: marker.id, hex: "abcdef") - - XCTAssertEqual(viewModel.notes.first?.customColorHex, "#ABCDEF") - XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, "#ABCDEF") - - viewModel.updateNoteColor(id: marker.id, color: .markerOrange) - - XCTAssertEqual(viewModel.notes.first?.color, .markerOrange) - XCTAssertNil(viewModel.notes.first?.customColorHex) - XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, MarkerColor.markerOrange.defaultHex) - } } diff --git a/JammLabTests/ViewModelNoteEditingTests.swift b/JammLabTests/ViewModelNoteEditingTests.swift new file mode 100644 index 0000000..e0cadf9 --- /dev/null +++ b/JammLabTests/ViewModelNoteEditingTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import JammLab + +final class ViewModelNoteEditingTests: XCTestCase { + @MainActor + func testUndoRestoresNoteUpdateOrderAndTitle() { + let undoManager = UndoManager() + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + let markerA = TimecodedNote(time: 10, title: "A") + let markerB = TimecodedNote(time: 2, title: "B") + let state = ProjectEditableState( + notes: [markerA, markerB], + selectedRegionID: nil, + activeLoopRegionID: nil, + loopRegion: .empty, + isLooping: false, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + mainTrackVolume: AppSliderDefaults.mainTrackVolume, + stemMixState: StemMixState(), + playbackMode: .original, + isClickEnabled: false, + clickVolume: AppSliderDefaults.clickVolume, + isSnapEnabled: false + ) + viewModel.restoreEditableState(state) + viewModel.undoManager = undoManager + + viewModel.updateNoteTitle(id: markerA.id, title: "Renamed") + + XCTAssertEqual(viewModel.notes.map(\.title), ["Renamed", "B"]) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.notes.map(\.title), ["A", "B"]) + } + + @MainActor + func testViewModelUpdatesPresetAndCustomNoteColors() { + let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) + let marker = TimecodedNote(time: 2, title: "A", color: .markerBlue, customColorHex: "#123456") + let state = ProjectEditableState( + notes: [marker], + selectedRegionID: nil, + activeLoopRegionID: nil, + loopRegion: .empty, + isLooping: false, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + mainTrackVolume: AppSliderDefaults.mainTrackVolume, + stemMixState: StemMixState(), + playbackMode: .original, + isClickEnabled: false, + clickVolume: AppSliderDefaults.clickVolume, + isSnapEnabled: false + ) + viewModel.restoreEditableState(state) + + viewModel.updateNoteCustomColor(id: marker.id, hex: "abcdef") + + XCTAssertEqual(viewModel.notes.first?.customColorHex, "#ABCDEF") + XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, "#ABCDEF") + + viewModel.updateNoteColor(id: marker.id, color: .markerOrange) + + XCTAssertEqual(viewModel.notes.first?.color, .markerOrange) + XCTAssertNil(viewModel.notes.first?.customColorHex) + XCTAssertEqual(viewModel.notes.first?.resolvedColorHex, MarkerColor.markerOrange.defaultHex) + } +} From badcd30e802ff9dd1414ea2d6f80d28fddbc3f76 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:01:17 +0300 Subject: [PATCH 62/80] refactor: split MusicXML export error test --- JammLab.xcodeproj/project.pbxproj | 4 ++ JammLabTests/NotationMusicXMLErrorTests.swift | 40 +++++++++++++++++++ JammLabTests/NotationMusicXMLTests.swift | 23 ----------- 3 files changed, 44 insertions(+), 23 deletions(-) create mode 100644 JammLabTests/NotationMusicXMLErrorTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index bb87464..3c07401 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -163,6 +163,7 @@ 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */; }; 9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */; }; 9FAB01472CE0000100112233 /* NotationMusicXMLRestExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */; }; + 9FAB01522CE0000100112233 /* NotationMusicXMLErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00522CE0000100112233 /* NotationMusicXMLErrorTests.swift */; }; 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */; }; 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */; }; 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */; }; @@ -407,6 +408,7 @@ 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = ""; }; 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLChordParserTests.swift; sourceTree = ""; }; 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLRestExportTests.swift; sourceTree = ""; }; + 9FAB00522CE0000100112233 /* NotationMusicXMLErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLErrorTests.swift; sourceTree = ""; }; 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationKeySignatureTests.swift; sourceTree = ""; }; 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeDisplayTests.swift; sourceTree = ""; }; 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationRegionLabelTests.swift; sourceTree = ""; }; @@ -737,6 +739,7 @@ 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */, 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */, 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */, + 9FAB00522CE0000100112233 /* NotationMusicXMLErrorTests.swift */, 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */, 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */, 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */, @@ -1187,6 +1190,7 @@ 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */, 9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */, 9FAB01472CE0000100112233 /* NotationMusicXMLRestExportTests.swift in Sources */, + 9FAB01522CE0000100112233 /* NotationMusicXMLErrorTests.swift in Sources */, 9FAB010E2CE0000100112233 /* NotationKeySignatureTests.swift in Sources */, 9FAB010F2CE0000100112233 /* NotationAttributeDisplayTests.swift in Sources */, 9FAB01102CE0000100112233 /* NotationRegionLabelTests.swift in Sources */, diff --git a/JammLabTests/NotationMusicXMLErrorTests.swift b/JammLabTests/NotationMusicXMLErrorTests.swift new file mode 100644 index 0000000..62909a3 --- /dev/null +++ b/JammLabTests/NotationMusicXMLErrorTests.swift @@ -0,0 +1,40 @@ +import Foundation +import XCTest +@testable import JammLab + +final class NotationMusicXMLErrorTests: XCTestCase { + func testMusicXMLExportFailsForUnsupportedHarmony() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + harmonySymbols: [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "G7alt") + ] + ) + + XCTAssertThrowsError( + try NotationExportService().export( + NotationExportRequest(displayName: "Song", score: state), + format: .musicXML + ) + ) { error in + XCTAssertEqual(error as? NotationExportError, .unsupportedChord(rawText: "G7alt", measureNumber: 1)) + } + } + + private func fourFourTempoMap(duration: TimeInterval) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ), + markers: [], + duration: duration + ) + } +} diff --git a/JammLabTests/NotationMusicXMLTests.swift b/JammLabTests/NotationMusicXMLTests.swift index f7d370d..622973d 100644 --- a/JammLabTests/NotationMusicXMLTests.swift +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -149,29 +149,6 @@ final class NotationMusicXMLTests: XCTestCase { XCTAssertEqual(try firstXMLChild(named: "type", in: firstMeasureRest).stringValue, "whole") } - func testMusicXMLExportFailsForUnsupportedHarmony() throws { - let state = NotationViewportFactory().scoreState( - tempoMap: fourFourTempoMap(duration: 4), - duration: 4, - currentTime: 0, - playbackMarkerTime: 0, - isPlaying: false, - keyName: "C major", - harmonySymbols: [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "G7alt") - ] - ) - - XCTAssertThrowsError( - try NotationExportService().export( - NotationExportRequest(displayName: "Song", score: state), - format: .musicXML - ) - ) { error in - XCTAssertEqual(error as? NotationExportError, .unsupportedChord(rawText: "G7alt", measureNumber: 1)) - } - } - private func childElements(in element: XMLElement) -> [XMLElement] { (element.children ?? []).compactMap { $0 as? XMLElement } } From 5482864d27c911cf8feb0d7fb889db2e4176d2af Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:05:45 +0300 Subject: [PATCH 63/80] refactor: split stem job input tests --- JammLab.xcodeproj/project.pbxproj | 4 ++ JammLabTests/StemJobInputTests.swift | 90 +++++++++++++++++++++++++ JammLabTests/StemJobWorkflowTests.swift | 85 ----------------------- 3 files changed, 94 insertions(+), 85 deletions(-) create mode 100644 JammLabTests/StemJobInputTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 3c07401..9791209 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -138,6 +138,7 @@ 9FAB014A2CE0000100112233 /* MediaImportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004A2CE0000100112233 /* MediaImportLogicTests.swift */; }; 9FAB014B2CE0000100112233 /* StemBackendResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004B2CE0000100112233 /* StemBackendResolverTests.swift */; }; 9FAB013C2CE0000100112233 /* StemJobWorkflowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */; }; + 9FAB01532CE0000100112233 /* StemJobInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00532CE0000100112233 /* StemJobInputTests.swift */; }; 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */; }; 9FAB01412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; @@ -383,6 +384,7 @@ 9FAB004A2CE0000100112233 /* MediaImportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaImportLogicTests.swift; sourceTree = ""; }; 9FAB004B2CE0000100112233 /* StemBackendResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemBackendResolverTests.swift; sourceTree = ""; }; 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemJobWorkflowTests.swift; sourceTree = ""; }; + 9FAB00532CE0000100112233 /* StemJobInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemJobInputTests.swift; sourceTree = ""; }; 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStoreTests.swift; sourceTree = ""; }; 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectPersistenceCoordinatorArtifactTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; @@ -714,6 +716,7 @@ 9FAB004A2CE0000100112233 /* MediaImportLogicTests.swift */, 9FAB004B2CE0000100112233 /* StemBackendResolverTests.swift */, 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */, + 9FAB00532CE0000100112233 /* StemJobInputTests.swift */, 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, @@ -1165,6 +1168,7 @@ 9FAB014A2CE0000100112233 /* MediaImportLogicTests.swift in Sources */, 9FAB014B2CE0000100112233 /* StemBackendResolverTests.swift in Sources */, 9FAB013C2CE0000100112233 /* StemJobWorkflowTests.swift in Sources */, + 9FAB01532CE0000100112233 /* StemJobInputTests.swift in Sources */, 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */, 9FAB01412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, diff --git a/JammLabTests/StemJobInputTests.swift b/JammLabTests/StemJobInputTests.swift new file mode 100644 index 0000000..f30cbf5 --- /dev/null +++ b/JammLabTests/StemJobInputTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import JammLab + +final class StemJobInputTests: XCTestCase { + func testStemJobInputUsesDirectOriginalPathWhenNotSandboxed() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false }, + applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) + ) + + let input = try service.jobInput( + for: audioURL, + jobDirectory: directory.appendingPathComponent("job", isDirectory: true), + mode: StemJobInputMode.direct + ) + + XCTAssertEqual(input.audioPath, audioURL.path) + XCTAssertNil(input.stagedInputDirectory) + } + + func testStemJobInputStagesAudioInsideJobDirectoryWhenSandboxed() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") + let jobDirectory = directory.appendingPathComponent("job", isDirectory: true) + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { true }, + applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) + ) + + let input = try service.jobInput(for: audioURL, jobDirectory: jobDirectory, mode: StemJobInputMode.staged) + + XCTAssertEqual(input.stagedInputDirectory, jobDirectory.appendingPathComponent("input", isDirectory: true)) + XCTAssertEqual(input.audioPath, jobDirectory.appendingPathComponent("input/song.mp3").path) + XCTAssertEqual(try String(contentsOfFile: input.audioPath, encoding: .utf8), "audio") + } + + func testStemJobRequestUsesStagedAudioPathButKeepsOriginalFingerprint() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") + let jobDirectory = directory.appendingPathComponent("job", isDirectory: true) + let cacheDirectory = directory.appendingPathComponent("cache", isDirectory: true) + let fingerprint = StemSourceFingerprint(path: audioURL.path, fileSize: 5, modificationTime: 123) + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { true }, + applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) + ) + + try service.createJobForTesting( + audioURL: audioURL, + fingerprint: fingerprint, + cacheKey: "cache-key", + cacheDirectory: cacheDirectory, + jobDirectory: jobDirectory, + inputMode: StemJobInputMode.staged, + method: .sixStem + ) + let requestData = try Data(contentsOf: jobDirectory.appendingPathComponent(StemJobFiles.requestFilename)) + let request = try JSONDecoder().decode(StemJobRequest.self, from: requestData) + + XCTAssertEqual(request.audioPath, jobDirectory.appendingPathComponent("input/song.mp3").path) + XCTAssertEqual(request.sourceFingerprint, fingerprint) + XCTAssertEqual(request.separationMethodID, StemSeparationMethod.sixStem.id) + XCTAssertEqual(request.modelName, StemSeparationMethod.sixStem.modelName) + XCTAssertEqual(request.expectedStemTypes, StemSeparationMethod.sixStem.stemTypes) + } + + func testStemInputPermissionFailureClassification() throws { + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false }, + applicationSupportDirectory: temporaryDirectory() + ) + let path = "/Users/example/Music/song.mp3" + let error = StemSeparationError.helperJobFailed("Failed: Operation not permitted: '\(path)'") + + XCTAssertTrue(service.isInputPermissionFailure(error, originalAudioPath: path)) + XCTAssertFalse(service.isInputPermissionFailure(error, originalAudioPath: "/other/song.mp3")) + } +} diff --git a/JammLabTests/StemJobWorkflowTests.swift b/JammLabTests/StemJobWorkflowTests.swift index 061f5b9..fdd66c8 100644 --- a/JammLabTests/StemJobWorkflowTests.swift +++ b/JammLabTests/StemJobWorkflowTests.swift @@ -142,89 +142,4 @@ final class StemJobWorkflowTests: XCTestCase { XCTAssertTrue(error.diagnostics.contains("backend failed")) } - func testStemJobInputUsesDirectOriginalPathWhenNotSandboxed() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false }, - applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) - ) - - let input = try service.jobInput( - for: audioURL, - jobDirectory: directory.appendingPathComponent("job", isDirectory: true), - mode: StemJobInputMode.direct - ) - - XCTAssertEqual(input.audioPath, audioURL.path) - XCTAssertNil(input.stagedInputDirectory) - } - - func testStemJobInputStagesAudioInsideJobDirectoryWhenSandboxed() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") - let jobDirectory = directory.appendingPathComponent("job", isDirectory: true) - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { true }, - applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) - ) - - let input = try service.jobInput(for: audioURL, jobDirectory: jobDirectory, mode: StemJobInputMode.staged) - - XCTAssertEqual(input.stagedInputDirectory, jobDirectory.appendingPathComponent("input", isDirectory: true)) - XCTAssertEqual(input.audioPath, jobDirectory.appendingPathComponent("input/song.mp3").path) - XCTAssertEqual(try String(contentsOfFile: input.audioPath, encoding: .utf8), "audio") - } - - func testStemJobRequestUsesStagedAudioPathButKeepsOriginalFingerprint() throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let audioURL = try temporaryFile(in: directory, name: "song.mp3", contents: "audio") - let jobDirectory = directory.appendingPathComponent("job", isDirectory: true) - let cacheDirectory = directory.appendingPathComponent("cache", isDirectory: true) - let fingerprint = StemSourceFingerprint(path: audioURL.path, fileSize: 5, modificationTime: 123) - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { true }, - applicationSupportDirectory: directory.appendingPathComponent("support", isDirectory: true) - ) - - try service.createJobForTesting( - audioURL: audioURL, - fingerprint: fingerprint, - cacheKey: "cache-key", - cacheDirectory: cacheDirectory, - jobDirectory: jobDirectory, - inputMode: StemJobInputMode.staged, - method: .sixStem - ) - let requestData = try Data(contentsOf: jobDirectory.appendingPathComponent(StemJobFiles.requestFilename)) - let request = try JSONDecoder().decode(StemJobRequest.self, from: requestData) - - XCTAssertEqual(request.audioPath, jobDirectory.appendingPathComponent("input/song.mp3").path) - XCTAssertEqual(request.sourceFingerprint, fingerprint) - XCTAssertEqual(request.separationMethodID, StemSeparationMethod.sixStem.id) - XCTAssertEqual(request.modelName, StemSeparationMethod.sixStem.modelName) - XCTAssertEqual(request.expectedStemTypes, StemSeparationMethod.sixStem.stemTypes) - } - - func testStemInputPermissionFailureClassification() throws { - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false }, - applicationSupportDirectory: temporaryDirectory() - ) - let path = "/Users/example/Music/song.mp3" - let error = StemSeparationError.helperJobFailed("Failed: Operation not permitted: '\(path)'") - - XCTAssertTrue(service.isInputPermissionFailure(error, originalAudioPath: path)) - XCTAssertFalse(service.isInputPermissionFailure(error, originalAudioPath: "/other/song.mp3")) - } } From 1885e16cdd2a68786fb743338649367faf6056ae Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:09:24 +0300 Subject: [PATCH 64/80] refactor: split playback timeline tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../ViewModelPlaybackStateTests.swift | 107 ----------------- .../ViewModelPlaybackTimelineTests.swift | 112 ++++++++++++++++++ 3 files changed, 116 insertions(+), 107 deletions(-) create mode 100644 JammLabTests/ViewModelPlaybackTimelineTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 9791209..6c92d7b 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 9FAB01452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */; }; 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; + 9FAB01542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */; }; 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */; }; 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; @@ -360,6 +361,7 @@ 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyNotationEventFilterTests.swift; sourceTree = ""; }; 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; + 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackTimelineTests.swift; sourceTree = ""; }; 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowTests.swift; sourceTree = ""; }; 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; @@ -692,6 +694,7 @@ 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */, 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, + 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */, 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */, 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, @@ -1144,6 +1147,7 @@ 9FAB01452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift in Sources */, 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, + 9FAB01542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift in Sources */, 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */, 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, diff --git a/JammLabTests/ViewModelPlaybackStateTests.swift b/JammLabTests/ViewModelPlaybackStateTests.swift index 76b4401..6c8ad8d 100644 --- a/JammLabTests/ViewModelPlaybackStateTests.swift +++ b/JammLabTests/ViewModelPlaybackStateTests.swift @@ -116,111 +116,4 @@ final class ViewModelPlaybackStateTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } - @MainActor - func testPlaybackClockFollowsZoomedTimelineNearRightEdge() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.isPlaying = true - engine.currentTime = 18.4 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 100 - viewModel.setTimelineVisibleRange(0...20) - viewModel.playbackState = .playing - - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound - viewModel.timelineVisibleRange.lowerBound, 20, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 16.8, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineViewport.xPosition(for: viewModel.currentTime, width: 100), 8, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 20, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testPlaybackFollowDoesNotDirtyOrReplaceUserTimelineRange() throws { - let audioURL = try temporaryAudioFile(duration: 100) - defer { try? FileManager.default.removeItem(at: audioURL) } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine - ) - let media = ImportedAudioFile(url: audioURL, displayName: "follow.wav", duration: 100) - try viewModel.loadImportedAudio(media) - viewModel.setTimelineVisibleRange(0...20) - viewModel.markProjectClean() - engine.isPlaying = true - engine.currentTime = 18.4 - viewModel.playbackState = .playing - - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 16.8, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 36.8, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 20, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testPlaybackClockDoesNotFollowFullTimelineRange() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.isPlaying = true - engine.currentTime = 95 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 100 - viewModel.setTimelineVisibleRange(0...100) - viewModel.playbackState = .playing - - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 100, accuracy: 0.0001) - } - - @MainActor - func testStopReturnsZoomedTimelineToPlaybackMarker() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.currentTime = 70 - let viewModel = AudioPlayerViewModel(playbackEngine: engine) - viewModel.duration = 100 - viewModel.setPlaybackMarkerExactly(to: 30) - viewModel.setTimelineVisibleRange(60...80) - viewModel.playbackState = .playing - - viewModel.stop() - - XCTAssertEqual(viewModel.currentTime, 30, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 30, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound - viewModel.timelineVisibleRange.lowerBound, 20, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 28.4, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineViewport.xPosition(for: viewModel.playbackMarkerTime, width: 100), 8, accuracy: 0.0001) - } - - @MainActor - func testPlaybackAutoStopAtEndReturnsToPlaybackMarker() { - let engine = MockPlaybackEngine() - engine.isLoaded = true - engine.isPlaying = false - engine.currentTime = 4 - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) - viewModel.duration = 4 - viewModel.setPlaybackMarkerExactly(to: 1) - viewModel.setTimelineVisibleRange(2...4) - engine.currentTime = 4 - viewModel.playbackState = .playing - - viewModel.refreshPlaybackPosition() - - XCTAssertEqual(viewModel.playbackState, .stopped) - XCTAssertEqual(viewModel.currentTime, 1, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 1, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0.84, accuracy: 0.0001) - XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 2.84, accuracy: 0.0001) - } } diff --git a/JammLabTests/ViewModelPlaybackTimelineTests.swift b/JammLabTests/ViewModelPlaybackTimelineTests.swift new file mode 100644 index 0000000..f5245fa --- /dev/null +++ b/JammLabTests/ViewModelPlaybackTimelineTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import JammLab + +final class ViewModelPlaybackTimelineTests: XCTestCase { + @MainActor + func testPlaybackClockFollowsZoomedTimelineNearRightEdge() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.isPlaying = true + engine.currentTime = 18.4 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 100 + viewModel.setTimelineVisibleRange(0...20) + viewModel.playbackState = .playing + + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound - viewModel.timelineVisibleRange.lowerBound, 20, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 16.8, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineViewport.xPosition(for: viewModel.currentTime, width: 100), 8, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 20, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testPlaybackFollowDoesNotDirtyOrReplaceUserTimelineRange() throws { + let audioURL = try temporaryAudioFile(duration: 100) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "follow.wav", duration: 100) + try viewModel.loadImportedAudio(media) + viewModel.setTimelineVisibleRange(0...20) + viewModel.markProjectClean() + engine.isPlaying = true + engine.currentTime = 18.4 + viewModel.playbackState = .playing + + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 16.8, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 36.8, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.userTimelineVisibleRange.upperBound, 20, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testPlaybackClockDoesNotFollowFullTimelineRange() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.isPlaying = true + engine.currentTime = 95 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 100 + viewModel.setTimelineVisibleRange(0...100) + viewModel.playbackState = .playing + + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 100, accuracy: 0.0001) + } + + @MainActor + func testStopReturnsZoomedTimelineToPlaybackMarker() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.currentTime = 70 + let viewModel = AudioPlayerViewModel(playbackEngine: engine) + viewModel.duration = 100 + viewModel.setPlaybackMarkerExactly(to: 30) + viewModel.setTimelineVisibleRange(60...80) + viewModel.playbackState = .playing + + viewModel.stop() + + XCTAssertEqual(viewModel.currentTime, 30, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 30, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound - viewModel.timelineVisibleRange.lowerBound, 20, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 28.4, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineViewport.xPosition(for: viewModel.playbackMarkerTime, width: 100), 8, accuracy: 0.0001) + } + + @MainActor + func testPlaybackAutoStopAtEndReturnsToPlaybackMarker() { + let engine = MockPlaybackEngine() + engine.isLoaded = true + engine.isPlaying = false + engine.currentTime = 4 + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel(playbackEngine: engine, videoFollower: videoFollower) + viewModel.duration = 4 + viewModel.setPlaybackMarkerExactly(to: 1) + viewModel.setTimelineVisibleRange(2...4) + engine.currentTime = 4 + viewModel.playbackState = .playing + + viewModel.refreshPlaybackPosition() + + XCTAssertEqual(viewModel.playbackState, .stopped) + XCTAssertEqual(viewModel.currentTime, 1, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 1, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0.84, accuracy: 0.0001) + XCTAssertEqual(viewModel.timelineVisibleRange.upperBound, 2.84, accuracy: 0.0001) + } +} From bcbd9ebe8adc5c4640c31c6bd4a1b01d4c0a7459 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:15:16 +0300 Subject: [PATCH 65/80] refactor: split project save destination tests --- JammLab.xcodeproj/project.pbxproj | 4 ++ .../ProjectSaveDestinationTests.swift | 43 +++++++++++++++++++ JammLabTests/TimelineProjectLogicTests.swift | 39 ----------------- 3 files changed, 47 insertions(+), 39 deletions(-) create mode 100644 JammLabTests/ProjectSaveDestinationTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 6c92d7b..47ddab4 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */; }; 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */; }; 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */; }; + 9FAB01552CE0000100112233 /* ProjectSaveDestinationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00552CE0000100112233 /* ProjectSaveDestinationTests.swift */; }; 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */; }; 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */; }; 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */; }; @@ -353,6 +354,7 @@ 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileImporterDurationTests.swift; sourceTree = ""; }; 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeakformLogicTests.swift; sourceTree = ""; }; 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProjectLogicTests.swift; sourceTree = ""; }; + 9FAB00552CE0000100112233 /* ProjectSaveDestinationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSaveDestinationTests.swift; sourceTree = ""; }; 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimecodedNoteColorTests.swift; sourceTree = ""; }; 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewportLogicTests.swift; sourceTree = ""; }; 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyTests.swift; sourceTree = ""; }; @@ -686,6 +688,7 @@ 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, + 9FAB00552CE0000100112233 /* ProjectSaveDestinationTests.swift */, 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */, 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */, @@ -1139,6 +1142,7 @@ 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, 9FBA01062D40000100112233 /* AudioSampleConverterTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, + 9FAB01552CE0000100112233 /* ProjectSaveDestinationTests.swift in Sources */, 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */, 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */, 9FAB01382CE0000100112233 /* AppHotkeyTests.swift in Sources */, diff --git a/JammLabTests/ProjectSaveDestinationTests.swift b/JammLabTests/ProjectSaveDestinationTests.swift new file mode 100644 index 0000000..f8a6d11 --- /dev/null +++ b/JammLabTests/ProjectSaveDestinationTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import JammLab + +final class ProjectSaveDestinationTests: XCTestCase { + func testProjectSaveDestinationCreatesProjectSubdirectory() { + let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song") + let destination = ProjectSaveDestination.projectFolder(selectedURL) + + XCTAssertEqual(destination.artifactRootURL.path, "/tmp/JammLab/Song") + XCTAssertEqual(destination.projectURL.path, "/tmp/JammLab/Song/Song.jammlab") + XCTAssertEqual(destination.securityScopedAccessURL.path, "/tmp/JammLab/Song") + XCTAssertTrue(destination.createSubdirectory) + } + + func testProjectSaveDestinationWithoutSubdirectoryUsesSelectedProjectFile() { + let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song.jammlab") + let destination = ProjectSaveDestination.projectFile(selectedURL) + + XCTAssertEqual(destination.artifactRootURL.path, "/tmp/JammLab") + XCTAssertEqual(destination.projectURL.path, "/tmp/JammLab/Song.jammlab") + XCTAssertEqual(destination.securityScopedAccessURL.path, "/tmp/JammLab") + XCTAssertFalse(destination.createSubdirectory) + } + + func testProjectSaveDestinationStripsJammlabExtensionFromProjectFolderSelection() { + let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song.jammlab") + let destination = ProjectSaveDestination.projectFolder(selectedURL) + + XCTAssertEqual(destination.artifactRootURL.path, "/tmp/JammLab/Song") + XCTAssertEqual(destination.projectURL.path, "/tmp/JammLab/Song/Song.jammlab") + XCTAssertEqual(destination.securityScopedAccessURL.path, "/tmp/JammLab/Song") + } + + func testProjectSaveDestinationAddsJammlabExtensionForFileMode() { + let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song") + let destination = ProjectSaveDestination.projectFile(selectedURL) + + XCTAssertEqual(destination.artifactRootURL.path, "/tmp/JammLab") + XCTAssertEqual(destination.projectURL.path, "/tmp/JammLab/Song.jammlab") + XCTAssertEqual(destination.securityScopedAccessURL.path, "/tmp/JammLab") + XCTAssertFalse(destination.createSubdirectory) + } +} diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index dcfc762..ba33f66 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -2,45 +2,6 @@ import XCTest @testable import JammLab final class TimelineProjectLogicTests: XCTestCase { - func testProjectSaveDestinationCreatesProjectSubdirectory() { - let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song") - let destination = ProjectSaveDestination.projectFolder(selectedURL) - - XCTAssertEqual(destination.artifactRootURL.path, "/tmp/JammLab/Song") - XCTAssertEqual(destination.projectURL.path, "/tmp/JammLab/Song/Song.jammlab") - XCTAssertEqual(destination.securityScopedAccessURL.path, "/tmp/JammLab/Song") - XCTAssertTrue(destination.createSubdirectory) - } - - func testProjectSaveDestinationWithoutSubdirectoryUsesSelectedProjectFile() { - let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song.jammlab") - let destination = ProjectSaveDestination.projectFile(selectedURL) - - XCTAssertEqual(destination.artifactRootURL.path, "/tmp/JammLab") - XCTAssertEqual(destination.projectURL.path, "/tmp/JammLab/Song.jammlab") - XCTAssertEqual(destination.securityScopedAccessURL.path, "/tmp/JammLab") - XCTAssertFalse(destination.createSubdirectory) - } - - func testProjectSaveDestinationStripsJammlabExtensionFromProjectFolderSelection() { - let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song.jammlab") - let destination = ProjectSaveDestination.projectFolder(selectedURL) - - XCTAssertEqual(destination.artifactRootURL.path, "/tmp/JammLab/Song") - XCTAssertEqual(destination.projectURL.path, "/tmp/JammLab/Song/Song.jammlab") - XCTAssertEqual(destination.securityScopedAccessURL.path, "/tmp/JammLab/Song") - } - - func testProjectSaveDestinationAddsJammlabExtensionForFileMode() { - let selectedURL = URL(fileURLWithPath: "/tmp/JammLab/Song") - let destination = ProjectSaveDestination.projectFile(selectedURL) - - XCTAssertEqual(destination.artifactRootURL.path, "/tmp/JammLab") - XCTAssertEqual(destination.projectURL.path, "/tmp/JammLab/Song.jammlab") - XCTAssertEqual(destination.securityScopedAccessURL.path, "/tmp/JammLab") - XCTAssertFalse(destination.createSubdirectory) - } - func testLoopRegionRespectsCustomMinimumLength() { let region = LoopRegion(start: 2, end: 5) From e01b1f1e9910f1d2f2252b6e88f8b830e3b8c722 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:18:44 +0300 Subject: [PATCH 66/80] refactor: split project media coordinator tests --- JammLab.xcodeproj/project.pbxproj | 4 + ...tPersistenceCoordinatorArtifactTests.swift | 109 -------------- ...jectPersistenceCoordinatorMediaTests.swift | 134 ++++++++++++++++++ 3 files changed, 138 insertions(+), 109 deletions(-) create mode 100644 JammLabTests/ProjectPersistenceCoordinatorMediaTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 47ddab4..072b0cd 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ 9FAB01532CE0000100112233 /* StemJobInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00532CE0000100112233 /* StemJobInputTests.swift */; }; 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */; }; 9FAB01412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */; }; + 9FAB01562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */; }; @@ -391,6 +392,7 @@ 9FAB00532CE0000100112233 /* StemJobInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemJobInputTests.swift; sourceTree = ""; }; 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStoreTests.swift; sourceTree = ""; }; 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectPersistenceCoordinatorArtifactTests.swift; sourceTree = ""; }; + 9FAB00562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectPersistenceCoordinatorMediaTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceSignalTests.swift; sourceTree = ""; }; @@ -725,6 +727,7 @@ 9FAB00532CE0000100112233 /* StemJobInputTests.swift */, 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */, + 9FAB00562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */, @@ -1179,6 +1182,7 @@ 9FAB01532CE0000100112233 /* StemJobInputTests.swift in Sources */, 9FAB01392CE0000100112233 /* ProjectArtifactStoreTests.swift in Sources */, 9FAB01412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift in Sources */, + 9FAB01562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */, diff --git a/JammLabTests/ProjectPersistenceCoordinatorArtifactTests.swift b/JammLabTests/ProjectPersistenceCoordinatorArtifactTests.swift index 2d962bd..9fdee7e 100644 --- a/JammLabTests/ProjectPersistenceCoordinatorArtifactTests.swift +++ b/JammLabTests/ProjectPersistenceCoordinatorArtifactTests.swift @@ -85,99 +85,6 @@ final class ProjectPersistenceCoordinatorArtifactTests: XCTestCase { XCTAssertEqual(result.stemMetadata?.stems.map { $0.url.deletingLastPathComponent() }, Array(repeating: store.stemsDirectory(for: projectURL), count: StemSeparationMethod.fourStem.stemTypes.count)) } - func testProjectPersistenceCoordinatorOpenMediaPrefersProjectLocalVideoAudio() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let projectService = ProjectDocumentService() - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") - try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) - let localAudioURL = store.videoAudioURL(for: projectURL) - try Data("local-audio".utf8).write(to: localAudioURL) - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - decodedDuration: { url in - XCTAssertEqual(url, localAudioURL) - return 9 - } - ) - let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, localAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, videoURL) - XCTAssertEqual(result.file.mediaKind, .video) - XCTAssertEqual(result.projectDuration, 9) - XCTAssertFalse(result.shouldAnalyzeTempo) - XCTAssertNil(result.warningMessage) - } - - func testProjectPersistenceCoordinatorOpenVideoWithoutLocalAudioUsesRuntimeCacheOnly() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let projectService = ProjectDocumentService() - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") - let extractedAudioURL = try temporaryFile(in: directory, name: "runtime-audio.m4a", contents: "audio") - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - importFileFromURL: { url in - XCTAssertEqual(url, videoURL) - return ImportedAudioFile( - url: extractedAudioURL, - sourceMediaURL: url, - displayName: url.lastPathComponent, - duration: 7, - mediaKind: .video - ) - } - ) - let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, extractedAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, videoURL) - XCTAssertEqual(result.file.mediaKind, .video) - XCTAssertEqual(result.projectDuration, 7) - XCTAssertFalse(FileManager.default.fileExists(atPath: store.mediaDirectory(for: projectURL).path)) - } - - func testProjectPersistenceCoordinatorMissingVideoSourceFallsBackToLocalAudioWithWarning() async throws { - let directory = temporaryDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - - let store = ProjectArtifactStore() - let projectURL = directory.appendingPathComponent("Song.jammlab") - try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) - let localAudioURL = store.videoAudioURL(for: projectURL) - try Data("local-audio".utf8).write(to: localAudioURL) - let coordinator = try makeProjectPersistenceCoordinator( - projectArtifactStore: store, - decodedDuration: { url in - XCTAssertEqual(url, localAudioURL) - return 6 - } - ) - let project = videoProject(bookmarkData: Data("invalid-bookmark".utf8), duration: 12) - - let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) - - XCTAssertEqual(result.file.url, localAudioURL) - XCTAssertEqual(result.file.sourceMediaURL, localAudioURL) - XCTAssertEqual(result.file.mediaKind, .audio) - XCTAssertNil(result.file.videoURL) - XCTAssertEqual(result.projectDuration, 6) - XCTAssertNotNil(result.warningMessage) - } } private extension ProjectPersistenceCoordinatorArtifactTests { @@ -198,20 +105,4 @@ private extension ProjectPersistenceCoordinatorArtifactTests { decodedDuration: decodedDuration ) } - - func videoProject(bookmarkData: Data, duration: TimeInterval) -> JammLabProject { - JammLabProject( - audioBookmarkData: bookmarkData, - audioDisplayName: "lesson.mov", - audioDuration: duration, - mediaKind: .video, - notes: [], - loopStart: 0, - loopEnd: duration, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - } } diff --git a/JammLabTests/ProjectPersistenceCoordinatorMediaTests.swift b/JammLabTests/ProjectPersistenceCoordinatorMediaTests.swift new file mode 100644 index 0000000..47a7b63 --- /dev/null +++ b/JammLabTests/ProjectPersistenceCoordinatorMediaTests.swift @@ -0,0 +1,134 @@ +import XCTest +@testable import JammLab + +final class ProjectPersistenceCoordinatorMediaTests: XCTestCase { + func testProjectPersistenceCoordinatorOpenMediaPrefersProjectLocalVideoAudio() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let projectService = ProjectDocumentService() + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") + try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) + let localAudioURL = store.videoAudioURL(for: projectURL) + try Data("local-audio".utf8).write(to: localAudioURL) + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + decodedDuration: { url in + XCTAssertEqual(url, localAudioURL) + return 9 + } + ) + let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, localAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, videoURL) + XCTAssertEqual(result.file.mediaKind, .video) + XCTAssertEqual(result.projectDuration, 9) + XCTAssertFalse(result.shouldAnalyzeTempo) + XCTAssertNil(result.warningMessage) + } + + func testProjectPersistenceCoordinatorOpenVideoWithoutLocalAudioUsesRuntimeCacheOnly() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let projectService = ProjectDocumentService() + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + let videoURL = try temporaryFile(in: directory, name: "lesson.mov", contents: "video") + let extractedAudioURL = try temporaryFile(in: directory, name: "runtime-audio.m4a", contents: "audio") + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + importFileFromURL: { url in + XCTAssertEqual(url, videoURL) + return ImportedAudioFile( + url: extractedAudioURL, + sourceMediaURL: url, + displayName: url.lastPathComponent, + duration: 7, + mediaKind: .video + ) + } + ) + let project = videoProject(bookmarkData: try projectService.bookmarkData(for: videoURL), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, extractedAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, videoURL) + XCTAssertEqual(result.file.mediaKind, .video) + XCTAssertEqual(result.projectDuration, 7) + XCTAssertFalse(FileManager.default.fileExists(atPath: store.mediaDirectory(for: projectURL).path)) + } + + func testProjectPersistenceCoordinatorMissingVideoSourceFallsBackToLocalAudioWithWarning() async throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let store = ProjectArtifactStore() + let projectURL = directory.appendingPathComponent("Song.jammlab") + try FileManager.default.createDirectory(at: store.mediaDirectory(for: projectURL), withIntermediateDirectories: true) + let localAudioURL = store.videoAudioURL(for: projectURL) + try Data("local-audio".utf8).write(to: localAudioURL) + let coordinator = try makeProjectPersistenceCoordinator( + projectArtifactStore: store, + decodedDuration: { url in + XCTAssertEqual(url, localAudioURL) + return 6 + } + ) + let project = videoProject(bookmarkData: Data("invalid-bookmark".utf8), duration: 12) + + let result = try await coordinator.resolveProjectMedia(project: project, projectURL: projectURL) + + XCTAssertEqual(result.file.url, localAudioURL) + XCTAssertEqual(result.file.sourceMediaURL, localAudioURL) + XCTAssertEqual(result.file.mediaKind, .audio) + XCTAssertNil(result.file.videoURL) + XCTAssertEqual(result.projectDuration, 6) + XCTAssertNotNil(result.warningMessage) + } +} + +private extension ProjectPersistenceCoordinatorMediaTests { + func makeProjectPersistenceCoordinator( + projectArtifactStore: ProjectArtifactStore, + importFileFromURL: ((URL) async throws -> ImportedAudioFile)? = nil, + decodedDuration: @escaping (URL) throws -> TimeInterval = { _ in 1 } + ) throws -> ProjectPersistenceCoordinator { + ProjectPersistenceCoordinator( + projectArtifactStore: projectArtifactStore, + projectDocumentService: ProjectDocumentService(), + peakformProvider: MockPeakformProvider(), + stemSeparationService: StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + applicationSupportDirectory: temporaryDirectory() + ), + importFileFromURL: importFileFromURL, + decodedDuration: decodedDuration + ) + } + + func videoProject(bookmarkData: Data, duration: TimeInterval) -> JammLabProject { + JammLabProject( + audioBookmarkData: bookmarkData, + audioDisplayName: "lesson.mov", + audioDuration: duration, + mediaKind: .video, + notes: [], + loopStart: 0, + loopEnd: duration, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + } +} From 10965011ad9c1124320db648cd8573fbe53ab4ca Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:26:01 +0300 Subject: [PATCH 67/80] refactor: split stem separation cancellation tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ViewModelStemPlaybackTests.swift | 106 ----------------- ...ModelStemSeparationCancellationTests.swift | 110 ++++++++++++++++++ 3 files changed, 114 insertions(+), 106 deletions(-) create mode 100644 JammLabTests/ViewModelStemSeparationCancellationTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 072b0cd..0bc2df7 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -117,6 +117,7 @@ 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */; }; 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */; }; + 9FAB01572CE0000100112233 /* ViewModelStemSeparationCancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00572CE0000100112233 /* ViewModelStemSeparationCancellationTests.swift */; }; 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */; }; 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */; }; @@ -366,6 +367,7 @@ 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackTimelineTests.swift; sourceTree = ""; }; 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemPlaybackTests.swift; sourceTree = ""; }; + 9FAB00572CE0000100112233 /* ViewModelStemSeparationCancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelStemSeparationCancellationTests.swift; sourceTree = ""; }; 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowTests.swift; sourceTree = ""; }; 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectKeyPersistenceTests.swift; sourceTree = ""; }; @@ -701,6 +703,7 @@ 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */, 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, + 9FAB00572CE0000100112233 /* ViewModelStemSeparationCancellationTests.swift */, 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */, 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, @@ -1156,6 +1159,7 @@ 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift in Sources */, 9FAB01212CE0000100112233 /* ViewModelStemPlaybackTests.swift in Sources */, + 9FAB01572CE0000100112233 /* ViewModelStemSeparationCancellationTests.swift in Sources */, 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */, 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */, diff --git a/JammLabTests/ViewModelStemPlaybackTests.swift b/JammLabTests/ViewModelStemPlaybackTests.swift index d1c394e..614f5e0 100644 --- a/JammLabTests/ViewModelStemPlaybackTests.swift +++ b/JammLabTests/ViewModelStemPlaybackTests.swift @@ -89,112 +89,6 @@ final class ViewModelStemPlaybackTests: XCTestCase { XCTAssertEqual(engine.currentTime, 12, accuracy: 0.0001) } - @MainActor - func testCancelledStemSeparationDoesNotOverwriteCancelledStateWhenTaskFinishesLater() async throws { - let audioURL = try temporaryAudioFile(duration: 1, namePrefix: "stems") - let supportDirectory = temporaryDirectory() - let jobsDirectory = StemJobFiles.currentJobsDirectory(in: supportDirectory) - try FileManager.default.createDirectory(at: jobsDirectory, withIntermediateDirectories: true) - try writeHeartbeat( - to: jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename), - updatedAt: Date() - ) - defer { - try? FileManager.default.removeItem(at: audioURL.deletingLastPathComponent()) - try? FileManager.default.removeItem(at: supportDirectory) - } - - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false }, - applicationSupportDirectory: supportDirectory - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - stemSeparationService: service - ) - let media = ImportedAudioFile(url: audioURL, displayName: "stems.caf", duration: 1) - try viewModel.loadImportedAudio(media) - - viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) - - viewModel.cancelStemSeparation() - try await Task.sleep(nanoseconds: 700_000_000) - - if case .cancelled = viewModel.stemSeparationState.phase { - } else { - XCTFail("Expected cancelled stem separation phase, got \(viewModel.stemSeparationState.phase)") - } - XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") - XCTAssertNil(viewModel.errorMessage) - XCTAssertNil(viewModel.stemSeparationTask) - XCTAssertNil(viewModel.stemSeparationRunID) - XCTAssertTrue(viewModel.stemFiles.isEmpty) - } - - @MainActor - func testStemSeparationRestartIsBlockedUntilCancelledTaskFinishes() async throws { - let audioURL = try temporaryAudioFile(duration: 1, namePrefix: "stems-restart") - let supportDirectory = temporaryDirectory() - let jobsDirectory = StemJobFiles.currentJobsDirectory(in: supportDirectory) - try FileManager.default.createDirectory(at: jobsDirectory, withIntermediateDirectories: true) - try writeHeartbeat( - to: jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename), - updatedAt: Date() - ) - defer { - try? FileManager.default.removeItem(at: audioURL.deletingLastPathComponent()) - try? FileManager.default.removeItem(at: supportDirectory) - } - - let service = StemSeparationService( - appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false }, - applicationSupportDirectory: supportDirectory - ) - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - stemSeparationService: service - ) - let media = ImportedAudioFile(url: audioURL, displayName: "stems-restart.caf", duration: 1) - try viewModel.loadImportedAudio(media) - - viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) - let firstRunID = try XCTUnwrap(viewModel.stemSeparationRunID) - - viewModel.cancelStemSeparation() - viewModel.separateStems() - - XCTAssertEqual(viewModel.stemSeparationRunID, firstRunID) - XCTAssertNotNil(viewModel.stemSeparationTask) - XCTAssertEqual(viewModel.stemSeparationState.phase, .processing) - XCTAssertEqual(viewModel.stemSeparationState.status, "Cancelling stem separation") - - try await Task.sleep(nanoseconds: 700_000_000) - XCTAssertNil(viewModel.stemSeparationTask) - XCTAssertNil(viewModel.stemSeparationRunID) - - viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) - let secondRunID = try XCTUnwrap(viewModel.stemSeparationRunID) - XCTAssertNotEqual(secondRunID, firstRunID) - - viewModel.cancelStemSeparation() - try await Task.sleep(nanoseconds: 700_000_000) - - XCTAssertEqual(viewModel.stemSeparationState.phase, .cancelled) - XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") - XCTAssertNil(viewModel.stemSeparationTask) - XCTAssertNil(viewModel.stemSeparationRunID) - XCTAssertTrue(viewModel.stemFiles.isEmpty) - } - private func testStemMetadata() -> StemCacheMetadata { StemCacheMetadata( cacheKey: "test-cache", diff --git a/JammLabTests/ViewModelStemSeparationCancellationTests.swift b/JammLabTests/ViewModelStemSeparationCancellationTests.swift new file mode 100644 index 0000000..f51e69b --- /dev/null +++ b/JammLabTests/ViewModelStemSeparationCancellationTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import JammLab + +final class ViewModelStemSeparationCancellationTests: XCTestCase { + @MainActor + func testCancelledStemSeparationDoesNotOverwriteCancelledStateWhenTaskFinishesLater() async throws { + let audioURL = try temporaryAudioFile(duration: 1, namePrefix: "stems") + let supportDirectory = temporaryDirectory() + let jobsDirectory = StemJobFiles.currentJobsDirectory(in: supportDirectory) + try FileManager.default.createDirectory(at: jobsDirectory, withIntermediateDirectories: true) + try writeHeartbeat( + to: jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename), + updatedAt: Date() + ) + defer { + try? FileManager.default.removeItem(at: audioURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: supportDirectory) + } + + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false }, + applicationSupportDirectory: supportDirectory + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + stemSeparationService: service + ) + let media = ImportedAudioFile(url: audioURL, displayName: "stems.caf", duration: 1) + try viewModel.loadImportedAudio(media) + + viewModel.separateStems() + try await Task.sleep(nanoseconds: 100_000_000) + + viewModel.cancelStemSeparation() + try await Task.sleep(nanoseconds: 700_000_000) + + if case .cancelled = viewModel.stemSeparationState.phase { + } else { + XCTFail("Expected cancelled stem separation phase, got \(viewModel.stemSeparationState.phase)") + } + XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") + XCTAssertNil(viewModel.errorMessage) + XCTAssertNil(viewModel.stemSeparationTask) + XCTAssertNil(viewModel.stemSeparationRunID) + XCTAssertTrue(viewModel.stemFiles.isEmpty) + } + + @MainActor + func testStemSeparationRestartIsBlockedUntilCancelledTaskFinishes() async throws { + let audioURL = try temporaryAudioFile(duration: 1, namePrefix: "stems-restart") + let supportDirectory = temporaryDirectory() + let jobsDirectory = StemJobFiles.currentJobsDirectory(in: supportDirectory) + try FileManager.default.createDirectory(at: jobsDirectory, withIntermediateDirectories: true) + try writeHeartbeat( + to: jobsDirectory.appendingPathComponent(StemJobFiles.heartbeatFilename), + updatedAt: Date() + ) + defer { + try? FileManager.default.removeItem(at: audioURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: supportDirectory) + } + + let service = StemSeparationService( + appSettingsStore: JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false }, + applicationSupportDirectory: supportDirectory + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + stemSeparationService: service + ) + let media = ImportedAudioFile(url: audioURL, displayName: "stems-restart.caf", duration: 1) + try viewModel.loadImportedAudio(media) + + viewModel.separateStems() + try await Task.sleep(nanoseconds: 100_000_000) + let firstRunID = try XCTUnwrap(viewModel.stemSeparationRunID) + + viewModel.cancelStemSeparation() + viewModel.separateStems() + + XCTAssertEqual(viewModel.stemSeparationRunID, firstRunID) + XCTAssertNotNil(viewModel.stemSeparationTask) + XCTAssertEqual(viewModel.stemSeparationState.phase, .processing) + XCTAssertEqual(viewModel.stemSeparationState.status, "Cancelling stem separation") + + try await Task.sleep(nanoseconds: 700_000_000) + XCTAssertNil(viewModel.stemSeparationTask) + XCTAssertNil(viewModel.stemSeparationRunID) + + viewModel.separateStems() + try await Task.sleep(nanoseconds: 100_000_000) + let secondRunID = try XCTUnwrap(viewModel.stemSeparationRunID) + XCTAssertNotEqual(secondRunID, firstRunID) + + viewModel.cancelStemSeparation() + try await Task.sleep(nanoseconds: 700_000_000) + + XCTAssertEqual(viewModel.stemSeparationState.phase, .cancelled) + XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") + XCTAssertNil(viewModel.stemSeparationTask) + XCTAssertNil(viewModel.stemSeparationRunID) + XCTAssertTrue(viewModel.stemFiles.isEmpty) + } +} From 0c863b595deeba6159f3c52c7f7cc425b14e59e0 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:29:49 +0300 Subject: [PATCH 68/80] refactor: split notation duration hotkey filter tests --- JammLab.xcodeproj/project.pbxproj | 4 + ...tkeyNotationDurationEventFilterTests.swift | 74 +++++++++++++++++++ .../AppHotkeyNotationEventFilterTests.swift | 68 ----------------- 3 files changed, 78 insertions(+), 68 deletions(-) create mode 100644 JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 0bc2df7..6097b6a 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -113,6 +113,7 @@ 9FAB014D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift */; }; 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */; }; 9FAB01452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */; }; + 9FAB01602CE0000100112233 /* AppHotkeyNotationDurationEventFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00602CE0000100112233 /* AppHotkeyNotationDurationEventFilterTests.swift */; }; 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */; }; 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */; }; 9FAB01542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */; }; @@ -363,6 +364,7 @@ 9FAB004D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyNotationMappingTests.swift; sourceTree = ""; }; 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyEventFilterTests.swift; sourceTree = ""; }; 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyNotationEventFilterTests.swift; sourceTree = ""; }; + 9FAB00602CE0000100112233 /* AppHotkeyNotationDurationEventFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHotkeyNotationDurationEventFilterTests.swift; sourceTree = ""; }; 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelResetTests.swift; sourceTree = ""; }; 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackStateTests.swift; sourceTree = ""; }; 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelPlaybackTimelineTests.swift; sourceTree = ""; }; @@ -699,6 +701,7 @@ 9FAB004D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift */, 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */, 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */, + 9FAB00602CE0000100112233 /* AppHotkeyNotationDurationEventFilterTests.swift */, 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */, @@ -1155,6 +1158,7 @@ 9FAB014D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift in Sources */, 9FAB013A2CE0000100112233 /* AppHotkeyEventFilterTests.swift in Sources */, 9FAB01452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift in Sources */, + 9FAB01602CE0000100112233 /* AppHotkeyNotationDurationEventFilterTests.swift in Sources */, 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, 9FAB01202CE0000100112233 /* ViewModelPlaybackStateTests.swift in Sources */, 9FAB01542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift in Sources */, diff --git a/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift b/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift new file mode 100644 index 0000000..feb6353 --- /dev/null +++ b/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift @@ -0,0 +1,74 @@ +import AppKit +import XCTest +@testable import JammLab + +final class AppHotkeyNotationDurationEventFilterTests: XCTestCase { + func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { + let durationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: false, + keyCode: 21 + )) + let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "4", + charactersIgnoringModifiers: "4", + isARepeat: true, + keyCode: 21 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ), + .setNotationDurationEighth + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: durationEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatDurationEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ) + ) + } +} diff --git a/JammLabTests/AppHotkeyNotationEventFilterTests.swift b/JammLabTests/AppHotkeyNotationEventFilterTests.swift index dd9c664..47dc752 100644 --- a/JammLabTests/AppHotkeyNotationEventFilterTests.swift +++ b/JammLabTests/AppHotkeyNotationEventFilterTests.swift @@ -170,72 +170,4 @@ final class AppHotkeyNotationEventFilterTests: XCTestCase { ) } - func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { - let durationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: false, - keyCode: 21 - )) - let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: true, - keyCode: 21 - )) - - XCTAssertEqual( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: AppHotkey.notationDurationHotkeys - ), - .setNotationDurationEighth - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: [.playPause] - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: NSTextView(), - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: durationEvent, - attachedWindowNumber: 42, - firstResponder: AbletonNumberFieldNSView(), - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - XCTAssertNil( - AppHotkeyEventFilter.hotkey( - for: repeatDurationEvent, - attachedWindowNumber: 42, - firstResponder: nil, - allowedHotkeys: AppHotkey.notationDurationHotkeys - ) - ) - } } From 2597e9d7980219d5ae3002c833c57507e3e3b0fe Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:32:12 +0300 Subject: [PATCH 69/80] refactor: split notation attribute staff inset tests --- JammLab.xcodeproj/project.pbxproj | 4 ++ .../NotationAttributeGeometryTests.swift | 58 ----------------- .../NotationAttributeStaffInsetTests.swift | 62 +++++++++++++++++++ 3 files changed, 66 insertions(+), 58 deletions(-) create mode 100644 JammLabTests/NotationAttributeStaffInsetTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 6097b6a..16ddd38 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -181,6 +181,7 @@ 9FAB01462CE0000100112233 /* NotationViewportTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00462CE0000100112233 /* NotationViewportTempoMapTests.swift */; }; 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */; }; 9FAB01432CE0000100112233 /* NotationAttributeGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */; }; + 9FAB01612CE0000100112233 /* NotationAttributeStaffInsetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00612CE0000100112233 /* NotationAttributeStaffInsetTests.swift */; }; 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */; }; 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */; }; 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */; }; @@ -432,6 +433,7 @@ 9FAB00462CE0000100112233 /* NotationViewportTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportTempoMapTests.swift; sourceTree = ""; }; 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureGeometryTests.swift; sourceTree = ""; }; 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeGeometryTests.swift; sourceTree = ""; }; + 9FAB00612CE0000100112233 /* NotationAttributeStaffInsetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationAttributeStaffInsetTests.swift; sourceTree = ""; }; 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationFallbackGeometryTests.swift; sourceTree = ""; }; 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSelectionOverlayLayoutTests.swift; sourceTree = ""; }; 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSlashBeatLayoutTests.swift; sourceTree = ""; }; @@ -769,6 +771,7 @@ 9FAB00462CE0000100112233 /* NotationViewportTempoMapTests.swift */, 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */, + 9FAB00612CE0000100112233 /* NotationAttributeStaffInsetTests.swift */, 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */, 9FAB00172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift */, 9FAB00182CE0000100112233 /* NotationSlashBeatLayoutTests.swift */, @@ -1226,6 +1229,7 @@ 9FAB01462CE0000100112233 /* NotationViewportTempoMapTests.swift in Sources */, 9FAB01162CE0000100112233 /* NotationMeasureGeometryTests.swift in Sources */, 9FAB01432CE0000100112233 /* NotationAttributeGeometryTests.swift in Sources */, + 9FAB01612CE0000100112233 /* NotationAttributeStaffInsetTests.swift in Sources */, 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */, 9FAB01172CE0000100112233 /* NotationSelectionOverlayLayoutTests.swift in Sources */, 9FAB01182CE0000100112233 /* NotationSlashBeatLayoutTests.swift in Sources */, diff --git a/JammLabTests/NotationAttributeGeometryTests.swift b/JammLabTests/NotationAttributeGeometryTests.swift index bcecb66..8b4adb1 100644 --- a/JammLabTests/NotationAttributeGeometryTests.swift +++ b/JammLabTests/NotationAttributeGeometryTests.swift @@ -2,64 +2,6 @@ import XCTest @testable import JammLab final class NotationAttributeGeometryTests: XCTestCase { - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { - let cMajor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "C major"), - timeSignature: .fourFour, - clef: .treble - ) - let aMinor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "A minor"), - timeSignature: .fourFour, - clef: .treble - ) - let fMajor = MeasureAttributes( - keySignature: KeySignature.normalized(from: "F major"), - timeSignature: .fourFour, - clef: .treble - ) - - XCTAssertTrue(cMajor.keySignature.notationAccidentalGlyphs(for: cMajor.clef).isEmpty) - XCTAssertTrue(aMinor.keySignature.notationAccidentalGlyphs(for: aMinor.clef).isEmpty) - XCTAssertFalse(fMajor.keySignature.notationAccidentalGlyphs(for: fMajor.clef).isEmpty) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .full), - AppTheme.Timeline.notationAttributeStaffTopInset, - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: aMinor, display: .full), - NotationMeasureLayout.attributeStaffTopInset(for: fMajor, display: .full), - accuracy: 0.0001 - ) - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .none), - 0, - accuracy: 0.0001 - ) - } - - func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForPartialAttributeBlocks() { - let attributes = MeasureAttributes( - keySignature: KeySignature.normalized(from: "Bb major"), - timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), - clef: .treble - ) - let partialDisplays = [ - NotationAttributeDisplay(showsClef: true, showsKeySignature: false, showsTimeSignature: false), - NotationAttributeDisplay(showsClef: false, showsKeySignature: true, showsTimeSignature: false), - NotationAttributeDisplay(showsClef: false, showsKeySignature: false, showsTimeSignature: true) - ] - - for display in partialDisplays { - XCTAssertEqual( - NotationMeasureLayout.attributeStaffTopInset(for: attributes, display: display), - AppTheme.Timeline.notationAttributeStaffTopInset, - accuracy: 0.0001 - ) - } - } - func testNotationMeasureLayoutOffsetsAttributedMeasurePlayheadAfterAttributes() { let attributes = MeasureAttributes( keySignature: KeySignature.normalized(from: "F major"), diff --git a/JammLabTests/NotationAttributeStaffInsetTests.swift b/JammLabTests/NotationAttributeStaffInsetTests.swift new file mode 100644 index 0000000..9764444 --- /dev/null +++ b/JammLabTests/NotationAttributeStaffInsetTests.swift @@ -0,0 +1,62 @@ +import XCTest +@testable import JammLab + +final class NotationAttributeStaffInsetTests: XCTestCase { + func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForZeroAccidentalKeys() { + let cMajor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "C major"), + timeSignature: .fourFour, + clef: .treble + ) + let aMinor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "A minor"), + timeSignature: .fourFour, + clef: .treble + ) + let fMajor = MeasureAttributes( + keySignature: KeySignature.normalized(from: "F major"), + timeSignature: .fourFour, + clef: .treble + ) + + XCTAssertTrue(cMajor.keySignature.notationAccidentalGlyphs(for: cMajor.clef).isEmpty) + XCTAssertTrue(aMinor.keySignature.notationAccidentalGlyphs(for: aMinor.clef).isEmpty) + XCTAssertFalse(fMajor.keySignature.notationAccidentalGlyphs(for: fMajor.clef).isEmpty) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .full), + AppTheme.Timeline.notationAttributeStaffTopInset, + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: aMinor, display: .full), + NotationMeasureLayout.attributeStaffTopInset(for: fMajor, display: .full), + accuracy: 0.0001 + ) + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: cMajor, display: .none), + 0, + accuracy: 0.0001 + ) + } + + func testNotationMeasureLayoutUsesSharedAttributeStaffInsetForPartialAttributeBlocks() { + let attributes = MeasureAttributes( + keySignature: KeySignature.normalized(from: "Bb major"), + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + clef: .treble + ) + let partialDisplays = [ + NotationAttributeDisplay(showsClef: true, showsKeySignature: false, showsTimeSignature: false), + NotationAttributeDisplay(showsClef: false, showsKeySignature: true, showsTimeSignature: false), + NotationAttributeDisplay(showsClef: false, showsKeySignature: false, showsTimeSignature: true) + ] + + for display in partialDisplays { + XCTAssertEqual( + NotationMeasureLayout.attributeStaffTopInset(for: attributes, display: display), + AppTheme.Timeline.notationAttributeStaffTopInset, + accuracy: 0.0001 + ) + } + } +} From e36fe1d549caaa017d5faf8887a1999b3fc4ab39 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:38:29 +0300 Subject: [PATCH 70/80] refactor: split project playback marker tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../ViewModelProjectPlaybackMarkerTests.swift | 86 +++++++++++++++++++ .../ViewModelProjectRestoreTests.swift | 82 ------------------ 3 files changed, 90 insertions(+), 82 deletions(-) create mode 100644 JammLabTests/ViewModelProjectPlaybackMarkerTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 16ddd38..df92e5a 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */; }; 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */; }; + 9FAB01622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift */; }; 9FAB01442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */; }; 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */; }; 9FAB01512CE0000100112233 /* ViewModelNoteEditingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00512CE0000100112233 /* ViewModelNoteEditingTests.swift */; }; @@ -375,6 +376,7 @@ 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectKeyPersistenceTests.swift; sourceTree = ""; }; 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectRestoreTests.swift; sourceTree = ""; }; + 9FAB00622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectPlaybackMarkerTests.swift; sourceTree = ""; }; 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTimelineRangePersistenceTests.swift; sourceTree = ""; }; 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelEditingStateTests.swift; sourceTree = ""; }; 9FAB00512CE0000100112233 /* ViewModelNoteEditingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNoteEditingTests.swift; sourceTree = ""; }; @@ -713,6 +715,7 @@ 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, + 9FAB00622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift */, 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */, 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, 9FAB00512CE0000100112233 /* ViewModelNoteEditingTests.swift */, @@ -1171,6 +1174,7 @@ 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */, 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */, + 9FAB01622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift in Sources */, 9FAB01442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift in Sources */, 9FAB01262CE0000100112233 /* ViewModelEditingStateTests.swift in Sources */, 9FAB01512CE0000100112233 /* ViewModelNoteEditingTests.swift in Sources */, diff --git a/JammLabTests/ViewModelProjectPlaybackMarkerTests.swift b/JammLabTests/ViewModelProjectPlaybackMarkerTests.swift new file mode 100644 index 0000000..f200608 --- /dev/null +++ b/JammLabTests/ViewModelProjectPlaybackMarkerTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import JammLab + +final class ViewModelProjectPlaybackMarkerTests: XCTestCase { + @MainActor + func testProjectOpenRestoresAndClampsPlaybackMarkerTime() async throws { + let audioURL = try temporaryAudioFile(duration: 2) + let projectURL = temporaryDirectory().appendingPathComponent("playback-marker-open.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 2, + notes: [], + loopStart: 0, + loopEnd: 2, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), + playbackMarkerTime: 99 + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "playback-marker-open", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let engine = MockPlaybackEngine() + let videoFollower = MockVideoFollower() + let viewModel = AudioPlayerViewModel( + playbackEngine: engine, + videoFollower: videoFollower, + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.playbackMarkerTime, 2, accuracy: 0.0001) + XCTAssertEqual(viewModel.currentTime, 2, accuracy: 0.0001) + XCTAssertEqual(engine.currentTime, 2, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testSaveProjectPersistsPlaybackMarkerTime() async throws { + let audioURL = try temporaryAudioFile(duration: 2) + let projectURL = temporaryDirectory().appendingPathComponent("playback-marker-save.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine, + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + let media = ImportedAudioFile(url: audioURL, displayName: "marker.wav", duration: 2) + try viewModel.loadImportedAudio(media) + + viewModel.locatePlaybackMarker(to: 1.25) + + XCTAssertTrue(viewModel.isProjectModified) + + let didSave = await viewModel.saveProject(to: projectURL) + + XCTAssertTrue(didSave) + XCTAssertFalse(viewModel.isProjectModified) + let savedProject = try projectService.load(from: projectURL) + XCTAssertEqual(try XCTUnwrap(savedProject.playbackMarkerTime), 1.25, accuracy: 0.0001) + } +} diff --git a/JammLabTests/ViewModelProjectRestoreTests.swift b/JammLabTests/ViewModelProjectRestoreTests.swift index 13249c7..293406e 100644 --- a/JammLabTests/ViewModelProjectRestoreTests.swift +++ b/JammLabTests/ViewModelProjectRestoreTests.swift @@ -54,88 +54,6 @@ final class ViewModelProjectRestoreTests: XCTestCase { XCTAssertTrue(engine.clickEnabled) } - @MainActor - func testProjectOpenRestoresAndClampsPlaybackMarkerTime() async throws { - let audioURL = try temporaryAudioFile(duration: 2) - let projectURL = temporaryDirectory().appendingPathComponent("playback-marker-open.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 2, - notes: [], - loopStart: 0, - loopEnd: 2, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM), - playbackMarkerTime: 99 - ) - try projectService.save(project, to: projectURL) - let entry = RecentProjectEntry( - displayName: "playback-marker-open", - bookmarkData: try projectService.bookmarkData(for: projectURL) - ) - let engine = MockPlaybackEngine() - let videoFollower = MockVideoFollower() - let viewModel = AudioPlayerViewModel( - playbackEngine: engine, - videoFollower: videoFollower, - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) - ) - - await viewModel.openRecentProject(entry) - - XCTAssertEqual(viewModel.playbackMarkerTime, 2, accuracy: 0.0001) - XCTAssertEqual(viewModel.currentTime, 2, accuracy: 0.0001) - XCTAssertEqual(engine.currentTime, 2, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(videoFollower.seekTimes.last), 2, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testSaveProjectPersistsPlaybackMarkerTime() async throws { - let audioURL = try temporaryAudioFile(duration: 2) - let projectURL = temporaryDirectory().appendingPathComponent("playback-marker-save.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel( - analyzer: MockAnalyzer(), - peakformProvider: MockPeakformProvider(), - playbackEngine: engine, - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - let media = ImportedAudioFile(url: audioURL, displayName: "marker.wav", duration: 2) - try viewModel.loadImportedAudio(media) - - viewModel.locatePlaybackMarker(to: 1.25) - - XCTAssertTrue(viewModel.isProjectModified) - - let didSave = await viewModel.saveProject(to: projectURL) - - XCTAssertTrue(didSave) - XCTAssertFalse(viewModel.isProjectModified) - let savedProject = try projectService.load(from: projectURL) - XCTAssertEqual(try XCTUnwrap(savedProject.playbackMarkerTime), 1.25, accuracy: 0.0001) - } - @MainActor func testLegacyProjectOpenDefaultsLoopClickAndSnapToOff() async throws { let audioURL = try temporaryAudioFile() From b4f6b34aee4cdd37096b7faa60635b00810dd855 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:41:46 +0300 Subject: [PATCH 71/80] refactor: split value slider logic tests --- JammLab.xcodeproj/project.pbxproj | 4 + JammLabTests/ControlLogicTests.swift | 97 ------------------ JammLabTests/JammValueSliderLogicTests.swift | 101 +++++++++++++++++++ 3 files changed, 105 insertions(+), 97 deletions(-) create mode 100644 JammLabTests/JammValueSliderLogicTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index df92e5a..4b5de44 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -153,6 +153,7 @@ 9FAB014F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */; }; 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */; }; 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */; }; + 9FAB01632CE0000100112233 /* JammValueSliderLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00632CE0000100112233 /* JammValueSliderLogicTests.swift */; }; 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */; }; 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */; }; 9FAB01302CE0000100112233 /* ClickSoundSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */; }; @@ -406,6 +407,7 @@ 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceAnalysisTests.swift; sourceTree = ""; }; 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceNoteHoldTests.swift; sourceTree = ""; }; 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlLogicTests.swift; sourceTree = ""; }; + 9FAB00632CE0000100112233 /* JammValueSliderLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JammValueSliderLogicTests.swift; sourceTree = ""; }; 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentProjectsStoreTests.swift; sourceTree = ""; }; 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTests.swift; sourceTree = ""; }; 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickSoundSettingsTests.swift; sourceTree = ""; }; @@ -745,6 +747,7 @@ 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */, 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */, 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, + 9FAB00632CE0000100112233 /* JammValueSliderLogicTests.swift */, 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */, 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */, @@ -1204,6 +1207,7 @@ 9FAB014F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift in Sources */, 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */, 9FAB012D2CE0000100112233 /* ControlLogicTests.swift in Sources */, + 9FAB01632CE0000100112233 /* JammValueSliderLogicTests.swift in Sources */, 9FAB012E2CE0000100112233 /* RecentProjectsStoreTests.swift in Sources */, 9FAB012F2CE0000100112233 /* ColorPaletteTests.swift in Sources */, 9FAB01302CE0000100112233 /* ClickSoundSettingsTests.swift in Sources */, diff --git a/JammLabTests/ControlLogicTests.swift b/JammLabTests/ControlLogicTests.swift index 73d0568..e360711 100644 --- a/JammLabTests/ControlLogicTests.swift +++ b/JammLabTests/ControlLogicTests.swift @@ -97,103 +97,6 @@ final class ControlLogicTests: XCTestCase { XCTAssertNil(AbletonNumberFieldLogic.parse("abc", configuration: negativeConfig)) } - func testJammValueSliderLogicClampsAndResetsDefault() { - let config = JammValueSliderConfiguration( - minValue: 0, - maxValue: 1, - defaultValue: 1.5, - step: 0.01, - precision: 2 - ) - - XCTAssertEqual(JammValueSliderLogic.clamp(-1, configuration: config), 0, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.clamp(2, configuration: config), 1, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.resetValue(configuration: config), 1, accuracy: 0.0001) - } - - func testJammValueSliderLogicNormalizesRanges() { - let volumeConfig = JammValueSliderConfiguration( - minValue: 0, - maxValue: 1, - defaultValue: 0.75, - step: 0.01, - precision: 2 - ) - let gainConfig = JammValueSliderConfiguration( - minValue: -60, - maxValue: 12, - defaultValue: 0, - step: 0.1, - precision: 1 - ) - let reversedConfig = JammValueSliderConfiguration( - minValue: 12, - maxValue: -60, - defaultValue: 0, - step: 0.1, - precision: 1 - ) - - XCTAssertEqual(JammValueSliderLogic.normalizedValue(0.25, configuration: volumeConfig), 0.25, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.normalizedValue(-60, configuration: gainConfig), 0, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.normalizedValue(12, configuration: gainConfig), 1, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.normalizedValue(-24, configuration: gainConfig), 0.5, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.normalizedValue(-24, configuration: reversedConfig), 0.5, accuracy: 0.0001) - } - - func testJammValueSliderLogicSnapsAndFormats() { - let integerConfig = JammValueSliderConfiguration( - minValue: 0, - maxValue: 10, - defaultValue: 5, - step: 1, - precision: 0 - ) - let fractionalConfig = JammValueSliderConfiguration( - minValue: -1, - maxValue: 1, - defaultValue: 0, - step: 0.25, - precision: 2 - ) - - XCTAssertEqual(JammValueSliderLogic.snapToStep(4.4, configuration: integerConfig), 4, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.snapToStep(4.6, configuration: integerConfig), 5, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.snapToStep(0.38, configuration: fractionalConfig), 0.5, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.format(0.38, configuration: fractionalConfig), "0.50") - } - - func testJammValueSliderLogicUsesDominantDragAxis() { - let config = JammValueSliderConfiguration( - minValue: 0, - maxValue: 1, - defaultValue: 0.5, - step: 0.01, - sensitivity: 1, - precision: 2 - ) - - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 10, deltaY: 2, configuration: config), 0.6, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 2, deltaY: 10, configuration: config), 0.6, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: -10, deltaY: 2, configuration: config), 0.4, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 2, deltaY: -10, configuration: config), 0.4, accuracy: 0.0001) - } - - func testJammValueSliderLogicSupportsSlowerIntegerPitchDrag() { - let config = JammValueSliderConfiguration( - minValue: -12, - maxValue: 12, - defaultValue: 0, - step: 1, - sensitivity: 0.08, - precision: 0 - ) - - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: 6, deltaY: 0, configuration: config), 0, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: 7, deltaY: 0, configuration: config), 1, accuracy: 0.0001) - XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: -7, deltaY: 0, configuration: config), -1, accuracy: 0.0001) - } - func testAppKitDragThresholdUsesVerticalDistanceForNumberField() { XCTAssertFalse(AppKitDragThreshold.exceedsVerticalThreshold(deltaY: 2.9, threshold: 3)) XCTAssertTrue(AppKitDragThreshold.exceedsVerticalThreshold(deltaY: 3, threshold: 3)) diff --git a/JammLabTests/JammValueSliderLogicTests.swift b/JammLabTests/JammValueSliderLogicTests.swift new file mode 100644 index 0000000..b4e855e --- /dev/null +++ b/JammLabTests/JammValueSliderLogicTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import JammLab + +final class JammValueSliderLogicTests: XCTestCase { + func testJammValueSliderLogicClampsAndResetsDefault() { + let config = JammValueSliderConfiguration( + minValue: 0, + maxValue: 1, + defaultValue: 1.5, + step: 0.01, + precision: 2 + ) + + XCTAssertEqual(JammValueSliderLogic.clamp(-1, configuration: config), 0, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.clamp(2, configuration: config), 1, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.resetValue(configuration: config), 1, accuracy: 0.0001) + } + + func testJammValueSliderLogicNormalizesRanges() { + let volumeConfig = JammValueSliderConfiguration( + minValue: 0, + maxValue: 1, + defaultValue: 0.75, + step: 0.01, + precision: 2 + ) + let gainConfig = JammValueSliderConfiguration( + minValue: -60, + maxValue: 12, + defaultValue: 0, + step: 0.1, + precision: 1 + ) + let reversedConfig = JammValueSliderConfiguration( + minValue: 12, + maxValue: -60, + defaultValue: 0, + step: 0.1, + precision: 1 + ) + + XCTAssertEqual(JammValueSliderLogic.normalizedValue(0.25, configuration: volumeConfig), 0.25, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.normalizedValue(-60, configuration: gainConfig), 0, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.normalizedValue(12, configuration: gainConfig), 1, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.normalizedValue(-24, configuration: gainConfig), 0.5, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.normalizedValue(-24, configuration: reversedConfig), 0.5, accuracy: 0.0001) + } + + func testJammValueSliderLogicSnapsAndFormats() { + let integerConfig = JammValueSliderConfiguration( + minValue: 0, + maxValue: 10, + defaultValue: 5, + step: 1, + precision: 0 + ) + let fractionalConfig = JammValueSliderConfiguration( + minValue: -1, + maxValue: 1, + defaultValue: 0, + step: 0.25, + precision: 2 + ) + + XCTAssertEqual(JammValueSliderLogic.snapToStep(4.4, configuration: integerConfig), 4, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.snapToStep(4.6, configuration: integerConfig), 5, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.snapToStep(0.38, configuration: fractionalConfig), 0.5, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.format(0.38, configuration: fractionalConfig), "0.50") + } + + func testJammValueSliderLogicUsesDominantDragAxis() { + let config = JammValueSliderConfiguration( + minValue: 0, + maxValue: 1, + defaultValue: 0.5, + step: 0.01, + sensitivity: 1, + precision: 2 + ) + + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 10, deltaY: 2, configuration: config), 0.6, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 2, deltaY: 10, configuration: config), 0.6, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: -10, deltaY: 2, configuration: config), 0.4, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0.5, deltaX: 2, deltaY: -10, configuration: config), 0.4, accuracy: 0.0001) + } + + func testJammValueSliderLogicSupportsSlowerIntegerPitchDrag() { + let config = JammValueSliderConfiguration( + minValue: -12, + maxValue: 12, + defaultValue: 0, + step: 1, + sensitivity: 0.08, + precision: 0 + ) + + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: 6, deltaY: 0, configuration: config), 0, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: 7, deltaY: 0, configuration: config), 1, accuracy: 0.0001) + XCTAssertEqual(JammValueSliderLogic.dragValue(startValue: 0, deltaX: -7, deltaY: 0, configuration: config), -1, accuracy: 0.0001) + } +} From a33a017a050c44b72d8ac5d733b277fff61cbd3a Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:44:46 +0300 Subject: [PATCH 72/80] refactor: split saved project key restore tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../ViewModelProjectKeyPersistenceTests.swift | 103 ---------------- ...ViewModelProjectSavedKeyRestoreTests.swift | 115 ++++++++++++++++++ 3 files changed, 119 insertions(+), 103 deletions(-) create mode 100644 JammLabTests/ViewModelProjectSavedKeyRestoreTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 4b5de44..6d45bec 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -122,6 +122,7 @@ 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */; }; 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */; }; 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */; }; + 9FAB01642CE0000100112233 /* ViewModelProjectSavedKeyRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00642CE0000100112233 /* ViewModelProjectSavedKeyRestoreTests.swift */; }; 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */; }; 9FAB01622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift */; }; 9FAB01442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */; }; @@ -376,6 +377,7 @@ 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowTests.swift; sourceTree = ""; }; 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelSettingsTests.swift; sourceTree = ""; }; 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectKeyPersistenceTests.swift; sourceTree = ""; }; + 9FAB00642CE0000100112233 /* ViewModelProjectSavedKeyRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectSavedKeyRestoreTests.swift; sourceTree = ""; }; 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectRestoreTests.swift; sourceTree = ""; }; 9FAB00622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelProjectPlaybackMarkerTests.swift; sourceTree = ""; }; 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTimelineRangePersistenceTests.swift; sourceTree = ""; }; @@ -716,6 +718,7 @@ 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */, 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, + 9FAB00642CE0000100112233 /* ViewModelProjectSavedKeyRestoreTests.swift */, 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, 9FAB00622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift */, 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */, @@ -1176,6 +1179,7 @@ 9FAB01222CE0000100112233 /* ViewModelVideoWindowTests.swift in Sources */, 9FAB01232CE0000100112233 /* ViewModelSettingsTests.swift in Sources */, 9FAB01242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift in Sources */, + 9FAB01642CE0000100112233 /* ViewModelProjectSavedKeyRestoreTests.swift in Sources */, 9FAB01252CE0000100112233 /* ViewModelProjectRestoreTests.swift in Sources */, 9FAB01622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift in Sources */, 9FAB01442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift in Sources */, diff --git a/JammLabTests/ViewModelProjectKeyPersistenceTests.swift b/JammLabTests/ViewModelProjectKeyPersistenceTests.swift index d6f2168..5323534 100644 --- a/JammLabTests/ViewModelProjectKeyPersistenceTests.swift +++ b/JammLabTests/ViewModelProjectKeyPersistenceTests.swift @@ -50,109 +50,6 @@ final class ViewModelProjectKeyPersistenceTests: XCTestCase { XCTAssertEqual(savedProject.projectKeySelection?.source, .auto) } - @MainActor - func testProjectOpenWithSavedKeySkipsKeyDetection() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("saved-key-open.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let savedKey = ProjectKeySelection( - tonic: .aSharpBb, - mode: .major, - source: .user, - confidence: nil - ) - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - projectKeySelection: savedKey, - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, - tempoBPM: AppDefaults.defaultTempoBPM, - beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) - ) - try projectService.save(project, to: projectURL) - let analyzer = MockAnalyzer() - analyzer.result = AnalysisResult(bpm: 90, keyName: "D minor", keyConfidence: 0.9) - let viewModel = AudioPlayerViewModel( - analyzer: analyzer, - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: projectURL) - await waitForAnalysisToFinish(viewModel) - - XCTAssertTrue(analyzer.calls.isEmpty) - XCTAssertEqual(viewModel.projectKeySelection, savedKey) - XCTAssertEqual(viewModel.effectiveKeyName, "Bb major") - XCTAssertFalse(viewModel.isProjectModified) - } - - @MainActor - func testProjectOpenWithSavedKeyAndMissingTempoRunsTempoOnlyAnalysis() async throws { - let audioURL = try temporaryAudioFile(duration: 4) - let projectURL = temporaryDirectory().appendingPathComponent("saved-key-missing-tempo.jammlab") - try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: audioURL) - try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) - } - - let projectService = ProjectDocumentService() - let savedKey = ProjectKeySelection( - tonic: .gSharpAb, - mode: .minor, - source: .user, - confidence: nil - ) - let project = JammLabProject( - audioBookmarkData: try projectService.bookmarkData(for: audioURL), - audioDisplayName: audioURL.lastPathComponent, - audioDuration: 4, - notes: [], - projectKeySelection: savedKey, - loopStart: 0, - loopEnd: 4, - playbackRate: AppSliderDefaults.playbackRate, - pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones - ) - try projectService.save(project, to: projectURL) - let analyzer = MockAnalyzer() - analyzer.result = AnalysisResult(bpm: 96, keyName: "D major", keyConfidence: 0.9) - let viewModel = AudioPlayerViewModel( - analyzer: analyzer, - peakformProvider: MockPeakformProvider(), - playbackEngine: MockPlaybackEngine(), - projectService: projectService, - recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), - isSandboxed: { false } - ) - - await viewModel.openProject(at: projectURL) - await waitForAnalysisToFinish(viewModel) - - XCTAssertEqual(analyzer.calls.count, 1) - XCTAssertEqual(analyzer.calls.first?.includesTempo, true) - XCTAssertEqual(analyzer.calls.first?.includesKey, false) - XCTAssertEqual(viewModel.projectKeySelection, savedKey) - XCTAssertEqual(viewModel.effectiveKeyName, "G# minor") - XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), 96, accuracy: 0.0001) - XCTAssertFalse(viewModel.isProjectModified) - } - @MainActor func testProjectOpenWithoutSavedKeyRunsKeyDetectionAndMarksDirtyForPersistence() async throws { let audioURL = try temporaryAudioFile(duration: 4) diff --git a/JammLabTests/ViewModelProjectSavedKeyRestoreTests.swift b/JammLabTests/ViewModelProjectSavedKeyRestoreTests.swift new file mode 100644 index 0000000..bcca40c --- /dev/null +++ b/JammLabTests/ViewModelProjectSavedKeyRestoreTests.swift @@ -0,0 +1,115 @@ +import XCTest +@testable import JammLab + +final class ViewModelProjectSavedKeyRestoreTests: XCTestCase { + @MainActor + private func waitForAnalysisToFinish(_ viewModel: AudioPlayerViewModel) async { + for _ in 0..<50 { + if !viewModel.isAnalyzing { return } + await Task.yield() + } + } + + @MainActor + func testProjectOpenWithSavedKeySkipsKeyDetection() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("saved-key-open.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let savedKey = ProjectKeySelection( + tonic: .aSharpBb, + mode: .major, + source: .user, + confidence: nil + ) + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + projectKeySelection: savedKey, + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: AppDefaults.defaultTempoBPM, + beatGridSettings: BeatGridSettings(bpm: AppDefaults.defaultTempoBPM) + ) + try projectService.save(project, to: projectURL) + let analyzer = MockAnalyzer() + analyzer.result = AnalysisResult(bpm: 90, keyName: "D minor", keyConfidence: 0.9) + let viewModel = AudioPlayerViewModel( + analyzer: analyzer, + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: projectURL) + await waitForAnalysisToFinish(viewModel) + + XCTAssertTrue(analyzer.calls.isEmpty) + XCTAssertEqual(viewModel.projectKeySelection, savedKey) + XCTAssertEqual(viewModel.effectiveKeyName, "Bb major") + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testProjectOpenWithSavedKeyAndMissingTempoRunsTempoOnlyAnalysis() async throws { + let audioURL = try temporaryAudioFile(duration: 4) + let projectURL = temporaryDirectory().appendingPathComponent("saved-key-missing-tempo.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let savedKey = ProjectKeySelection( + tonic: .gSharpAb, + mode: .minor, + source: .user, + confidence: nil + ) + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 4, + notes: [], + projectKeySelection: savedKey, + loopStart: 0, + loopEnd: 4, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones + ) + try projectService.save(project, to: projectURL) + let analyzer = MockAnalyzer() + analyzer.result = AnalysisResult(bpm: 96, keyName: "D major", keyConfidence: 0.9) + let viewModel = AudioPlayerViewModel( + analyzer: analyzer, + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + + await viewModel.openProject(at: projectURL) + await waitForAnalysisToFinish(viewModel) + + XCTAssertEqual(analyzer.calls.count, 1) + XCTAssertEqual(analyzer.calls.first?.includesTempo, true) + XCTAssertEqual(analyzer.calls.first?.includesKey, false) + XCTAssertEqual(viewModel.projectKeySelection, savedKey) + XCTAssertEqual(viewModel.effectiveKeyName, "G# minor") + XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), 96, accuracy: 0.0001) + XCTAssertFalse(viewModel.isProjectModified) + } +} From e4ac97b8802b05f839d8bc07696c3c16b7794b5c Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:47:23 +0300 Subject: [PATCH 73/80] refactor: split notation duration selection tests --- JammLab.xcodeproj/project.pbxproj | 4 ++ ...wModelNotationDurationSelectionTests.swift | 40 +++++++++++++++++++ .../ViewModelNotationSelectionTests.swift | 36 ----------------- 3 files changed, 44 insertions(+), 36 deletions(-) create mode 100644 JammLabTests/ViewModelNotationDurationSelectionTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 6d45bec..19bd1ca 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -132,6 +132,7 @@ 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */; }; 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */; }; 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */; }; + 9FAB01652CE0000100112233 /* ViewModelNotationDurationSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00652CE0000100112233 /* ViewModelNotationDurationSelectionTests.swift */; }; 9FAB014E2CE0000100112233 /* ViewModelNotationExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */; }; 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */; }; 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */; }; @@ -387,6 +388,7 @@ 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionLoopTests.swift; sourceTree = ""; }; 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelRegionActivationTests.swift; sourceTree = ""; }; 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationSelectionTests.swift; sourceTree = ""; }; + 9FAB00652CE0000100112233 /* ViewModelNotationDurationSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationDurationSelectionTests.swift; sourceTree = ""; }; 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationExportTests.swift; sourceTree = ""; }; 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationClipboardTests.swift; sourceTree = ""; }; 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationPasteTests.swift; sourceTree = ""; }; @@ -728,6 +730,7 @@ 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */, 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, + 9FAB00652CE0000100112233 /* ViewModelNotationDurationSelectionTests.swift */, 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */, 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */, 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */, @@ -1189,6 +1192,7 @@ 9FAB01282CE0000100112233 /* ViewModelRegionLoopTests.swift in Sources */, 9FAB01482CE0000100112233 /* ViewModelRegionActivationTests.swift in Sources */, 9FAB01292CE0000100112233 /* ViewModelNotationSelectionTests.swift in Sources */, + 9FAB01652CE0000100112233 /* ViewModelNotationDurationSelectionTests.swift in Sources */, 9FAB014E2CE0000100112233 /* ViewModelNotationExportTests.swift in Sources */, 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */, 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */, diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift new file mode 100644 index 0000000..c053b2e --- /dev/null +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -0,0 +1,40 @@ +import XCTest +@testable import JammLab + +final class ViewModelNotationDurationSelectionTests: XCTestCase { + @MainActor + func testChangingSelectedWholeRestToQuarterCreatesTwoQuartersAndHalfInFourFour() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + viewModel.markProjectClean() + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + viewModel.setNotationDurationDenominator(4) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [4, 4, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 1, 2]) + XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 0) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testChangingSelectedWholeRestToHalfInThreeFourCreatesHalfAndQuarter() throws { + let viewModel = try loadedNotationViewModel(duration: 6) + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + viewModel.markProjectClean() + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + viewModel.setNotationDurationDenominator(2) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [2, 4]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [2, 1]) + XCTAssertTrue(viewModel.isProjectModified) + } +} diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift index 8806299..19c3d21 100644 --- a/JammLabTests/ViewModelNotationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -87,42 +87,6 @@ final class ViewModelNotationSelectionTests: XCTestCase { XCTAssertNil(viewModel.selectedNotationItem) } - @MainActor - func testChangingSelectedWholeRestToQuarterCreatesTwoQuartersAndHalfInFourFour() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let measure = try notationMeasure(1, in: viewModel) - let item = try XCTUnwrap(measure.notationItems.first) - viewModel.markProjectClean() - - viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) - viewModel.setNotationDurationDenominator(4) - - let updatedMeasure = try notationMeasure(1, in: viewModel) - XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [4, 4, 2]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 2]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 1, 2]) - XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 0) - XCTAssertTrue(viewModel.isProjectModified) - } - - @MainActor - func testChangingSelectedWholeRestToHalfInThreeFourCreatesHalfAndQuarter() throws { - let viewModel = try loadedNotationViewModel(duration: 6) - viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) - let measure = try notationMeasure(1, in: viewModel) - let item = try XCTUnwrap(measure.notationItems.first) - viewModel.markProjectClean() - - viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) - viewModel.setNotationDurationDenominator(2) - - let updatedMeasure = try notationMeasure(1, in: viewModel) - XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [2, 4]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 2]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [2, 1]) - XCTAssertTrue(viewModel.isProjectModified) - } - @MainActor func testShiftSelectingNotationMeasuresBuildsContiguousRange() throws { let viewModel = try loadedNotationViewModel(duration: 8) From 061cd8119fc5a066ba1d80ce39ba2f4585012592 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:50:13 +0300 Subject: [PATCH 74/80] refactor: split tuner input error tests --- JammLab.xcodeproj/project.pbxproj | 4 ++ .../TunerInputServiceErrorTests.swift | 55 +++++++++++++++++++ JammLabTests/TunerInputServiceTests.swift | 51 ----------------- 3 files changed, 59 insertions(+), 51 deletions(-) create mode 100644 JammLabTests/TunerInputServiceErrorTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 19bd1ca..1d54173 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ 9FAB01562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift */; }; 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */; }; 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */; }; + 9FAB01662CE0000100112233 /* TunerInputServiceErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00662CE0000100112233 /* TunerInputServiceErrorTests.swift */; }; 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */; }; 9FAB014F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */; }; 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */; }; @@ -407,6 +408,7 @@ 9FAB00562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectPersistenceCoordinatorMediaTests.swift; sourceTree = ""; }; 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemHelperProcessControllerTests.swift; sourceTree = ""; }; 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceTests.swift; sourceTree = ""; }; + 9FAB00662CE0000100112233 /* TunerInputServiceErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceErrorTests.swift; sourceTree = ""; }; 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceSignalTests.swift; sourceTree = ""; }; 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceAnalysisTests.swift; sourceTree = ""; }; 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerInputServiceNoteHoldTests.swift; sourceTree = ""; }; @@ -749,6 +751,7 @@ 9FAB00562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, + 9FAB00662CE0000100112233 /* TunerInputServiceErrorTests.swift */, 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */, 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */, 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */, @@ -1211,6 +1214,7 @@ 9FAB01562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, 9FAB01072CE0000100112233 /* TunerInputServiceTests.swift in Sources */, + 9FAB01662CE0000100112233 /* TunerInputServiceErrorTests.swift in Sources */, 9FAB013E2CE0000100112233 /* TunerInputServiceSignalTests.swift in Sources */, 9FAB014F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift in Sources */, 9FAB013B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift in Sources */, diff --git a/JammLabTests/TunerInputServiceErrorTests.swift b/JammLabTests/TunerInputServiceErrorTests.swift new file mode 100644 index 0000000..1122d21 --- /dev/null +++ b/JammLabTests/TunerInputServiceErrorTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import JammLab + +final class TunerInputServiceErrorTests: XCTestCase { + @MainActor + func testTunerInputServicePublishesInputDeviceSwitchErrors() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + engine.startErrors = [TunerInputServiceError.inputDeviceSwitchFailed(-1)] + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + + XCTAssertEqual(engine.startDeviceIDs, [42]) + XCTAssertGreaterThanOrEqual(engine.stopCallCount, 1) + XCTAssertEqual(service.errorMessage, TunerInputServiceError.inputDeviceSwitchFailed(-1).localizedDescription) + } + + @MainActor + func testTunerInputServicePublishesNamedInvalidElementStatus() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.deviceIDs["input-1"] = 42 + let engine = MockTunerInputEngine() + engine.startErrors = [TunerInputServiceError.inputDeviceSwitchFailed(-10877)] + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + + XCTAssertEqual( + service.errorMessage, + "Audio input device switch failed with status -10877 (kAudioUnitErr_InvalidElement)." + ) + XCTAssertEqual(service.inputDebugSnapshot.deviceSwitchStatus, -10877) + XCTAssertEqual( + service.inputDebugSnapshot.lastErrorMessage, + "Audio input device switch failed with status -10877 (kAudioUnitErr_InvalidElement)." + ) + XCTAssertEqual(service.inputSignalLevel, 0) + } +} diff --git a/JammLabTests/TunerInputServiceTests.swift b/JammLabTests/TunerInputServiceTests.swift index cb44b2a..65ff630 100644 --- a/JammLabTests/TunerInputServiceTests.swift +++ b/JammLabTests/TunerInputServiceTests.swift @@ -134,55 +134,4 @@ final class TunerInputServiceTests: XCTestCase { XCTAssertGreaterThanOrEqual(engine.stopCallCount, 2) } - @MainActor - func testTunerInputServicePublishesInputDeviceSwitchErrors() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - engine.startErrors = [TunerInputServiceError.inputDeviceSwitchFailed(-1)] - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - - XCTAssertEqual(engine.startDeviceIDs, [42]) - XCTAssertGreaterThanOrEqual(engine.stopCallCount, 1) - XCTAssertEqual(service.errorMessage, TunerInputServiceError.inputDeviceSwitchFailed(-1).localizedDescription) - } - - @MainActor - func testTunerInputServicePublishesNamedInvalidElementStatus() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.deviceIDs["input-1"] = 42 - let engine = MockTunerInputEngine() - engine.startErrors = [TunerInputServiceError.inputDeviceSwitchFailed(-10877)] - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - - XCTAssertEqual( - service.errorMessage, - "Audio input device switch failed with status -10877 (kAudioUnitErr_InvalidElement)." - ) - XCTAssertEqual(service.inputDebugSnapshot.deviceSwitchStatus, -10877) - XCTAssertEqual( - service.inputDebugSnapshot.lastErrorMessage, - "Audio input device switch failed with status -10877 (kAudioUnitErr_InvalidElement)." - ) - XCTAssertEqual(service.inputSignalLevel, 0) - } - } From a33237772d1948b7056f41ec6cbaf36c8afd7938 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:53:25 +0300 Subject: [PATCH 75/80] refactor: split notation range paste tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../ViewModelNotationPasteTests.swift | 87 ------------------ .../ViewModelNotationRangePasteTests.swift | 91 +++++++++++++++++++ 3 files changed, 95 insertions(+), 87 deletions(-) create mode 100644 JammLabTests/ViewModelNotationRangePasteTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 1d54173..0526d02 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ 9FAB014E2CE0000100112233 /* ViewModelNotationExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */; }; 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */; }; 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */; }; + 9FAB01672CE0000100112233 /* ViewModelNotationRangePasteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00672CE0000100112233 /* ViewModelNotationRangePasteTests.swift */; }; 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */; }; 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */; }; 9FAB01502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift */; }; @@ -393,6 +394,7 @@ 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationExportTests.swift; sourceTree = ""; }; 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationClipboardTests.swift; sourceTree = ""; }; 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationPasteTests.swift; sourceTree = ""; }; + 9FAB00672CE0000100112233 /* ViewModelNotationRangePasteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationRangePasteTests.swift; sourceTree = ""; }; 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelNotationTrackCollapseTests.swift; sourceTree = ""; }; 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoProjectPersistenceTests.swift; sourceTree = ""; }; 9FAB00502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelVideoWindowProjectRestoreTests.swift; sourceTree = ""; }; @@ -736,6 +738,7 @@ 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */, 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */, 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */, + 9FAB00672CE0000100112233 /* ViewModelNotationRangePasteTests.swift */, 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, 9FAB00502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift */, @@ -1199,6 +1202,7 @@ 9FAB014E2CE0000100112233 /* ViewModelNotationExportTests.swift in Sources */, 9FAB013D2CE0000100112233 /* ViewModelNotationClipboardTests.swift in Sources */, 9FAB014C2CE0000100112233 /* ViewModelNotationPasteTests.swift in Sources */, + 9FAB01672CE0000100112233 /* ViewModelNotationRangePasteTests.swift in Sources */, 9FAB012A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift in Sources */, 9FAB012B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift in Sources */, 9FAB01502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift in Sources */, diff --git a/JammLabTests/ViewModelNotationPasteTests.swift b/JammLabTests/ViewModelNotationPasteTests.swift index cd0986b..1c46ec7 100644 --- a/JammLabTests/ViewModelNotationPasteTests.swift +++ b/JammLabTests/ViewModelNotationPasteTests.swift @@ -48,38 +48,6 @@ final class ViewModelNotationPasteTests: XCTestCase { XCTAssertTrue(viewModel.isProjectModified) } - @MainActor - func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - let targetMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let thirdMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - } - let fourthMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - } - XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) - } - @MainActor func testPastingEmptyNotationMeasureClearsTarget() throws { let viewModel = try loadedNotationViewModel(duration: 8) @@ -100,61 +68,6 @@ final class ViewModelNotationPasteTests: XCTestCase { }) } - @MainActor - func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let secondMeasure = try notationMeasure(2, in: viewModel) - let targetMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(targetMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - XCTAssertEqual(viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) - }.map(\.rawText), ["C"]) - XCTAssertFalse(viewModel.harmonySymbols.contains { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - }) - } - - @MainActor - func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { - let viewModel = try loadedNotationViewModel(duration: 8) - let sourceMeasure = try notationMeasure(1, in: viewModel) - let thirdMeasure = try notationMeasure(3, in: viewModel) - let fourthMeasure = try notationMeasure(4, in: viewModel) - viewModel.harmonySymbols = [ - HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), - HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), - HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), - HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") - ] - - viewModel.selectNotationMeasure(sourceMeasure) - viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) - XCTAssertTrue(viewModel.copySelectedNotationMeasure()) - viewModel.selectNotationMeasure(fourthMeasure) - - XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) - - let fourthMeasureSymbols = viewModel.harmonySymbols.filter { - NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) - } - XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) - XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) - } - @MainActor func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { let viewModel = try loadedNotationViewModel(duration: 8) diff --git a/JammLabTests/ViewModelNotationRangePasteTests.swift b/JammLabTests/ViewModelNotationRangePasteTests.swift new file mode 100644 index 0000000..95b6739 --- /dev/null +++ b/JammLabTests/ViewModelNotationRangePasteTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import JammLab + +final class ViewModelNotationRangePasteTests: XCTestCase { + @MainActor + func testPasteNotationMeasureRangeStartsAtFirstSelectedTarget() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let thirdMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + } + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(thirdMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["F"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [3, 4]) + } + + @MainActor + func testPastingNotationMeasureRangePreservesEmptyMeasuresByClearingTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + let targetMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(targetMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + XCTAssertEqual(viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: targetMeasure) + }.map(\.rawText), ["C"]) + XCTAssertFalse(viewModel.harmonySymbols.contains { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + }) + } + + @MainActor + func testPastingNotationMeasureRangeIgnoresOverflowBeyondAvailableTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let sourceMeasure = try notationMeasure(1, in: viewModel) + let thirdMeasure = try notationMeasure(3, in: viewModel) + let fourthMeasure = try notationMeasure(4, in: viewModel) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "F"), + HarmonySymbol(time: 4, measureNumber: 3, offsetInQuarterNotes: 0, rawText: "G"), + HarmonySymbol(time: 6, measureNumber: 4, offsetInQuarterNotes: 0, rawText: "Am") + ] + + viewModel.selectNotationMeasure(sourceMeasure) + viewModel.selectNotationMeasure(thirdMeasure, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(fourthMeasure) + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let fourthMeasureSymbols = viewModel.harmonySymbols.filter { + NotationMeasureTiming.containsEventTime($0.time, in: fourthMeasure) + } + XCTAssertEqual(fourthMeasureSymbols.map(\.rawText), ["C"]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [4]) + } +} From 93e5ff18415d5c1dc1b233621fd007b6c6fbd700 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 17:56:29 +0300 Subject: [PATCH 76/80] refactor: split project state normalizer tests --- JammLab.xcodeproj/project.pbxproj | 4 + .../ProjectStateNormalizerTests.swift | 86 +++++++++++++++++++ JammLabTests/TimelineProjectLogicTests.swift | 81 ----------------- 3 files changed, 90 insertions(+), 81 deletions(-) create mode 100644 JammLabTests/ProjectStateNormalizerTests.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 0526d02..cbc83f1 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 9FAB01012CE0000100112233 /* AudioFileImporterDurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */; }; 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */; }; 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */; }; + 9FAB01682CE0000100112233 /* ProjectStateNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00682CE0000100112233 /* ProjectStateNormalizerTests.swift */; }; 9FAB01552CE0000100112233 /* ProjectSaveDestinationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00552CE0000100112233 /* ProjectSaveDestinationTests.swift */; }; 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */; }; 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */; }; @@ -364,6 +365,7 @@ 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFileImporterDurationTests.swift; sourceTree = ""; }; 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeakformLogicTests.swift; sourceTree = ""; }; 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProjectLogicTests.swift; sourceTree = ""; }; + 9FAB00682CE0000100112233 /* ProjectStateNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectStateNormalizerTests.swift; sourceTree = ""; }; 9FAB00552CE0000100112233 /* ProjectSaveDestinationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSaveDestinationTests.swift; sourceTree = ""; }; 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimecodedNoteColorTests.swift; sourceTree = ""; }; 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewportLogicTests.swift; sourceTree = ""; }; @@ -708,6 +710,7 @@ 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, + 9FAB00682CE0000100112233 /* ProjectStateNormalizerTests.swift */, 9FAB00552CE0000100112233 /* ProjectSaveDestinationTests.swift */, 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */, 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, @@ -1172,6 +1175,7 @@ 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, 9FBA01062D40000100112233 /* AudioSampleConverterTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, + 9FAB01682CE0000100112233 /* ProjectStateNormalizerTests.swift in Sources */, 9FAB01552CE0000100112233 /* ProjectSaveDestinationTests.swift in Sources */, 9FAB01422CE0000100112233 /* TimecodedNoteColorTests.swift in Sources */, 9FAB01372CE0000100112233 /* TimelineViewportLogicTests.swift in Sources */, diff --git a/JammLabTests/ProjectStateNormalizerTests.swift b/JammLabTests/ProjectStateNormalizerTests.swift new file mode 100644 index 0000000..1e7bd25 --- /dev/null +++ b/JammLabTests/ProjectStateNormalizerTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import JammLab + +final class ProjectStateNormalizerTests: XCTestCase { + func testProjectStateNormalizerClampsInvalidValues() throws { + let region = TimecodedNote(kind: .region, time: -5, duration: 100, title: "", color: .regionPlum) + let marker = TimecodedNote(time: 999, title: "") + let notes = ProjectStateNormalizer.normalizedNotes([region, marker], duration: 12) + + XCTAssertEqual(notes.count, 2) + XCTAssertEqual(notes[0].time, 0, accuracy: 0.0001) + XCTAssertEqual(try XCTUnwrap(notes[0].duration), 12, accuracy: 0.0001) + XCTAssertEqual(notes[0].title, "Region") + XCTAssertEqual(notes[1].time, 12, accuracy: 0.0001) + XCTAssertEqual(notes[1].title, "Marker") + + let loop = ProjectStateNormalizer.normalizedLoopRegion(start: 11.9, end: 11.95, duration: 12, minimumLength: 1) + XCTAssertEqual(loop.start, 11, accuracy: 0.0001) + XCTAssertEqual(loop.end, 12, accuracy: 0.0001) + } + + func testProjectStateNormalizerClampsAndSortsHarmonySymbols() throws { + let laterID = UUID(uuidString: "00000000-0000-0000-0000-000000000301")! + let earlierID = UUID(uuidString: "00000000-0000-0000-0000-000000000302")! + + let symbols = ProjectStateNormalizer.normalizedHarmonySymbols([ + HarmonySymbol( + id: laterID, + time: 999, + measureNumber: 0, + offsetInQuarterNotes: .nan, + rawText: "G7 alt" + ), + HarmonySymbol( + id: earlierID, + time: -5, + measureNumber: -3, + offsetInQuarterNotes: 1.5, + rawText: " Cmaj7 " + ) + ], duration: 12) + + XCTAssertEqual(symbols.map(\.id), [earlierID, laterID]) + XCTAssertEqual(symbols[0].time, 0, accuracy: 0.0001) + XCTAssertEqual(symbols[0].measureNumber, 1) + XCTAssertEqual(symbols[0].offsetInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertEqual(symbols[0].rawText, " Cmaj7 ") + XCTAssertEqual(symbols[1].time, 12, accuracy: 0.0001) + XCTAssertEqual(symbols[1].measureNumber, 1) + XCTAssertEqual(symbols[1].offsetInQuarterNotes, 0, accuracy: 0.0001) + XCTAssertEqual(symbols[1].rawText, "G7 alt") + } + + func testProjectStateNormalizerUsesSliderDefaultsForPlaybackControls() { + XCTAssertEqual( + ProjectStateNormalizer.normalizedPlaybackRate(0), + AppSliderDefaults.minimumPlaybackRate, + accuracy: 0.0001 + ) + XCTAssertEqual( + ProjectStateNormalizer.normalizedPlaybackRate(2), + AppSliderDefaults.maximumPlaybackRate, + accuracy: 0.0001 + ) + XCTAssertEqual( + ProjectStateNormalizer.normalizedPlaybackRate(.nan), + AppSliderDefaults.playbackRate, + accuracy: 0.0001 + ) + XCTAssertEqual( + ProjectStateNormalizer.normalizedPitchShift(-24), + AppSliderDefaults.minimumPitchShiftSemitones, + accuracy: 0.0001 + ) + XCTAssertEqual( + ProjectStateNormalizer.normalizedPitchShift(24), + AppSliderDefaults.maximumPitchShiftSemitones, + accuracy: 0.0001 + ) + XCTAssertEqual( + ProjectStateNormalizer.normalizedPitchShift(.nan), + AppSliderDefaults.pitchShiftSemitones, + accuracy: 0.0001 + ) + } +} diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index ba33f66..e62ba48 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -97,85 +97,4 @@ final class TimelineProjectLogicTests: XCTestCase { XCTAssertEqual(decoded.harmonySymbols, [symbol]) } - func testProjectStateNormalizerClampsInvalidValues() throws { - let region = TimecodedNote(kind: .region, time: -5, duration: 100, title: "", color: .regionPlum) - let marker = TimecodedNote(time: 999, title: "") - let notes = ProjectStateNormalizer.normalizedNotes([region, marker], duration: 12) - - XCTAssertEqual(notes.count, 2) - XCTAssertEqual(notes[0].time, 0, accuracy: 0.0001) - XCTAssertEqual(try XCTUnwrap(notes[0].duration), 12, accuracy: 0.0001) - XCTAssertEqual(notes[0].title, "Region") - XCTAssertEqual(notes[1].time, 12, accuracy: 0.0001) - XCTAssertEqual(notes[1].title, "Marker") - - let loop = ProjectStateNormalizer.normalizedLoopRegion(start: 11.9, end: 11.95, duration: 12, minimumLength: 1) - XCTAssertEqual(loop.start, 11, accuracy: 0.0001) - XCTAssertEqual(loop.end, 12, accuracy: 0.0001) - } - - func testProjectStateNormalizerClampsAndSortsHarmonySymbols() throws { - let laterID = UUID(uuidString: "00000000-0000-0000-0000-000000000301")! - let earlierID = UUID(uuidString: "00000000-0000-0000-0000-000000000302")! - - let symbols = ProjectStateNormalizer.normalizedHarmonySymbols([ - HarmonySymbol( - id: laterID, - time: 999, - measureNumber: 0, - offsetInQuarterNotes: .nan, - rawText: "G7 alt" - ), - HarmonySymbol( - id: earlierID, - time: -5, - measureNumber: -3, - offsetInQuarterNotes: 1.5, - rawText: " Cmaj7 " - ) - ], duration: 12) - - XCTAssertEqual(symbols.map(\.id), [earlierID, laterID]) - XCTAssertEqual(symbols[0].time, 0, accuracy: 0.0001) - XCTAssertEqual(symbols[0].measureNumber, 1) - XCTAssertEqual(symbols[0].offsetInQuarterNotes, 1.5, accuracy: 0.0001) - XCTAssertEqual(symbols[0].rawText, " Cmaj7 ") - XCTAssertEqual(symbols[1].time, 12, accuracy: 0.0001) - XCTAssertEqual(symbols[1].measureNumber, 1) - XCTAssertEqual(symbols[1].offsetInQuarterNotes, 0, accuracy: 0.0001) - XCTAssertEqual(symbols[1].rawText, "G7 alt") - } - - func testProjectStateNormalizerUsesSliderDefaultsForPlaybackControls() { - XCTAssertEqual( - ProjectStateNormalizer.normalizedPlaybackRate(0), - AppSliderDefaults.minimumPlaybackRate, - accuracy: 0.0001 - ) - XCTAssertEqual( - ProjectStateNormalizer.normalizedPlaybackRate(2), - AppSliderDefaults.maximumPlaybackRate, - accuracy: 0.0001 - ) - XCTAssertEqual( - ProjectStateNormalizer.normalizedPlaybackRate(.nan), - AppSliderDefaults.playbackRate, - accuracy: 0.0001 - ) - XCTAssertEqual( - ProjectStateNormalizer.normalizedPitchShift(-24), - AppSliderDefaults.minimumPitchShiftSemitones, - accuracy: 0.0001 - ) - XCTAssertEqual( - ProjectStateNormalizer.normalizedPitchShift(24), - AppSliderDefaults.maximumPitchShiftSemitones, - accuracy: 0.0001 - ) - XCTAssertEqual( - ProjectStateNormalizer.normalizedPitchShift(.nan), - AppSliderDefaults.pitchShiftSemitones, - accuracy: 0.0001 - ) - } } From 757ca07b7f628d49abf0611dc6f756bf00ce63dd Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 18:03:07 +0300 Subject: [PATCH 77/80] refactor: share numeric control logic --- .../Views/Components/AbletonNumberField.swift | 19 ++++++++++++------- .../Components/AppKitControlHelpers.swift | 18 ++++++++++++++++++ .../Views/Components/JammValueSlider.swift | 19 ++++++++++++------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/JammLab/Views/Components/AbletonNumberField.swift b/JammLab/Views/Components/AbletonNumberField.swift index 90813fc..34ceaad 100644 --- a/JammLab/Views/Components/AbletonNumberField.swift +++ b/JammLab/Views/Components/AbletonNumberField.swift @@ -28,15 +28,20 @@ struct AbletonNumberFieldConfiguration: Equatable { enum AbletonNumberFieldLogic { static func clamp(_ value: Double, configuration: AbletonNumberFieldConfiguration) -> Double { - guard value.isFinite else { return configuration.minValue } - return min(configuration.maxValue, max(configuration.minValue, value)) + NumericControlLogic.clamp( + value, + minValue: configuration.minValue, + maxValue: configuration.maxValue + ) } static func snapToStep(_ value: Double, configuration: AbletonNumberFieldConfiguration) -> Double { - let clampedValue = clamp(value, configuration: configuration) - let steps = ((clampedValue - configuration.minValue) / configuration.step).rounded() - let snapped = configuration.minValue + steps * configuration.step - return clamp(snapped, configuration: configuration) + NumericControlLogic.snapToStep( + value, + minValue: configuration.minValue, + maxValue: configuration.maxValue, + step: configuration.step + ) } static func resetValue(configuration: AbletonNumberFieldConfiguration) -> Double { @@ -45,7 +50,7 @@ enum AbletonNumberFieldLogic { static func format(_ value: Double, configuration: AbletonNumberFieldConfiguration) -> String { let snappedValue = snapToStep(value, configuration: configuration) - return String(format: "%.\(configuration.precision)f", snappedValue) + return NumericControlLogic.format(snappedValue, precision: configuration.precision) } static func parse(_ text: String, configuration: AbletonNumberFieldConfiguration) -> Double? { diff --git a/JammLab/Views/Components/AppKitControlHelpers.swift b/JammLab/Views/Components/AppKitControlHelpers.swift index dff8a3d..437ef00 100644 --- a/JammLab/Views/Components/AppKitControlHelpers.swift +++ b/JammLab/Views/Components/AppKitControlHelpers.swift @@ -40,6 +40,24 @@ enum AppKitDragThreshold { } } +enum NumericControlLogic { + static func clamp(_ value: Double, minValue: Double, maxValue: Double) -> Double { + guard value.isFinite else { return minValue } + return min(maxValue, max(minValue, value)) + } + + static func snapToStep(_ value: Double, minValue: Double, maxValue: Double, step: Double) -> Double { + let clampedValue = clamp(value, minValue: minValue, maxValue: maxValue) + let steps = ((clampedValue - minValue) / step).rounded() + let snappedValue = minValue + steps * step + return clamp(snappedValue, minValue: minValue, maxValue: maxValue) + } + + static func format(_ value: Double, precision: Int) -> String { + String(format: "%.\(precision)f", value) + } +} + extension NSView { func configureCompactVerticalControlSizing() { setContentHuggingPriority(.required, for: .vertical) diff --git a/JammLab/Views/Components/JammValueSlider.swift b/JammLab/Views/Components/JammValueSlider.swift index 387ebd0..f6a7154 100644 --- a/JammLab/Views/Components/JammValueSlider.swift +++ b/JammLab/Views/Components/JammValueSlider.swift @@ -28,15 +28,20 @@ struct JammValueSliderConfiguration: Equatable { enum JammValueSliderLogic { static func clamp(_ value: Double, configuration: JammValueSliderConfiguration) -> Double { - guard value.isFinite else { return configuration.minValue } - return min(configuration.maxValue, max(configuration.minValue, value)) + NumericControlLogic.clamp( + value, + minValue: configuration.minValue, + maxValue: configuration.maxValue + ) } static func snapToStep(_ value: Double, configuration: JammValueSliderConfiguration) -> Double { - let clampedValue = clamp(value, configuration: configuration) - let steps = ((clampedValue - configuration.minValue) / configuration.step).rounded() - let snappedValue = configuration.minValue + steps * configuration.step - return clamp(snappedValue, configuration: configuration) + NumericControlLogic.snapToStep( + value, + minValue: configuration.minValue, + maxValue: configuration.maxValue, + step: configuration.step + ) } static func resetValue(configuration: JammValueSliderConfiguration) -> Double { @@ -51,7 +56,7 @@ enum JammValueSliderLogic { static func format(_ value: Double, configuration: JammValueSliderConfiguration) -> String { let snappedValue = snapToStep(value, configuration: configuration) - return String(format: "%.\(configuration.precision)f", snappedValue) + return NumericControlLogic.format(snappedValue, precision: configuration.precision) } static func dragValue( From eb212f8b48ee950fdfab2c824819b933126f687c Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 18:06:33 +0300 Subject: [PATCH 78/80] refactor: share color hex helpers --- JammLab/DesignSystem/AppTheme.swift | 31 +++++++++++++++++++++++ JammLab/Views/NoteColorPresentation.swift | 25 ------------------ JammLab/Views/SettingsContentViews.swift | 6 +---- JammLabTests/ColorPaletteTests.swift | 13 ++++++++++ 4 files changed, 45 insertions(+), 30 deletions(-) diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index b4da14e..e4303f5 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -65,6 +65,37 @@ struct AppThemeColors: Equatable { } } +extension NSColor { + convenience init?(hexString: String) { + let trimmed = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let normalized = trimmed.hasPrefix("#") ? trimmed : "#\(trimmed)" + let digits = String(normalized.dropFirst()) + guard normalized.count == 7, let value = Int(digits, radix: 16) else { return nil } + + let red = CGFloat((value >> 16) & 0xFF) / 255 + let green = CGFloat((value >> 8) & 0xFF) / 255 + let blue = CGFloat(value & 0xFF) / 255 + + self.init(srgbRed: red, green: green, blue: blue, alpha: 1) + } + + var hexString: String? { + hexString(using: .sRGB) + } + + func hexString(using colorSpace: NSColorSpace, fallsBackToOriginalColor: Bool = false) -> String? { + guard let rgbColor = usingColorSpace(colorSpace) ?? (fallsBackToOriginalColor ? self : nil) else { + return nil + } + + let red = Int((rgbColor.redComponent * 255).rounded()) + let green = Int((rgbColor.greenComponent * 255).rounded()) + let blue = Int((rgbColor.blueComponent * 255).rounded()) + + return String(format: "#%02X%02X%02X", red, green, blue) + } +} + private struct AppThemeColorsKey: EnvironmentKey { static let defaultValue = AppThemeColors.default } diff --git a/JammLab/Views/NoteColorPresentation.swift b/JammLab/Views/NoteColorPresentation.swift index 2ee0de6..487b49e 100644 --- a/JammLab/Views/NoteColorPresentation.swift +++ b/JammLab/Views/NoteColorPresentation.swift @@ -55,28 +55,3 @@ final class NoteColorPanelPresenter: NSObject { onColorChanged?(hex) } } - -private extension NSColor { - convenience init?(hexString: String) { - guard let normalized = TimecodedNote.normalizedColorHex(hexString) else { return nil } - - let digits = String(normalized.dropFirst()) - guard let value = Int(digits, radix: 16) else { return nil } - - let red = CGFloat((value >> 16) & 0xFF) / 255 - let green = CGFloat((value >> 8) & 0xFF) / 255 - let blue = CGFloat(value & 0xFF) / 255 - - self.init(srgbRed: red, green: green, blue: blue, alpha: 1) - } - - var hexString: String? { - guard let rgbColor = usingColorSpace(.sRGB) else { return nil } - - let red = Int((rgbColor.redComponent * 255).rounded()) - let green = Int((rgbColor.greenComponent * 255).rounded()) - let blue = Int((rgbColor.blueComponent * 255).rounded()) - - return String(format: "#%02X%02X%02X", red, green, blue) - } -} diff --git a/JammLab/Views/SettingsContentViews.swift b/JammLab/Views/SettingsContentViews.swift index e4ee7f1..79d02e6 100644 --- a/JammLab/Views/SettingsContentViews.swift +++ b/JammLab/Views/SettingsContentViews.swift @@ -37,11 +37,7 @@ struct ThemeColorsSettingsContentView: View { private static func hexString(from color: Color) -> String { let nsColor = NSColor(color) - let rgbColor = nsColor.usingColorSpace(.deviceRGB) ?? nsColor - let red = Int((rgbColor.redComponent * 255).rounded()) - let green = Int((rgbColor.greenComponent * 255).rounded()) - let blue = Int((rgbColor.blueComponent * 255).rounded()) - return String(format: "#%02X%02X%02X", red, green, blue) + return nsColor.hexString(using: .deviceRGB, fallsBackToOriginalColor: true) ?? "#000000" } } diff --git a/JammLabTests/ColorPaletteTests.swift b/JammLabTests/ColorPaletteTests.swift index c781a7a..c4ef865 100644 --- a/JammLabTests/ColorPaletteTests.swift +++ b/JammLabTests/ColorPaletteTests.swift @@ -1,3 +1,4 @@ +import AppKit import XCTest @testable import JammLab @@ -102,4 +103,16 @@ final class ColorPaletteTests: XCTestCase { XCTAssertNil(values["accentText"]) XCTAssertEqual(values.count, AppColorRole.allCases.count) } + + func testNSColorHexHelpersParseAndFormatRGBColors() throws { + let color = try XCTUnwrap(NSColor(hexString: "abcdef")) + + XCTAssertEqual(color.hexString, "#ABCDEF") + XCTAssertNil(NSColor(hexString: "#12GG34")) + XCTAssertEqual( + NSColor(srgbRed: 1, green: 128.0 / 255.0, blue: 0, alpha: 1) + .hexString(using: .deviceRGB, fallsBackToOriginalColor: true), + "#FF8000" + ) + } } From 355e6aba20eee6ad4d52e4e0919934e43926658b Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 18:09:02 +0300 Subject: [PATCH 79/80] refactor: reuse volume clamp helper --- JammLab/ViewModels/AudioPlayerViewModel+Playback.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift b/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift index 7728020..d0df10e 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift @@ -156,7 +156,7 @@ extension AudioPlayerViewModel { } func applyClickVolume(_ volume: Float, shouldPersist: Bool) { - clickVolume = min(1, max(0, volume)) + clickVolume = clampedVolume(volume) playbackEngine.setClickVolume(clickVolume) guard shouldPersist else { return } UserDefaults.standard.set(clickVolume, forKey: "metronome.volume") @@ -164,7 +164,7 @@ extension AudioPlayerViewModel { func setMainTrackVolume(_ volume: Float) { performUndoableEdit("Change Main Volume") { - mainTrackVolume = min(1, max(0, volume)) + mainTrackVolume = clampedVolume(volume) playbackEngine.setMainVolume(mainTrackVolume) } } From 37308d2e536ac8a037e66be9706b0584373d6daf Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 8 Jul 2026 18:13:09 +0300 Subject: [PATCH 80/80] refactor: share SHA256 hex formatting --- JammLab/Services/CachedPeakformProvider.swift | 8 +++++++- JammLab/Services/StemSeparationService.swift | 2 +- JammLab/Services/VideoAudioExtractionService.swift | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/JammLab/Services/CachedPeakformProvider.swift b/JammLab/Services/CachedPeakformProvider.swift index 5256f2a..581a2d3 100644 --- a/JammLab/Services/CachedPeakformProvider.swift +++ b/JammLab/Services/CachedPeakformProvider.swift @@ -1,6 +1,12 @@ import CryptoKit import Foundation +extension SHA256.Digest { + var lowercaseHexString: String { + map { String(format: "%02x", $0) }.joined() + } +} + final class CachedPeakformProvider: PeakformProvider { let samplesPerPeakLevels: [Int] @@ -72,6 +78,6 @@ final class CachedPeakformProvider: PeakformProvider { return true }) {} - return hasher.finalize().map { String(format: "%02x", $0) }.joined() + return hasher.finalize().lowercaseHexString } } diff --git a/JammLab/Services/StemSeparationService.swift b/JammLab/Services/StemSeparationService.swift index 69546a8..e5cfd90 100644 --- a/JammLab/Services/StemSeparationService.swift +++ b/JammLab/Services/StemSeparationService.swift @@ -508,7 +508,7 @@ final class StemSeparationService { ].joined(separator: "|") let digest = SHA256.hash(data: Data(rawValue.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + return digest.lowercaseHexString } private func applicationSupportDirectory() -> URL { diff --git a/JammLab/Services/VideoAudioExtractionService.swift b/JammLab/Services/VideoAudioExtractionService.swift index e1c26fe..4792d4e 100644 --- a/JammLab/Services/VideoAudioExtractionService.swift +++ b/JammLab/Services/VideoAudioExtractionService.swift @@ -75,7 +75,7 @@ final class VideoAudioExtractionService { static func cacheKey(for mediaURL: URL) -> String { let identity = mediaIdentity(for: mediaURL) let digest = SHA256.hash(data: Data(identity.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + return digest.lowercaseHexString } static func mediaIdentity(for mediaURL: URL) -> String {