diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 4a43286..cbc83f1 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -97,21 +97,107 @@ 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 */; }; 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 /* SettingsAndControlLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; @@ -138,6 +224,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 */ @@ -275,13 +362,101 @@ 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 /* SettingsAndControlLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAndControlLogicTests.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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; @@ -308,6 +483,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 = ""; }; @@ -529,14 +705,103 @@ 9F8B03012C10000100112233 /* JammLabTests */ = { isa = PBXGroup; children = ( - 9FAB00012CE0000100112233 /* AudioTimingLogicTests.swift */, + 9FAB00012CE0000100112233 /* AudioFileImporterDurationTests.swift */, 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */, 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, + 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, - 9FAB00042CE0000100112233 /* ViewModelLifecycleTests.swift */, + 9FAB00682CE0000100112233 /* ProjectStateNormalizerTests.swift */, + 9FAB00552CE0000100112233 /* ProjectSaveDestinationTests.swift */, + 9FAB00422CE0000100112233 /* TimecodedNoteColorTests.swift */, + 9FAB00372CE0000100112233 /* TimelineViewportLogicTests.swift */, + 9FAB00382CE0000100112233 /* AppHotkeyTests.swift */, + 9FAB004D2CE0000100112233 /* AppHotkeyNotationMappingTests.swift */, + 9FAB003A2CE0000100112233 /* AppHotkeyEventFilterTests.swift */, + 9FAB00452CE0000100112233 /* AppHotkeyNotationEventFilterTests.swift */, + 9FAB00602CE0000100112233 /* AppHotkeyNotationDurationEventFilterTests.swift */, + 9FAB001F2CE0000100112233 /* ViewModelResetTests.swift */, + 9FAB00202CE0000100112233 /* ViewModelPlaybackStateTests.swift */, + 9FAB00542CE0000100112233 /* ViewModelPlaybackTimelineTests.swift */, + 9FAB00212CE0000100112233 /* ViewModelStemPlaybackTests.swift */, + 9FAB00572CE0000100112233 /* ViewModelStemSeparationCancellationTests.swift */, + 9FAB00222CE0000100112233 /* ViewModelVideoWindowTests.swift */, + 9FAB00232CE0000100112233 /* ViewModelSettingsTests.swift */, + 9FAB00242CE0000100112233 /* ViewModelProjectKeyPersistenceTests.swift */, + 9FAB00642CE0000100112233 /* ViewModelProjectSavedKeyRestoreTests.swift */, + 9FAB00252CE0000100112233 /* ViewModelProjectRestoreTests.swift */, + 9FAB00622CE0000100112233 /* ViewModelProjectPlaybackMarkerTests.swift */, + 9FAB00442CE0000100112233 /* ViewModelTimelineRangePersistenceTests.swift */, + 9FAB00262CE0000100112233 /* ViewModelEditingStateTests.swift */, + 9FAB00512CE0000100112233 /* ViewModelNoteEditingTests.swift */, + 9FAB00272CE0000100112233 /* ViewModelTempoMapTests.swift */, + 9FAB00282CE0000100112233 /* ViewModelRegionLoopTests.swift */, + 9FAB00482CE0000100112233 /* ViewModelRegionActivationTests.swift */, + 9FAB00292CE0000100112233 /* ViewModelNotationSelectionTests.swift */, + 9FAB00652CE0000100112233 /* ViewModelNotationDurationSelectionTests.swift */, + 9FAB004E2CE0000100112233 /* ViewModelNotationExportTests.swift */, + 9FAB003D2CE0000100112233 /* ViewModelNotationClipboardTests.swift */, + 9FAB004C2CE0000100112233 /* ViewModelNotationPasteTests.swift */, + 9FAB00672CE0000100112233 /* ViewModelNotationRangePasteTests.swift */, + 9FAB002A2CE0000100112233 /* ViewModelNotationTrackCollapseTests.swift */, + 9FAB002B2CE0000100112233 /* ViewModelVideoProjectPersistenceTests.swift */, + 9FAB00502CE0000100112233 /* ViewModelVideoWindowProjectRestoreTests.swift */, + 9FAB002C2CE0000100112233 /* ViewModelProjectSaveCloseTests.swift */, 9FAB00052CE0000100112233 /* StemWorkflowLogicTests.swift */, + 9FAB00492CE0000100112233 /* StemWorkflowPersistenceTests.swift */, + 9FAB004A2CE0000100112233 /* MediaImportLogicTests.swift */, + 9FAB004B2CE0000100112233 /* StemBackendResolverTests.swift */, + 9FAB003C2CE0000100112233 /* StemJobWorkflowTests.swift */, + 9FAB00532CE0000100112233 /* StemJobInputTests.swift */, + 9FAB00392CE0000100112233 /* ProjectArtifactStoreTests.swift */, + 9FAB00412CE0000100112233 /* ProjectPersistenceCoordinatorArtifactTests.swift */, + 9FAB00562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift */, 9FAB00062CE0000100112233 /* StemHelperProcessControllerTests.swift */, - 9FAB00072CE0000100112233 /* SettingsAndControlLogicTests.swift */, + 9FAB00072CE0000100112233 /* TunerInputServiceTests.swift */, + 9FAB00662CE0000100112233 /* TunerInputServiceErrorTests.swift */, + 9FAB003E2CE0000100112233 /* TunerInputServiceSignalTests.swift */, + 9FAB004F2CE0000100112233 /* TunerInputServiceAnalysisTests.swift */, + 9FAB003B2CE0000100112233 /* TunerInputServiceNoteHoldTests.swift */, + 9FAB002D2CE0000100112233 /* ControlLogicTests.swift */, + 9FAB00632CE0000100112233 /* JammValueSliderLogicTests.swift */, + 9FAB002E2CE0000100112233 /* RecentProjectsStoreTests.swift */, + 9FAB002F2CE0000100112233 /* ColorPaletteTests.swift */, + 9FAB00302CE0000100112233 /* ClickSoundSettingsTests.swift */, + 9FAB00312CE0000100112233 /* AppSettingsStoreTests.swift */, + 9FAB00322CE0000100112233 /* AudioDeviceTestDoubles.swift */, + 9FAB00332CE0000100112233 /* AudioSettingsDeviceTests.swift */, + 9FAB00342CE0000100112233 /* TunerInputDeviceResolverTests.swift */, + 9FAB00352CE0000100112233 /* TunerInputLogicTests.swift */, + 9FAB00362CE0000100112233 /* TunerInputServiceTestDoubles.swift */, + 9FAB00082CE0000100112233 /* ClickRenderStateTests.swift */, + 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */, + 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */, + 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */, + 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */, + 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */, + 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */, + 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */, + 9FAB00522CE0000100112233 /* NotationMusicXMLErrorTests.swift */, + 9FAB000E2CE0000100112233 /* NotationKeySignatureTests.swift */, + 9FAB000F2CE0000100112233 /* NotationAttributeDisplayTests.swift */, + 9FAB00102CE0000100112233 /* NotationRegionLabelTests.swift */, + 9FAB00112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift */, + 9FAB00122CE0000100112233 /* NotationScoreStateTests.swift */, + 9FAB00132CE0000100112233 /* NotationHarmonyPlacementTests.swift */, + 9FAB00142CE0000100112233 /* NotationMeasureTimingTests.swift */, + 9FAB00152CE0000100112233 /* NotationViewportTests.swift */, + 9FAB00462CE0000100112233 /* NotationViewportTempoMapTests.swift */, + 9FAB00162CE0000100112233 /* NotationMeasureGeometryTests.swift */, + 9FAB00432CE0000100112233 /* NotationAttributeGeometryTests.swift */, + 9FAB00612CE0000100112233 /* NotationAttributeStaffInsetTests.swift */, + 9FAB003F2CE0000100112233 /* NotationFallbackGeometryTests.swift */, + 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; @@ -905,17 +1170,104 @@ 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 */, + 9FBA01062D40000100112233 /* AudioSampleConverterTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, - 9FAB01042CE0000100112233 /* ViewModelLifecycleTests.swift in Sources */, + 9FAB01682CE0000100112233 /* ProjectStateNormalizerTests.swift in Sources */, + 9FAB01552CE0000100112233 /* ProjectSaveDestinationTests.swift in Sources */, + 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 */, + 9FAB01602CE0000100112233 /* AppHotkeyNotationDurationEventFilterTests.swift in Sources */, + 9FAB011F2CE0000100112233 /* ViewModelResetTests.swift in Sources */, + 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 */, + 9FAB01642CE0000100112233 /* ViewModelProjectSavedKeyRestoreTests.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 */, + 9FAB01272CE0000100112233 /* ViewModelTempoMapTests.swift in Sources */, + 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 */, + 9FAB01672CE0000100112233 /* ViewModelNotationRangePasteTests.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 */, + 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 */, + 9FAB01562CE0000100112233 /* ProjectPersistenceCoordinatorMediaTests.swift in Sources */, 9FAB01062CE0000100112233 /* StemHelperProcessControllerTests.swift in Sources */, - 9FAB01072CE0000100112233 /* SettingsAndControlLogicTests.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 */, + 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 */, + 9FAB01312CE0000100112233 /* AppSettingsStoreTests.swift in Sources */, + 9FAB01322CE0000100112233 /* AudioDeviceTestDoubles.swift in Sources */, + 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 */, + 9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */, + 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */, + 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 */, + 9FAB01112CE0000100112233 /* NotationVisibleMeasureFitterTests.swift in Sources */, + 9FAB01122CE0000100112233 /* NotationScoreStateTests.swift in Sources */, + 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 */, + 9FAB01612CE0000100112233 /* NotationAttributeStaffInsetTests.swift in Sources */, + 9FAB013F2CE0000100112233 /* NotationFallbackGeometryTests.swift in Sources */, + 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 */, - 9F9401022CA0000100112233 /* AppSettingsStore.swift in Sources */, - 9F9301032C90000100112233 /* StemBackendResolver.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 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/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/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 { 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) } } 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 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( 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/AppHotkeyEventFilterTests.swift b/JammLabTests/AppHotkeyEventFilterTests.swift new file mode 100644 index 0000000..3bbca38 --- /dev/null +++ b/JammLabTests/AppHotkeyEventFilterTests.swift @@ -0,0 +1,104 @@ +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] + ) + ) + } + +} 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 new file mode 100644 index 0000000..47dc752 --- /dev/null +++ b/JammLabTests/AppHotkeyNotationEventFilterTests.swift @@ -0,0 +1,173 @@ +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] + ) + ) + } + +} 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 new file mode 100644 index 0000000..70c5361 --- /dev/null +++ b/JammLabTests/AppHotkeyTests.swift @@ -0,0 +1,91 @@ +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 testAppHotkeyMappingsDoNotContainDuplicateKeyLabels() { + let keys = AppHotkey.allCases.map(\.key) + XCTAssertEqual(keys.count, Set(keys).count) + } + + 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") + } + +} 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/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/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/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.. 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) - - 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 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 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 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")! - 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), - 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) - 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 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"), - 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) - - 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, - 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, - 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 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, - 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 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( - 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 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 - - 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) - } - - 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) - } - - 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, - 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 - ) -> [NotationMeasureCanvasGeometry] { - (0.. 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 + } +} 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/ColorPaletteTests.swift b/JammLabTests/ColorPaletteTests.swift new file mode 100644 index 0000000..c4ef865 --- /dev/null +++ b/JammLabTests/ColorPaletteTests.swift @@ -0,0 +1,118 @@ +import AppKit +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) + } + + 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" + ) + } +} diff --git a/JammLabTests/ControlLogicTests.swift b/JammLabTests/ControlLogicTests.swift new file mode 100644 index 0000000..e360711 --- /dev/null +++ b/JammLabTests/ControlLogicTests.swift @@ -0,0 +1,112 @@ +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 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/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) + } +} 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/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/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/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/NotationAttributeGeometryTests.swift b/JammLabTests/NotationAttributeGeometryTests.swift new file mode 100644 index 0000000..8b4adb1 --- /dev/null +++ b/JammLabTests/NotationAttributeGeometryTests.swift @@ -0,0 +1,158 @@ +import XCTest +@testable import JammLab + +final class NotationAttributeGeometryTests: XCTestCase { + 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/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 + ) + } + } +} 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/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 + ) + } +} 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: [] + ) + } +} 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) + } +} diff --git a/JammLabTests/NotationMeasureGeometryTests.swift b/JammLabTests/NotationMeasureGeometryTests.swift new file mode 100644 index 0000000..9bd78fa --- /dev/null +++ b/JammLabTests/NotationMeasureGeometryTests.swift @@ -0,0 +1,140 @@ +import XCTest +@testable import JammLab + +final class NotationMeasureGeometryTests: XCTestCase { + 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 }) + } + +} 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 + ) + } +} 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/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/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 new file mode 100644 index 0000000..622973d --- /dev/null +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -0,0 +1,208 @@ +import Foundation +import XCTest +@testable import JammLab + +final class NotationMusicXMLTests: XCTestCase { + 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") + } + + 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 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 + ) + } +} 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) + } +} 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/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 + ) + } +} 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/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 + ) + } +} diff --git a/JammLabTests/NotationSelectionOverlayLayoutTests.swift b/JammLabTests/NotationSelectionOverlayLayoutTests.swift new file mode 100644 index 0000000..71f3f1b --- /dev/null +++ b/JammLabTests/NotationSelectionOverlayLayoutTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import JammLab + +final class NotationSelectionOverlayLayoutTests: XCTestCase { + 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, []) + } + + private func selectionOverlayTestGeometries( + count: Int, + width: CGFloat + ) -> [NotationMeasureCanvasGeometry] { + (0.. 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 new file mode 100644 index 0000000..bcd4b63 --- /dev/null +++ b/JammLabTests/NotationViewportTests.swift @@ -0,0 +1,163 @@ +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 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 + ) + } + +} 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: [] + ) + } +} diff --git a/JammLabTests/PitchDetectionTests.swift b/JammLabTests/PitchDetectionTests.swift index 12746c6..36244be 100644 --- a/JammLabTests/PitchDetectionTests.swift +++ b/JammLabTests/PitchDetectionTests.swift @@ -115,62 +115,6 @@ final class PitchDetectionTests: XCTestCase { XCTAssertEqual(result.octave, 4) } - 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 sineWave( frequency: Double, duration: Double, @@ -199,60 +143,4 @@ final class PitchDetectionTests: XCTestCase { try file.write(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.. 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 + ) + } +} 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) + ) + } +} 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/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/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 deleted file mode 100644 index 337ba2f..0000000 --- a/JammLabTests/SettingsAndControlLogicTests.swift +++ /dev/null @@ -1,1485 +0,0 @@ -import AVFoundation -import CoreAudio -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) - - 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 = [ - 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 = [ - 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, - "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() - let settingsStore = JammLab.AppSettingsStore(defaults: defaults) - settingsStore.updateAudioInputDeviceUID("input-1") - let provider = MockAudioDeviceProvider() - provider.inputDevicesResult = [ - AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: false) - ] - provider.deviceIDs["input-1"] = 42 - let permission = MockAudioInputPermissionProvider(status: .notDetermined, requestResult: true) - let engine = MockTunerInputEngine() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: permission, - inputEngine: engine - ) - - XCTAssertEqual(permission.requestAccessCount, 0) - XCTAssertEqual(engine.startDeviceIDs, []) - - await service.start() - - XCTAssertEqual(permission.requestAccessCount, 1) - XCTAssertEqual(engine.startDeviceIDs, [42]) - XCTAssertNil(service.errorMessage) - } - - @MainActor - func testTunerInputServiceDoesNotStartEngineWhenPermissionDenied() async throws { - let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) - let permission = MockAudioInputPermissionProvider(status: .denied) - let engine = MockTunerInputEngine() - let service = TunerInputService( - appSettingsStore: settingsStore, - inputPermissionProvider: permission, - inputEngine: engine - ) - - await service.start() - - XCTAssertEqual(permission.requestAccessCount, 0) - XCTAssertEqual(engine.startDeviceIDs, []) - XCTAssertEqual(service.errorMessage, TunerInputServiceError.microphonePermissionDenied.localizedDescription) - XCTAssertEqual(service.inputSignalLevel, 0) - XCTAssertEqual(service.inputDebugSnapshot.permissionStatus, .denied) - XCTAssertEqual(service.inputDebugSnapshot.permissionRequestGranted, false) - XCTAssertEqual(service.inputDebugSnapshot.lastErrorMessage, TunerInputServiceError.microphonePermissionDenied.localizedDescription) - } - - @MainActor - func testTunerInputServiceFallsBackToDefaultWhenSavedInputIsUnavailableWithoutClearingSetting() async throws { - let defaults = try temporaryUserDefaults() - let settingsStore = JammLab.AppSettingsStore(defaults: defaults) - settingsStore.updateAudioInputDeviceUID("missing-input") - let provider = MockAudioDeviceProvider() - provider.defaultInputDeviceID = 7 - provider.inputDevicesResult = [ - AudioDeviceInfo(uid: "default-input", name: "Default Input", kind: .input, isDefault: true) - ] - let engine = MockTunerInputEngine() - let service = TunerInputService( - appSettingsStore: settingsStore, - audioDeviceProvider: provider, - inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), - inputEngine: engine - ) - - await service.start() - - XCTAssertEqual(engine.startDeviceIDs, [7]) - XCTAssertEqual(settingsStore.audioDeviceSettings.inputDeviceUID, "missing-input") - XCTAssertEqual(AppSettingsStore(defaults: defaults).audioDeviceSettings.inputDeviceUID, "missing-input") - XCTAssertEqual(service.inputDeviceName, "Default Input") - XCTAssertEqual(service.inputDiagnosticMessage, "Selected tuner input is unavailable. Using System Default.") - XCTAssertEqual(service.inputDebugSnapshot.savedInputDeviceUID, "missing-input") - XCTAssertEqual(service.inputDebugSnapshot.resolvedDeviceName, "Default Input") - XCTAssertEqual(service.inputDebugSnapshot.resolvedDeviceID, 7) - XCTAssertTrue(service.inputDebugSnapshot.didFallbackToDefaultDevice) - XCTAssertNil(service.errorMessage) - } - - @MainActor - func testTunerInputServiceIgnoresOutputDeviceChangesWhileRunning() 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() - XCTAssertEqual(engine.startDeviceIDs, [42]) - - settingsStore.updateAudioOutputDeviceUID("output-1") - await drainMainQueue() - - XCTAssertEqual(engine.startDeviceIDs, [42]) - } - - @MainActor - func testTunerInputServiceRestartsForInputDeviceChangesWhileRunning() 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 drainMainQueue() - await Task.yield() - await drainMainQueue() - - XCTAssertEqual(engine.startDeviceIDs, [42, 84]) - 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) - } - - @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 drainMainQueue() - await Task.yield() - await drainMainQueue() - - 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 drainMainQueue() - - 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 drainMainQueue() - - 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 makeInt16Buffer(samples: [1000, -1000, 1000, -1000])) - await drainMainQueue() - - 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 drainMainQueue() - await Task.yield() - await drainMainQueue() - - XCTAssertEqual(engine.startDeviceIDs, [42, 84]) - - engine.sendAudioBuffer(samples: [0.5, -0.5, 0.5, -0.5], toStartAt: 0) - await drainMainQueue() - - 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: sineWave(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 drainMainQueue() - - 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: markerSamples(1)) - await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) - - engine.sendAudioBuffer(samples: markerSamples(2)) - engine.sendAudioBuffer(samples: markerSamples(3)) - detector.releaseFirstDetection() - - let didPublishLatestResult = await waitForMainActorCondition { 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: markerSamples(1)) - await fulfillment(of: [detector.firstDetectionStarted], timeout: 2) - - service.stop() - detector.releaseFirstDetection() - await drainMainQueue() - await Task.yield() - await drainMainQueue() - - 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: markerSamples(1), sampleRate: 48_000) - - let didRecordSampleRate = await waitForMainActorCondition { detector.sampleRates == [48_000] } - 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: markerSamples(1)) - let didPublishDetectedNote = await waitForMainActorCondition { service.currentResult?.noteName == "A" } - XCTAssertTrue(didPublishDetectedNote) - - engine.sendAudioBuffer(samples: markerSamples(2)) - let didProcessMissingResult = await waitForMainActorCondition { 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: markerSamples(1)) - let didPublishDetectedNote = await waitForMainActorCondition { service.currentResult?.noteName == "A" } - XCTAssertTrue(didPublishDetectedNote) - - engine.sendAudioBuffer(samples: markerSamples(2)) - let didProcessMissingResult = await waitForMainActorCondition { detector.detectCallCount == 2 } - XCTAssertTrue(didProcessMissingResult) - let didClearHeldNote = await waitForMainActorCondition { 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: markerSamples(1)) - let didPublishFirstNote = await waitForMainActorCondition { service.currentResult?.noteName == "A" } - XCTAssertTrue(didPublishFirstNote) - - engine.sendAudioBuffer(samples: markerSamples(2)) - let didPublishReplacementNote = await waitForMainActorCondition { 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: markerSamples(1)) - let didPublishDetectedNote = await waitForMainActorCondition { service.currentResult?.noteName == "A" } - XCTAssertTrue(didPublishDetectedNote) - - engine.sendAudioBuffer(samples: markerSamples(2)) - let didProcessMissingResult = await waitForMainActorCondition { detector.detectCallCount == 2 } - XCTAssertTrue(didProcessMissingResult) - service.stop() - - XCTAssertNil(service.currentResult) - try? await Task.sleep(nanoseconds: 250_000_000) - 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 - } - - 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 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 - - 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 { - 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 -} - -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, - 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/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/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 new file mode 100644 index 0000000..fdd66c8 --- /dev/null +++ b/JammLabTests/StemJobWorkflowTests.swift @@ -0,0 +1,145 @@ +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 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")) + } + +} diff --git a/JammLabTests/StemWorkflowLogicTests.swift b/JammLabTests/StemWorkflowLogicTests.swift index 0e3b0e9..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,696 +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) - } - - 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")) - } - - 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) - ) - } } 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) + } +} 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") + } +} 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 cd6c44b..e62ba48 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -1,172 +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) - - 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) @@ -196,136 +31,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 = """ { @@ -392,673 +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 - ) - } - - 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) + } +} 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, []) + } +} 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/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/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/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/TunerInputServiceSignalTests.swift b/JammLabTests/TunerInputServiceSignalTests.swift new file mode 100644 index 0000000..1289e94 --- /dev/null +++ b/JammLabTests/TunerInputServiceSignalTests.swift @@ -0,0 +1,165 @@ +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) + } + +} diff --git a/JammLabTests/TunerInputServiceTestDoubles.swift b/JammLabTests/TunerInputServiceTestDoubles.swift new file mode 100644 index 0000000..9fe00cc --- /dev/null +++ b/JammLabTests/TunerInputServiceTestDoubles.swift @@ -0,0 +1,268 @@ +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() + } +} + +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 new file mode 100644 index 0000000..65ff630 --- /dev/null +++ b/JammLabTests/TunerInputServiceTests.swift @@ -0,0 +1,137 @@ +import CoreAudio +import XCTest +@testable import JammLab + +final class TunerInputServiceTests: XCTestCase { + @MainActor + func testTunerInputServiceRequestsPermissionOnlyWhenStarted() async throws { + let defaults = try temporaryUserDefaults() + let settingsStore = JammLab.AppSettingsStore(defaults: defaults) + settingsStore.updateAudioInputDeviceUID("input-1") + let provider = MockAudioDeviceProvider() + provider.inputDevicesResult = [ + AudioDeviceInfo(uid: "input-1", name: "Input 1", kind: .input, isDefault: false) + ] + provider.deviceIDs["input-1"] = 42 + let permission = MockAudioInputPermissionProvider(status: .notDetermined, requestResult: true) + let engine = MockTunerInputEngine() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: permission, + inputEngine: engine + ) + + XCTAssertEqual(permission.requestAccessCount, 0) + XCTAssertEqual(engine.startDeviceIDs, []) + + await service.start() + + XCTAssertEqual(permission.requestAccessCount, 1) + XCTAssertEqual(engine.startDeviceIDs, [42]) + XCTAssertNil(service.errorMessage) + } + + @MainActor + func testTunerInputServiceDoesNotStartEngineWhenPermissionDenied() async throws { + let settingsStore = JammLab.AppSettingsStore(defaults: try temporaryUserDefaults()) + let permission = MockAudioInputPermissionProvider(status: .denied) + let engine = MockTunerInputEngine() + let service = TunerInputService( + appSettingsStore: settingsStore, + inputPermissionProvider: permission, + inputEngine: engine + ) + + await service.start() + + XCTAssertEqual(permission.requestAccessCount, 0) + XCTAssertEqual(engine.startDeviceIDs, []) + XCTAssertEqual(service.errorMessage, TunerInputServiceError.microphonePermissionDenied.localizedDescription) + XCTAssertEqual(service.inputSignalLevel, 0) + XCTAssertEqual(service.inputDebugSnapshot.permissionStatus, .denied) + XCTAssertEqual(service.inputDebugSnapshot.permissionRequestGranted, false) + XCTAssertEqual(service.inputDebugSnapshot.lastErrorMessage, TunerInputServiceError.microphonePermissionDenied.localizedDescription) + } + + @MainActor + func testTunerInputServiceFallsBackToDefaultWhenSavedInputIsUnavailableWithoutClearingSetting() async throws { + let defaults = try temporaryUserDefaults() + let settingsStore = JammLab.AppSettingsStore(defaults: defaults) + settingsStore.updateAudioInputDeviceUID("missing-input") + let provider = MockAudioDeviceProvider() + provider.defaultInputDeviceID = 7 + provider.inputDevicesResult = [ + AudioDeviceInfo(uid: "default-input", name: "Default Input", kind: .input, isDefault: true) + ] + let engine = MockTunerInputEngine() + let service = TunerInputService( + appSettingsStore: settingsStore, + audioDeviceProvider: provider, + inputPermissionProvider: MockAudioInputPermissionProvider(status: .authorized), + inputEngine: engine + ) + + await service.start() + + XCTAssertEqual(engine.startDeviceIDs, [7]) + XCTAssertEqual(settingsStore.audioDeviceSettings.inputDeviceUID, "missing-input") + XCTAssertEqual(AppSettingsStore(defaults: defaults).audioDeviceSettings.inputDeviceUID, "missing-input") + XCTAssertEqual(service.inputDeviceName, "Default Input") + XCTAssertEqual(service.inputDiagnosticMessage, "Selected tuner input is unavailable. Using System Default.") + XCTAssertEqual(service.inputDebugSnapshot.savedInputDeviceUID, "missing-input") + XCTAssertEqual(service.inputDebugSnapshot.resolvedDeviceName, "Default Input") + XCTAssertEqual(service.inputDebugSnapshot.resolvedDeviceID, 7) + XCTAssertTrue(service.inputDebugSnapshot.didFallbackToDefaultDevice) + XCTAssertNil(service.errorMessage) + } + + @MainActor + func testTunerInputServiceIgnoresOutputDeviceChangesWhileRunning() 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() + XCTAssertEqual(engine.startDeviceIDs, [42]) + + settingsStore.updateAudioOutputDeviceUID("output-1") + await tunerDrainMainQueue() + + XCTAssertEqual(engine.startDeviceIDs, [42]) + } + + @MainActor + func testTunerInputServiceRestartsForInputDeviceChangesWhileRunning() 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]) + XCTAssertGreaterThanOrEqual(engine.stopCallCount, 2) + } + +} diff --git a/JammLabTests/ViewModelEditingStateTests.swift b/JammLabTests/ViewModelEditingStateTests.swift new file mode 100644 index 0000000..81646a6 --- /dev/null +++ b/JammLabTests/ViewModelEditingStateTests.swift @@ -0,0 +1,167 @@ +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) + } + +} diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift deleted file mode 100644 index 3358b84..0000000 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ /dev/null @@ -1,2936 +0,0 @@ -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 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()) - - 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()) - - viewModel.setTimelineVisibleRange(-20...80) - - XCTAssertEqual(viewModel.timelineVisibleRange.lowerBound, 0, accuracy: 0.0001) - 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() - 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() - 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 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) - 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 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) - 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") - 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 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) - 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) - 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 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 - 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) - 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 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() - 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 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 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( - 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()) - - 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)" - ) - } - - 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 - ) - } - - 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 - ) - } - - @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) - } - - 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/ViewModelNotationClipboardTests.swift b/JammLabTests/ViewModelNotationClipboardTests.swift new file mode 100644 index 0000000..3c58bcd --- /dev/null +++ b/JammLabTests/ViewModelNotationClipboardTests.swift @@ -0,0 +1,85 @@ +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 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/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/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/ViewModelNotationPasteTests.swift b/JammLabTests/ViewModelNotationPasteTests.swift new file mode 100644 index 0000000..1c46ec7 --- /dev/null +++ b/JammLabTests/ViewModelNotationPasteTests.swift @@ -0,0 +1,95 @@ +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 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 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) + } +} 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]) + } +} diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift new file mode 100644 index 0000000..19c3d21 --- /dev/null +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -0,0 +1,162 @@ +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 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 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) + } + +} + +extension XCTestCase { + @MainActor + 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 + 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 }) + } +} 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 + ) + } +} 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) + } +} diff --git a/JammLabTests/ViewModelPlaybackStateTests.swift b/JammLabTests/ViewModelPlaybackStateTests.swift new file mode 100644 index 0000000..6c8ad8d --- /dev/null +++ b/JammLabTests/ViewModelPlaybackStateTests.swift @@ -0,0 +1,119 @@ +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) + } + +} 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) + } +} diff --git a/JammLabTests/ViewModelProjectKeyPersistenceTests.swift b/JammLabTests/ViewModelProjectKeyPersistenceTests.swift new file mode 100644 index 0000000..5323534 --- /dev/null +++ b/JammLabTests/ViewModelProjectKeyPersistenceTests.swift @@ -0,0 +1,105 @@ +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 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") + } +} 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 new file mode 100644 index 0000000..293406e --- /dev/null +++ b/JammLabTests/ViewModelProjectRestoreTests.swift @@ -0,0 +1,130 @@ +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 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.") + } +} 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)" + ) + } +} 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) + } +} 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 new file mode 100644 index 0000000..1bdd326 --- /dev/null +++ b/JammLabTests/ViewModelRegionLoopTests.swift @@ -0,0 +1,151 @@ +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) + 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 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) + } +} 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) + } +} 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) + } +} diff --git a/JammLabTests/ViewModelStemPlaybackTests.swift b/JammLabTests/ViewModelStemPlaybackTests.swift new file mode 100644 index 0000000..614f5e0 --- /dev/null +++ b/JammLabTests/ViewModelStemPlaybackTests.swift @@ -0,0 +1,110 @@ +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) + } + + 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/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) + } +} 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) + } +} 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) + } +} diff --git a/JammLabTests/ViewModelVideoProjectPersistenceTests.swift b/JammLabTests/ViewModelVideoProjectPersistenceTests.swift new file mode 100644 index 0000000..4c50e3a --- /dev/null +++ b/JammLabTests/ViewModelVideoProjectPersistenceTests.swift @@ -0,0 +1,98 @@ +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() + 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) + } + +} 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 + ) + } +} 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) + } +}