diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..7a66249 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,42 @@ +name: Build & Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode 15 + run: sudo xcode-select -s /Applications/Xcode_15.4.app/Contents/Developer + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Build app + run: | + xcodebuild build \ + -project EDFViewerMac.xcodeproj \ + -scheme EDFViewerMac \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY=- \ + | xcpretty || true + + - name: Run tests + run: | + xcodebuild test \ + -project EDFViewerMac.xcodeproj \ + -scheme EDFViewerMacTests \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY=- \ + | xcpretty || true diff --git a/.gitignore b/.gitignore index 52fe2f7..832fa51 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,15 @@ # Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - -## User settings xcuserdata/ +*.xcscmblueprint +*.xccheckout -## Obj-C/Swift specific -*.hmap +## Build +build/ +DerivedData/ +.build/ -## App packaging +## User settings +*.hmap *.ipa *.dSYM.zip *.dSYM @@ -17,45 +18,18 @@ xcuserdata/ timeline.xctimeline playground.xcworkspace -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm - -.build/ +# macOS +.DS_Store +*.swp +*~ -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace +# Swift Package Manager +Package.resolved # Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - Carthage/Build/ # fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png diff --git a/EDFViewerMac.xcodeproj/project.pbxproj b/EDFViewerMac.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9413d4e --- /dev/null +++ b/EDFViewerMac.xcodeproj/project.pbxproj @@ -0,0 +1,492 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0189F499CBA2FA02C568C8DF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6F4AA165B20C7A22C4666652 /* Assets.xcassets */; }; + 331E6A3E3DD6153B97ACEE5E /* EDFViewerMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBDB47CF168549400858F67 /* EDFViewerMacApp.swift */; }; + 38EBB60755D0D4BF96168425 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277E64FC6A22251D3963489E /* Models.swift */; }; + 4893F89CFEA376361F6BCE3F /* ViewerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74E5E248D171C4D22769FF1 /* ViewerViewModel.swift */; }; + 4F0FA489159F9E0F24EBBA9B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BBBEE954FC91DD87D35C0F6 /* ContentView.swift */; }; + 522181A6606FED6AD7AACD89 /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 72A5CC800534BE9F6ABF0271 /* Fixtures */; }; + 52C55A2071571C081BF044C8 /* EDFReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF8C31DFA559F5C09DCB5F76 /* EDFReader.swift */; }; + 5E30C7E6FC222DD0CCF2C6CA /* SignalProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D77077467CB2C00AC9729E /* SignalProcessing.swift */; }; + 79EC8ACF0C645A877E4088ED /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277E64FC6A22251D3963489E /* Models.swift */; }; + 88357D1328CDEFDFA10F3BC6 /* ViewerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D30423A2EA593113195FE7 /* ViewerViewModelTests.swift */; }; + 8C13F9FE1B2B30AE67962BD0 /* ViewerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74E5E248D171C4D22769FF1 /* ViewerViewModel.swift */; }; + 8C24A0BC681D828230745D92 /* WaveformMinMaxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38E06C1F78853D5E4E61C0D /* WaveformMinMaxView.swift */; }; + 9AE0AC69672428C5C9066DA1 /* SignalProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D77077467CB2C00AC9729E /* SignalProcessing.swift */; }; + A12C86EB7DB490B17FCA5669 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BBBEE954FC91DD87D35C0F6 /* ContentView.swift */; }; + B6506E56AAE7144B26E32235 /* EDFReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3691C97574D02C73304ACA8 /* EDFReaderTests.swift */; }; + B7CB8881E1070FBF4BD44ED1 /* WaveformMinMaxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E38E06C1F78853D5E4E61C0D /* WaveformMinMaxView.swift */; }; + BC0E02DFE3122F4782538402 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23549711775AE252DC4F2904 /* SettingsView.swift */; }; + C4626A72D53262A2C2DD3E5A /* EDFReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF8C31DFA559F5C09DCB5F76 /* EDFReader.swift */; }; + C5D7FF78E15B1BF29E738205 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6F4AA165B20C7A22C4666652 /* Assets.xcassets */; }; + CB97548711B45BF303A56023 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23549711775AE252DC4F2904 /* SettingsView.swift */; }; + F2D21BB2907F3848546FB912 /* EDFReadIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BEFD278699AD177C2EF54A4 /* EDFReadIntegrationTests.swift */; }; + F2D685683E4CE4752C8A335B /* SignalProcessingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05107E923F2EC062933663BB /* SignalProcessingTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 05107E923F2EC062933663BB /* SignalProcessingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalProcessingTests.swift; sourceTree = ""; }; + 23549711775AE252DC4F2904 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 277E64FC6A22251D3963489E /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 642C7A3D6CBF2EA4D518F57E /* EDFViewerMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EDFViewerMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6BBBEE954FC91DD87D35C0F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 6F4AA165B20C7A22C4666652 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 72A5CC800534BE9F6ABF0271 /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Fixtures; path = Tests/EDFViewerMacTests/Fixtures; sourceTree = SOURCE_ROOT; }; + 73D77077467CB2C00AC9729E /* SignalProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalProcessing.swift; sourceTree = ""; }; + 9BEFD278699AD177C2EF54A4 /* EDFReadIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EDFReadIntegrationTests.swift; sourceTree = ""; }; + 9EBDB47CF168549400858F67 /* EDFViewerMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EDFViewerMacApp.swift; sourceTree = ""; }; + A0D30423A2EA593113195FE7 /* ViewerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewerViewModelTests.swift; sourceTree = ""; }; + E38E06C1F78853D5E4E61C0D /* WaveformMinMaxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformMinMaxView.swift; sourceTree = ""; }; + EF8C31DFA559F5C09DCB5F76 /* EDFReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EDFReader.swift; sourceTree = ""; }; + F3691C97574D02C73304ACA8 /* EDFReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EDFReaderTests.swift; sourceTree = ""; }; + F74E5E248D171C4D22769FF1 /* ViewerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewerViewModel.swift; sourceTree = ""; }; + FF3952EB1E6AE31866D64165 /* EDFViewerMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EDFViewerMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 10BE9C25C0AA4690074BA6E6 /* Products */ = { + isa = PBXGroup; + children = ( + FF3952EB1E6AE31866D64165 /* EDFViewerMac.app */, + 642C7A3D6CBF2EA4D518F57E /* EDFViewerMacTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 602B4F90E92E4139B0FB63CD /* App */ = { + isa = PBXGroup; + children = ( + 9EBDB47CF168549400858F67 /* EDFViewerMacApp.swift */, + ); + path = App; + sourceTree = ""; + }; + 6212636E960242A1FBD2AB9A /* UI */ = { + isa = PBXGroup; + children = ( + 6BBBEE954FC91DD87D35C0F6 /* ContentView.swift */, + 23549711775AE252DC4F2904 /* SettingsView.swift */, + F74E5E248D171C4D22769FF1 /* ViewerViewModel.swift */, + E38E06C1F78853D5E4E61C0D /* WaveformMinMaxView.swift */, + ); + path = UI; + sourceTree = ""; + }; + 8DE7AB746B1D517FEE8A1D82 /* EDFViewerMacTests */ = { + isa = PBXGroup; + children = ( + F3691C97574D02C73304ACA8 /* EDFReaderTests.swift */, + 9BEFD278699AD177C2EF54A4 /* EDFReadIntegrationTests.swift */, + 05107E923F2EC062933663BB /* SignalProcessingTests.swift */, + A0D30423A2EA593113195FE7 /* ViewerViewModelTests.swift */, + ); + name = EDFViewerMacTests; + path = Tests/EDFViewerMacTests; + sourceTree = ""; + }; + A2F717F3F64CE410012A6F29 = { + isa = PBXGroup; + children = ( + 72A5CC800534BE9F6ABF0271 /* Fixtures */, + DD5A95D9E7628A7A44068021 /* EDFViewerMac */, + 8DE7AB746B1D517FEE8A1D82 /* EDFViewerMacTests */, + 10BE9C25C0AA4690074BA6E6 /* Products */, + ); + sourceTree = ""; + }; + DD5A95D9E7628A7A44068021 /* EDFViewerMac */ = { + isa = PBXGroup; + children = ( + 6F4AA165B20C7A22C4666652 /* Assets.xcassets */, + 602B4F90E92E4139B0FB63CD /* App */, + E0886E15B1137D6865D6261D /* Core */, + 6212636E960242A1FBD2AB9A /* UI */, + ); + name = EDFViewerMac; + path = Sources/EDFViewerMac; + sourceTree = ""; + }; + E0886E15B1137D6865D6261D /* Core */ = { + isa = PBXGroup; + children = ( + EF8C31DFA559F5C09DCB5F76 /* EDFReader.swift */, + 277E64FC6A22251D3963489E /* Models.swift */, + 73D77077467CB2C00AC9729E /* SignalProcessing.swift */, + ); + path = Core; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + D24B0A7AC084FF701C6C53E1 /* EDFViewerMacTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 82646AF6A9C63BDEE710A79A /* Build configuration list for PBXNativeTarget "EDFViewerMacTests" */; + buildPhases = ( + CE7DF445F97308FBB40783D9 /* Sources */, + 71D83192CF93FA501CDD3AD1 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = EDFViewerMacTests; + packageProductDependencies = ( + ); + productName = EDFViewerMacTests; + productReference = 642C7A3D6CBF2EA4D518F57E /* EDFViewerMacTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + E9C17CA237A96BB23B8A4900 /* EDFViewerMac */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6EB6653DC04993E69AF12B39 /* Build configuration list for PBXNativeTarget "EDFViewerMac" */; + buildPhases = ( + 63666D1677C8F136D90A07EE /* Sources */, + 390E59A88B470D9F115AFA7F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = EDFViewerMac; + packageProductDependencies = ( + ); + productName = EDFViewerMac; + productReference = FF3952EB1E6AE31866D64165 /* EDFViewerMac.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8B024E27BCB2363FE07D4324 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + E9C17CA237A96BB23B8A4900 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 92A067BA7D1452EDA8F23285 /* Build configuration list for PBXProject "EDFViewerMac" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = A2F717F3F64CE410012A6F29; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E9C17CA237A96BB23B8A4900 /* EDFViewerMac */, + D24B0A7AC084FF701C6C53E1 /* EDFViewerMacTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 390E59A88B470D9F115AFA7F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C5D7FF78E15B1BF29E738205 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 71D83192CF93FA501CDD3AD1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0189F499CBA2FA02C568C8DF /* Assets.xcassets in Resources */, + 522181A6606FED6AD7AACD89 /* Fixtures in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 63666D1677C8F136D90A07EE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4F0FA489159F9E0F24EBBA9B /* ContentView.swift in Sources */, + C4626A72D53262A2C2DD3E5A /* EDFReader.swift in Sources */, + 331E6A3E3DD6153B97ACEE5E /* EDFViewerMacApp.swift in Sources */, + 79EC8ACF0C645A877E4088ED /* Models.swift in Sources */, + BC0E02DFE3122F4782538402 /* SettingsView.swift in Sources */, + 9AE0AC69672428C5C9066DA1 /* SignalProcessing.swift in Sources */, + 8C13F9FE1B2B30AE67962BD0 /* ViewerViewModel.swift in Sources */, + B7CB8881E1070FBF4BD44ED1 /* WaveformMinMaxView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CE7DF445F97308FBB40783D9 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A12C86EB7DB490B17FCA5669 /* ContentView.swift in Sources */, + F2D21BB2907F3848546FB912 /* EDFReadIntegrationTests.swift in Sources */, + 52C55A2071571C081BF044C8 /* EDFReader.swift in Sources */, + B6506E56AAE7144B26E32235 /* EDFReaderTests.swift in Sources */, + 38EBB60755D0D4BF96168425 /* Models.swift in Sources */, + CB97548711B45BF303A56023 /* SettingsView.swift in Sources */, + 5E30C7E6FC222DD0CCF2C6CA /* SignalProcessing.swift in Sources */, + F2D685683E4CE4752C8A335B /* SignalProcessingTests.swift in Sources */, + 4893F89CFEA376361F6BCE3F /* ViewerViewModel.swift in Sources */, + 88357D1328CDEFDFA10F3BC6 /* ViewerViewModelTests.swift in Sources */, + 8C24A0BC681D828230745D92 /* WaveformMinMaxView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 02E1FD94EBE991D8C10F158F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_GENERATION_MODE = GeneratedFile; + INFOPLIST_KEY_CFBundleDisplayName = "EDF Viewer"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.medical"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.edfviewer.EDFViewerMac; + PRODUCT_MODULE_NAME = EDFViewerMac; + PRODUCT_NAME = "EDF Viewer"; + SDKROOT = macosx; + }; + name = Debug; + }; + 09197CDB005B109210CAA7C7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.edfviewer.EDFViewerMacTests; + PRODUCT_MODULE_NAME = EDFViewerMac; + SDKROOT = macosx; + }; + name = Release; + }; + 69798B97A10D34627630E915 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.edfviewer.EDFViewerMacTests; + PRODUCT_MODULE_NAME = EDFViewerMac; + SDKROOT = macosx; + }; + name = Debug; + }; + 8FE3420A784B327A87AE4C5E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + BCB2E36216767DBDC006BCAC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + BE95C49F31585BF90D08217F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_GENERATION_MODE = GeneratedFile; + INFOPLIST_KEY_CFBundleDisplayName = "EDF Viewer"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.medical"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.edfviewer.EDFViewerMac; + PRODUCT_MODULE_NAME = EDFViewerMac; + PRODUCT_NAME = "EDF Viewer"; + SDKROOT = macosx; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 6EB6653DC04993E69AF12B39 /* Build configuration list for PBXNativeTarget "EDFViewerMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 02E1FD94EBE991D8C10F158F /* Debug */, + BE95C49F31585BF90D08217F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 82646AF6A9C63BDEE710A79A /* Build configuration list for PBXNativeTarget "EDFViewerMacTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 69798B97A10D34627630E915 /* Debug */, + 09197CDB005B109210CAA7C7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 92A067BA7D1452EDA8F23285 /* Build configuration list for PBXProject "EDFViewerMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8FE3420A784B327A87AE4C5E /* Debug */, + BCB2E36216767DBDC006BCAC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 8B024E27BCB2363FE07D4324 /* Project object */; +} diff --git a/EDFViewerMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/EDFViewerMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/EDFViewerMac.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a113c36 --- /dev/null +++ b/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "EDFViewer-MacOS", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "EDFViewerMac", targets: ["EDFViewerMac"]) + ], + targets: [ + .executableTarget( + name: "EDFViewerMac", + path: "Sources/EDFViewerMac" + ) + ] +) diff --git a/README.md b/README.md index a506861..7374cc4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ # EDFViewer-MacOS -EDFBrowser-MacOS + +A macOS SwiftUI viewer scaffold for EDF/BDF files inspired by EDFbrowser. + +## What is included + +- Native macOS app entry point using `SwiftUI`. +- Sidebar for channel list and channel selection. +- Waveform pane rendered with min/max downsampling (`Canvas`) for responsive zoomed-out views. +- Reader abstraction (`EDFReading`) so you can swap in a pure C EDFlib-backed implementation without changing the UI. +- Temporary `MockEDFReader` that generates deterministic synthetic EEG-like waveforms so the UI can run before EDFlib wiring is complete. + +## Project layout + +- `Package.swift`: Swift Package config for a macOS executable app target. +- `Sources/EDFViewerMac/App`: app lifecycle and scene setup. +- `Sources/EDFViewerMac/Core`: models, reader protocol, and signal processing. +- `Sources/EDFViewerMac/UI`: SwiftUI screens and waveform rendering. + +## Run locally on macOS + +```bash +swift run EDFViewerMac +``` + +> This command requires a macOS environment with Xcode/Apple SDKs installed. + +## Next step: EDFlib integration + +1. Vendor `edflib.c` and `edflib.h` into the repository. +2. Add a C target (for example `CEDFlib`) in `Package.swift`. +3. Implement an `EDFlibReader` that conforms to `EDFReading`. +4. Update `EDFReaderFactory` to construct `EDFlibReader` for real EDF/BDF files. + +This keeps the core parser layer independent from Qt while preserving a native SwiftUI macOS UI. diff --git a/Sources/EDFViewerMac/App/EDFViewerMacApp.swift b/Sources/EDFViewerMac/App/EDFViewerMacApp.swift new file mode 100644 index 0000000..c42a027 --- /dev/null +++ b/Sources/EDFViewerMac/App/EDFViewerMacApp.swift @@ -0,0 +1,18 @@ +import SwiftUI + +@main +struct EDFViewerMacApp: App { + @StateObject private var viewModel = ViewerViewModel() + + var body: some Scene { + WindowGroup("EDF Viewer") { + ContentView(viewModel: viewModel) + .frame(minWidth: 1100, minHeight: 700) + } + .windowResizability(.contentSize) + + Settings { + SettingsView() + } + } +} diff --git a/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..69b2718 --- /dev/null +++ b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "filename": "icon_16x16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "icon_32x32.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "icon_32x32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "icon_64x64.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "icon_128x128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "icon_256x256.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "icon_256x256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "icon_512x512.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "icon_512x512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "icon_1024x1024.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png new file mode 100644 index 0000000..f1766fc Binary files /dev/null and b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png differ diff --git a/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..9e54e4e Binary files /dev/null and b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..35ff1d1 Binary files /dev/null and b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..9d02021 Binary files /dev/null and b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..b3fdda0 Binary files /dev/null and b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..019b0d2 Binary files /dev/null and b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_64x64.png b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_64x64.png new file mode 100644 index 0000000..4787375 Binary files /dev/null and b/Sources/EDFViewerMac/Assets.xcassets/AppIcon.appiconset/icon_64x64.png differ diff --git a/Sources/EDFViewerMac/Assets.xcassets/Contents.json b/Sources/EDFViewerMac/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/EDFViewerMac/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/EDFViewerMac/Core/EDFReader.swift b/Sources/EDFViewerMac/Core/EDFReader.swift new file mode 100644 index 0000000..8354d68 --- /dev/null +++ b/Sources/EDFViewerMac/Core/EDFReader.swift @@ -0,0 +1,35 @@ +import Foundation + +protocol EDFReading { + var channels: [ChannelInfo] { get } + var fileDurationSeconds: Double { get } + func readWindow(channelID: Int, startSeconds: Double, durationSeconds: Double) async throws -> WaveformWindow +} + +final class MockEDFReader: EDFReading { + let channels: [ChannelInfo] + let fileDurationSeconds: Double = 120 + + init(fileURL: URL) { + channels = [ + ChannelInfo(id: 0, label: "Fp1-F7", sampleRateHz: 256, unit: "uV"), + ChannelInfo(id: 1, label: "F7-T3", sampleRateHz: 256, unit: "uV"), + ChannelInfo(id: 2, label: "T3-T5", sampleRateHz: 256, unit: "uV") + ] + } + + func readWindow(channelID: Int, startSeconds: Double, durationSeconds: Double) async throws -> WaveformWindow { + let sampleRate = channels.first(where: { $0.id == channelID })?.sampleRateHz ?? 256 + let sampleCount = max(1, Int(durationSeconds * sampleRate)) + var values = [Float]() + values.reserveCapacity(sampleCount) + + for i in 0.. DownsampledWaveform { + guard !samples.isEmpty, bucketCount > 0 else { + return DownsampledWaveform(mins: [], maxs: []) + } + + let step = max(1, samples.count / bucketCount) + var mins: [Float] = [] + var maxs: [Float] = [] + mins.reserveCapacity(bucketCount) + maxs.reserveCapacity(bucketCount) + + var index = 0 + while index < samples.count { + let end = min(samples.count, index + step) + var minValue = samples[index] + var maxValue = samples[index] + + if index + 1 < end { + for value in samples[(index + 1).. EDFReading + + init(makeReader: @escaping (URL) throws -> EDFReading = MockEDFReader.init) { + self.makeReader = makeReader + } + + func openFile(url: URL) { + Task { + do { + let createdReader = try makeReader(url) + reader = createdReader + channels = createdReader.channels + selectedChannelID = createdReader.channels.first?.id + openedFileURL = url + errorMessage = nil + await refreshWaveform(pixelWidth: 1400) + } catch { + errorMessage = "Failed to open EDF/BDF file: \(error.localizedDescription)" + } + } + } + + func zoom(by factor: Double, pixelWidth: Int) async { + visibleDurationSeconds = max(0.25, min(60, visibleDurationSeconds * factor)) + await refreshWaveform(pixelWidth: pixelWidth) + } + + func pan(by deltaSeconds: Double, pixelWidth: Int) async { + visibleStartSeconds = max(0, visibleStartSeconds + deltaSeconds) + await refreshWaveform(pixelWidth: pixelWidth) + } + + func selectChannel(_ id: Int, pixelWidth: Int) async { + selectedChannelID = id + await refreshWaveform(pixelWidth: pixelWidth) + } + + func refreshWaveform(pixelWidth: Int) async { + guard let reader, let channelID = selectedChannelID else { + waveform = .init(mins: [], maxs: []) + return + } + + do { + let window = try await reader.readWindow( + channelID: channelID, + startSeconds: visibleStartSeconds, + durationSeconds: visibleDurationSeconds + ) + waveform = SignalProcessing.downsampleMinMax(window.samples, bucketCount: max(10, pixelWidth)) + } catch { + errorMessage = "Failed to read signal window: \(error.localizedDescription)" + } + } +} diff --git a/Sources/EDFViewerMac/UI/WaveformMinMaxView.swift b/Sources/EDFViewerMac/UI/WaveformMinMaxView.swift new file mode 100644 index 0000000..37d91a4 --- /dev/null +++ b/Sources/EDFViewerMac/UI/WaveformMinMaxView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct WaveformMinMaxView: View { + let waveform: DownsampledWaveform + + var body: some View { + Canvas { context, size in + guard !waveform.mins.isEmpty, waveform.mins.count == waveform.maxs.count else { return } + + let minValue = waveform.mins.min() ?? -1 + let maxValue = waveform.maxs.max() ?? 1 + let span = max(0.001, maxValue - minValue) + + let xStep = size.width / CGFloat(waveform.mins.count) + var path = Path() + + for i in waveform.mins.indices { + let x = CGFloat(i) * xStep + let top = size.height * (1 - CGFloat((waveform.maxs[i] - minValue) / span)) + let bottom = size.height * (1 - CGFloat((waveform.mins[i] - minValue) / span)) + path.move(to: CGPoint(x: x, y: top)) + path.addLine(to: CGPoint(x: x, y: bottom)) + } + + context.stroke(path, with: .color(.accentColor), lineWidth: 1) + } + .drawingGroup() + } +} diff --git a/Tests/EDFViewerMacTests/EDFReadIntegrationTests.swift b/Tests/EDFViewerMacTests/EDFReadIntegrationTests.swift new file mode 100644 index 0000000..91f377d --- /dev/null +++ b/Tests/EDFViewerMacTests/EDFReadIntegrationTests.swift @@ -0,0 +1,78 @@ +import XCTest + + +/// Integration tests that will exercise the full read path once the real EDFReader is implemented. +/// For now, they verify the test fixture is accessible and validate the mock reader contract. +final class EDFReadIntegrationTests: XCTestCase { + + private func fixtureURL() -> URL { + let bundle = Bundle(for: type(of: self)) + // Folder reference: Fixtures/test_1ch_1s.edf + if let url = bundle.url(forResource: "test_1ch_1s", withExtension: "edf", subdirectory: "Fixtures") { + return url + } + // Flat resource fallback + return bundle.url(forResource: "test_1ch_1s", withExtension: "edf")! + } + + func testFixtureFileIsReadable() throws { + let url = fixtureURL() + let data = try Data(contentsOf: url) + // EDF header: version is "0" padded to 8 bytes + let version = String(data: data[0..<8], encoding: .ascii)?.trimmingCharacters(in: .whitespaces) + XCTAssertEqual(version, "0") + } + + func testFixtureHeaderFieldsAreValid() throws { + let url = fixtureURL() + let data = try Data(contentsOf: url) + + // Number of signals at offset 252, 4 bytes + let numSignals = String(data: data[252..<256], encoding: .ascii)?.trimmingCharacters(in: .whitespaces) + XCTAssertEqual(numSignals, "1") + + // Duration of data record at offset 244, 8 bytes + let duration = String(data: data[244..<252], encoding: .ascii)?.trimmingCharacters(in: .whitespaces) + XCTAssertEqual(duration, "1.0") + + // Number of data records at offset 236, 8 bytes + let numRecords = String(data: data[236..<244], encoding: .ascii)?.trimmingCharacters(in: .whitespaces) + XCTAssertEqual(numRecords, "1") + } + + func testFixtureSignalLabel() throws { + let url = fixtureURL() + let data = try Data(contentsOf: url) + + // Signal label starts at byte 256 (after 256-byte main header), 16 bytes + let label = String(data: data[256..<272], encoding: .ascii)?.trimmingCharacters(in: .whitespaces) + XCTAssertEqual(label, "EEG Fp1") + } + + func testFixtureDataRecordSize() throws { + let url = fixtureURL() + let data = try Data(contentsOf: url) + + // Header = 256 + 1*256 = 512 bytes + // Data = 256 samples × 2 bytes = 512 bytes + // Total = 1024 bytes + XCTAssertEqual(data.count, 1024) + } + + func testFixtureFirstAndLastSample() throws { + let url = fixtureURL() + let data = try Data(contentsOf: url) + + let headerSize = 512 + // First sample: digital min = -32768 + let first = data.subdata(in: headerSize..<(headerSize + 2)) + .withUnsafeBytes { $0.load(as: Int16.self) } + XCTAssertEqual(first, -32768) + + // Last sample: digital max = 32767 + let lastOffset = headerSize + (255 * 2) + let last = data.subdata(in: lastOffset..<(lastOffset + 2)) + .withUnsafeBytes { $0.load(as: Int16.self) } + XCTAssertEqual(last, 32767) + } +} diff --git a/Tests/EDFViewerMacTests/EDFReaderTests.swift b/Tests/EDFViewerMacTests/EDFReaderTests.swift new file mode 100644 index 0000000..3fe094e --- /dev/null +++ b/Tests/EDFViewerMacTests/EDFReaderTests.swift @@ -0,0 +1,48 @@ +import XCTest + + +final class EDFReaderTests: XCTestCase { + + // MARK: - MockEDFReader baseline tests + + func testMockReaderChannelCount() { + let reader = MockEDFReader(fileURL: URL(fileURLWithPath: "/tmp/fake.edf")) + XCTAssertEqual(reader.channels.count, 3) + } + + func testMockReaderFileDuration() { + let reader = MockEDFReader(fileURL: URL(fileURLWithPath: "/tmp/fake.edf")) + XCTAssertEqual(reader.fileDurationSeconds, 120) + } + + func testMockReaderReadWindow() async throws { + let reader = MockEDFReader(fileURL: URL(fileURLWithPath: "/tmp/fake.edf")) + let window = try await reader.readWindow(channelID: 0, startSeconds: 0, durationSeconds: 1.0) + // 256 Hz × 1 second = 256 samples + XCTAssertEqual(window.samples.count, 256) + XCTAssertEqual(window.startSeconds, 0) + XCTAssertEqual(window.durationSeconds, 1.0) + } + + // MARK: - Real EDFReader stubs (will be filled in when the real reader is implemented) + // These tests use the fixture file at Tests/EDFViewerMacTests/Fixtures/test_1ch_1s.edf + // + // The fixture is a valid EDF file with: + // - 1 channel: "EEG Fp1", 256 Hz, unit "uV" + // - Physical range: -100 to 100 + // - Digital range: -32768 to 32767 + // - 1 data record, 1 second duration + // - Samples: ramp from digital min to digital max + + private func fixtureURL() -> URL { + let bundle = Bundle(for: type(of: self)) + if let url = bundle.url(forResource: "test_1ch_1s", withExtension: "edf", subdirectory: "Fixtures") { + return url + } + return bundle.url(forResource: "test_1ch_1s", withExtension: "edf")! + } + + func testFixtureFileExists() { + XCTAssertNoThrow(fixtureURL(), "Fixture EDF file must be bundled in the test target") + } +} diff --git a/Tests/EDFViewerMacTests/Fixtures/test_1ch_1s.edf b/Tests/EDFViewerMacTests/Fixtures/test_1ch_1s.edf new file mode 100644 index 0000000..8684d56 Binary files /dev/null and b/Tests/EDFViewerMacTests/Fixtures/test_1ch_1s.edf differ diff --git a/Tests/EDFViewerMacTests/SignalProcessingTests.swift b/Tests/EDFViewerMacTests/SignalProcessingTests.swift new file mode 100644 index 0000000..52c46a5 --- /dev/null +++ b/Tests/EDFViewerMacTests/SignalProcessingTests.swift @@ -0,0 +1,55 @@ +import XCTest + + +final class SignalProcessingTests: XCTestCase { + + func testEmptyInput() { + let result = SignalProcessing.downsampleMinMax([], bucketCount: 10) + XCTAssertTrue(result.mins.isEmpty) + XCTAssertTrue(result.maxs.isEmpty) + } + + func testZeroBuckets() { + let result = SignalProcessing.downsampleMinMax([1, 2, 3], bucketCount: 0) + XCTAssertTrue(result.mins.isEmpty) + XCTAssertTrue(result.maxs.isEmpty) + } + + func testSingleSample() { + let result = SignalProcessing.downsampleMinMax([42.0], bucketCount: 5) + XCTAssertEqual(result.mins.count, 1) + XCTAssertEqual(result.mins[0], 42.0) + XCTAssertEqual(result.maxs[0], 42.0) + } + + func testBucketCountGreaterThanSamples() { + let samples: [Float] = [1, 2, 3] + let result = SignalProcessing.downsampleMinMax(samples, bucketCount: 100) + // step = max(1, 3/100) = 1, so one bucket per sample + XCTAssertEqual(result.mins.count, 3) + XCTAssertEqual(result.mins, [1, 2, 3]) + XCTAssertEqual(result.maxs, [1, 2, 3]) + } + + func testKnownMinMax() { + // 8 samples into 2 buckets → step of 4 + let samples: [Float] = [5, 1, 8, 3, 10, 2, 7, 4] + let result = SignalProcessing.downsampleMinMax(samples, bucketCount: 2) + XCTAssertEqual(result.mins.count, 2) + // Bucket 0: [5,1,8,3] → min=1, max=8 + XCTAssertEqual(result.mins[0], 1.0) + XCTAssertEqual(result.maxs[0], 8.0) + // Bucket 1: [10,2,7,4] → min=2, max=10 + XCTAssertEqual(result.mins[1], 2.0) + XCTAssertEqual(result.maxs[1], 10.0) + } + + func testAllSameValues() { + let samples: [Float] = [7, 7, 7, 7] + let result = SignalProcessing.downsampleMinMax(samples, bucketCount: 2) + for i in 0..