diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f60e8d6a7..e79f7024f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,22 +10,22 @@ on: jobs: ios-latest: - name: Unit Tests (iOS 17.4, Xcode 15.3) - runs-on: macOS-14 + name: Unit Tests (iOS 18.0, Xcode 16.0) + runs-on: macOS-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests run: | - .scripts/test.sh -s "Nuke" -d "OS=17.4,name=iPhone 15 Pro" - .scripts/test.sh -s "NukeUI" -d "OS=17.4,name=iPhone 15 Pro" - .scripts/test.sh -s "NukeExtensions" -d "OS=17.4,name=iPhone 15 Pro" + .scripts/test.sh -s "Nuke" -d "OS=18.0,name=iPhone 16 Pro" + .scripts/test.sh -s "NukeUI" -d "OS=18.0,name=iPhone 16 Pro" + .scripts/test.sh -s "NukeExtensions" -d "OS=18.0,name=iPhone 16 Pro" macos-latest: - name: Unit Tests (macOS, Xcode 15.3) - runs-on: macOS-14 + name: Unit Tests (macOS, Xcode 16.0) + runs-on: macOS-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests @@ -34,17 +34,17 @@ jobs: .scripts/test.sh -s "NukeUI" -d "platform=macOS" .scripts/test.sh -s "NukeExtensions" -d "platform=macOS" tvos-latest: - name: Unit Tests (tvOS 17.4, Xcode 15.3) - runs-on: macOS-14 + name: Unit Tests (tvOS 18.0, Xcode 16.0) + runs-on: macOS-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests run: | - .scripts/test.sh -s "Nuke" -d "OS=17.4,name=Apple TV" - .scripts/test.sh -s "NukeUI" -d "OS=17.4,name=Apple TV" - .scripts/test.sh -s "NukeExtensions" -d "OS=17.4,name=Apple TV" + .scripts/test.sh -s "Nuke" -d "OS=18.0,name=Apple TV" + .scripts/test.sh -s "NukeUI" -d "OS=18.0,name=Apple TV" + .scripts/test.sh -s "NukeExtensions" -d "OS=18.0,name=Apple TV" # There is a problem with watchOS runners where they often fail to launch on CI # # watchos-latest: @@ -59,27 +59,30 @@ jobs: # .scripts/test.sh -s "Nuke" -d "OS=9.1,name=Apple Watch Series 8 (45mm)" # .scripts/test.sh -s "NukeUI" -d "OS=9.1,name=Apple Watch Series 8 (45mm)" # .scripts/test.sh -s "Nuke Extensions" -d "OS=9.1,name=Apple Watch Series 8 (45mm)" - ios-xcode-14-3-1: - name: Unit Tests (iOS 17.0, Xcode 15.0) - runs-on: macOS-13 - env: - DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer - steps: - - uses: actions/checkout@v2 - - name: Run Tests - run: | - .scripts/test.sh -s "Nuke" -d "OS=17.0,name=iPhone 15 Pro" - .scripts/test.sh -s "NukeUI" -d "OS=17.0,name=iPhone 15 Pro" - .scripts/test.sh -s "NukeExtensions" -d "OS=17.0,name=iPhone 15 Pro" + +# Nuke 13.0 supports only the latest version of Xcode (16). +# +# ios-xcode-14-3-1: +# name: Unit Tests (iOS 17.0, Xcode 15.0) +# runs-on: macOS-13 +# env: +# DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer +# steps: +# - uses: actions/checkout@v2 +# - name: Run Tests +# run: | +# .scripts/test.sh -s "Nuke" -d "OS=17.0,name=iPhone 15 Pro" +# .scripts/test.sh -s "NukeUI" -d "OS=17.0,name=iPhone 15 Pro" +# .scripts/test.sh -s "NukeExtensions" -d "OS=17.0,name=iPhone 15 Pro" ios-thread-safety: name: Thread Safety Tests (TSan Enabled) - runs-on: macOS-14 + runs-on: macOS-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests - run: .scripts/test.sh -s "Nuke Thread Safety Tests" -d "OS=17.4,name=iPhone 15 Pro" + run: .scripts/test.sh -s "Nuke Thread Safety Tests" -d "OS=18.0,name=iPhone 16 Pro" # ios-memory-management-tests: # name: Memory Management Tests # runs-on: macOS-13 @@ -91,18 +94,18 @@ jobs: # run: .scripts/test.sh -s "Nuke Memory Management Tests" -d "OS=14.4,name=iPhone 12 Pro" ios-performance-tests: name: Performance Tests - runs-on: macOS-14 + runs-on: macOS-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Run Tests - run: .scripts/test.sh -s "Nuke Performance Tests" -d "OS=17.4,name=iPhone 15 Pro" + run: .scripts/test.sh -s "Nuke Performance Tests" -d "OS=18.0,name=iPhone 16 Pro" swift-build: name: Swift Build (SPM) - runs-on: macOS-14 + runs-on: macOS-15 env: - DEVELOPER_DIR: /Applications/Xcode_15.3.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_16.0.app/Contents/Developer steps: - uses: actions/checkout@v2 - name: Build diff --git a/Documentation/Nuke.docc/Extensions/ImageRequest-Extension.md b/Documentation/Nuke.docc/Extensions/ImageRequest-Extension.md index 3dcc415ce..5ce467e3e 100644 --- a/Documentation/Nuke.docc/Extensions/ImageRequest-Extension.md +++ b/Documentation/Nuke.docc/Extensions/ImageRequest-Extension.md @@ -17,7 +17,6 @@ request.processors = [.resize(width: 320)] - ``init(url:processors:priority:options:userInfo:)`` - ``init(urlRequest:processors:priority:options:userInfo:)`` - ``init(id:data:processors:priority:options:userInfo:)`` -- ``init(id:dataPublisher:processors:priority:options:userInfo:)`` - ``init(stringLiteral:)`` ### Options diff --git a/Nuke.xcodeproj/project.pbxproj b/Nuke.xcodeproj/project.pbxproj index 8d53148ba..bddc5a873 100644 --- a/Nuke.xcodeproj/project.pbxproj +++ b/Nuke.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 0C09B1661FE9A65700E8FE3B /* fixture.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C09B1651FE9A65600E8FE3B /* fixture.jpeg */; }; 0C09B1691FE9A65700E8FE3B /* fixture.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C09B1651FE9A65600E8FE3B /* fixture.jpeg */; }; 0C09B16F1FE9A6D800E8FE3B /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068D1BCA888800089D7F /* Helpers.swift */; }; - 0C0F7BF12287F6EE0034E656 /* TaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0F7BF02287F6EE0034E656 /* TaskTests.swift */; }; 0C0FD5E01CA47FE1002A78FB /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0FD5D01CA47FE1002A78FB /* DataLoader.swift */; }; 0C0FD5EC1CA47FE1002A78FB /* ImagePipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0FD5D31CA47FE1002A78FB /* ImagePipeline.swift */; }; 0C0FD5FC1CA47FE1002A78FB /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0FD5D71CA47FE1002A78FB /* ImageCache.swift */; }; @@ -20,10 +19,14 @@ 0C0FD6041CA47FE1002A78FB /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0FD5D91CA47FE1002A78FB /* ImageRequest.swift */; }; 0C1453A02657EFA7005E24B3 /* ImagePipelineObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C14539F2657EFA7005E24B3 /* ImagePipelineObserver.swift */; }; 0C1453A12657EFA7005E24B3 /* ImagePipelineObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C14539F2657EFA7005E24B3 /* ImagePipelineObserver.swift */; }; + 0C16C85F2C7150C800B2A560 /* ImagePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C88C578263DAF1E0061A008 /* ImagePublisherTests.swift */; }; + 0C16C8632C726B1B00B2A560 /* ImagePipeline+Closures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C16C8622C726B1B00B2A560 /* ImagePipeline+Closures.swift */; }; 0C179C7B2283597F008AB488 /* ImageEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C179C7A2283597F008AB488 /* ImageEncoding.swift */; }; 0C1B9880294E28D800C09310 /* Nuke.docc in Sources */ = {isa = PBXBuildFile; fileRef = 0C1B987F294E28D800C09310 /* Nuke.docc */; }; 0C1C201D29ABBF19004B38FD /* Nuke.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; }; 0C1C201E29ABBF19004B38FD /* Nuke.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0C9174901BAE99EE004A7905 /* Nuke.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 0C1D2D5E2C714FF900BB81B3 /* ImagePipeline+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1D2D5D2C714FF900BB81B3 /* ImagePipeline+Combine.swift */; }; + 0C1D2D5F2C71505900BB81B3 /* ImagePipelinePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE6202426543EC700AAB8C3 /* ImagePipelinePublisherTests.swift */; }; 0C1E620B1D6F817700AD5CF5 /* ImageRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1E620A1D6F817700AD5CF5 /* ImageRequestTests.swift */; }; 0C1ECA421D526461009063A9 /* ImageCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C06871BCA888800089D7F /* ImageCacheTests.swift */; }; 0C222DE3294E2DEA00012288 /* NukeUI.docc in Sources */ = {isa = PBXBuildFile; fileRef = 0C222DE2294E2DEA00012288 /* NukeUI.docc */; }; @@ -108,7 +111,6 @@ 0C64F73D2438371A001983C6 /* img_751.heic in Resources */ = {isa = PBXBuildFile; fileRef = 0C64F73C243836B5001983C6 /* img_751.heic */; }; 0C64F73F243838BF001983C6 /* swift.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C64F73E243838BF001983C6 /* swift.png */; }; 0C68F609208A1F40007DC696 /* ImageDecoderRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68F608208A1F40007DC696 /* ImageDecoderRegistryTests.swift */; }; - 0C69FA4E1D4E222D00DA9982 /* ImagePrefetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD195291D4348AC00E011BB /* ImagePrefetcherTests.swift */; }; 0C6B5BDB257010B400D763F2 /* image-p3.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 0C6B5BDA257010B400D763F2 /* image-p3.jpg */; }; 0C6B5BE1257010D300D763F2 /* ImagePipelineFormatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C6B5BE0257010D300D763F2 /* ImagePipelineFormatsTests.swift */; }; 0C6CF0CD1DAF789C007B8C0E /* XCTestCaseExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7C068E1BCA888800089D7F /* XCTestCaseExtensions.swift */; }; @@ -144,7 +146,8 @@ 0C8684FF20BDD578009FF7CC /* ImagePipelineProgressiveDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C2A8CFA20970D8D0013FD65 /* ImagePipelineProgressiveDecodingTests.swift */; }; 0C86AB6A228B3B5100A81BA1 /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C86AB69228B3B5100A81BA1 /* ImageTask.swift */; }; 0C880532242E7B1500F8C5B3 /* ImagePipelineDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C880531242E7B1500F8C5B3 /* ImagePipelineDecodingTests.swift */; }; - 0C88C579263DAF1E0061A008 /* ImagePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C88C578263DAF1E0061A008 /* ImagePublisherTests.swift */; }; + 0C8C614D2CCD760C00532008 /* ImagePrefetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD195291D4348AC00E011BB /* ImagePrefetcherTests.swift */; }; + 0C8C614E2CCD8D4500532008 /* TaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0F7BF02287F6EE0034E656 /* TaskTests.swift */; }; 0C8D7BD31D9DBF1600D12EB7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8D7BD21D9DBF1600D12EB7 /* AppDelegate.swift */; }; 0C8D7BD51D9DBF1600D12EB7 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C8D7BD41D9DBF1600D12EB7 /* ViewController.swift */; }; 0C8D7BD81D9DBF1600D12EB7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0C8D7BD61D9DBF1600D12EB7 /* Main.storyboard */; }; @@ -188,14 +191,12 @@ 0CA4ECCD26E68FA100BAC8E5 /* DataLoading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECCC26E68FA100BAC8E5 /* DataLoading.swift */; }; 0CA4ECD026E68FC000BAC8E5 /* DataCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECCF26E68FC000BAC8E5 /* DataCaching.swift */; }; 0CA4ECD326E68FDC00BAC8E5 /* ImageCaching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA4ECD226E68FDC00BAC8E5 /* ImageCaching.swift */; }; - 0CA5D954263CCEA500E08E17 /* ImagePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA5D953263CCEA500E08E17 /* ImagePublisher.swift */; }; - 0CA8D8ED2958DA3700EDAA2C /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA8D8EC2958DA3700EDAA2C /* Atomic.swift */; }; + 0CA8D8ED2958DA3700EDAA2C /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA8D8EC2958DA3700EDAA2C /* Mutex.swift */; }; 0CAAB0101E45D6DA00924450 /* NukeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAAB00F1E45D6DA00924450 /* NukeExtensions.swift */; }; 0CAAB0131E45D6DA00924450 /* NukeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAAB00F1E45D6DA00924450 /* NukeExtensions.swift */; }; 0CB0479A2856D9AC00DF9B6D /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB047992856D9AC00DF9B6D /* Cache.swift */; }; 0CB26802208F2565004C83F4 /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB26801208F2565004C83F4 /* DataCache.swift */; }; 0CB26807208F25C2004C83F4 /* DataCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB26806208F25C2004C83F4 /* DataCacheTests.swift */; }; - 0CB2EFD22110F38600F7C63F /* ImagePipelineConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB2EFD12110F38600F7C63F /* ImagePipelineConfigurationTests.swift */; }; 0CB2EFD62110F52C00F7C63F /* RateLimiterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB2EFD52110F52C00F7C63F /* RateLimiterTests.swift */; }; 0CB402D525B6569700F5A241 /* TaskFetchOriginalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB402D425B6569700F5A241 /* TaskFetchOriginalData.swift */; }; 0CB402DB25B656D200F5A241 /* TaskFetchOriginalImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB402DA25B656D200F5A241 /* TaskFetchOriginalImage.swift */; }; @@ -231,6 +232,8 @@ 0CB644C92856807F00916267 /* fixture.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0C09B1651FE9A65600E8FE3B /* fixture.jpeg */; }; 0CB644CA2856807F00916267 /* swift.png in Resources */ = {isa = PBXBuildFile; fileRef = 0C64F73E243838BF001983C6 /* swift.png */; }; 0CBA07862852DA8B00CE29F4 /* ImagePipeline+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBA07852852DA8B00CE29F4 /* ImagePipeline+Error.swift */; }; + 0CC04B0A2C5698D500F1164D /* ImagePipelineActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC04B092C5698D500F1164D /* ImagePipelineActor.swift */; }; + 0CC04B1C2C56AEF000F1164D /* ImagePipelineLoadDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD37C9925BA36D5006C2C36 /* ImagePipelineLoadDataTests.swift */; }; 0CC36A1925B8BC2500811018 /* RateLimiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC36A1825B8BC2500811018 /* RateLimiter.swift */; }; 0CC36A2525B8BC4900811018 /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC36A2425B8BC4900811018 /* Operation.swift */; }; 0CC36A2C25B8BC6300811018 /* LinkedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC36A2B25B8BC6300811018 /* LinkedList.swift */; }; @@ -243,14 +246,11 @@ 0CC6279E25C100E300466F04 /* ImageCachePerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC6279D25C100E300466F04 /* ImageCachePerformanceTests.swift */; }; 0CC627A525C100FA00466F04 /* ImageProcessingPerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC627A425C100FA00466F04 /* ImageProcessingPerformanceTests.swift */; }; 0CCBB534217D0B980026F552 /* MockProgressiveDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCBB533217D0B980026F552 /* MockProgressiveDataLoader.swift */; }; - 0CD37C9A25BA36D5006C2C36 /* ImagePipelineLoadDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD37C9925BA36D5006C2C36 /* ImagePipelineLoadDataTests.swift */; }; 0CE2D9BA2084FDDD00934B28 /* ImageDecoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE2D9B92084FDDD00934B28 /* ImageDecoding.swift */; }; 0CE334DB2724563D0017BB8D /* ImageProcessorsProtocolExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE334DA2724563D0017BB8D /* ImageProcessorsProtocolExtensionsTests.swift */; }; 0CE3992D1D4697CE00A87D47 /* ImagePipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE3992C1D4697CE00A87D47 /* ImagePipelineTests.swift */; }; 0CE5F6832156386B0046609F /* ResumableDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE5F681215638300046609F /* ResumableDataTests.swift */; }; - 0CE6202126542F7200AAB8C3 /* DataPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE6202026542F7200AAB8C3 /* DataPublisher.swift */; }; - 0CE6202326543B6A00AAB8C3 /* TaskFetchWithPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE6202226543B6A00AAB8C3 /* TaskFetchWithPublisher.swift */; }; - 0CE6202526543EC700AAB8C3 /* ImagePipelinePublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE6202426543EC700AAB8C3 /* ImagePipelinePublisherTests.swift */; }; + 0CE6202326543B6A00AAB8C3 /* TaskFetchWithClosure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE6202226543B6A00AAB8C3 /* TaskFetchWithClosure.swift */; }; 0CE6202726546FD100AAB8C3 /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE6202626546FD100AAB8C3 /* CombineExtensions.swift */; }; 0CE745751D4767B900123F65 /* MockImageDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE745741D4767B900123F65 /* MockImageDecoder.swift */; }; 0CF1754C22913F9800A8946E /* ImagePipeline+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF1754B22913F9800A8946E /* ImagePipeline+Configuration.swift */; }; @@ -263,7 +263,6 @@ 0CF5456B25B39A0E00B45F1E /* right-orientation.jpeg in Resources */ = {isa = PBXBuildFile; fileRef = 0CF5456A25B39A0E00B45F1E /* right-orientation.jpeg */; }; 0CF58FF726DAAC3800D2650D /* ImageDownsampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF58FF626DAAC3800D2650D /* ImageDownsampleTests.swift */; }; 2DFD93B0233A6AB300D84DB9 /* ImagePipelineProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFD93AF233A6AB300D84DB9 /* ImagePipelineProcessorTests.swift */; }; - 4480674C2A448C9F00DE7CF8 /* DataPublisherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4480674B2A448C9F00DE7CF8 /* DataPublisherTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -354,9 +353,11 @@ 0C0FD5D81CA47FE1002A78FB /* ImageProcessing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageProcessing.swift; sourceTree = ""; }; 0C0FD5D91CA47FE1002A78FB /* ImageRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = ""; }; 0C14539F2657EFA7005E24B3 /* ImagePipelineObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineObserver.swift; sourceTree = ""; }; + 0C16C8622C726B1B00B2A560 /* ImagePipeline+Closures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Closures.swift"; sourceTree = ""; }; 0C179C772282AC50008AB488 /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text; path = .swiftlint.yml; sourceTree = ""; }; 0C179C7A2283597F008AB488 /* ImageEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageEncoding.swift; sourceTree = ""; }; 0C1B987F294E28D800C09310 /* Nuke.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Nuke.docc; sourceTree = ""; }; + 0C1D2D5D2C714FF900BB81B3 /* ImagePipeline+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Combine.swift"; sourceTree = ""; }; 0C1E620A1D6F817700AD5CF5 /* ImageRequestTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageRequestTests.swift; sourceTree = ""; }; 0C222DE2294E2DEA00012288 /* NukeUI.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = NukeUI.docc; sourceTree = ""; }; 0C222DE4294E2E0200012288 /* NukeExtensions.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = NukeExtensions.docc; sourceTree = ""; }; @@ -477,13 +478,11 @@ 0CA4ECCC26E68FA100BAC8E5 /* DataLoading.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoading.swift; sourceTree = ""; }; 0CA4ECCF26E68FC000BAC8E5 /* DataCaching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCaching.swift; sourceTree = ""; }; 0CA4ECD226E68FDC00BAC8E5 /* ImageCaching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCaching.swift; sourceTree = ""; }; - 0CA5D953263CCEA500E08E17 /* ImagePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePublisher.swift; sourceTree = ""; }; - 0CA8D8EC2958DA3700EDAA2C /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + 0CA8D8EC2958DA3700EDAA2C /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = ""; }; 0CAAB00F1E45D6DA00924450 /* NukeExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NukeExtensions.swift; sourceTree = ""; }; 0CB047992856D9AC00DF9B6D /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; 0CB26801208F2565004C83F4 /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; 0CB26806208F25C2004C83F4 /* DataCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCacheTests.swift; sourceTree = ""; }; - 0CB2EFD12110F38600F7C63F /* ImagePipelineConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineConfigurationTests.swift; sourceTree = ""; }; 0CB2EFD52110F52C00F7C63F /* RateLimiterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimiterTests.swift; sourceTree = ""; }; 0CB402D425B6569700F5A241 /* TaskFetchOriginalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalData.swift; sourceTree = ""; }; 0CB402DA25B656D200F5A241 /* TaskFetchOriginalImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchOriginalImage.swift; sourceTree = ""; }; @@ -492,6 +491,7 @@ 0CB6449B28567E5400916267 /* ImageViewLoadingOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewLoadingOptionsTests.swift; sourceTree = ""; }; 0CB644AA28567EEA00916267 /* ImageViewExtensionsProgressiveDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewExtensionsProgressiveDecodingTests.swift; sourceTree = ""; }; 0CBA07852852DA8B00CE29F4 /* ImagePipeline+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImagePipeline+Error.swift"; sourceTree = ""; }; + 0CC04B092C5698D500F1164D /* ImagePipelineActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineActor.swift; sourceTree = ""; }; 0CC36A1825B8BC2500811018 /* RateLimiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimiter.swift; sourceTree = ""; }; 0CC36A2425B8BC4900811018 /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; 0CC36A2B25B8BC6300811018 /* LinkedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedList.swift; sourceTree = ""; }; @@ -517,8 +517,7 @@ 0CE5F681215638300046609F /* ResumableDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumableDataTests.swift; sourceTree = ""; }; 0CE5F78720A22ABF00BC3283 /* Nuke 6 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 6 Migration Guide.md"; sourceTree = ""; }; 0CE5F78820A22ABF00BC3283 /* Nuke 7 Migration Guide.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Nuke 7 Migration Guide.md"; sourceTree = ""; }; - 0CE6202026542F7200AAB8C3 /* DataPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPublisher.swift; sourceTree = ""; }; - 0CE6202226543B6A00AAB8C3 /* TaskFetchWithPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchWithPublisher.swift; sourceTree = ""; }; + 0CE6202226543B6A00AAB8C3 /* TaskFetchWithClosure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFetchWithClosure.swift; sourceTree = ""; }; 0CE6202426543EC700AAB8C3 /* ImagePipelinePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelinePublisherTests.swift; sourceTree = ""; }; 0CE6202626546FD100AAB8C3 /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = ""; }; 0CE745741D4767B900123F65 /* MockImageDecoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockImageDecoder.swift; sourceTree = ""; }; @@ -528,7 +527,6 @@ 0CF5456A25B39A0E00B45F1E /* right-orientation.jpeg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "right-orientation.jpeg"; sourceTree = ""; }; 0CF58FF626DAAC3800D2650D /* ImageDownsampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownsampleTests.swift; sourceTree = ""; }; 2DFD93AF233A6AB300D84DB9 /* ImagePipelineProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePipelineProcessorTests.swift; sourceTree = ""; }; - 4480674B2A448C9F00DE7CF8 /* DataPublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPublisherTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -682,6 +680,7 @@ children = ( 0C55FD1B28567926000FD2C9 /* ImageLoadingOptions.swift */, 0C55FD1C28567926000FD2C9 /* ImageViewExtensions.swift */, + 0C1D2D5D2C714FF900BB81B3 /* ImagePipeline+Combine.swift */, ); path = NukeExtensions; sourceTree = ""; @@ -690,7 +689,6 @@ isa = PBXGroup; children = ( 0C1E620A1D6F817700AD5CF5 /* ImageRequestTests.swift */, - 4480674B2A448C9F00DE7CF8 /* DataPublisherTests.swift */, 0C7C06871BCA888800089D7F /* ImageCacheTests.swift */, 0C70D9772089017500A49DAC /* ImageDecoderTests.swift */, 0C68F608208A1F40007DC696 /* ImageDecoderRegistryTests.swift */, @@ -701,7 +699,6 @@ 0C4AF1E91FE8551D002F86CB /* LinkedListTest.swift */, 0CB2EFD52110F52C00F7C63F /* RateLimiterTests.swift */, 0C0F7BF02287F6EE0034E656 /* TaskTests.swift */, - 0C88C578263DAF1E0061A008 /* ImagePublisherTests.swift */, 0C472F802654AA46007FC0F0 /* DeprecationTests.swift */, 0C91B0E82438E245007F9100 /* ImagePipelineTests */, 0C91B0EA2438E269007F9100 /* ImageProcessorsTests */, @@ -714,6 +711,8 @@ children = ( 0CCBB52F217D0B6A0026F552 /* ImageViewIntegrationTests.swift */, 0C94466D1D47EC0E006DB314 /* ImageViewExtensionsTests.swift */, + 0CE6202426543EC700AAB8C3 /* ImagePipelinePublisherTests.swift */, + 0C88C578263DAF1E0061A008 /* ImagePublisherTests.swift */, 0CB6449B28567E5400916267 /* ImageViewLoadingOptionsTests.swift */, 0CB6449928567DE000916267 /* NukeExtensionsTestsHelpers.swift */, 0CB644AA28567EEA00916267 /* ImageViewExtensionsProgressiveDecodingTests.swift */, @@ -824,7 +823,6 @@ 0CC6271425BDF7A100466F04 /* ImagePipelineImageCacheTests.swift */, 0C6D0A8B20E57C810037B68F /* ImagePipelineDataCacheTests.swift */, 0C9B6E7520B9F3E2001924B8 /* ImagePipelineCoalescingTests.swift */, - 0CB2EFD12110F38600F7C63F /* ImagePipelineConfigurationTests.swift */, 0C2A8CF620970B790013FD65 /* ImagePipelineResumableDataTests.swift */, 0C2A8CFA20970D8D0013FD65 /* ImagePipelineProgressiveDecodingTests.swift */, 2DFD93AF233A6AB300D84DB9 /* ImagePipelineProcessorTests.swift */, @@ -833,7 +831,6 @@ 0CD37C9925BA36D5006C2C36 /* ImagePipelineLoadDataTests.swift */, 0C53C8AE263C7B1700E62D03 /* ImagePipelineDelegateTests.swift */, 0CA3BA62285C11EA0079A444 /* ImagePipelineTaskDelegateTests.swift */, - 0CE6202426543EC700AAB8C3 /* ImagePipelinePublisherTests.swift */, 0C5D5A9C2724773A0056B95B /* ImagePipelineAsyncAwaitTests.swift */, 0C78A2A8263F560A0051E0FF /* ImagePipelineCacheTests.swift */, 0C967EB228688B3F0050E083 /* DocumentationTests.swift */, @@ -950,7 +947,7 @@ 0C2A368A26437BF100F1D000 /* TaskLoadData.swift */, 0CB402DA25B656D200F5A241 /* TaskFetchOriginalImage.swift */, 0CB402D425B6569700F5A241 /* TaskFetchOriginalData.swift */, - 0CE6202226543B6A00AAB8C3 /* TaskFetchWithPublisher.swift */, + 0CE6202226543B6A00AAB8C3 /* TaskFetchWithClosure.swift */, ); path = Tasks; sourceTree = ""; @@ -963,6 +960,8 @@ 0C53C8B0263C968200E62D03 /* ImagePipeline+Delegate.swift */, 0C78A2A6263F4E680051E0FF /* ImagePipeline+Cache.swift */, 0CBA07852852DA8B00CE29F4 /* ImagePipeline+Error.swift */, + 0C16C8622C726B1B00B2A560 /* ImagePipeline+Closures.swift */, + 0CC04B092C5698D500F1164D /* ImagePipelineActor.swift */, ); path = Pipeline; sourceTree = ""; @@ -997,9 +996,7 @@ 0CC36A3225B8BC7900811018 /* ResumableData.swift */, 0CC36A4025B8BCAC00811018 /* Log.swift */, 0C7150081FC9724C00B880AC /* Extensions.swift */, - 0CA8D8EC2958DA3700EDAA2C /* Atomic.swift */, - 0CE6202026542F7200AAB8C3 /* DataPublisher.swift */, - 0CA5D953263CCEA500E08E17 /* ImagePublisher.swift */, + 0CA8D8EC2958DA3700EDAA2C /* Mutex.swift */, 0C472F822654AD69007FC0F0 /* ImageRequestKeys.swift */, ); path = Internal; @@ -1573,6 +1570,7 @@ 0C55FD1F28567926000FD2C9 /* ImageViewExtensions.swift in Sources */, 0C222DE5294E2E0300012288 /* NukeExtensions.docc in Sources */, 0C55FD1E28567926000FD2C9 /* ImageLoadingOptions.swift in Sources */, + 0C1D2D5E2C714FF900BB81B3 /* ImagePipeline+Combine.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1580,6 +1578,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0C16C85F2C7150C800B2A560 /* ImagePublisherTests.swift in Sources */, 0CB6449828567DCA00916267 /* CombineExtensions.swift in Sources */, 0CB6449728567DCA00916267 /* NukeExtensions.swift in Sources */, 0C55FD2728567C12000FD2C9 /* ImageViewExtensionsTests.swift in Sources */, @@ -1598,6 +1597,7 @@ 0CB6448C28567DC300916267 /* MockDataCache.swift in Sources */, 0CB6449628567DCA00916267 /* XCTestCaseExtensions.swift in Sources */, 0CB6448F28567DC300916267 /* MockImageEncoder.swift in Sources */, + 0C1D2D5F2C71505900BB81B3 /* ImagePipelinePublisherTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1616,10 +1616,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4480674C2A448C9F00DE7CF8 /* DataPublisherTests.swift in Sources */, - 0CD37C9A25BA36D5006C2C36 /* ImagePipelineLoadDataTests.swift in Sources */, + 0CC04B1C2C56AEF000F1164D /* ImagePipelineLoadDataTests.swift in Sources */, 0C75279F1D473AEF00EC6222 /* MockImageProcessor.swift in Sources */, - 0C69FA4E1D4E222D00DA9982 /* ImagePrefetcherTests.swift in Sources */, 0CB26807208F25C2004C83F4 /* DataCacheTests.swift in Sources */, 0C880532242E7B1500F8C5B3 /* ImagePipelineDecodingTests.swift in Sources */, 0C91B0F02438E352007F9100 /* RoundedCornersTests.swift in Sources */, @@ -1628,8 +1626,6 @@ 0CAAB0101E45D6DA00924450 /* NukeExtensions.swift in Sources */, 0CE745751D4767B900123F65 /* MockImageDecoder.swift in Sources */, 0C70D9782089017500A49DAC /* ImageDecoderTests.swift in Sources */, - 0C88C579263DAF1E0061A008 /* ImagePublisherTests.swift in Sources */, - 0CB2EFD22110F38600F7C63F /* ImagePipelineConfigurationTests.swift in Sources */, 0C7082612640521900C62638 /* MockImageEncoder.swift in Sources */, 0CE6202726546FD100AAB8C3 /* CombineExtensions.swift in Sources */, 0C6B5BE1257010D300D763F2 /* ImagePipelineFormatsTests.swift in Sources */, @@ -1639,6 +1635,7 @@ 0C75279E1D473AEF00EC6222 /* MockImageCache.swift in Sources */, 0CCBB534217D0B980026F552 /* MockProgressiveDataLoader.swift in Sources */, 0C7CE28F24393ACC0018C8C3 /* CoreImageFilterTests.swift in Sources */, + 0C8C614D2CCD760C00532008 /* ImagePrefetcherTests.swift in Sources */, 0C53C8AF263C7B1700E62D03 /* ImagePipelineDelegateTests.swift in Sources */, 0C1453A02657EFA7005E24B3 /* ImagePipelineObserver.swift in Sources */, 0C1E620B1D6F817700AD5CF5 /* ImageRequestTests.swift in Sources */, @@ -1655,6 +1652,7 @@ 0C91B0F42438E38B007F9100 /* CompositionTests.swift in Sources */, 0C91B0F62438E3CB007F9100 /* GaussianBlurTests.swift in Sources */, 0C6D0A8820E574400037B68F /* MockDataCache.swift in Sources */, + 0C8C614E2CCD8D4500532008 /* TaskTests.swift in Sources */, 0C472F812654AA46007FC0F0 /* DeprecationTests.swift in Sources */, 0C9B6E7620B9F3E2001924B8 /* ImagePipelineCoalescingTests.swift in Sources */, 0C91B0F22438E374007F9100 /* AnonymousTests.swift in Sources */, @@ -1666,9 +1664,7 @@ 0CA3BA63285C11EA0079A444 /* ImagePipelineTaskDelegateTests.swift in Sources */, 0C6D0A8C20E57C810037B68F /* ImagePipelineDataCacheTests.swift in Sources */, 0C68F609208A1F40007DC696 /* ImageDecoderRegistryTests.swift in Sources */, - 0CE6202526543EC700AAB8C3 /* ImagePipelinePublisherTests.swift in Sources */, 0C91B0EE2438E307007F9100 /* CircleTests.swift in Sources */, - 0C0F7BF12287F6EE0034E656 /* TaskTests.swift in Sources */, 0CC6271525BDF7A100466F04 /* ImagePipelineImageCacheTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1717,7 +1713,6 @@ 0CC36A2C25B8BC6300811018 /* LinkedList.swift in Sources */, 0C179C7B2283597F008AB488 /* ImageEncoding.swift in Sources */, 0CB4030125B6639200F5A241 /* TaskLoadImage.swift in Sources */, - 0CA5D954263CCEA500E08E17 /* ImagePublisher.swift in Sources */, 0CA4ECCD26E68FA100BAC8E5 /* DataLoading.swift in Sources */, 0CA4ECAF26E683FD00BAC8E5 /* ImageEncoders+Default.swift in Sources */, 0CC36A2525B8BC4900811018 /* Operation.swift in Sources */, @@ -1725,6 +1720,7 @@ 0C78A2A7263F4E680051E0FF /* ImagePipeline+Cache.swift in Sources */, 0CA4ECD026E68FC000BAC8E5 /* DataCaching.swift in Sources */, 0CA4ECCA26E6868300BAC8E5 /* ImageProcessingOptions.swift in Sources */, + 0CC04B0A2C5698D500F1164D /* ImagePipelineActor.swift in Sources */, 0C53C8B1263C968200E62D03 /* ImagePipeline+Delegate.swift in Sources */, 0CA4ECBC26E6856300BAC8E5 /* ImageDecompression.swift in Sources */, 0CA4ECD326E68FDC00BAC8E5 /* ImageCaching.swift in Sources */, @@ -1732,20 +1728,20 @@ 0CA4ECC826E6864D00BAC8E5 /* ImageProcessors+RoundedCorners.swift in Sources */, 0CB402DB25B656D200F5A241 /* TaskFetchOriginalImage.swift in Sources */, 0C472F842654AD88007FC0F0 /* ImageRequestKeys.swift in Sources */, - 0CE6202126542F7200AAB8C3 /* DataPublisher.swift in Sources */, 0CB0479A2856D9AC00DF9B6D /* Cache.swift in Sources */, 0CA4ECB626E6846800BAC8E5 /* ImageProcessors+Resize.swift in Sources */, 0C1B9880294E28D800C09310 /* Nuke.docc in Sources */, 0CC36A3325B8BC7900811018 /* ResumableData.swift in Sources */, - 0CE6202326543B6A00AAB8C3 /* TaskFetchWithPublisher.swift in Sources */, + 0CE6202326543B6A00AAB8C3 /* TaskFetchWithClosure.swift in Sources */, 0CA4ECC426E685F500BAC8E5 /* ImageProcessors+GaussianBlur.swift in Sources */, 0CA4EC9B26E67D3000BAC8E5 /* ImageDecoders+Empty.swift in Sources */, 0CB26802208F2565004C83F4 /* DataCache.swift in Sources */, + 0C16C8632C726B1B00B2A560 /* ImagePipeline+Closures.swift in Sources */, 0CA4EC9F26E67D6200BAC8E5 /* ImageDecoderRegistry.swift in Sources */, 0CA4ECBA26E6850B00BAC8E5 /* Graphics.swift in Sources */, 0CA4ECB426E6844B00BAC8E5 /* ImageProcessors.swift in Sources */, 0C2A368B26437BF100F1D000 /* TaskLoadData.swift in Sources */, - 0CA8D8ED2958DA3700EDAA2C /* Atomic.swift in Sources */, + 0CA8D8ED2958DA3700EDAA2C /* Mutex.swift in Sources */, 0C0FD6041CA47FE1002A78FB /* ImageRequest.swift in Sources */, 0CA4EC9926E67CEC00BAC8E5 /* ImageDecoders+Default.swift in Sources */, 0CA4ECC226E685E100BAC8E5 /* ImageProcessors+Composition.swift in Sources */, @@ -1841,7 +1837,6 @@ OTHER_SWIFT_FLAGS = "-D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nukeui; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -1861,7 +1856,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nukeui; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Release; }; @@ -1879,6 +1873,7 @@ OTHER_SWIFT_FLAGS = "-D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.nukeui-unit-tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -1895,6 +1890,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.nukeui-unit-tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -1911,6 +1907,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.Nuke-Thread-Safety-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -1926,6 +1923,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.Nuke-Thread-Safety-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -1946,7 +1944,6 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.nuke-extensions"; PRODUCT_NAME = NukeExtensions; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -1966,7 +1963,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.nuke-extensions"; PRODUCT_NAME = NukeExtensions; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Release; }; @@ -1988,6 +1984,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.NukeExtensionsTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -2008,6 +2005,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.NukeExtensionsTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -2028,7 +2026,6 @@ OTHER_SWIFT_FLAGS = "-D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nukevideo; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -2048,7 +2045,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nukevideo; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Release; }; @@ -2065,6 +2061,8 @@ OTHER_SWIFT_FLAGS = "-D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.Nuke-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -2080,6 +2078,8 @@ ); PRODUCT_BUNDLE_IDENTIFIER = "com.github.kean.Nuke-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 5.0; }; name = Release; }; @@ -2144,6 +2144,7 @@ PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Nuke Tests Host.app/Nuke Tests Host"; }; name = Debug; @@ -2165,6 +2166,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Nuke Tests Host.app/Nuke Tests Host"; }; name = Release; @@ -2226,7 +2228,7 @@ SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; SUPPORTS_MACCATALYST = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TVOS_DEPLOYMENT_TARGET = 13.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -2283,7 +2285,7 @@ SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "watchsimulator watchos macosx iphonesimulator iphoneos driverkit appletvsimulator appletvos"; SUPPORTS_MACCATALYST = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TVOS_DEPLOYMENT_TARGET = 13.0; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; @@ -2309,7 +2311,6 @@ OTHER_SWIFT_FLAGS = "-D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nuke; PRODUCT_NAME = Nuke; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; @@ -2329,7 +2330,6 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.github.kean.nuke; PRODUCT_NAME = Nuke; - SWIFT_STRICT_CONCURRENCY = complete; }; name = Release; }; diff --git a/Package.swift b/Package.swift index e41ee56c3..b8217f1d4 100644 --- a/Package.swift +++ b/Package.swift @@ -1,13 +1,13 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "Nuke", platforms: [ - .iOS(.v13), - .tvOS(.v13), - .macOS(.v10_15), - .watchOS(.v6), + .iOS(.v14), + .tvOS(.v14), + .macOS(.v11), + .watchOS(.v7), .visionOS(.v1), ], products: [ diff --git a/Sources/Nuke/Caching/Cache.swift b/Sources/Nuke/Caching/Cache.swift index f38f9e86e..d8ac82e25 100644 --- a/Sources/Nuke/Caching/Cache.swift +++ b/Sources/Nuke/Caching/Cache.swift @@ -56,8 +56,8 @@ final class Cache: @unchecked Sendable { self.memoryPressure.resume() #if os(iOS) || os(tvOS) || os(visionOS) - Task { - await registerForEnterBackground() + Task { @MainActor in + registerForEnterBackground() } #endif } @@ -70,7 +70,7 @@ final class Cache: @unchecked Sendable { } #if os(iOS) || os(tvOS) || os(visionOS) - @MainActor private func registerForEnterBackground() { + private func registerForEnterBackground() { notificationObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in self?.clearCacheOnEnterBackground() } diff --git a/Sources/Nuke/Caching/DataCache.swift b/Sources/Nuke/Caching/DataCache.swift index 8a63cf1c3..20578f82f 100644 --- a/Sources/Nuke/Caching/DataCache.swift +++ b/Sources/Nuke/Caching/DataCache.swift @@ -48,14 +48,6 @@ public final class DataCache: DataCaching, @unchecked Sendable { /// The time interval between cache sweeps. The default value is 1 hour. public var sweepInterval: TimeInterval = 3600 - // Deprecated in Nuke 12.2 - @available(*, deprecated, message: "It's not recommended to use compression with the popular image formats that already compress the data") - public var isCompressionEnabled: Bool { - get { _isCompressionEnabled } - set { _isCompressionEnabled = newValue } - } - var _isCompressionEnabled = false - // Staging private let lock = NSLock() @@ -143,7 +135,7 @@ public final class DataCache: DataCaching, @unchecked Sendable { guard let url = url(for: key) else { return nil } - return try? decompressed(Data(contentsOf: url)) + return try? Data(contentsOf: url) } /// Returns `true` if the cache contains the data for the given key. @@ -322,33 +314,17 @@ public final class DataCache: DataCaching, @unchecked Sendable { switch change.type { case let .add(data): do { - try compressed(data).write(to: url) + try data.write(to: url) } catch let error as NSError { guard error.code == CocoaError.fileNoSuchFile.rawValue && error.domain == CocoaError.errorDomain else { return } try? FileManager.default.createDirectory(at: self.path, withIntermediateDirectories: true, attributes: nil) - try? compressed(data).write(to: url) // re-create a directory and try again + try? data.write(to: url) // re-create a directory and try again } case .remove: try? FileManager.default.removeItem(at: url) } } - // MARK: Compression - - private func compressed(_ data: Data) throws -> Data { - guard _isCompressionEnabled else { - return data - } - return try (data as NSData).compressed(using: .lzfse) as Data - } - - private func decompressed(_ data: Data) throws -> Data { - guard _isCompressionEnabled else { - return data - } - return try (data as NSData).decompressed(using: .lzfse) as Data - } - // MARK: Sweep /// Synchronously performs a cache sweep and removes the least recently items diff --git a/Sources/Nuke/Decoding/ImageDecoderRegistry.swift b/Sources/Nuke/Decoding/ImageDecoderRegistry.swift index 3c5b54650..8c86727d3 100644 --- a/Sources/Nuke/Decoding/ImageDecoderRegistry.swift +++ b/Sources/Nuke/Decoding/ImageDecoderRegistry.swift @@ -54,7 +54,7 @@ public final class ImageDecoderRegistry: @unchecked Sendable { } /// Image decoding context used when selecting which decoder to use. -public struct ImageDecodingContext: @unchecked Sendable { +public struct ImageDecodingContext: Sendable { public var request: ImageRequest public var data: Data /// Returns `true` if the download was completed. diff --git a/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift b/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift index 415a0f1bb..ba9f6033c 100644 --- a/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift +++ b/Sources/Nuke/Encoding/ImageEncoders+ImageIO.swift @@ -30,7 +30,7 @@ extension ImageEncoders { self.compressionRatio = compressionRatio } - private static let availability = Atomic<[AssetType: Bool]>(value: [:]) + private static let availability = Mutex<[AssetType: Bool]>([:]) /// Returns `true` if the encoding is available for the given format on /// the current hardware. Some of the most recent formats might not be diff --git a/Sources/Nuke/Encoding/ImageEncoding.swift b/Sources/Nuke/Encoding/ImageEncoding.swift index 1385ea2e6..749c982da 100644 --- a/Sources/Nuke/Encoding/ImageEncoding.swift +++ b/Sources/Nuke/Encoding/ImageEncoding.swift @@ -32,6 +32,8 @@ extension ImageEncoding { } } +// note: @unchecked was added to surpress build errors with NSImage on macOS + /// Image encoding context used when selecting which encoder to use. public struct ImageEncodingContext: @unchecked Sendable { public let request: ImageRequest diff --git a/Sources/Nuke/ImageContainer.swift b/Sources/Nuke/ImageContainer.swift index 346a50622..086f4c410 100644 --- a/Sources/Nuke/ImageContainer.swift +++ b/Sources/Nuke/ImageContainer.swift @@ -19,7 +19,7 @@ public typealias PlatformImage = NSImage #endif /// An image container with an image and associated metadata. -public struct ImageContainer: @unchecked Sendable { +public struct ImageContainer: Sendable { #if os(macOS) /// A fetched image. public var image: NSImage { diff --git a/Sources/Nuke/ImageRequest.swift b/Sources/Nuke/ImageRequest.swift index 96d20870f..cedc32812 100644 --- a/Sources/Nuke/ImageRequest.swift +++ b/Sources/Nuke/ImageRequest.swift @@ -28,7 +28,7 @@ import AppKit /// ) /// let image = try await pipeline.image(for: request) /// ``` -public struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStringLiteral { +public struct ImageRequest: CustomStringConvertible, @unchecked Sendable, ExpressibleByStringLiteral { // MARK: Options /// The relative priority of the request. The priority affects the order in @@ -69,7 +69,7 @@ public struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStri switch ref.resource { case .url(let url): return url.map { URLRequest(url: $0) } // create lazily case .urlRequest(let urlRequest): return urlRequest - case .publisher: return nil + case .closure: return nil } } @@ -80,7 +80,7 @@ public struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStri switch ref.resource { case .url(let url): return url case .urlRequest(let request): return request.url - case .publisher: return nil + case .closure: return nil } } @@ -202,51 +202,7 @@ public struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStri // pipeline by using a custom DataLoader and passing an async function in // the request userInfo. g self.ref = Container( - resource: .publisher(DataPublisher(id: id, data)), - processors: processors, - priority: priority, - options: options, - userInfo: userInfo - ) - } - - /// Initializes a request with the given data publisher. - /// - /// For example, here is how you can use it with the Photos framework (the - /// `imageDataPublisher` API is a custom convenience extension not included - /// in the framework). - /// - /// ```swift - /// let request = ImageRequest( - /// id: asset.localIdentifier, - /// dataPublisher: PHAssetManager.imageDataPublisher(for: asset) - /// ) - /// ``` - /// - /// - important: If you are using a pipeline with a custom configuration that - /// enables aggressive disk cache, fetched data will be stored in this cache. - /// You can use ``Options-swift.struct/disableDiskCache`` to disable it. - /// - /// - parameters: - /// - id: Uniquely identifies the fetched image. - /// - data: A data publisher to be used for fetching image data. - /// - processors: Processors to be apply to the image. See to learn more. - /// - priority: The priority of the request, ``Priority-swift.enum/normal`` by default. - /// - options: Image loading options. - /// - userInfo: Custom info passed alongside the request. - public init

( - id: String, - dataPublisher: P, - processors: [any ImageProcessing] = [], - priority: Priority = .normal, - options: Options = [], - userInfo: [UserInfoKey: Any]? = nil - ) where P: Publisher, P.Output == Data { - // It could technically be implemented without any special change to the - // pipeline by using a custom DataLoader and passing a publisher in the - // request userInfo. - self.ref = Container( - resource: .publisher(DataPublisher(id: id, dataPublisher)), + resource: .closure(data, id: id), processors: processors, priority: priority, options: options, @@ -470,8 +426,8 @@ public struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStri (ref.userInfo?[.scaleKey] as? NSNumber)?.floatValue } - var publisher: DataPublisher? { - if case .publisher(let publisher) = ref.resource { return publisher } + var closure: (@Sendable () async throws -> Data)? { + if case .closure(let closure, _) = ref.resource { return closure } return nil } } @@ -481,7 +437,7 @@ public struct ImageRequest: CustomStringConvertible, Sendable, ExpressibleByStri extension ImageRequest { /// Just like many Swift built-in types, ``ImageRequest`` uses CoW approach to /// avoid memberwise retain/releases when ``ImageRequest`` is passed around. - private final class Container: @unchecked Sendable { + private final class Container { // It's beneficial to put resource before priority and options because // of the resource size/stride of 9/16. Priority (1 byte) and Options // (2 bytes) slot just right in the remaining space. @@ -519,13 +475,13 @@ extension ImageRequest { enum Resource: CustomStringConvertible { case url(URL?) case urlRequest(URLRequest) - case publisher(DataPublisher) + case closure(@Sendable () async throws -> Data, id: String) var description: String { switch self { case .url(let url): return "\(url?.absoluteString ?? "nil")" case .urlRequest(let urlRequest): return "\(urlRequest)" - case .publisher(let data): return "\(data)" + case .closure(_, let id): return id } } @@ -533,7 +489,7 @@ extension ImageRequest { switch self { case .url(let url): return url?.absoluteString case .urlRequest(let urlRequest): return urlRequest.url?.absoluteString - case .publisher(let publisher): return publisher.id + case .closure(_, let id): return id } } } diff --git a/Sources/Nuke/ImageResponse.swift b/Sources/Nuke/ImageResponse.swift index 0999a8b68..dd5b3b0ea 100644 --- a/Sources/Nuke/ImageResponse.swift +++ b/Sources/Nuke/ImageResponse.swift @@ -13,7 +13,7 @@ import AppKit #endif /// An image response that contains a fetched image and some metadata. -public struct ImageResponse: @unchecked Sendable { +public struct ImageResponse: Sendable { /// An image container with an image and associated metadata. public var container: ImageContainer diff --git a/Sources/Nuke/ImageTask.swift b/Sources/Nuke/ImageTask.swift index 7f91c077e..018f70e60 100644 --- a/Sources/Nuke/ImageTask.swift +++ b/Sources/Nuke/ImageTask.swift @@ -18,25 +18,26 @@ import AppKit /// The pipeline maintains a strong reference to the task until the request /// finishes or fails; you do not need to maintain a reference to the task unless /// it is useful for your app. -public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Sendable { +@ImagePipelineActor +public final class ImageTask: Hashable { /// An identifier that uniquely identifies the task within a given pipeline. /// Unique only within that pipeline. - public let taskId: Int64 + public nonisolated let taskId: Int64 /// The original request that the task was created with. - public let request: ImageRequest + public nonisolated let request: ImageRequest /// The priority of the task. The priority can be updated dynamically even /// for a task that is already running. - public var priority: ImageRequest.Priority { - get { withLock { $0.priority } } + public nonisolated var priority: ImageRequest.Priority { + get { nonisolatedState.withLock(\.priority) } set { setPriority(newValue) } } /// Returns the current download progress. Returns zeros before the download /// is started and the expected size of the resource is known. - public var currentProgress: Progress { - withLock { $0.progress } + public nonisolated var currentProgress: Progress { + nonisolatedState.withLock(\.progress) } /// The download progress. @@ -59,12 +60,10 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send } /// The current state of the task. - public var state: State { - withLock { $0.state } - } + public private(set) var state: State = .running /// The state of the image task. - public enum State { + public enum State: Sendable { /// The task is currently running. case running /// The task has received a cancel message. @@ -73,6 +72,11 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send case completed } + /// Returns `true` if the task cancellation is initiated. + public nonisolated var isCancelling: Bool { + nonisolatedState.withLock(\.isCancelling) + } + // MARK: - Async/Await /// Returns the response image. @@ -86,7 +90,7 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send public var response: ImageResponse { get async throws { try await withTaskCancellationHandler { - try await _task.value + try await task.value } onCancel: { cancel() } @@ -94,7 +98,7 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send } /// The stream of progress updates. - public var progress: AsyncStream { + public nonisolated var progress: AsyncStream { makeStream { if case .progress(let value) = $0 { return value } return nil @@ -105,7 +109,7 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send /// progressive decoding. /// /// - seealso: ``ImagePipeline/Configuration-swift.struct/isProgressiveDecodingEnabled`` - public var previews: AsyncStream { + public nonisolated var previews: AsyncStream { makeStream { if case .preview(let value) = $0 { return value } return nil @@ -115,7 +119,7 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send // MARK: - Events /// The events sent by the pipeline during the task execution. - public var events: AsyncStream { makeStream { $0 } } + public nonisolated var events: AsyncStream { makeStream { $0 } } /// An event produced during the runetime of the task. public enum Event: Sendable { @@ -132,126 +136,113 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send case finished(Result) } - private var publicState: PublicState + private let nonisolatedState: Mutex private let isDataTask: Bool - private let onEvent: ((Event, ImageTask) -> Void)? - private let lock: os_unfair_lock_t - private let queue: DispatchQueue + private let onEvent: (@Sendable (Event, ImageTask) -> Void)? + private nonisolated(unsafe) var task: Task! private weak var pipeline: ImagePipeline? - // State synchronized on `pipeline.queue`. - var _task: Task! - var _continuation: UnsafeContinuation? - var _state: State = .running - private var _events: PassthroughSubject? + private var continuation: UnsafeContinuation? + private var _events: PassthroughSubject? - deinit { - lock.deinitialize(count: 1) - lock.deallocate() - } - - init(taskId: Int64, request: ImageRequest, isDataTask: Bool, pipeline: ImagePipeline, onEvent: ((Event, ImageTask) -> Void)?) { + nonisolated init(taskId: Int64, request: ImageRequest, isDataTask: Bool, pipeline: ImagePipeline, onEvent: (@Sendable (Event, ImageTask) -> Void)?) { self.taskId = taskId self.request = request - self.publicState = PublicState(priority: request.priority) + self.nonisolatedState = Mutex(ImageTaskState(priority: request.priority)) self.isDataTask = isDataTask self.pipeline = pipeline - self.queue = pipeline.queue self.onEvent = onEvent + self.task = Task { @ImagePipelineActor in + try await perform() + } + } - lock = .allocate(capacity: 1) - lock.initialize(to: os_unfair_lock()) + private func perform() async throws -> ImageResponse { + try await withUnsafeThrowingContinuation { + continuation = $0 + // The task gets started asynchronously in a `Task` and cancellation + // can happen before the pipeline reaches `startImageTask`. In that + // case, the `cancel` method do no send the task event. + guard state != .cancelled else { + return dispatch(.cancelled) // Important to set after continuation + } + pipeline?.startImageTask(self, isDataTask: isDataTask) + } } /// Marks task as being cancelled. /// /// The pipeline will immediately cancel any work associated with a task /// unless there is an equivalent outstanding task running. - public func cancel() { - let didChange: Bool = withLock { - guard $0.state == .running else { return false } - $0.state = .cancelled + public nonisolated func cancel() { + guard nonisolatedState.withLock({ + guard !$0.isCancelling else { return false } + $0.isCancelling = true return true + }) else { return } + Task { @ImagePipelineActor in + pipeline?.cancelImageTask(self) } - guard didChange else { return } // Make sure it gets called once (expensive) - pipeline?.imageTaskCancelCalled(self) } - private func setPriority(_ newValue: ImageRequest.Priority) { - let didChange: Bool = withLock { + private nonisolated func setPriority(_ newValue: ImageRequest.Priority) { + guard nonisolatedState.withLock({ guard $0.priority != newValue else { return false } $0.priority = newValue - return $0.state == .running + return !$0.isCancelling + }) else { return } + Task { @ImagePipelineActor in + pipeline?.imageTask(self, didChangePriority: newValue) } - guard didChange else { return } - pipeline?.imageTaskUpdatePriorityCalled(self, priority: newValue) } // MARK: Internals /// Gets called when the task is cancelled either by the user or by an /// external event such as session invalidation. - /// - /// synchronized on `pipeline.queue`. func _cancel() { - guard _setState(.cancelled) else { return } - _dispatch(.cancelled) + guard state == .running else { return } + state = .cancelled + dispatch(.cancelled) } /// Gets called when the associated task sends a new event. - /// - /// synchronized on `pipeline.queue`. - func _process(_ event: AsyncTask.Event) { + func process(_ event: AsyncTask.Event) { + guard state == .running else { return } switch event { case let .value(response, isCompleted): if isCompleted { - _finish(.success(response)) + state = .completed + dispatch(.finished(.success(response))) } else { - _dispatch(.preview(response)) + dispatch(.preview(response)) } case let .progress(value): - withLock { $0.progress = value } - _dispatch(.progress(value)) + nonisolatedState.withLock { $0.progress = value } + dispatch(.progress(value)) case let .error(error): - _finish(.failure(error)) - } - } - - /// Synchronized on `pipeline.queue`. - private func _finish(_ result: Result) { - guard _setState(.completed) else { return } - _dispatch(.finished(result)) - } - - /// Synchronized on `pipeline.queue`. - func _setState(_ state: State) -> Bool { - guard _state == .running else { return false } - _state = state - if onEvent == nil { - withLock { $0.state = state } + state = .completed + dispatch(.finished(.failure(error))) } - return true } /// Dispatches the given event to the observers. /// /// - warning: The task needs to be fully wired (`_continuation` present) /// before it can start sending the events. - /// - /// synchronized on `pipeline.queue`. - func _dispatch(_ event: Event) { - guard _continuation != nil else { + private func dispatch(_ event: Event) { + guard continuation != nil else { return // Task isn't fully wired yet } _events?.send(event) switch event { case .cancelled: _events?.send(completion: .finished) - _continuation?.resume(throwing: CancellationError()) + continuation?.resume(throwing: CancellationError()) case .finished(let result): let result = result.mapError { $0 as Error } _events?.send(completion: .finished) - _continuation?.resume(with: result) + continuation?.resume(with: result) default: break } @@ -262,34 +253,25 @@ public final class ImageTask: Hashable, CustomStringConvertible, @unchecked Send // MARK: Hashable - public func hash(into hasher: inout Hasher) { + public nonisolated func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self).hashValue) } - public static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { + public nonisolated static func == (lhs: ImageTask, rhs: ImageTask) -> Bool { ObjectIdentifier(lhs) == ObjectIdentifier(rhs) } - - // MARK: CustomStringConvertible - - public var description: String { - "ImageTask(id: \(taskId), priority: \(priority), progress: \(currentProgress.completed) / \(currentProgress.total), state: \(state))" - } } -@available(*, deprecated, renamed: "ImageTask", message: "Async/Await support was added directly to the existing `ImageTask` type") -public typealias AsyncImageTask = ImageTask - // MARK: - ImageTask (Private) extension ImageTask { - private func makeStream(of closure: @Sendable @escaping (Event) -> T?) -> AsyncStream { + private nonisolated func makeStream(of closure: @Sendable @escaping (Event) -> T?) -> AsyncStream { AsyncStream { continuation in - self.queue.async { - guard let events = self._makeEventsSubject() else { + Task { @ImagePipelineActor in + guard state == .running else { return continuation.finish() } - let cancellable = events.sink { _ in + let cancellable = makeEvents().sink { _ in continuation.finish() } receiveValue: { event in if let value = closure(event) { @@ -309,29 +291,16 @@ extension ImageTask { } } - // Synchronized on `pipeline.queue` - private func _makeEventsSubject() -> PassthroughSubject? { - guard _state == .running else { - return nil - } + private func makeEvents() -> PassthroughSubject { if _events == nil { _events = PassthroughSubject() } return _events! } +} - private func withLock(_ closure: (inout PublicState) -> T) -> T { - os_unfair_lock_lock(lock) - defer { os_unfair_lock_unlock(lock) } - return closure(&publicState) - } - - /// Contains the state synchronized using the internal lock. - /// - /// - warning: Must be accessed using `withLock`. - private struct PublicState { - var state: ImageTask.State = .running - var priority: ImageRequest.Priority - var progress = Progress(completed: 0, total: 0) - } +private struct ImageTaskState { + var isCancelling = false + var priority: ImageRequest.Priority + var progress = ImageTask.Progress(completed: 0, total: 0) } diff --git a/Sources/Nuke/Internal/DataPublisher.swift b/Sources/Nuke/Internal/DataPublisher.swift deleted file mode 100644 index fc3afca54..000000000 --- a/Sources/Nuke/Internal/DataPublisher.swift +++ /dev/null @@ -1,60 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import Foundation -@preconcurrency import Combine - -final class DataPublisher { - let id: String - private let _sink: (@escaping ((PublisherCompletion) -> Void), @escaping ((Data) -> Void)) -> any Cancellable - - init(id: String, _ publisher: P) where P.Output == Data { - self.id = id - self._sink = { onCompletion, onValue in - let cancellable = publisher.sink(receiveCompletion: { - switch $0 { - case .finished: onCompletion(.finished) - case .failure(let error): onCompletion(.failure(error)) - } - }, receiveValue: { - onValue($0) - }) - return AnonymousCancellable { cancellable.cancel() } - } - } - - convenience init(id: String, _ data: @Sendable @escaping () async throws -> Data) { - self.init(id: id, publisher(from: data)) - } - - func sink(receiveCompletion: @escaping ((PublisherCompletion) -> Void), receiveValue: @escaping ((Data) -> Void)) -> any Cancellable { - _sink(receiveCompletion, receiveValue) - } -} - -private func publisher(from closure: @Sendable @escaping () async throws -> Data) -> AnyPublisher { - Deferred { - Future { promise in - let promise = UncheckedSendableBox(value: promise) - Task { - do { - let data = try await closure() - promise.value(.success(data)) - } catch { - promise.value(.failure(error)) - } - } - } - }.eraseToAnyPublisher() -} - -enum PublisherCompletion { - case finished - case failure(Error) -} - -/// - warning: Avoid using it! -struct UncheckedSendableBox: @unchecked Sendable { - let value: Value -} diff --git a/Sources/Nuke/Internal/Extensions.swift b/Sources/Nuke/Internal/Extensions.swift index 03b46dc5f..c3943676b 100644 --- a/Sources/Nuke/Internal/Extensions.swift +++ b/Sources/Nuke/Internal/Extensions.swift @@ -49,15 +49,3 @@ extension ImageRequest.Priority { } } } - -final class AnonymousCancellable: Cancellable { - let onCancel: @Sendable () -> Void - - init(_ onCancel: @Sendable @escaping () -> Void) { - self.onCancel = onCancel - } - - func cancel() { - onCancel() - } -} diff --git a/Sources/Nuke/Internal/ImageRequestKeys.swift b/Sources/Nuke/Internal/ImageRequestKeys.swift index 90046776b..213eb7a13 100644 --- a/Sources/Nuke/Internal/ImageRequestKeys.swift +++ b/Sources/Nuke/Internal/ImageRequestKeys.swift @@ -78,7 +78,7 @@ struct TaskFetchOriginalDataKey: Hashable { init(_ request: ImageRequest) { self.imageId = request.imageId switch request.resource { - case .url, .publisher: + case .url, .closure: self.cachePolicy = .useProtocolCachePolicy self.allowsCellularAccess = true case let .urlRequest(urlRequest): diff --git a/Sources/Nuke/Internal/Log.swift b/Sources/Nuke/Internal/Log.swift index cc725ae4f..a24a17a7d 100644 --- a/Sources/Nuke/Internal/Log.swift +++ b/Sources/Nuke/Internal/Log.swift @@ -24,7 +24,7 @@ func signpost(_ name: StaticString, _ work: () throws -> T) rethrows -> T { return result } -private let log = Atomic(value: OSLog(subsystem: "com.github.kean.Nuke.ImagePipeline", category: "Image Loading")) +private let log = Mutex(OSLog(subsystem: "com.github.kean.Nuke.ImagePipeline", category: "Image Loading")) enum Formatter { static func bytes(_ count: Int) -> String { diff --git a/Sources/Nuke/Internal/Atomic.swift b/Sources/Nuke/Internal/Mutex.swift similarity index 77% rename from Sources/Nuke/Internal/Atomic.swift rename to Sources/Nuke/Internal/Mutex.swift index 43055ce35..a01da7c4d 100644 --- a/Sources/Nuke/Internal/Atomic.swift +++ b/Sources/Nuke/Internal/Mutex.swift @@ -4,11 +4,11 @@ import Foundation -final class Atomic: @unchecked Sendable { +final class Mutex: @unchecked Sendable { private var _value: T private let lock: os_unfair_lock_t - init(value: T) { + init(_ value: T) { self._value = value self.lock = .allocate(capacity: 1) self.lock.initialize(to: os_unfair_lock()) @@ -38,3 +38,13 @@ final class Atomic: @unchecked Sendable { return closure(&_value) } } + +extension Mutex where T: BinaryInteger { + func incremented() -> T { + withLock { + let value = $0 + $0 += 1 + return value + } + } +} diff --git a/Sources/Nuke/Internal/RateLimiter.swift b/Sources/Nuke/Internal/RateLimiter.swift index d85c34a41..85173fb3a 100644 --- a/Sources/Nuke/Internal/RateLimiter.swift +++ b/Sources/Nuke/Internal/RateLimiter.swift @@ -13,12 +13,12 @@ import Foundation /// The implementation supports quick bursts of requests which can be executed /// without any delays when "the bucket is full". This is important to prevent /// rate limiter from affecting "normal" requests flow. -final class RateLimiter: @unchecked Sendable { +@ImagePipelineActor +final class RateLimiter { // This type isn't really Sendable and requires the caller to use the same // queue as it does for synchronization. - private let bucket: TokenBucket - private let queue: DispatchQueue + private var bucket: TokenBucket private var pending = LinkedList() // fast append, fast remove first private var isExecutingPendingTasks = false @@ -30,8 +30,7 @@ final class RateLimiter: @unchecked Sendable { /// - rate: Maximum number of requests per second. 80 by default. /// - burst: Maximum number of requests which can be executed without any /// delays when "bucket is full". 25 by default. - init(queue: DispatchQueue, rate: Int = 80, burst: Int = 25) { - self.queue = queue + nonisolated init(rate: Int = 80, burst: Int = 25) { self.bucket = TokenBucket(rate: Double(rate), burst: Double(burst)) } @@ -56,7 +55,10 @@ final class RateLimiter: @unchecked Sendable { let bucketRate = 1000.0 / bucket.rate let delay = Int(2.1 * bucketRate) // 14 ms for rate 80 (default) let bounds = min(100, max(15, delay)) - queue.asyncAfter(deadline: .now() + .milliseconds(bounds)) { self.executePendingTasks() } + Task { + try? await Task.sleep(nanoseconds: UInt64(bounds) * 1_000_000) + self.executePendingTasks() + } } private func executePendingTasks() { @@ -70,7 +72,7 @@ final class RateLimiter: @unchecked Sendable { } } -private final class TokenBucket { +private struct TokenBucket { let rate: Double private let burst: Double // maximum bucket size private var bucket: Double @@ -86,7 +88,7 @@ private final class TokenBucket { } /// Returns `true` if the closure was executed, `false` if dropped. - func execute(_ work: () -> Bool) -> Bool { + mutating func execute(_ work: () -> Bool) -> Bool { refill() guard bucket >= 1.0 else { return false // bucket is empty @@ -97,7 +99,7 @@ private final class TokenBucket { return true } - private func refill() { + private mutating func refill() { let now = CFAbsoluteTimeGetCurrent() bucket += rate * max(0, now - timestamp) // rate * (time delta) timestamp = now diff --git a/Sources/Nuke/Internal/ResumableData.swift b/Sources/Nuke/Internal/ResumableData.swift index 97efc57e2..513a484c2 100644 --- a/Sources/Nuke/Internal/ResumableData.swift +++ b/Sources/Nuke/Internal/ResumableData.swift @@ -6,7 +6,7 @@ import Foundation /// Resumable data support. For more info see: /// - https://developer.apple.com/library/content/qa/qa1761/_index.html -struct ResumableData: @unchecked Sendable { +struct ResumableData: Sendable { let data: Data let validator: String // Either Last-Modified or ETag @@ -67,29 +67,29 @@ final class ResumableDataStorage: @unchecked Sendable { static let shared = ResumableDataStorage() private let lock = NSLock() - private var registeredPipelines = Set() + private var namespaces = Set() private var cache: Cache? // MARK: Registration - func register(_ pipeline: ImagePipeline) { + func register(_ namespace: UUID) { lock.lock() defer { lock.unlock() } - if registeredPipelines.isEmpty { + if namespaces.isEmpty { // 32 MB cache = Cache(costLimit: 32000000, countLimit: 100) } - registeredPipelines.insert(pipeline.id) + namespaces.insert(namespace) } - func unregister(_ pipeline: ImagePipeline) { + func unregister(_ namespace: UUID) { lock.lock() defer { lock.unlock() } - registeredPipelines.remove(pipeline.id) - if registeredPipelines.isEmpty { + namespaces.remove(namespace) + if namespaces.isEmpty { cache = nil // Deallocate storage } } @@ -103,31 +103,31 @@ final class ResumableDataStorage: @unchecked Sendable { // MARK: Storage - func removeResumableData(for request: ImageRequest, pipeline: ImagePipeline) -> ResumableData? { + func removeResumableData(for request: ImageRequest, namespace: UUID) -> ResumableData? { lock.lock() defer { lock.unlock() } - guard let key = Key(request: request, pipeline: pipeline) else { return nil } + guard let key = Key(request: request, namespace: namespace) else { return nil } return cache?.removeValue(forKey: key) } - func storeResumableData(_ data: ResumableData, for request: ImageRequest, pipeline: ImagePipeline) { + func storeResumableData(_ data: ResumableData, for request: ImageRequest, namespace: UUID) { lock.lock() defer { lock.unlock() } - guard let key = Key(request: request, pipeline: pipeline) else { return } + guard let key = Key(request: request, namespace: namespace) else { return } cache?.set(data, forKey: key, cost: data.data.count) } private struct Key: Hashable { - let pipelineId: UUID + let namespace: UUID let imageId: String - init?(request: ImageRequest, pipeline: ImagePipeline) { + init?(request: ImageRequest, namespace: UUID) { guard let imageId = request.imageId else { return nil } - self.pipelineId = pipeline.id + self.namespace = namespace self.imageId = imageId } } diff --git a/Sources/Nuke/Loading/DataLoader.swift b/Sources/Nuke/Loading/DataLoader.swift index a3076d237..e1b3a2a0b 100644 --- a/Sources/Nuke/Loading/DataLoader.swift +++ b/Sources/Nuke/Loading/DataLoader.swift @@ -90,9 +90,23 @@ public final class DataLoader: DataLoading, @unchecked Sendable { #endif }() - public func loadData(with request: URLRequest, - didReceiveData: @escaping (Data, URLResponse) -> Void, - completion: @escaping (Swift.Error?) -> Void) -> any Cancellable { + public func loadData(for request: URLRequest) -> AsyncThrowingStream<(Data, URLResponse), Swift.Error> { + AsyncThrowingStream { continuation in + let task = loadData(with: request) { data, response in + continuation.yield((data, response)) + } completion: { error in + continuation.finish(throwing: error) + } + continuation.onTermination = { reason in + switch reason { + case .cancelled: task.cancel() + default: break + } + } + } + } + + private func loadData(with request: URLRequest, didReceiveData: @escaping (Data, URLResponse) -> Void, completion: @escaping (Swift.Error?) -> Void) -> URLSessionTask { let task = session.dataTask(with: request) if #available(iOS 14.5, tvOS 14.5, watchOS 7.4, macOS 11.3, *) { task.prefersIncrementalDelivery = prefersIncrementalDelivery @@ -130,13 +144,13 @@ private final class _DataLoader: NSObject, URLSessionDataDelegate, @unchecked Se func loadData(with task: URLSessionDataTask, session: URLSession, didReceiveData: @escaping (Data, URLResponse) -> Void, - completion: @escaping (Error?) -> Void) -> any Cancellable { + completion: @escaping (Error?) -> Void) -> URLSessionTask { let handler = _Handler(didReceiveData: didReceiveData, completion: completion) session.delegateQueue.addOperation { // `URLSession` is configured to use this same queue self.handlers[task] = handler } task.resume() - return AnonymousCancellable { task.cancel() } + return task } // MARK: URLSessionDelegate @@ -223,6 +237,7 @@ private final class _DataLoader: NSObject, URLSessionDataDelegate, @unchecked Se private final class _Handler: @unchecked Sendable { let didReceiveData: (Data, URLResponse) -> Void let completion: (Error?) -> Void + var resumableData: Data? init(didReceiveData: @escaping (Data, URLResponse) -> Void, completion: @escaping (Error?) -> Void) { self.didReceiveData = didReceiveData diff --git a/Sources/Nuke/Loading/DataLoading.swift b/Sources/Nuke/Loading/DataLoading.swift index e4da75e17..e5bef5279 100644 --- a/Sources/Nuke/Loading/DataLoading.swift +++ b/Sources/Nuke/Loading/DataLoading.swift @@ -6,16 +6,9 @@ import Foundation /// Fetches original image data. public protocol DataLoading: Sendable { - /// - parameter didReceiveData: Can be called multiple times if streaming + /// Returns data for the given request. + /// + /// - returns: Sequence that can be called more than once if streaming /// is supported. - /// - parameter completion: Must be called once after all (or none in case - /// of an error) `didReceiveData` closures have been called. - func loadData(with request: URLRequest, - didReceiveData: @escaping (Data, URLResponse) -> Void, - completion: @escaping (Error?) -> Void) -> any Cancellable -} - -/// A unit of work that can be cancelled. -public protocol Cancellable: AnyObject, Sendable { - func cancel() + func loadData(for request: URLRequest) -> AsyncThrowingStream<(Data, URLResponse), Swift.Error> } diff --git a/Sources/Nuke/Pipeline/ImagePipeline+Closures.swift b/Sources/Nuke/Pipeline/ImagePipeline+Closures.swift new file mode 100644 index 000000000..c041daf8e --- /dev/null +++ b/Sources/Nuke/Pipeline/ImagePipeline+Closures.swift @@ -0,0 +1,107 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +extension ImagePipeline { + /// Loads an image for the given request. + /// + /// - warning: Soft-deprecated in Nuke 13.0. + /// + /// - parameters: + /// - request: An image request. + /// - completion: A closure to be called on the main thread when the request + /// is finished. + @discardableResult public nonisolated func loadImage( + with url: URL, + completion: @MainActor @Sendable @escaping (_ result: Result) -> Void + ) -> ImageTask { + _loadImage(with: ImageRequest(url: url), progress: nil, completion: completion) + } + + /// Loads an image for the given request. + /// + /// - warning: Soft-deprecated in Nuke 13.0. + /// + /// - parameters: + /// - request: An image request. + /// - completion: A closure to be called on the main thread when the request + /// is finished. + @discardableResult public nonisolated func loadImage( + with request: ImageRequest, + completion: @MainActor @Sendable @escaping (_ result: Result) -> Void + ) -> ImageTask { + _loadImage(with: request, progress: nil, completion: completion) + } + + /// Loads an image for the given request. + /// + /// - warning: Soft-deprecated in Nuke 13.0. + /// + /// - parameters: + /// - request: An image request. + /// - progress: A closure to be called periodically on the main thread when + /// the progress is updated. + /// - completion: A closure to be called on the main thread when the request + /// is finished. + @discardableResult public nonisolated func loadImage( + with request: ImageRequest, + progress: (@MainActor @Sendable (_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)?, + completion: @MainActor @Sendable @escaping (_ result: Result) -> Void + ) -> ImageTask { + _loadImage(with: request, progress: { + progress?($0, $1.completed, $1.total) + }, completion: completion) + } + + /// Loads the image data for the given request. The data doesn't get decoded + /// or processed in any other way. + /// + /// You can call ``loadImage(with:completion:)-43osv`` for the request at any point after calling + /// ``loadData(with:completion:)-6cwk3``, the pipeline will use the same operation to load the data, + /// no duplicated work will be performed. + /// + /// - warning: Soft-deprecated in Nuke 13.0. + /// + /// - parameters: + /// - request: An image request. + /// - progress: A closure to be called periodically on the main thread when the progress is updated. + /// - completion: A closure to be called on the main thread when the request is finished. + @discardableResult public nonisolated func loadData( + with request: ImageRequest, + progress progressHandler: (@MainActor @Sendable (_ completed: Int64, _ total: Int64) -> Void)? = nil, + completion: @MainActor @Sendable @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void + ) -> ImageTask { + _loadImage(with: request, isDataTask: true) { _, progress in + progressHandler?(progress.completed, progress.total) + } completion: { result in + let result = result.map { response in + // Data should never be empty + (data: response.container.data ?? Data(), response: response.urlResponse) + } + completion(result) + } + } + + private nonisolated func _loadImage( + with request: ImageRequest, + isDataTask: Bool = false, + progress: (@MainActor @Sendable (ImageResponse?, ImageTask.Progress) -> Void)?, + completion: @MainActor @Sendable @escaping (Result) -> Void + ) -> ImageTask { + makeImageTask(with: request, isDataTask: isDataTask) { event, task in + DispatchQueue.main.async { + // The callback-based API guarantees that after cancellation no + // event are called on the callback queue. + guard !task.isCancelling else { return } + switch event { + case .progress(let value): progress?(nil, value) + case .preview(let response): progress?(response, task.currentProgress) + case .cancelled: break // The legacy APIs do not send cancellation events + case .finished(let result): completion(result) + } + } + } + } +} diff --git a/Sources/Nuke/Pipeline/ImagePipeline+Configuration.swift b/Sources/Nuke/Pipeline/ImagePipeline+Configuration.swift index 0ce00f09f..b5fc9b639 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline+Configuration.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline+Configuration.swift @@ -118,16 +118,6 @@ extension ImagePipeline { /// `data` schemes) inline without using the data loader. By default, `true`. public var isLocalResourcesSupportEnabled = true - /// A queue on which all callbacks, like `progress` and `completion` - /// callbacks are called. `.main` by default. - @available(*, deprecated, message: "`ImagePipeline` no longer supports changing the callback queue") - public var callbackQueue: DispatchQueue { - get { _callbackQueue } - set { _callbackQueue = newValue } - } - - var _callbackQueue = DispatchQueue.main - // MARK: - Options (Shared) /// `false` by default. If `true`, enables `os_signpost` logging for @@ -140,7 +130,7 @@ extension ImagePipeline { set { _isSignpostLoggingEnabled.value = newValue } } - private static let _isSignpostLoggingEnabled = Atomic(value: false) + private static let _isSignpostLoggingEnabled = Mutex(false) private var isCustomImageCacheProvided = false @@ -151,10 +141,6 @@ extension ImagePipeline { /// Data loading queue. Default maximum concurrent task count is 6. public var dataLoadingQueue = OperationQueue(maxConcurrentCount: 6) - // Deprecated in Nuke 12.6 - @available(*, deprecated, message: "The pipeline now performs cache lookup on the internal queue, reducing the amount of context switching") - public var dataCachingQueue = OperationQueue(maxConcurrentCount: 2) - /// Image decoding queue. Default maximum concurrent task count is 1. public var imageDecodingQueue = OperationQueue(maxConcurrentCount: 1) diff --git a/Sources/Nuke/Pipeline/ImagePipeline+Delegate.swift b/Sources/Nuke/Pipeline/ImagePipeline+Delegate.swift index 3e6f4d99d..dd2065da8 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline+Delegate.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline+Delegate.swift @@ -8,84 +8,71 @@ import Foundation /// /// - important: The delegate methods are performed on the pipeline queue in the /// background. -public protocol ImagePipelineDelegate: AnyObject, Sendable { - // MARK: Configuration - - /// Returns data loader for the given request. - func dataLoader(for request: ImageRequest, pipeline: ImagePipeline) -> any DataLoading - - /// Returns image decoder for the given context. - func imageDecoder(for context: ImageDecodingContext, pipeline: ImagePipeline) -> (any ImageDecoding)? - - /// Returns image encoder for the given context. - func imageEncoder(for context: ImageEncodingContext, pipeline: ImagePipeline) -> any ImageEncoding - - // MARK: Caching - - /// Returns in-memory image cache for the given request. Return `nil` to prevent cache reads and writes. - func imageCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any ImageCaching)? - - /// Returns disk cache for the given request. Return `nil` to prevent cache - /// reads and writes. - func dataCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any DataCaching)? - - /// Returns a cache key identifying the image produced for the given request - /// (including image processors). The key is used for both in-memory and - /// on-disk caches. - /// - /// Return `nil` to use a default key. - func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? - - /// Gets called when the pipeline is about to save data for the given request. - /// The implementation must call the completion closure passing `non-nil` data - /// to enable caching or `nil` to prevent it. - /// - /// This method calls only if the request parameters and data caching policy - /// of the pipeline already allow caching. - /// - /// - parameters: - /// - data: Either the original data or the encoded image in case of storing - /// a processed or re-encoded image. - /// - image: Non-nil in case storing an encoded image. - /// - request: The request for which image is being stored. - /// - completion: The implementation must call the completion closure - /// passing `non-nil` data to enable caching or `nil` to prevent it. You can - /// safely call it synchronously. The callback gets called on the background - /// thread. - func willCache(data: Data, image: ImageContainer?, for request: ImageRequest, pipeline: ImagePipeline, completion: @escaping (Data?) -> Void) - - // MARK: Decompression - - func shouldDecompress(response: ImageResponse, for request: ImageRequest, pipeline: ImagePipeline) -> Bool - - func decompress(response: ImageResponse, request: ImageRequest, pipeline: ImagePipeline) -> ImageResponse - - // MARK: ImageTask - - /// Gets called when the task is created. Unlike other methods, it is called - /// immediately on the caller's queue. - func imageTaskCreated(_ task: ImageTask, pipeline: ImagePipeline) - - /// Gets called when the task receives an event. - func imageTask(_ task: ImageTask, didReceiveEvent event: ImageTask.Event, pipeline: ImagePipeline) - - /// - warning: Soft-deprecated in Nuke 12.7. - func imageTaskDidStart(_ task: ImageTask, pipeline: ImagePipeline) - - /// - warning: Soft-deprecated in Nuke 12.7. - func imageTask(_ task: ImageTask, didUpdateProgress progress: ImageTask.Progress, pipeline: ImagePipeline) - - /// - warning: Soft-deprecated in Nuke 12.7. - func imageTask(_ task: ImageTask, didReceivePreview response: ImageResponse, pipeline: ImagePipeline) - - /// - warning: Soft-deprecated in Nuke 12.7. - func imageTaskDidCancel(_ task: ImageTask, pipeline: ImagePipeline) - - /// - warning: Soft-deprecated in Nuke 12.7. - func imageTask(_ task: ImageTask, didCompleteWithResult result: Result, pipeline: ImagePipeline) +extension ImagePipeline { + public protocol Delegate: AnyObject, Sendable { + // MARK: Configuration + + /// Returns data loader for the given request. + func dataLoader(for request: ImageRequest, pipeline: ImagePipeline) -> any DataLoading + + /// Returns image decoder for the given context. + func imageDecoder(for context: ImageDecodingContext, pipeline: ImagePipeline) -> (any ImageDecoding)? + + /// Returns image encoder for the given context. + func imageEncoder(for context: ImageEncodingContext, pipeline: ImagePipeline) -> any ImageEncoding + + // MARK: Caching + + /// Returns in-memory image cache for the given request. Return `nil` to prevent cache reads and writes. + func imageCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any ImageCaching)? + + /// Returns disk cache for the given request. Return `nil` to prevent cache + /// reads and writes. + func dataCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any DataCaching)? + + /// Returns a cache key identifying the image produced for the given request + /// (including image processors). The key is used for both in-memory and + /// on-disk caches. + /// + /// Return `nil` to use a default key. + func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? + + /// Gets called when the pipeline is about to save data for the given request. + /// The implementation must call the completion closure passing `non-nil` data + /// to enable caching or `nil` to prevent it. + /// + /// This method calls only if the request parameters and data caching policy + /// of the pipeline already allow caching. + /// + /// - parameters: + /// - data: Either the original data or the encoded image in case of storing + /// a processed or re-encoded image. + /// - image: Non-nil in case storing an encoded image. + /// - request: The request for which image is being stored. + /// - completion: The implementation must call the completion closure + /// passing `non-nil` data to enable caching or `nil` to prevent it. You can + /// safely call it synchronously. The callback gets called on the background + /// thread. + func willCache(data: Data, image: ImageContainer?, for request: ImageRequest, pipeline: ImagePipeline, completion: @escaping (Data?) -> Void) + + // MARK: Decompression + + func shouldDecompress(response: ImageResponse, for request: ImageRequest, pipeline: ImagePipeline) -> Bool + + func decompress(response: ImageResponse, request: ImageRequest, pipeline: ImagePipeline) -> ImageResponse + + // MARK: ImageTask + + /// Gets called when the task is created. Unlike other methods, it is called + /// immediately on the caller's queue. + func imageTaskCreated(_ task: ImageTask, pipeline: ImagePipeline) + + /// Gets called when the task receives an event. + func imageTask(_ task: ImageTask, didReceiveEvent event: ImageTask.Event, pipeline: ImagePipeline) + } } -extension ImagePipelineDelegate { +extension ImagePipeline.Delegate { public func imageCache(for request: ImageRequest, pipeline: ImagePipeline) -> (any ImageCaching)? { pipeline.configuration.imageCache } @@ -127,16 +114,10 @@ extension ImagePipelineDelegate { public func imageTaskCreated(_ task: ImageTask, pipeline: ImagePipeline) {} public func imageTask(_ task: ImageTask, didReceiveEvent event: ImageTask.Event, pipeline: ImagePipeline) {} - - public func imageTaskDidStart(_ task: ImageTask, pipeline: ImagePipeline) {} - - public func imageTask(_ task: ImageTask, didUpdateProgress progress: ImageTask.Progress, pipeline: ImagePipeline) {} - - public func imageTask(_ task: ImageTask, didReceivePreview response: ImageResponse, pipeline: ImagePipeline) {} - - public func imageTaskDidCancel(_ task: ImageTask, pipeline: ImagePipeline) {} - - public func imageTask(_ task: ImageTask, didCompleteWithResult result: Result, pipeline: ImagePipeline) {} } -final class ImagePipelineDefaultDelegate: ImagePipelineDelegate {} +final class ImagePipelineDefaultDelegate: ImagePipeline.Delegate {} + +// Deprecated in Nuke 13.0 +@available(*, deprecated, renamed: "ImagePipeline.Delegate", message: "") +public typealias ImagePipelineDelegate = ImagePipeline.Delegate diff --git a/Sources/Nuke/Pipeline/ImagePipeline+Error.swift b/Sources/Nuke/Pipeline/ImagePipeline+Error.swift index 0e0152a3d..fe42f0e99 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline+Error.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline+Error.swift @@ -6,7 +6,7 @@ import Foundation extension ImagePipeline { /// Represents all possible image pipeline errors. - public enum Error: Swift.Error, CustomStringConvertible, @unchecked Sendable { + public enum Error: Swift.Error, CustomStringConvertible, Sendable { /// Returned if data not cached and ``ImageRequest/Options-swift.struct/returnCacheDataDontLoad`` option is specified. case dataMissingInCache /// Data loader failed to load image data with a wrapped error. diff --git a/Sources/Nuke/Pipeline/ImagePipeline.swift b/Sources/Nuke/Pipeline/ImagePipeline.swift index f25cd07da..326169b7e 100644 --- a/Sources/Nuke/Pipeline/ImagePipeline.swift +++ b/Sources/Nuke/Pipeline/ImagePipeline.swift @@ -3,7 +3,6 @@ // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). import Foundation -import Combine #if canImport(UIKit) import UIKit @@ -13,23 +12,24 @@ import UIKit import AppKit #endif -/// The pipeline downloads and caches images, and prepares them for display. -public final class ImagePipeline: @unchecked Sendable { +/// The pipeline downloads and caches images, and prepares them for display. +@ImagePipelineActor +public final class ImagePipeline { /// Returns the shared image pipeline. - public static var shared: ImagePipeline { + public nonisolated static var shared: ImagePipeline { get { _shared.value } set { _shared.value = newValue } } - private static let _shared = Atomic(value: ImagePipeline(configuration: .withURLCache)) + private nonisolated static let _shared = Mutex(ImagePipeline(configuration: .withURLCache)) /// The pipeline configuration. - public let configuration: Configuration + public nonisolated let configuration: Configuration /// Provides access to the underlying caching subsystems. - public var cache: ImagePipeline.Cache { .init(pipeline: self) } + public nonisolated var cache: ImagePipeline.Cache { .init(pipeline: self) } - let delegate: any ImagePipelineDelegate + let delegate: any ImagePipeline.Delegate private var tasks = [ImageTask: TaskSubscription]() @@ -38,28 +38,18 @@ public final class ImagePipeline: @unchecked Sendable { private let tasksFetchOriginalImage: TaskPool private let tasksFetchOriginalData: TaskPool - // The queue on which the entire subsystem is synchronized. - let queue = DispatchQueue(label: "com.github.kean.Nuke.ImagePipeline", qos: .userInitiated) private var isInvalidated = false - private var nextTaskId: Int64 { - os_unfair_lock_lock(lock) - defer { os_unfair_lock_unlock(lock) } - _nextTaskId += 1 - return _nextTaskId - } - private var _nextTaskId: Int64 = 0 - private let lock: os_unfair_lock_t + private nonisolated let nextTaskId = Mutex(0) let rateLimiter: RateLimiter? let id = UUID() - var onTaskStarted: ((ImageTask) -> Void)? // Debug purposes - deinit { - lock.deinitialize(count: 1) - lock.deallocate() + // For testing purposes + nonisolated(unsafe) var onTaskStarted: ((ImageTask) -> Void)? - ResumableDataStorage.shared.unregister(self) + deinit { + ResumableDataStorage.shared.unregister(id) } /// Initializes the instance with the given configuration. @@ -67,9 +57,9 @@ public final class ImagePipeline: @unchecked Sendable { /// - parameters: /// - configuration: The pipeline configuration. /// - delegate: Provides more ways to customize the pipeline behavior on per-request basis. - public init(configuration: Configuration = Configuration(), delegate: (any ImagePipelineDelegate)? = nil) { + public nonisolated init(configuration: Configuration = Configuration(), delegate: (any ImagePipeline.Delegate)? = nil) { self.configuration = configuration - self.rateLimiter = configuration.isRateLimiterEnabled ? RateLimiter(queue: queue) : nil + self.rateLimiter = configuration.isRateLimiterEnabled ? RateLimiter() : nil self.delegate = delegate ?? ImagePipelineDefaultDelegate() (configuration.dataLoader as? DataLoader)?.prefersIncrementalDelivery = configuration.isProgressiveDecodingEnabled @@ -79,10 +69,7 @@ public final class ImagePipeline: @unchecked Sendable { self.tasksFetchOriginalImage = TaskPool(isCoalescingEnabled) self.tasksFetchOriginalData = TaskPool(isCoalescingEnabled) - self.lock = .allocate(capacity: 1) - self.lock.initialize(to: os_unfair_lock()) - - ResumableDataStorage.shared.register(self) + ResumableDataStorage.shared.register(id) } /// A convenience way to initialize the pipeline with a closure. @@ -99,7 +86,7 @@ public final class ImagePipeline: @unchecked Sendable { /// - parameters: /// - configuration: The pipeline configuration. /// - delegate: Provides more ways to customize the pipeline behavior on per-request basis. - public convenience init(delegate: (any ImagePipelineDelegate)? = nil, _ configure: (inout ImagePipeline.Configuration) -> Void) { + public nonisolated convenience init(delegate: (any ImagePipeline.Delegate)? = nil, _ configure: (inout ImagePipeline.Configuration) -> Void) { var configuration = ImagePipeline.Configuration() configure(&configuration) self.init(configuration: configuration, delegate: delegate) @@ -107,28 +94,28 @@ public final class ImagePipeline: @unchecked Sendable { /// Invalidates the pipeline and cancels all outstanding tasks. Any new /// requests will immediately fail with ``ImagePipeline/Error/pipelineInvalidated`` error. - public func invalidate() { - queue.async { + public nonisolated func invalidate() { + Task { @ImagePipelineActor in guard !self.isInvalidated else { return } self.isInvalidated = true - self.tasks.keys.forEach(self.cancelImageTask) + self.tasks.keys.forEach { cancelImageTask($0) } } } - // MARK: - Loading Images (Async/Await) + // MARK: - Loading Images /// Creates a task with the given URL. /// /// The task starts executing the moment it is created. - public func imageTask(with url: URL) -> ImageTask { + public nonisolated func imageTask(with url: URL) -> ImageTask { imageTask(with: ImageRequest(url: url)) } /// Creates a task with the given request. /// /// The task starts executing the moment it is created. - public func imageTask(with request: ImageRequest) -> ImageTask { - makeStartedImageTask(with: request) + public nonisolated func imageTask(with request: ImageRequest) -> ImageTask { + makeImageTask(with: request) } /// Returns an image for the given URL. @@ -141,220 +128,44 @@ public final class ImagePipeline: @unchecked Sendable { try await imageTask(with: request).image } - // MARK: - Loading Data (Async/Await) + // MARK: - Loading Data /// Returns image data for the given request. /// /// - parameter request: An image request. public func data(for request: ImageRequest) async throws -> (Data, URLResponse?) { - let task = makeStartedImageTask(with: request, isDataTask: true) + let task = makeImageTask(with: request, isDataTask: true) let response = try await task.response return (response.container.data ?? Data(), response.urlResponse) } - // MARK: - Loading Images (Closures) - - /// Loads an image for the given request. - /// - /// - parameters: - /// - request: An image request. - /// - completion: A closure to be called on the main thread when the request - /// is finished. - @discardableResult public func loadImage( - with url: URL, - completion: @escaping (_ result: Result) -> Void - ) -> ImageTask { - _loadImage(with: ImageRequest(url: url), progress: nil, completion: completion) - } - - /// Loads an image for the given request. - /// - /// - parameters: - /// - request: An image request. - /// - completion: A closure to be called on the main thread when the request - /// is finished. - @discardableResult public func loadImage( - with request: ImageRequest, - completion: @escaping (_ result: Result) -> Void - ) -> ImageTask { - _loadImage(with: request, progress: nil, completion: completion) - } - - /// Loads an image for the given request. - /// - /// - parameters: - /// - request: An image request. - /// - progress: A closure to be called periodically on the main thread when - /// the progress is updated. - /// - completion: A closure to be called on the main thread when the request - /// is finished. - @discardableResult public func loadImage( - with request: ImageRequest, - queue: DispatchQueue? = nil, - progress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping (_ result: Result) -> Void - ) -> ImageTask { - _loadImage(with: request, queue: queue, progress: { - progress?($0, $1.completed, $1.total) - }, completion: completion) - } - - func _loadImage( - with request: ImageRequest, - isDataTask: Bool = false, - queue callbackQueue: DispatchQueue? = nil, - progress: ((ImageResponse?, ImageTask.Progress) -> Void)?, - completion: @escaping (Result) -> Void - ) -> ImageTask { - makeStartedImageTask(with: request, isDataTask: isDataTask) { [weak self] event, task in - self?.dispatchCallback(to: callbackQueue) { - // The callback-based API guarantees that after cancellation no - // event are called on the callback queue. - guard task.state != .cancelled else { return } - switch event { - case .progress(let value): progress?(nil, value) - case .preview(let response): progress?(response, task.currentProgress) - case .cancelled: break // The legacy APIs do not send cancellation events - case .finished(let result): - _ = task._setState(.completed) // Important to do it on the callback queue - completion(result) - } - } - } - } - - // nuke-13: requires callbacks to be @MainActor @Sendable or deprecate this entire API - private func dispatchCallback(to callbackQueue: DispatchQueue?, _ closure: @escaping () -> Void) { - let box = UncheckedSendableBox(value: closure) - if callbackQueue === self.queue { - closure() - } else { - (callbackQueue ?? self.configuration._callbackQueue).async { - box.value() - } - } - } - - // MARK: - Loading Data (Closures) - - /// Loads image data for the given request. The data doesn't get decoded - /// or processed in any other way. - @discardableResult public func loadData(with request: ImageRequest, completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void) -> ImageTask { - _loadData(with: request, queue: nil, progress: nil, completion: completion) - } - - private func _loadData( - with request: ImageRequest, - queue: DispatchQueue?, - progress progressHandler: ((_ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) -> ImageTask { - _loadImage(with: request, isDataTask: true, queue: queue) { _, progress in - progressHandler?(progress.completed, progress.total) - } completion: { result in - let result = result.map { response in - // Data should never be empty - (data: response.container.data ?? Data(), response: response.urlResponse) - } - completion(result) - } - } - - /// Loads the image data for the given request. The data doesn't get decoded - /// or processed in any other way. - /// - /// You can call ``loadImage(with:completion:)-43osv`` for the request at any point after calling - /// ``loadData(with:completion:)-6cwk3``, the pipeline will use the same operation to load the data, - /// no duplicated work will be performed. - /// - /// - parameters: - /// - request: An image request. - /// - queue: A queue on which to execute `progress` and `completion` - /// callbacks. By default, the pipeline uses `.main` queue. - /// - progress: A closure to be called periodically on the main thread when the progress is updated. - /// - completion: A closure to be called on the main thread when the request is finished. - @discardableResult public func loadData( - with request: ImageRequest, - queue: DispatchQueue? = nil, - progress progressHandler: ((_ completed: Int64, _ total: Int64) -> Void)?, - completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void - ) -> ImageTask { - _loadImage(with: request, isDataTask: true, queue: queue) { _, progress in - progressHandler?(progress.completed, progress.total) - } completion: { result in - let result = result.map { response in - // Data should never be empty - (data: response.container.data ?? Data(), response: response.urlResponse) - } - completion(result) - } - } - - // MARK: - Loading Images (Combine) - - /// Returns a publisher which starts a new ``ImageTask`` when a subscriber is added. - public func imagePublisher(with url: URL) -> AnyPublisher { - imagePublisher(with: ImageRequest(url: url)) - } - - /// Returns a publisher which starts a new ``ImageTask`` when a subscriber is added. - public func imagePublisher(with request: ImageRequest) -> AnyPublisher { - ImagePublisher(request: request, pipeline: self).eraseToAnyPublisher() - } - // MARK: - ImageTask (Internal) - private func makeStartedImageTask(with request: ImageRequest, isDataTask: Bool = false, onEvent: ((ImageTask.Event, ImageTask) -> Void)? = nil) -> ImageTask { - let task = ImageTask(taskId: nextTaskId, request: request, isDataTask: isDataTask, pipeline: self, onEvent: onEvent) - // Important to call it before `imageTaskStartCalled` - if !isDataTask { - delegate.imageTaskCreated(task, pipeline: self) - } - task._task = Task { - try await withUnsafeThrowingContinuation { continuation in - self.queue.async { - task._continuation = continuation - self.startImageTask(task, isDataTask: isDataTask) - } - } - } + nonisolated func makeImageTask(with request: ImageRequest, isDataTask: Bool = false, onEvent: (@Sendable (ImageTask.Event, ImageTask) -> Void)? = nil) -> ImageTask { + let task = ImageTask(taskId: nextTaskId.incremented(), request: request, isDataTask: isDataTask, pipeline: self, onEvent: onEvent) + delegate.imageTaskCreated(task, pipeline: self) return task } // By this time, the task has `continuation` set and is fully wired. - private func startImageTask(_ task: ImageTask, isDataTask: Bool) { - guard task._state != .cancelled else { - // The task gets started asynchronously in a `Task` and cancellation - // can happen before the pipeline reached `startImageTask`. In that - // case, the `cancel` method do no send the task event. - return task._dispatch(.cancelled) - } + func startImageTask(_ task: ImageTask, isDataTask: Bool) { guard !isInvalidated else { - return task._process(.error(.pipelineInvalidated)) + return task.process(.error(.pipelineInvalidated)) } let worker = isDataTask ? makeTaskLoadData(for: task.request) : makeTaskLoadImage(for: task.request) tasks[task] = worker.subscribe(priority: task.priority.taskPriority, subscriber: task) { [weak task] in - task?._process($0) + task?.process($0) } - delegate.imageTaskDidStart(task, pipeline: self) onTaskStarted?(task) } - private func cancelImageTask(_ task: ImageTask) { + func cancelImageTask(_ task: ImageTask) { tasks.removeValue(forKey: task)?.unsubscribe() task._cancel() } - // MARK: - Image Task Events - - func imageTaskCancelCalled(_ task: ImageTask) { - queue.async { self.cancelImageTask(task) } - } - - func imageTaskUpdatePriorityCalled(_ task: ImageTask, priority: ImageRequest.Priority) { - queue.async { - self.tasks[task]?.setPriority(priority.taskPriority) - } + func imageTask(_ task: ImageTask, didChangePriority priority: ImageRequest.Priority) { + self.tasks[task]?.setPriority(priority.taskPriority) } func imageTask(_ task: ImageTask, didProcessEvent event: ImageTask.Event, isDataTask: Bool) { @@ -363,19 +174,8 @@ public final class ImagePipeline: @unchecked Sendable { tasks[task] = nil default: break } - if !isDataTask { delegate.imageTask(task, didReceiveEvent: event, pipeline: self) - switch event { - case .progress(let progress): - delegate.imageTask(task, didUpdateProgress: progress, pipeline: self) - case .preview(let response): - delegate.imageTask(task, didReceivePreview: response, pipeline: self) - case .cancelled: - delegate.imageTaskDidCancel(task, pipeline: self) - case .finished(let result): - delegate.imageTask(task, didCompleteWithResult: result, pipeline: self) - } } } @@ -419,21 +219,7 @@ public final class ImagePipeline: @unchecked Sendable { func makeTaskFetchOriginalData(for request: ImageRequest) -> AsyncTask<(Data, URLResponse?), Error>.Publisher { tasksFetchOriginalData.publisherForKey(TaskFetchOriginalDataKey(request)) { - request.publisher == nil ? TaskFetchOriginalData(self, request) : TaskFetchWithPublisher(self, request) + request.closure == nil ? TaskFetchOriginalData(self, request) : TaskFetchWithClosure(self, request) } } - - // MARK: - Deprecated - - // Deprecated in Nuke 12.7 - @available(*, deprecated, message: "Please the variant variant that accepts `ImageRequest` as a parameter") - @discardableResult public func loadData(with url: URL, completion: @escaping (Result<(data: Data, response: URLResponse?), Error>) -> Void) -> ImageTask { - loadData(with: ImageRequest(url: url), queue: nil, progress: nil, completion: completion) - } - - // Deprecated in Nuke 12.7 - @available(*, deprecated, message: "Please the variant that accepts `ImageRequest` as a parameter") - @discardableResult public func data(for url: URL) async throws -> (Data, URLResponse?) { - try await data(for: ImageRequest(url: url)) - } } diff --git a/Sources/Nuke/Pipeline/ImagePipelineActor.swift b/Sources/Nuke/Pipeline/ImagePipelineActor.swift new file mode 100644 index 000000000..f801cd4dc --- /dev/null +++ b/Sources/Nuke/Pipeline/ImagePipelineActor.swift @@ -0,0 +1,13 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +// swiftlint:disable convenience_type +@globalActor +public struct ImagePipelineActor { + public actor ImagePipelineActor { } + public static let shared = ImagePipelineActor() +} +// swiftlint:enable convenience_type diff --git a/Sources/Nuke/Prefetching/ImagePrefetcher.swift b/Sources/Nuke/Prefetching/ImagePrefetcher.swift index 210ca60fb..f95ea6880 100644 --- a/Sources/Nuke/Prefetching/ImagePrefetcher.swift +++ b/Sources/Nuke/Prefetching/ImagePrefetcher.swift @@ -11,23 +11,32 @@ import Foundation /// /// All ``ImagePrefetcher`` methods are thread-safe and are optimized to be used /// even from the main thread during scrolling. -public final class ImagePrefetcher: @unchecked Sendable { - /// Pauses the prefetching. +@ImagePipelineActor +public final class ImagePrefetcher { +/// Pauses the prefetching. /// /// - note: When you pause, the prefetcher will finish outstanding tasks /// (by default, there are only 2 at a time), and pause the rest. - public var isPaused: Bool = false { - didSet { queue.isSuspended = isPaused } + public nonisolated var isPaused: Bool { + get { queue.isSuspended } + set { queue.isSuspended = newValue } } /// The priority of the requests. By default, ``ImageRequest/Priority-swift.enum/low``. /// /// Changing the priority also changes the priority of all of the outstanding /// tasks managed by the prefetcher. - public var priority: ImageRequest.Priority = .low { - didSet { - let newValue = priority - pipeline.queue.async { self.didUpdatePriority(to: newValue) } + public nonisolated var priority: ImageRequest.Priority { + get { _priority.value } + set { + guard _priority.withLock({ + guard $0 != newValue else { return false } + $0 = newValue + return true + }) else { return } + Task { + await didUpdatePriority(to: newValue) + } } } @@ -48,15 +57,15 @@ public final class ImagePrefetcher: @unchecked Sendable { /// The closure that gets called when the prefetching completes for all the /// scheduled requests. The closure is always called on completion, /// regardless of whether the requests succeed or some fail. - /// - /// - note: The closure is called on the main queue. - public var didComplete: (@MainActor @Sendable () -> Void)? private let pipeline: ImagePipeline - private var tasks = [TaskLoadImageKey: Task]() private let destination: Destination - private var _priority: ImageRequest.Priority = .low - let queue = OperationQueue() // internal for testing + private var tasks = [TaskLoadImageKey: PrefetchTask]() + private let _priority = Mutex(ImageRequest.Priority.low) + + // For testing purposes + nonisolated(unsafe) var didComplete: (@Sendable () -> Void)? + nonisolated let queue = OperationQueue() /// Initializes the ``ImagePrefetcher`` instance. /// @@ -64,20 +73,21 @@ public final class ImagePrefetcher: @unchecked Sendable { /// - pipeline: The pipeline used for loading images. /// - destination: By default load images in all cache layers. /// - maxConcurrentRequestCount: 2 by default. - public init(pipeline: ImagePipeline = ImagePipeline.shared, - destination: Destination = .memoryCache, - maxConcurrentRequestCount: Int = 2) { + public nonisolated init( + pipeline: ImagePipeline = ImagePipeline.shared, + destination: Destination = .memoryCache, + maxConcurrentRequestCount: Int = 2 + ) { self.pipeline = pipeline self.destination = destination self.queue.maxConcurrentOperationCount = maxConcurrentRequestCount - self.queue.underlyingQueue = pipeline.queue } deinit { let tasks = self.tasks.values // Make sure we don't retain self self.tasks.removeAll() - pipeline.queue.async { + Task { @ImagePipelineActor in for task in tasks { task.cancel() } @@ -87,7 +97,7 @@ public final class ImagePrefetcher: @unchecked Sendable { /// Starts prefetching images for the given URL. /// /// See also ``startPrefetching(with:)-718dg`` that works with ``ImageRequest``. - public func startPrefetching(with urls: [URL]) { + public nonisolated func startPrefetching(with urls: [URL]) { startPrefetching(with: urls.map { ImageRequest(url: $0) }) } @@ -101,17 +111,18 @@ public final class ImagePrefetcher: @unchecked Sendable { /// (`.low` by default). /// /// See also ``startPrefetching(with:)-1jef2`` that works with `URL`. - public func startPrefetching(with requests: [ImageRequest]) { - pipeline.queue.async { + public nonisolated func startPrefetching(with requests: [ImageRequest]) { + Task { @ImagePipelineActor in self._startPrefetching(with: requests) } } - public func _startPrefetching(with requests: [ImageRequest]) { + private func _startPrefetching(with requests: [ImageRequest]) { + let priority = _priority.value for request in requests { var request = request - if _priority != request.priority { - request.priority = _priority + if priority != request.priority { + request.priority = priority } _startPrefetching(with: request) } @@ -126,41 +137,43 @@ public final class ImagePrefetcher: @unchecked Sendable { guard tasks[key] == nil else { return } - let task = Task(request: request, key: key) + let task = PrefetchTask(request: request, key: key) task.operation = queue.add { [weak self] finish in guard let self else { return finish() } - self.loadImage(task: task, finish: finish) + Task { @ImagePipelineActor in + self.loadImage(task: task, finish: finish) + } } tasks[key] = task return } - private func loadImage(task: Task, finish: @escaping () -> Void) { - task.imageTask = pipeline._loadImage(with: task.request, isDataTask: destination == .diskCache, queue: pipeline.queue, progress: nil) { [weak self] _ in + private func loadImage(task: PrefetchTask, finish: @escaping () -> Void) { + let imageTask = pipeline.makeImageTask(with: task.request, isDataTask: destination == .diskCache) + task.imageTask = imageTask + Task { [weak self] in + _ = try? await imageTask.response self?._remove(task) finish() } task.onCancelled = finish } - private func _remove(_ task: Task) { + private func _remove(_ task: PrefetchTask) { guard tasks[task.key] === task else { return } // Should never happen tasks[task.key] = nil sendCompletionIfNeeded() } private func sendCompletionIfNeeded() { - guard tasks.isEmpty, let callback = didComplete else { - return - } - DispatchQueue.main.async(execute: callback) + if tasks.isEmpty { didComplete?() } } /// Stops prefetching images for the given URLs and cancels outstanding /// requests. /// /// See also ``stopPrefetching(with:)-8cdam`` that works with ``ImageRequest``. - public func stopPrefetching(with urls: [URL]) { + public nonisolated func stopPrefetching(with urls: [URL]) { stopPrefetching(with: urls.map { ImageRequest(url: $0) }) } @@ -172,8 +185,8 @@ public final class ImagePrefetcher: @unchecked Sendable { /// of ``ImagePrefetcher``. /// /// See also ``stopPrefetching(with:)-2tcyq`` that works with `URL`. - public func stopPrefetching(with requests: [ImageRequest]) { - pipeline.queue.async { + public nonisolated func stopPrefetching(with requests: [ImageRequest]) { + Task { @ImagePipelineActor in for request in requests { self._stopPrefetching(with: request) } @@ -187,22 +200,20 @@ public final class ImagePrefetcher: @unchecked Sendable { } /// Stops all prefetching tasks. - public func stopPrefetching() { - pipeline.queue.async { + public nonisolated func stopPrefetching() { + Task { @ImagePipelineActor in self.tasks.values.forEach { $0.cancel() } self.tasks.removeAll() } } private func didUpdatePriority(to priority: ImageRequest.Priority) { - guard _priority != priority else { return } - _priority = priority for task in tasks.values { task.imageTask?.priority = priority } } - private final class Task: @unchecked Sendable { + private final class PrefetchTask: @unchecked Sendable { let key: TaskLoadImageKey let request: ImageRequest weak var imageTask: ImageTask? diff --git a/Sources/Nuke/Processing/ImageProcessingOptions.swift b/Sources/Nuke/Processing/ImageProcessingOptions.swift index 2ace3a688..03d652503 100644 --- a/Sources/Nuke/Processing/ImageProcessingOptions.swift +++ b/Sources/Nuke/Processing/ImageProcessingOptions.swift @@ -34,7 +34,7 @@ public enum ImageProcessingOptions: Sendable { /// views in which they get displayed. If you can't guarantee that, pleasee /// consider adding border to a view layer. This should be your primary /// option regardless. - public struct Border: Hashable, CustomStringConvertible, @unchecked Sendable { + public struct Border: Hashable, CustomStringConvertible, Sendable { public let width: CGFloat #if canImport(UIKit) diff --git a/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift b/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift index a199e9120..fb7b2cd25 100644 --- a/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift +++ b/Sources/Nuke/Processing/ImageProcessors+CoreImage.swift @@ -83,7 +83,7 @@ extension ImageProcessors { set { _context.value = newValue } } - private static let _context = Atomic(value: CIContext(options: [.priorityRequestLow: true])) + private static let _context = Mutex(CIContext(options: [.priorityRequestLow: true])) static func applyFilter(named name: String, parameters: [String: Any] = [:], to image: PlatformImage) throws -> PlatformImage { guard let filter = CIFilter(name: name, parameters: parameters) else { diff --git a/Sources/Nuke/Processing/ImageProcessors+Resize.swift b/Sources/Nuke/Processing/ImageProcessors+Resize.swift index 984136c2e..c87a7d935 100644 --- a/Sources/Nuke/Processing/ImageProcessors+Resize.swift +++ b/Sources/Nuke/Processing/ImageProcessors+Resize.swift @@ -19,10 +19,6 @@ extension ImageProcessors { private let crop: Bool private let upscale: Bool - // Deprecated in Nuke 12.0 - @available(*, deprecated, message: "Renamed to `ImageProcessingOptions.ContentMode") - public typealias ContentMode = ImageProcessingOptions.ContentMode - /// Initializes the processor with the given size. /// /// - parameters: diff --git a/Sources/Nuke/Tasks/AsyncPipelineTask.swift b/Sources/Nuke/Tasks/AsyncPipelineTask.swift index 2865e3bae..1f5d97818 100644 --- a/Sources/Nuke/Tasks/AsyncPipelineTask.swift +++ b/Sources/Nuke/Tasks/AsyncPipelineTask.swift @@ -6,7 +6,7 @@ import Foundation // Each task holds a strong reference to the pipeline. This is by design. The // user does not need to hold a strong reference to the pipeline. -class AsyncPipelineTask: AsyncTask, @unchecked Sendable { +class AsyncPipelineTask: AsyncTask { let pipeline: ImagePipeline // A canonical request representing the unit work performed by the task. let request: ImageRequest @@ -19,6 +19,7 @@ class AsyncPipelineTask: AsyncTask, // Returns all image tasks subscribed to the current pipeline task. // A suboptimal approach just to make the new DiskCachPolicy.automatic work. +@ImagePipelineActor protocol ImageTaskSubscribers { var imageTasks: [ImageTask] { get } } @@ -50,12 +51,9 @@ extension AsyncPipelineTask { guard decoder.isAsynchronous else { return completion(decode()) } - operation = pipeline.configuration.imageDecodingQueue.add { [weak self] in - guard let self else { return } + operation = pipeline.configuration.imageDecodingQueue.add { let response = decode() - self.pipeline.queue.async { - completion(response) - } + completion(response) } } } diff --git a/Sources/Nuke/Tasks/AsyncTask.swift b/Sources/Nuke/Tasks/AsyncTask.swift index e381f51fe..c5d06a5d7 100644 --- a/Sources/Nuke/Tasks/AsyncTask.swift +++ b/Sources/Nuke/Tasks/AsyncTask.swift @@ -14,9 +14,8 @@ import Foundation /// automatically cancels them, updates the priority, etc. Most steps in the /// image pipeline are represented using Operation to take advantage of these features. /// -/// - warning: Must be thread-confined! -class AsyncTask: AsyncTaskSubscriptionDelegate, @unchecked Sendable { - +@ImagePipelineActor +class AsyncTask: AsyncTaskSubscriptionDelegate { private struct Subscription { let closure: (Event) -> Void weak var subscriber: AnyObject? @@ -76,6 +75,8 @@ class AsyncTask: AsyncTaskSubscriptionDelegate /// Override this to start image task. Only gets called once. func start() {} + init() {} + // MARK: - Managing Observers /// - notes: Returns `nil` if the task was disposed. @@ -218,6 +219,7 @@ class AsyncTask: AsyncTaskSubscriptionDelegate extension AsyncTask { /// Publishes the results of the task. + @ImagePipelineActor struct Publisher { fileprivate let task: AsyncTask @@ -281,7 +283,8 @@ extension AsyncTask.Event: Equatable where Value: Equatable, Error: Equatable {} /// Represents a subscription to a task. The observer must retain a strong /// reference to a subscription. -struct TaskSubscription: Sendable { +@ImagePipelineActor +struct TaskSubscription { private let task: any AsyncTaskSubscriptionDelegate private let key: TaskSubscriptionKey @@ -311,7 +314,8 @@ struct TaskSubscription: Sendable { } } -private protocol AsyncTaskSubscriptionDelegate: AnyObject, Sendable { +@ImagePipelineActor +private protocol AsyncTaskSubscriptionDelegate: AnyObject { func unsubsribe(key: TaskSubscriptionKey) func setPriority(_ priority: TaskPriority, for observer: TaskSubscriptionKey) } @@ -320,12 +324,12 @@ private typealias TaskSubscriptionKey = Int // MARK: - TaskPool -/// Contains the tasks which haven't completed yet. +@ImagePipelineActor final class TaskPool { private let isCoalescingEnabled: Bool private var map = [Key: AsyncTask]() - init(_ isCoalescingEnabled: Bool) { + nonisolated init(_ isCoalescingEnabled: Bool) { self.isCoalescingEnabled = isCoalescingEnabled } diff --git a/Sources/Nuke/Tasks/TaskFetchOriginalData.swift b/Sources/Nuke/Tasks/TaskFetchOriginalData.swift index 519330f22..75ebf0c85 100644 --- a/Sources/Nuke/Tasks/TaskFetchOriginalData.swift +++ b/Sources/Nuke/Tasks/TaskFetchOriginalData.swift @@ -6,7 +6,7 @@ import Foundation /// Fetches original image from the data loader (`DataLoading`) and stores it /// in the disk cache (`DataCaching`). -final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unchecked Sendable { +final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)> { private var urlResponse: URLResponse? private var resumableData: ResumableData? private var resumedDataCount: Int64 = 0 @@ -54,7 +54,7 @@ final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unc guard let self else { return finish() } - self.pipeline.queue.async { + Task { @ImagePipelineActor in self.loadData(urlRequest: urlRequest, finish: finish) } } @@ -70,7 +70,7 @@ final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unc // back in the cache if the request fails to complete again). var urlRequest = urlRequest if pipeline.configuration.isResumableDataEnabled, - let resumableData = ResumableDataStorage.shared.removeResumableData(for: request, pipeline: pipeline) { + let resumableData = ResumableDataStorage.shared.removeResumableData(for: request, namespace: pipeline.id) { // Update headers to add "Range" and "If-Range" headers resumableData.resume(request: &urlRequest) // Save resumable data to be used later (before using it, the pipeline @@ -78,28 +78,28 @@ final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unc self.resumableData = resumableData } - signpost(self, "LoadImageData", .begin, "URL: \(urlRequest.url?.absoluteString ?? ""), resumable data: \(Formatter.bytes(resumableData?.data.count ?? 0))") + signpost(self, "LoadImageData", .begin, "URL: \(String(describing: urlRequest.url))") let dataLoader = pipeline.delegate.dataLoader(for: request, pipeline: pipeline) - let dataTask = dataLoader.loadData(with: urlRequest, didReceiveData: { [weak self] data, response in - guard let self else { return } - self.pipeline.queue.async { - self.dataTask(didReceiveData: data, response: response) + + let task = Task { @ImagePipelineActor in + do { + for try await (data, response) in dataLoader.loadData(for: urlRequest) { + dataTask(didReceiveData: data, response: response) + } + dataTaskDidFinish(error: nil) + } catch { + dataTaskDidFinish(error: error) } - }, completion: { [weak self] error in - finish() // Finish the operation! - guard let self else { return } signpost(self, "LoadImageData", .end, "Finished with size \(Formatter.bytes(self.data.count))") - self.pipeline.queue.async { - self.dataTaskDidFinish(error: error) - } - }) + finish() // Finish the operation! + } onCancelled = { [weak self] in guard let self else { return } signpost(self, "LoadImageData", .end, "Cancelled") - dataTask.cancel() + task.cancel() finish() // Finish the operation! self.tryToSaveResumableData() @@ -162,7 +162,7 @@ final class TaskFetchOriginalData: AsyncPipelineTask<(Data, URLResponse?)>, @unc if pipeline.configuration.isResumableDataEnabled, let response = urlResponse, !data.isEmpty, let resumableData = ResumableData(response: response, data: data) { - ResumableDataStorage.shared.storeResumableData(resumableData, for: request, pipeline: pipeline) + ResumableDataStorage.shared.storeResumableData(resumableData, for: request, namespace: pipeline.id) } } } diff --git a/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift b/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift index 1f9901de2..9fd668fa2 100644 --- a/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift +++ b/Sources/Nuke/Tasks/TaskFetchOriginalImage.swift @@ -5,7 +5,7 @@ import Foundation /// Receives data from ``TaskLoadImageData`` and decodes it as it arrives. -final class TaskFetchOriginalImage: AsyncPipelineTask, @unchecked Sendable { +final class TaskFetchOriginalImage: AsyncPipelineTask { private var decoder: (any ImageDecoding)? override func start() { @@ -37,9 +37,11 @@ final class TaskFetchOriginalImage: AsyncPipelineTask, @unchecked } return } - - decode(context, decoder: decoder) { [weak self] in - self?.didFinishDecoding(context: context, result: $0) + decode(context, decoder: decoder) { [weak self] result in + guard let self else { return } + Task { + await self.didFinishDecoding(context: context, result: result) + } } } diff --git a/Sources/Nuke/Tasks/TaskFetchWithPublisher.swift b/Sources/Nuke/Tasks/TaskFetchWithClosure.swift similarity index 56% rename from Sources/Nuke/Tasks/TaskFetchWithPublisher.swift rename to Sources/Nuke/Tasks/TaskFetchWithClosure.swift index 19faec294..7f7fa8996 100644 --- a/Sources/Nuke/Tasks/TaskFetchWithPublisher.swift +++ b/Sources/Nuke/Tasks/TaskFetchWithClosure.swift @@ -6,7 +6,7 @@ import Foundation /// Fetches data using the publisher provided with the request. /// Unlike `TaskFetchOriginalImageData`, there is no resumable data involved. -final class TaskFetchWithPublisher: AsyncPipelineTask<(Data, URLResponse?)>, @unchecked Sendable { +final class TaskFetchWithClosure: AsyncPipelineTask<(Data, URLResponse?)> { private lazy var data = Data() override func start() { @@ -19,7 +19,7 @@ final class TaskFetchWithPublisher: AsyncPipelineTask<(Data, URLResponse?)>, @un guard let self else { return finish() } - self.pipeline.queue.async { + Task { @ImagePipelineActor in self.loadData { finish() } } } @@ -32,41 +32,27 @@ final class TaskFetchWithPublisher: AsyncPipelineTask<(Data, URLResponse?)>, @un return finish() } - guard let publisher = request.publisher else { + guard let closure = request.closure else { send(error: .dataLoadingFailed(error: URLError(.unknown))) // This is just a placeholder error, never thrown return assertionFailure("This should never happen") } - let cancellable = publisher.sink(receiveCompletion: { [weak self] result in - finish() // Finish the operation! - guard let self else { return } - self.pipeline.queue.async { - self.dataTaskDidFinish(result) - } - }, receiveValue: { [weak self] data in - guard let self else { return } - self.pipeline.queue.async { - self.data.append(data) + let task = Task { @ImagePipelineActor in + do { + let data = try await closure() + guard !data.isEmpty else { + throw ImagePipeline.Error.dataIsEmpty + } + storeDataInCacheIfNeeded(data) + send(value: (data, nil), isCompleted: true) + } catch { + send(error: .dataLoadingFailed(error: error)) } - }) - + finish() // Finish the operation! + } onCancelled = { finish() - cancellable.cancel() - } - } - - private func dataTaskDidFinish(_ result: PublisherCompletion) { - switch result { - case .finished: - guard !data.isEmpty else { - send(error: .dataIsEmpty) - return - } - storeDataInCacheIfNeeded(data) - send(value: (data, nil), isCompleted: true) - case .failure(let error): - send(error: .dataLoadingFailed(error: error)) + task.cancel() } } } diff --git a/Sources/Nuke/Tasks/TaskLoadData.swift b/Sources/Nuke/Tasks/TaskLoadData.swift index c571c666e..02b75086d 100644 --- a/Sources/Nuke/Tasks/TaskLoadData.swift +++ b/Sources/Nuke/Tasks/TaskLoadData.swift @@ -5,7 +5,7 @@ import Foundation /// Wrapper for tasks created by `loadData` calls. -final class TaskLoadData: AsyncPipelineTask, @unchecked Sendable { +final class TaskLoadData: AsyncPipelineTask { override func start() { if let data = pipeline.cache.cachedData(for: request) { let container = ImageContainer(image: .init(), data: data) diff --git a/Sources/Nuke/Tasks/TaskLoadImage.swift b/Sources/Nuke/Tasks/TaskLoadImage.swift index 2f4b6b7e2..e15b49c8a 100644 --- a/Sources/Nuke/Tasks/TaskLoadImage.swift +++ b/Sources/Nuke/Tasks/TaskLoadImage.swift @@ -9,7 +9,7 @@ import Foundation /// Performs all the quick cache lookups and also manages image processing. /// The coalescing for image processing is implemented on demand (extends the /// scenarios in which coalescing can kick in). -final class TaskLoadImage: AsyncPipelineTask, @unchecked Sendable { +final class TaskLoadImage: AsyncPipelineTask { override func start() { if let container = pipeline.cache[request] { let response = ImageResponse(container: container, request: request, cacheType: .memory) @@ -30,8 +30,11 @@ final class TaskLoadImage: AsyncPipelineTask, @unchecked Sendable guard let decoder = pipeline.delegate.imageDecoder(for: context, pipeline: pipeline) else { return didFinishDecoding(with: nil) } - decode(context, decoder: decoder) { [weak self] in - self?.didFinishDecoding(with: try? $0.get()) + decode(context, decoder: decoder) { [weak self] result in + guard let self else { return } + Task { + await self.didFinishDecoding(with: try? result.get()) + } } } @@ -82,7 +85,7 @@ final class TaskLoadImage: AsyncPipelineTask, @unchecked Sendable ImagePipeline.Error.processingFailed(processor: processor, context: context, error: error) } } - self.pipeline.queue.async { + Task { @ImagePipelineActor in self.operation = nil self.didFinishProcessing(result: result, isCompleted: isCompleted) } @@ -117,7 +120,7 @@ final class TaskLoadImage: AsyncPipelineTask, @unchecked Sendable let response = signpost(isCompleted ? "DecompressImage" : "DecompressProgressiveImage") { self.pipeline.delegate.decompress(response: response, request: self.request, pipeline: self.pipeline) } - self.pipeline.queue.async { + Task { @ImagePipelineActor in self.operation = nil self.didReceiveDecompressedImage(response, isCompleted: isCompleted) } diff --git a/Sources/Nuke/Internal/ImagePublisher.swift b/Sources/NukeExtensions/ImagePipeline+Combine.swift similarity index 81% rename from Sources/Nuke/Internal/ImagePublisher.swift rename to Sources/NukeExtensions/ImagePipeline+Combine.swift index 985fbc992..aeaa886f9 100644 --- a/Sources/Nuke/Internal/ImagePublisher.swift +++ b/Sources/NukeExtensions/ImagePipeline+Combine.swift @@ -1,9 +1,22 @@ // The MIT License (MIT) // -// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). +// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -import Foundation import Combine +import Foundation +import Nuke + +extension ImagePipeline { + /// Returns a publisher which starts a new ``ImageTask`` when a subscriber is added. + public nonisolated func imagePublisher(with url: URL) -> AnyPublisher { + imagePublisher(with: ImageRequest(url: url)) + } + + /// Returns a publisher which starts a new ``ImageTask`` when a subscriber is added. + public nonisolated func imagePublisher(with request: ImageRequest) -> AnyPublisher { + ImagePublisher(request: request, pipeline: self).eraseToAnyPublisher() + } +} /// A publisher that starts a new `ImageTask` when a subscriber is added. /// diff --git a/Sources/NukeExtensions/ImageViewExtensions.swift b/Sources/NukeExtensions/ImageViewExtensions.swift index 82bd8d7ea..b1fca39ec 100644 --- a/Sources/NukeExtensions/ImageViewExtensions.swift +++ b/Sources/NukeExtensions/ImageViewExtensions.swift @@ -290,7 +290,7 @@ private final class ImageViewController { imageView.nuke_display(image: nil, data: nil) // Remove previously displayed images (if any) } - task = pipeline.loadImage(with: request, queue: .main, progress: { [weak self] response, completedCount, totalCount in + task = pipeline.loadImage(with: request, progress: { [weak self] response, completedCount, totalCount in if let response, options.isProgressiveRenderingEnabled { self?.handle(partialImage: response) } diff --git a/Sources/NukeUI/FetchImage.swift b/Sources/NukeUI/FetchImage.swift index e0fc9025c..eef3c12d3 100644 --- a/Sources/NukeUI/FetchImage.swift +++ b/Sources/NukeUI/FetchImage.swift @@ -194,11 +194,8 @@ public final class FetchImage: ObservableObject, Identifiable { // MARK: Load (Combine) - /// Loads an image with the given publisher. - /// - /// - important: Some `FetchImage` features, such as progress reporting and - /// dynamically changing the request priority, are not available when - /// working with a publisher. + // Deprecated in Nuke 13.0 + @available(*, deprecated, message: "Please use Async/Await instead") public func load(_ publisher: P) where P.Output == ImageResponse { reset() diff --git a/Sources/NukeUI/LazyImageView.swift b/Sources/NukeUI/LazyImageView.swift index 5bc7fcff5..60252f363 100644 --- a/Sources/NukeUI/LazyImageView.swift +++ b/Sources/NukeUI/LazyImageView.swift @@ -290,22 +290,18 @@ public final class LazyImageView: _PlatformBaseView { setPlaceholderViewHidden(false) - let task = pipeline.loadImage( - with: request, - queue: .main, - progress: { [weak self] response, completed, total in - guard let self else { return } - let progress = ImageTask.Progress(completed: completed, total: total) - if let response { - self.handle(preview: response) - self.onPreview?(response) - } else { - self.onProgress?(progress) - } - }, - completion: { [weak self] result in - self?.handle(result: result.mapError { $0 }, isSync: false) + let task = pipeline.loadImage(with: request, progress: { [weak self] response, completed, total in + guard let self else { return } + let progress = ImageTask.Progress(completed: completed, total: total) + if let response { + self.handle(preview: response) + self.onPreview?(response) + } else { + self.onProgress?(progress) } + }, completion: { [weak self] result in + self?.handle(result: result.mapError { $0 }, isSync: false) + } ) imageTask = task onStart?(task) diff --git a/Sources/NukeVideo/VideoPlayerView.swift b/Sources/NukeVideo/VideoPlayerView.swift index 9120033ec..28d94864b 100644 --- a/Sources/NukeVideo/VideoPlayerView.swift +++ b/Sources/NukeVideo/VideoPlayerView.swift @@ -2,12 +2,7 @@ // // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -#if swift(>=6.0) import AVKit -#else -@preconcurrency import AVKit -#endif - import Foundation #if os(macOS) diff --git a/Tests/Helpers.swift b/Tests/Helpers.swift index bc341248e..b6a97d406 100644 --- a/Tests/Helpers.swift +++ b/Tests/Helpers.swift @@ -169,7 +169,7 @@ extension Result { } } -@propertyWrapper final class Atomic { +@propertyWrapper final class Mutex { private var value: T private let lock: os_unfair_lock_t diff --git a/Tests/ImagePipelineObserver.swift b/Tests/ImagePipelineObserver.swift index c619c59b8..27477f998 100644 --- a/Tests/ImagePipelineObserver.swift +++ b/Tests/ImagePipelineObserver.swift @@ -5,82 +5,57 @@ import XCTest @testable import Nuke -final class ImagePipelineObserver: ImagePipelineDelegate, @unchecked Sendable { - var startedTaskCount = 0 +final class ImagePipelineObserver: ImagePipeline.Delegate, @unchecked Sendable { + var createdTaskCount = 0 var cancelledTaskCount = 0 var completedTaskCount = 0 - static let didStartTask = Notification.Name("com.github.kean.Nuke.Tests.ImagePipelineObserver.DidStartTask") + static let didCreateTask = Notification.Name("com.github.kean.Nuke.Tests.ImagePipelineObserver.didCreateTask") static let didCancelTask = Notification.Name("com.github.kean.Nuke.Tests.ImagePipelineObserver.DidCancelTask") static let didCompleteTask = Notification.Name("com.github.kean.Nuke.Tests.ImagePipelineObserver.DidFinishTask") static let taskKey = "taskKey" static let resultKey = "resultKey" - var events = [ImageTaskEvent]() - - var onTaskCreated: ((ImageTask) -> Void)? + var events = [ImageTask.Event]() private let lock = NSLock() - private func append(_ event: ImageTaskEvent) { + private func append(_ event: ImageTask.Event) { lock.lock() events.append(event) lock.unlock() } func imageTaskCreated(_ task: ImageTask, pipeline: ImagePipeline) { - onTaskCreated?(task) - append(.created) - } - - func imageTaskDidStart(_ task: ImageTask, pipeline: ImagePipeline) { - startedTaskCount += 1 - NotificationCenter.default.post(name: ImagePipelineObserver.didStartTask, object: self, userInfo: [ImagePipelineObserver.taskKey: task]) - append(.started) - } - - func imageTaskDidCancel(_ task: ImageTask, pipeline: ImagePipeline) { - append(.cancelled) - - cancelledTaskCount += 1 - NotificationCenter.default.post(name: ImagePipelineObserver.didCancelTask, object: self, userInfo: [ImagePipelineObserver.taskKey: task]) - } - - func imageTask(_ task: ImageTask, didUpdateProgress progress: ImageTask.Progress, pipeline: ImagePipeline) { - append(.progressUpdated(completedUnitCount: progress.completed, totalUnitCount: progress.total)) - } - - func imageTask(_ task: ImageTask, didReceivePreview response: ImageResponse, pipeline: ImagePipeline) { - append(.intermediateResponseReceived(response: response)) - } - - func imageTask(_ task: ImageTask, didCompleteWithResult result: Result, pipeline: ImagePipeline) { - append(.completed(result: result)) - - completedTaskCount += 1 - NotificationCenter.default.post(name: ImagePipelineObserver.didCompleteTask, object: self, userInfo: [ImagePipelineObserver.taskKey: task, ImagePipelineObserver.resultKey: result]) + createdTaskCount += 1 + NotificationCenter.default.post(name: ImagePipelineObserver.didCreateTask, object: self, userInfo: [ImagePipelineObserver.taskKey: task]) + } + + func imageTask(_ task: ImageTask, didReceiveEvent event: ImageTask.Event, pipeline: ImagePipeline) { + append(event) + + switch event { + case .finished(let result): + completedTaskCount += 1 + NotificationCenter.default.post(name: ImagePipelineObserver.didCompleteTask, object: self, userInfo: [ImagePipelineObserver.taskKey: task, ImagePipelineObserver.resultKey: result]) + case .cancelled: + cancelledTaskCount += 1 + NotificationCenter.default.post(name: ImagePipelineObserver.didCancelTask, object: self, userInfo: [ImagePipelineObserver.taskKey: task]) + default: + break + } } } -enum ImageTaskEvent: Equatable { - case created - case started - case cancelled - case intermediateResponseReceived(response: ImageResponse) - case progressUpdated(completedUnitCount: Int64, totalUnitCount: Int64) - case completed(result: Result) - - static func == (lhs: ImageTaskEvent, rhs: ImageTaskEvent) -> Bool { +extension ImageTask.Event: @retroactive Equatable { + public static func == (lhs: ImageTask.Event, rhs: ImageTask.Event) -> Bool { switch (lhs, rhs) { - case (.created, .created): return true - case (.started, .started): return true - case (.cancelled, .cancelled): return true - case let (.intermediateResponseReceived(lhs), .intermediateResponseReceived(rhs)): return lhs == rhs - case let (.progressUpdated(lhsTotal, lhsCompleted), .progressUpdated(rhsTotal, rhsCompleted)): - return (lhsTotal, lhsCompleted) == (rhsTotal, rhsCompleted) - case let (.completed(lhs), .completed(rhs)): return lhs == rhs - default: return false + case let (.progress(lhs), .progress(rhs)): lhs == rhs + case let (.preview(lhs), .preview(rhs)): lhs == rhs + case let (.finished(lhs), .finished(rhs)): lhs == rhs + case (.cancelled, .cancelled): true + default: false } } } diff --git a/Tests/MockDataLoader.swift b/Tests/MockDataLoader.swift index 272acce19..e9979be38 100644 --- a/Tests/MockDataLoader.swift +++ b/Tests/MockDataLoader.swift @@ -7,18 +7,18 @@ import Nuke private let data: Data = Test.data(name: "fixture", extension: "jpeg") -private final class MockDataTask: Cancellable, @unchecked Sendable { +private final class MockDataTask: MockDataTaskProtocol, @unchecked Sendable { var _cancel: () -> Void = { } func cancel() { _cancel() } } -class MockDataLoader: DataLoading, @unchecked Sendable { +class MockDataLoader: MockDataLoading, DataLoading, @unchecked Sendable { static let DidStartTask = Notification.Name("com.github.kean.Nuke.Tests.MockDataLoader.DidStartTask") static let DidCancelTask = Notification.Name("com.github.kean.Nuke.Tests.MockDataLoader.DidCancelTask") - @Atomic var createdTaskCount = 0 + @Mutex var createdTaskCount = 0 var results = [URL: Result<(Data, URLResponse), NSError>]() let queue = OperationQueue() var isSuspended: Bool { @@ -26,7 +26,7 @@ class MockDataLoader: DataLoading, @unchecked Sendable { set { queue.isSuspended = newValue } } - func loadData(with request: URLRequest, didReceiveData: @escaping (Data, URLResponse) -> Void, completion: @escaping (Error?) -> Void) -> Cancellable { + func loadData(with request: URLRequest, didReceiveData: @Sendable @escaping (Data, URLResponse) -> Void, completion: @Sendable @escaping (Error?) -> Void) -> MockDataTaskProtocol { let task = MockDataTask() NotificationCenter.default.post(name: MockDataLoader.DidStartTask, object: self) @@ -61,3 +61,31 @@ class MockDataLoader: DataLoading, @unchecked Sendable { return task } } + +// Remove these and update to implement the actual protocol. +protocol MockDataLoading: DataLoading { + func loadData(with request: URLRequest, didReceiveData: @Sendable @escaping (Data, URLResponse) -> Void, completion: @Sendable @escaping (Error?) -> Void) -> MockDataTaskProtocol +} + +extension MockDataLoading where Self: DataLoading { + func loadData(for request: URLRequest) -> AsyncThrowingStream<(Data, URLResponse), any Error> { + AsyncThrowingStream { continuation in + let task = loadData(with: request) { data, response in + continuation.yield((data, response)) + } completion: { error in + continuation.finish(throwing: error) + } + continuation.onTermination = { reason in + switch reason { + case .cancelled: task.cancel() + default: break + } + } + } + } +} + +protocol MockDataTaskProtocol: Sendable { + func cancel() +} + diff --git a/Tests/MockProgressiveDataLoader.swift b/Tests/MockProgressiveDataLoader.swift index 84ff40dfe..5ee65f035 100644 --- a/Tests/MockProgressiveDataLoader.swift +++ b/Tests/MockProgressiveDataLoader.swift @@ -7,12 +7,12 @@ import Nuke // One-shot data loader that servers data split into chunks, only send one chunk // per one `resume()` call. -final class MockProgressiveDataLoader: DataLoading, @unchecked Sendable { +final class MockProgressiveDataLoader: MockDataLoading, DataLoading, @unchecked Sendable { let urlResponse: HTTPURLResponse var chunks: [Data] let data = Test.data(name: "progressive", extension: "jpeg") - class _MockTask: Cancellable, @unchecked Sendable { + class _MockTask: MockDataTaskProtocol, @unchecked Sendable { func cancel() { // Do nothing } @@ -26,7 +26,7 @@ final class MockProgressiveDataLoader: DataLoading, @unchecked Sendable { self.chunks = Array(_createChunks(for: data, size: data.count / 3)) } - func loadData(with request: URLRequest, didReceiveData: @escaping (Data, URLResponse) -> Void, completion: @escaping (Error?) -> Void) -> Cancellable { + func loadData(with request: URLRequest, didReceiveData: @Sendable @escaping (Data, URLResponse) -> Void, completion: @Sendable @escaping (Error?) -> Void) -> MockDataTaskProtocol { self.didReceiveData = didReceiveData self.completion = completion self.resume() diff --git a/Tests/NukeExtensions.swift b/Tests/NukeExtensions.swift index b449836b5..38cf8fe91 100644 --- a/Tests/NukeExtensions.swift +++ b/Tests/NukeExtensions.swift @@ -5,7 +5,7 @@ import Foundation import Nuke -extension ImagePipeline.Error: Equatable { +extension ImagePipeline.Error: @retroactive Equatable { public static func == (lhs: ImagePipeline.Error, rhs: ImagePipeline.Error) -> Bool { switch (lhs, rhs) { case (.dataMissingInCache, .dataMissingInCache): return true @@ -22,14 +22,14 @@ extension ImagePipeline.Error: Equatable { } } -extension ImageResponse: Equatable { +extension ImageResponse: @retroactive Equatable { public static func == (lhs: ImageResponse, rhs: ImageResponse) -> Bool { return lhs.image === rhs.image } } extension ImagePipeline { - func reconfigured(_ configure: (inout ImagePipeline.Configuration) -> Void) -> ImagePipeline { + nonisolated func reconfigured(_ configure: (inout ImagePipeline.Configuration) -> Void) -> ImagePipeline { var configuration = self.configuration configure(&configuration) return ImagePipeline(configuration: configuration) @@ -37,13 +37,16 @@ extension ImagePipeline { } extension ImagePipeline { + @MainActor private static var stack = [ImagePipeline]() + @MainActor static func pushShared(_ shared: ImagePipeline) { stack.append(ImagePipeline.shared) ImagePipeline.shared = shared } + @MainActor static func popShared() { ImagePipeline.shared = stack.removeLast() } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelinePublisherTests.swift b/Tests/NukeExtensionsTests/ImagePipelinePublisherTests.swift similarity index 76% rename from Tests/NukeTests/ImagePipelineTests/ImagePipelinePublisherTests.swift rename to Tests/NukeExtensionsTests/ImagePipelinePublisherTests.swift index 2009f3aad..d8a18bb02 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelinePublisherTests.swift +++ b/Tests/NukeExtensionsTests/ImagePipelinePublisherTests.swift @@ -27,43 +27,6 @@ class ImagePipelinePublisherTests: XCTestCase { } } - func testLoadWithPublisher() throws { - // GIVEN - let request = ImageRequest(id: "a", dataPublisher: Just(Test.data)) - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - - // THEN - let image = try XCTUnwrap(record.image) - XCTAssertEqual(image.sizeInPixels, CGSize(width: 640, height: 480)) - } - - func testLoadWithPublisherAndApplyProcessor() throws { - // GIVEN - var request = ImageRequest(id: "a", dataPublisher: Just(Test.data)) - request.processors = [MockImageProcessor(id: "1")] - - // WHEN - let record = expect(pipeline).toLoadImage(with: request) - wait() - - // THEN - let image = try XCTUnwrap(record.image) - XCTAssertEqual(image.sizeInPixels, CGSize(width: 640, height: 480)) - XCTAssertEqual(image.nk_test_processorIDs, ["1"]) - } - - func testImageRequestWithPublisher() { - // GIVEN - let request = ImageRequest(id: "a", dataPublisher: Just(Test.data)) - - // THEN - XCTAssertNil(request.urlRequest) - XCTAssertNil(request.url) - } - func testCancellation() { // GIVEN dataLoader.isSuspended = true @@ -77,19 +40,6 @@ class ImagePipelinePublisherTests: XCTestCase { wait() } - func testDataIsStoredInDataCache() { - // GIVEN - let request = ImageRequest(id: "a", dataPublisher: Just(Test.data)) - - // WHEN - expect(pipeline).toLoadImage(with: request) - - // THEN - wait { _ in - XCTAssertFalse(self.dataCache.store.isEmpty) - } - } - func testInitWithURL() { _ = pipeline.imagePublisher(with: URL(string: "https://example.com/image.jpeg")!) } diff --git a/Tests/NukeTests/ImagePublisherTests.swift b/Tests/NukeExtensionsTests/ImagePublisherTests.swift similarity index 100% rename from Tests/NukeTests/ImagePublisherTests.swift rename to Tests/NukeExtensionsTests/ImagePublisherTests.swift diff --git a/Tests/NukeExtensionsTests/ImageViewExtensionsTests.swift b/Tests/NukeExtensionsTests/ImageViewExtensionsTests.swift index 40b671de2..d30a872d2 100644 --- a/Tests/NukeExtensionsTests/ImageViewExtensionsTests.swift +++ b/Tests/NukeExtensionsTests/ImageViewExtensionsTests.swift @@ -35,7 +35,8 @@ class ImageViewExtensionsTests: XCTestCase { imageView = _ImageView() } - + + @MainActor override func tearDown() { super.tearDown() @@ -270,7 +271,7 @@ class ImageViewExtensionsTests: XCTestCase { dataLoader.isSuspended = true // Given an image view with an associated image task - expectNotification(ImagePipelineObserver.didStartTask, object: observer) + expectNotification(ImagePipelineObserver.didCreateTask, object: observer) NukeExtensions.loadImage(with: Test.url, into: imageView) wait() @@ -287,7 +288,7 @@ class ImageViewExtensionsTests: XCTestCase { dataLoader.isSuspended = true // Given an image view with an associated image task - expectNotification(ImagePipelineObserver.didStartTask, object: observer) + expectNotification(ImagePipelineObserver.didCreateTask, object: observer) NukeExtensions.loadImage(with: Test.url, into: imageView) wait() @@ -307,7 +308,7 @@ class ImageViewExtensionsTests: XCTestCase { autoreleasepool { // Given an image view with an associated image task var imageView: _ImageView! = _ImageView() - expectNotification(ImagePipelineObserver.didStartTask, object: observer) + expectNotification(ImagePipelineObserver.didCreateTask, object: observer) NukeExtensions.loadImage(with: Test.url, into: imageView) wait() diff --git a/Tests/NukeExtensionsTests/ImageViewIntegrationTests.swift b/Tests/NukeExtensionsTests/ImageViewIntegrationTests.swift index 38b64892d..795e72830 100644 --- a/Tests/NukeExtensionsTests/ImageViewIntegrationTests.swift +++ b/Tests/NukeExtensionsTests/ImageViewIntegrationTests.swift @@ -26,7 +26,8 @@ class ImageViewIntegrationTests: XCTestCase { imageView = _ImageView() } - + + @MainActor override func tearDown() { super.tearDown() diff --git a/Tests/NukeExtensionsTests/ImageViewLoadingOptionsTests.swift b/Tests/NukeExtensionsTests/ImageViewLoadingOptionsTests.swift index 2cce4be80..81b76bbc9 100644 --- a/Tests/NukeExtensionsTests/ImageViewLoadingOptionsTests.swift +++ b/Tests/NukeExtensionsTests/ImageViewLoadingOptionsTests.swift @@ -28,7 +28,8 @@ class ImageViewLoadingOptionsTests: XCTestCase { imageView = _ImageView() } - + + @MainActor override func tearDown() { super.tearDown() diff --git a/Tests/NukePerformanceTests/ImagePipelinePerformanceTests.swift b/Tests/NukePerformanceTests/ImagePipelinePerformanceTests.swift index f7431c22e..772cc6f7a 100644 --- a/Tests/NukePerformanceTests/ImagePipelinePerformanceTests.swift +++ b/Tests/NukePerformanceTests/ImagePipelinePerformanceTests.swift @@ -18,7 +18,7 @@ class ImagePipelinePerfomanceTests: XCTestCase { var finished: Int = 0 let semaphore = DispatchSemaphore(value: 0) for request in requests { - pipeline.loadImage(with: request, queue: callbackQueue, progress: nil) { _ in + pipeline.loadImage(with: request, progress: nil) { _ in finished += 1 if finished == requests.count { semaphore.signal() diff --git a/Tests/NukeTests/DataPublisherTests.swift b/Tests/NukeTests/DataPublisherTests.swift deleted file mode 100644 index cf460fab7..000000000 --- a/Tests/NukeTests/DataPublisherTests.swift +++ /dev/null @@ -1,40 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -import Combine -@testable import Nuke - -internal final class DataPublisherTests: XCTestCase { - - private var cancellable: (any Nuke.Cancellable)? - - func testInitNotStartsExecutionRightAway() { - let operation = MockOperation() - let publisher = DataPublisher(id: UUID().uuidString) { - await operation.execute() - } - - XCTAssertEqual(0, operation.executeCalls) - - let expOp = expectation(description: "Waits for MockOperation to complete execution") - cancellable = publisher.sink { completion in expOp.fulfill() } receiveValue: { _ in } - wait(for: [expOp], timeout: 0.2) - - XCTAssertEqual(1, operation.executeCalls) - } - - private final class MockOperation: @unchecked Sendable { - - private(set) var executeCalls = 0 - - func execute() async -> Data { - executeCalls += 1 - await Task.yield() - return Data() - } - - } - -} diff --git a/Tests/NukeTests/ImageCacheTests.swift b/Tests/NukeTests/ImageCacheTests.swift index 788b9925a..6b5ecc396 100644 --- a/Tests/NukeTests/ImageCacheTests.swift +++ b/Tests/NukeTests/ImageCacheTests.swift @@ -2,422 +2,397 @@ // // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -import XCTest +import Foundation +import Testing @testable import Nuke -private func _request(index: Int) -> ImageRequest { - return ImageRequest(url: URL(string: "http://example.com/img\(index)")!) -} -private let request1 = _request(index: 1) -private let request2 = _request(index: 2) -private let request3 = _request(index: 3) +#if canImport(UIKit) +import UIKit +#endif -class ImageCacheTests: XCTestCase, @unchecked Sendable { - var cache: ImageCache! - - override func setUp() { - super.setUp() - - cache = ImageCache() +@Suite struct ImageCacheTests { + let cache = ImageCache() + + init() { cache.entryCostLimit = 2 } - + // MARK: - Basics - - @MainActor - func testCacheCreation() { - XCTAssertEqual(cache.totalCount, 0) - XCTAssertNil(cache[Test.request]) + + @Test func cacheCreation() { + #expect(cache.totalCount == 0) + #expect(cache[Test.request] == nil) } - - @MainActor - func testThatImageIsStored() { + + @Test func imageIsStored() { // When cache[Test.request] = Test.container - + // Then - XCTAssertEqual(cache.totalCount, 1) - XCTAssertNotNil(cache[Test.request]) + #expect(cache.totalCount == 1) + #expect(cache[Test.request] != nil) } - + // MARK: - Subscript - - @MainActor - func testThatImageIsStoredUsingSubscript() { + + @Test func imageIsStoredUsingSubscript() { // When cache[Test.request] = Test.container - + // Then - XCTAssertNotNil(cache[Test.request]) + #expect(cache[Test.request] != nil) } - + // MARK: - Count - - @MainActor - func testThatTotalCountChanges() { - XCTAssertEqual(cache.totalCount, 0) - + + @Test func totalCountChanges() { + #expect(cache.totalCount == 0) + cache[request1] = Test.container - XCTAssertEqual(cache.totalCount, 1) - + #expect(cache.totalCount == 1) + cache[request2] = Test.container - XCTAssertEqual(cache.totalCount, 2) - + #expect(cache.totalCount == 2) + cache[request2] = nil - XCTAssertEqual(cache.totalCount, 1) - + #expect(cache.totalCount == 1) + cache[request1] = nil - XCTAssertEqual(cache.totalCount, 0) + #expect(cache.totalCount == 0) } - - @MainActor - func testThatCountLimitChanges() { + + @Test func countLimitChanges() { // When cache.countLimit = 1 - + // Then - XCTAssertEqual(cache.countLimit, 1) + #expect(cache.countLimit == 1) } - - @MainActor - func testThatTTLChanges() { - //when + + @Test func ttlChanges() { + // when cache.ttl = 1 - + // Then - XCTAssertEqual(cache.ttl, 1) + #expect(cache.ttl == 1) } - - @MainActor - func testThatItemsAreRemoveImmediatelyWhenCountLimitIsReached() { + + @Test func itemsAreRemoveImmediatelyWhenCountLimitIsReached() { // Given cache.countLimit = 1 - + // When cache[request1] = Test.container cache[request2] = Test.container - + // Then - XCTAssertNil(cache[request1]) - XCTAssertNotNil(cache[request2]) + #expect(cache[request1] == nil) + #expect(cache[request2] != nil) } - - @MainActor - func testTrimToCount() { + + @Test func trimToCount() { // Given cache[request1] = Test.container cache[request2] = Test.container - + // When cache.trim(toCount: 1) - + // Then - XCTAssertNil(cache[request1]) - XCTAssertNotNil(cache[request2]) + #expect(cache[request1] == nil) + #expect(cache[request2] != nil) } - - @MainActor - func testThatImagesAreRemovedOnCountLimitChange() { + + @Test func imagesAreRemovedOnCountLimitChange() { // Given cache.countLimit = 2 - + cache[request1] = Test.container cache[request2] = Test.container - + // When cache.countLimit = 1 - + // Then - XCTAssertNil(cache[request1]) - XCTAssertNotNil(cache[request2]) + #expect(cache[request1] == nil) + #expect(cache[request2] != nil) } - + // MARK: Cost - -#if !os(macOS) - - @MainActor - func testDefaultImageCost() { - XCTAssertEqual(cache.cost(for: ImageContainer(image: Test.image)), 1228800) + +#if canImport(UIKit) + + @Test func defaultImageCost() { + #expect(cache.cost(for: ImageContainer(image: Test.image)) == 1228800) } - - @MainActor - func testThatTotalCostChanges() { + + @Test func totalCostChanges() { let imageCost = cache.cost(for: ImageContainer(image: Test.image)) - XCTAssertEqual(cache.totalCost, 0) - + #expect(cache.totalCost == 0) + cache[request1] = Test.container - XCTAssertEqual(cache.totalCost, imageCost) - + #expect(cache.totalCost == imageCost) + cache[request2] = Test.container - XCTAssertEqual(cache.totalCost, 2 * imageCost) - + #expect(cache.totalCost == 2 * imageCost) + cache[request2] = nil - XCTAssertEqual(cache.totalCost, imageCost) - + #expect(cache.totalCost == imageCost) + cache[request1] = nil - XCTAssertEqual(cache.totalCost, 0) + #expect(cache.totalCost == 0) } - - @MainActor - func testThatCostLimitChanged() { + + @Test func costLimitChanged() { // Given let cost = cache.cost(for: ImageContainer(image: Test.image)) - + // When cache.costLimit = Int(Double(cost) * 1.5) - + // Then - XCTAssertEqual(cache.costLimit, Int(Double(cost) * 1.5)) + #expect(cache.costLimit == Int(Double(cost) * 1.5)) } - - @MainActor - func testThatItemsAreRemoveImmediatelyWhenCostLimitIsReached() { + + @Test func itemsAreRemoveImmediatelyWhenCostLimitIsReached() { // Given let cost = cache.cost(for: ImageContainer(image: Test.image)) cache.costLimit = Int(Double(cost) * 1.5) - + // When/Then cache[request1] = Test.container - + // LRU item is released cache[request2] = Test.container - XCTAssertNil(cache[request1]) - XCTAssertNotNil(cache[request2]) + #expect(cache[request1] == nil) + #expect(cache[request2] != nil) } - - @MainActor - func testEntryCostLimitEntryStored() { + + @Test func entryCostLimitEntryStored() { // Given let container = ImageContainer(image: Test.image) let cost = cache.cost(for: container) cache.costLimit = Int(Double(cost) * 15) cache.entryCostLimit = 0.1 - + // When cache[Test.request] = container - + // Then - XCTAssertNotNil(cache[Test.request]) - XCTAssertEqual(cache.totalCount, 1) + #expect(cache[Test.request] != nil) + #expect(cache.totalCount == 1) } - - @MainActor - func testEntryCostLimitEntryNotStored() { + + @Test func entryCostLimitEntryNotStored() { // Given let container = ImageContainer(image: Test.image) let cost = cache.cost(for: container) cache.costLimit = Int(Double(cost) * 3) cache.entryCostLimit = 0.1 - + // When cache[Test.request] = container - + // Then - XCTAssertNil(cache[Test.request]) - XCTAssertEqual(cache.totalCount, 0) + #expect(cache[Test.request] == nil) + #expect(cache.totalCount == 0) } - - @MainActor - func testTrimToCost() { + + @Test func trimToCost() { // Given cache.costLimit = Int.max - + cache[request1] = Test.container cache[request2] = Test.container - + // When let cost = cache.cost(for: ImageContainer(image: Test.image)) cache.trim(toCost: Int(Double(cost) * 1.5)) - + // Then - XCTAssertNil(cache[request1]) - XCTAssertNotNil(cache[request2]) + #expect(cache[request1] == nil) + #expect(cache[request2] != nil) } - - @MainActor - func testThatImagesAreRemovedOnCostLimitChange() { + + @Test func imagesAreRemovedOnCostLimitChange() { // Given let cost = cache.cost(for: ImageContainer(image: Test.image)) cache.costLimit = Int(Double(cost) * 2.5) - + cache[request1] = Test.container cache[request2] = Test.container - + // When cache.costLimit = cost - + // Then - XCTAssertNil(cache[request1]) - XCTAssertNotNil(cache[request2]) + #expect(cache[request1] == nil) + #expect(cache[request2] != nil) } - - @MainActor - func testImageContainerWithoutAssociatedDataCost() { + + @Test func imageContainerWithoutAssociatedDataCost() { // Given let data = Test.data(name: "cat", extension: "gif") let image = PlatformImage(data: data)! let container = ImageContainer(image: image, data: nil) - + // Then - XCTAssertEqual(cache.cost(for: container), 558000) + #expect(cache.cost(for: container) == 558000) } - - @MainActor - func testImageContainerWithAssociatedDataCost() { + + @Test func imageContainerWithAssociatedDataCost() { // Given let data = Test.data(name: "cat", extension: "gif") let image = PlatformImage(data: data)! let container = ImageContainer(image: image, data: data) - + // Then - XCTAssertEqual(cache.cost(for: container), 558000 + 427672) + #expect(cache.cost(for: container) == 558000 + 427672) } - + #endif - + // MARK: LRU - - @MainActor - func testThatLeastRecentItemsAreRemoved() { + + @Test func leastRecentItemsAreRemoved() { // Given let cost = cache.cost(for: ImageContainer(image: Test.image)) cache.costLimit = Int(Double(cost) * 2.5) - + cache[request1] = Test.container cache[request2] = Test.container cache[request3] = Test.container - + // Then - XCTAssertNil(cache[request1]) - XCTAssertNotNil(cache[request2]) - XCTAssertNotNil(cache[request3]) + #expect(cache[request1] == nil) + #expect(cache[request2] != nil) + #expect(cache[request3] != nil) } - - @MainActor - func testThatItemsAreTouched() { + + @Test func itemsAreTouched() { // Given let cost = cache.cost(for: ImageContainer(image: Test.image)) cache.costLimit = Int(Double(cost) * 2.5) - + cache[request1] = Test.container cache[request2] = Test.container _ = cache[request1] // Touched image - + // When cache[request3] = Test.container - + // Then - XCTAssertNotNil(cache[request1]) - XCTAssertNil(cache[request2]) - XCTAssertNotNil(cache[request3]) + #expect(cache[request1] != nil) + #expect(cache[request2] == nil) + #expect(cache[request3] != nil) } - + // MARK: Misc - - @MainActor - func testRemoveAll() { + + @Test func removeAll() { // GIVEN cache[request1] = Test.container cache[request2] = Test.container - + // WHEN cache.removeAll() - + // THEN - XCTAssertEqual(cache.totalCount, 0) - XCTAssertEqual(cache.totalCost, 0) + #expect(cache.totalCount == 0) + #expect(cache.totalCost == 0) } - -#if os(iOS) || os(tvOS) || os(visionOS) - @MainActor - func testThatSomeImagesAreRemovedOnDidEnterBackground() async { + +#if canImport(UIKit) + @Test @MainActor func someImagesAreRemovedOnDidEnterBackground() async { // GIVEN cache.costLimit = Int.max cache.countLimit = 10 // 1 out of 10 images should remain - + for i in 0..<10 { cache[_request(index: i)] = Test.container } - XCTAssertEqual(cache.totalCount, 10) - + #expect(cache.totalCount == 10) + // WHEN let task = Task { @MainActor in NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - + // THEN - XCTAssertEqual(cache.totalCount, 1) + #expect(cache.totalCount == 1) } await task.value } - - @MainActor - func testThatSomeImagesAreRemovedBasedOnCostOnDidEnterBackground() async { + + @Test @MainActor func someImagesAreRemovedBasedOnCostOnDidEnterBackground() async { // GIVEN let cost = cache.cost(for: ImageContainer(image: Test.image)) cache.costLimit = cost * 10 cache.countLimit = Int.max - + for index in 0..<10 { let request = ImageRequest(url: URL(string: "http://example.com/img\(index)")!) cache[request] = Test.container } - XCTAssertEqual(cache.totalCount, 10) - + #expect(cache.totalCount == 10) + // WHEN let task = Task { @MainActor in NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - + // THEN - XCTAssertEqual(cache.totalCount, 1) + #expect(cache.totalCount == 1) } await task.value } #endif } -class InternalCacheTTLTests: XCTestCase { +@Suite struct InternalCacheTTLTests { let cache = Cache(costLimit: 1000, countLimit: 1000) - + // MARK: TTL - - @MainActor - func testTTL() { + + @Test func ttl() { // Given cache.set(1, forKey: 1, cost: 1, ttl: 0.05) // 50 ms - XCTAssertNotNil(cache.value(forKey: 1)) - + #expect(cache.value(forKey: 1) != nil) + // When usleep(55 * 1000) - + // Then - XCTAssertNil(cache.value(forKey: 1)) + #expect(cache.value(forKey: 1) == nil) } - - @MainActor - func testDefaultTTLIsUsed() { + + @Test func defaultTTLIsUsed() { // Given cache.conf.ttl = 0.05// 50 ms cache.set(1, forKey: 1, cost: 1) - XCTAssertNotNil(cache.value(forKey: 1)) - + #expect(cache.value(forKey: 1) != nil) + // When usleep(55 * 1000) - + // Then - XCTAssertNil(cache.value(forKey: 1)) + #expect(cache.value(forKey: 1) == nil) } - - @MainActor - func testDefaultToNonExpiringEntries() { + + @Test func defaultToNonExpiringEntries() { // Given cache.set(1, forKey: 1, cost: 1) - XCTAssertNotNil(cache.value(forKey: 1)) - + #expect(cache.value(forKey: 1) != nil) + // When usleep(55 * 1000) - + // Then - XCTAssertNotNil(cache.value(forKey: 1)) + #expect(cache.value(forKey: 1) != nil) } } + +private func _request(index: Int) -> ImageRequest { + return ImageRequest(url: URL(string: "http://example.com/img\(index)")!) +} +private let request1 = _request(index: 1) +private let request2 = _request(index: 2) +private let request3 = _request(index: 3) diff --git a/Tests/NukeTests/ImageDecoderRegistryTests.swift b/Tests/NukeTests/ImageDecoderRegistryTests.swift index b22864fec..c59889084 100644 --- a/Tests/NukeTests/ImageDecoderRegistryTests.swift +++ b/Tests/NukeTests/ImageDecoderRegistryTests.swift @@ -2,20 +2,20 @@ // // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing @testable import Nuke -final class ImageDecoderRegistryTests: XCTestCase { - func testDefaultDecoderIsReturned() { +@Suite struct ImageDecoderRegistryTests { + @Test func defaultDecoderIsReturned() { // Given let context = ImageDecodingContext.mock // Then let decoder = ImageDecoderRegistry().decoder(for: context) - XCTAssertTrue(decoder is ImageDecoders.Default) + #expect(decoder is ImageDecoders.Default) } - func testRegisterDecoder() { + @Test func registerDecoder() { // Given let registry = ImageDecoderRegistry() let context = ImageDecodingContext.mock @@ -27,7 +27,7 @@ final class ImageDecoderRegistryTests: XCTestCase { // Then let decoder1 = registry.decoder(for: context) as? MockImageDecoder - XCTAssertEqual(decoder1?.name, "A") + #expect(decoder1?.name == "A") // When registry.register { _ in @@ -36,27 +36,27 @@ final class ImageDecoderRegistryTests: XCTestCase { // Then let decoder2 = registry.decoder(for: context) as? MockImageDecoder - XCTAssertEqual(decoder2?.name, "B") + #expect(decoder2?.name == "B") } - - func testClearDecoders() { + + @Test func clearDecoders() { // Given let registry = ImageDecoderRegistry() let context = ImageDecodingContext.mock - + registry.register { _ in return MockImageDecoder(name: "A") } // When registry.clear() - + // Then let noDecoder = registry.decoder(for: context) - XCTAssertNil(noDecoder) + #expect(noDecoder == nil) } - func testWhenReturningNextDecoderIsEvaluated() { + @Test func whenReturningNextDecoderIsEvaluated() { // Given let registry = ImageDecoderRegistry() registry.register { _ in @@ -68,6 +68,6 @@ final class ImageDecoderRegistryTests: XCTestCase { let decoder = ImageDecoderRegistry().decoder(for: context) // Then - XCTAssertTrue(decoder is ImageDecoders.Default) + #expect(decoder is ImageDecoders.Default) } } diff --git a/Tests/NukeTests/ImageDecoderTests.swift b/Tests/NukeTests/ImageDecoderTests.swift index 74420bb09..6d22f4b40 100644 --- a/Tests/NukeTests/ImageDecoderTests.swift +++ b/Tests/NukeTests/ImageDecoderTests.swift @@ -2,237 +2,238 @@ // // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -import XCTest +import Foundation +import Testing @testable import Nuke -class ImageDecoderTests: XCTestCase { - func testDecodePNG() throws { +@Suite struct ImageDecoderTests { + @Test func decodePNG() throws { // Given let data = Test.data(name: "fixture", extension: "png") let decoder = ImageDecoders.Default() - + // When - let container = try XCTUnwrap(decoder.decode(data)) - + let container = try #require(try decoder.decode(data)) + // Then - XCTAssertEqual(container.type, .png) - XCTAssertFalse(container.isPreview) - XCTAssertNil(container.data) - XCTAssertTrue(container.userInfo.isEmpty) + #expect(container.type == .png) + #expect(!container.isPreview) + #expect(container.data == nil) + #expect(container.userInfo.isEmpty) } - - func testDecodeJPEG() throws { + + @Test func decodeJPEG() throws { // Given let data = Test.data(name: "baseline", extension: "jpeg") let decoder = ImageDecoders.Default() - + // When - let container = try XCTUnwrap(decoder.decode(data)) - + let container = try #require(try decoder.decode(data)) + // Then - XCTAssertEqual(container.type, .jpeg) - XCTAssertFalse(container.isPreview) - XCTAssertNil(container.data) - XCTAssertTrue(container.userInfo.isEmpty) + #expect(container.type == .jpeg) + #expect(!container.isPreview) + #expect(container.data == nil) + #expect(container.userInfo.isEmpty) } - - func testDecodingProgressiveJPEG() { + + @Test func decodingProgressiveJPEG() { let data = Test.data(name: "progressive", extension: "jpeg") let decoder = ImageDecoders.Default() - + // Just before the Start Of Frame - XCTAssertNil(decoder.decodePartiallyDownloadedData(data[0...358])) - XCTAssertEqual(decoder.numberOfScans, 0) - + #expect(decoder.decodePartiallyDownloadedData(data[0...358]) == nil) + #expect(decoder.numberOfScans == 0) + // Right after the Start Of Frame - XCTAssertNil(decoder.decodePartiallyDownloadedData(data[0...359])) - XCTAssertEqual(decoder.numberOfScans, 0) // still haven't finished the first scan - + #expect(decoder.decodePartiallyDownloadedData(data[0...359]) == nil) + #expect(decoder.numberOfScans == 0) // still haven't finished the first scan // still haven't finished the first scan + // Just before the first Start Of Scan - XCTAssertNil(decoder.decodePartiallyDownloadedData(data[0...438])) - XCTAssertEqual(decoder.numberOfScans, 0) // still haven't finished the first scan - + #expect(decoder.decodePartiallyDownloadedData(data[0...438]) == nil) + #expect(decoder.numberOfScans == 0) // still haven't finished the first scan // still haven't finished the first scan + // Found the first Start Of Scan - XCTAssertNil(decoder.decodePartiallyDownloadedData(data[0...439])) - XCTAssertEqual(decoder.numberOfScans, 1) - + #expect(decoder.decodePartiallyDownloadedData(data[0...439]) == nil) + #expect(decoder.numberOfScans == 1) + // Found the second Start of Scan let scan1 = decoder.decodePartiallyDownloadedData(data[0...2952]) - XCTAssertNotNil(scan1) - XCTAssertEqual(scan1?.isPreview, true) + #expect(scan1 != nil) + #expect(scan1?.isPreview == true) if let image = scan1?.image { #if os(macOS) - XCTAssertEqual(image.size.width, 450) - XCTAssertEqual(image.size.height, 300) + #expect(image.size.width == 450) + #expect(image.size.height == 300) #else - XCTAssertEqual(image.size.width * image.scale, 450) - XCTAssertEqual(image.size.height * image.scale, 300) + #expect(image.size.width * image.scale == 450) + #expect(image.size.height * image.scale == 300) #endif } - XCTAssertEqual(decoder.numberOfScans, 2) - XCTAssertEqual(scan1?.userInfo[.scanNumberKey] as? Int, 2) - + #expect(decoder.numberOfScans == 2) + #expect(scan1?.userInfo[.scanNumberKey] as? Int == 2) + // Feed all data and see how many scans are there // In practice the moment we finish receiving data we call // `decode(data: data, isCompleted: true)` so we might not scan all the // of the bytes and encounter all of the scans (e.g. the final chunk // of data that we receive contains multiple scans). - XCTAssertNotNil(decoder.decodePartiallyDownloadedData(data)) - XCTAssertEqual(decoder.numberOfScans, 10) + #expect(decoder.decodePartiallyDownloadedData(data) != nil) + #expect(decoder.numberOfScans == 10) } - - func testDecodeGIF() throws { + + @Test func decodeGIF() throws { // Given let data = Test.data(name: "cat", extension: "gif") let decoder = ImageDecoders.Default() - + // When - let container = try XCTUnwrap(decoder.decode(data)) - + let container = try #require(try decoder.decode(data)) + // Then - XCTAssertEqual(container.type, .gif) - XCTAssertFalse(container.isPreview) - XCTAssertNotNil(container.data) - XCTAssertTrue(container.userInfo.isEmpty) + #expect(container.type == .gif) + #expect(!container.isPreview) + #expect(container.data != nil) + #expect(container.userInfo.isEmpty) } - - func testDecodeHEIC() throws { + + @Test func decodeHEIC() throws { // Given let data = Test.data(name: "img_751", extension: "heic") let decoder = ImageDecoders.Default() - + // When - let container = try XCTUnwrap(decoder.decode(data)) - + let container = try #require(try decoder.decode(data)) + // Then - XCTAssertNil(container.type) // TODO: update when HEIF support is added - XCTAssertFalse(container.isPreview) - XCTAssertNil(container.data) - XCTAssertTrue(container.userInfo.isEmpty) + #expect(container.type == nil) // TODO: update when HEIF support is added // TODO: update when HEIF support is added + #expect(!container.isPreview) + #expect(container.data == nil) + #expect(container.userInfo.isEmpty) } - - func testDecodingGIFDataAttached() throws { + + @Test func decodingGIFDataAttached() throws { let data = Test.data(name: "cat", extension: "gif") - XCTAssertNotNil(try ImageDecoders.Default().decode(data).data) + #expect(try ImageDecoders.Default().decode(data).data != nil) } - - func testDecodingGIFPreview() throws { + + @Test func decodingGIFPreview() throws { let data = Test.data(name: "cat", extension: "gif") - XCTAssertEqual(data.count, 427672) // 427 KB + #expect(data.count == 427672) // 427 KB // 427 KB let chunk = data[...60000] // 6 KB let response = try ImageDecoders.Default().decode(chunk) - XCTAssertEqual(response.image.sizeInPixels, CGSize(width: 500, height: 279)) + #expect(response.image.sizeInPixels == CGSize(width: 500, height: 279)) } - - func testDecodingGIFPreviewGeneratedOnlyOnce() throws { + + @Test func decodingGIFPreviewGeneratedOnlyOnce() throws { let data = Test.data(name: "cat", extension: "gif") - XCTAssertEqual(data.count, 427672) // 427 KB + #expect(data.count == 427672) // 427 KB // 427 KB let chunk = data[...60000] // 6 KB - + let context = ImageDecodingContext.mock(data: chunk) - let decoder = try XCTUnwrap(ImageDecoders.Default(context: context)) - - XCTAssertNotNil(decoder.decodePartiallyDownloadedData(chunk)) - XCTAssertNil(decoder.decodePartiallyDownloadedData(chunk)) + let decoder = try #require(ImageDecoders.Default(context: context)) + + #expect(decoder.decodePartiallyDownloadedData(chunk) != nil) + #expect(decoder.decodePartiallyDownloadedData(chunk) == nil) } - - func testDecodingPNGDataNotAttached() throws { + + @Test func decodingPNGDataNotAttached() throws { let data = Test.data(name: "fixture", extension: "png") let container = try ImageDecoders.Default().decode(data) - XCTAssertNil(container.data) + #expect(container.data == nil) } - + #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) - func testDecodeBaselineWebP() throws { + @Test func decodeBaselineWebP() throws { if #available(OSX 11.0, iOS 14.0, watchOS 7.0, tvOS 999.0, *) { let data = Test.data(name: "baseline", extension: "webp") let container = try ImageDecoders.Default().decode(data) - XCTAssertEqual(container.image.sizeInPixels, CGSize(width: 550, height: 368)) - XCTAssertNil(container.data) + #expect(container.image.sizeInPixels == CGSize(width: 550, height: 368)) + #expect(container.data == nil) } } #endif } -class ImageTypeTests: XCTestCase { +@Suite struct ImageTypeTests { // MARK: PNG - - func testDetectPNG() { + + @Test func detectPNG() { let data = Test.data(name: "fixture", extension: "png") - XCTAssertNil(AssetType(data[0..<1])) - XCTAssertNil(AssetType(data[0..<7])) - XCTAssertEqual(AssetType(data[0..<8]), .png) - XCTAssertEqual(AssetType(data), .png) + #expect(AssetType(data[0..<1]) == nil) + #expect(AssetType(data[0..<7]) == nil) + #expect(AssetType(data[0..<8]) == .png) + #expect(AssetType(data) == .png) } - + // MARK: GIF - - func testDetectGIF() { + + @Test func detectGIF() { let data = Test.data(name: "cat", extension: "gif") - XCTAssertEqual(AssetType(data), .gif) + #expect(AssetType(data) == .gif) } - + // MARK: JPEG - - func testDetectBaselineJPEG() { + + @Test func detectBaselineJPEG() { let data = Test.data(name: "baseline", extension: "jpeg") - XCTAssertNil(AssetType(data[0..<1])) - XCTAssertNil(AssetType(data[0..<2])) - XCTAssertEqual(AssetType(data[0..<3]), .jpeg) - XCTAssertEqual(AssetType(data), .jpeg) + #expect(AssetType(data[0..<1]) == nil) + #expect(AssetType(data[0..<2]) == nil) + #expect(AssetType(data[0..<3]) == .jpeg) + #expect(AssetType(data) == .jpeg) } - - func testDetectProgressiveJPEG() { + + @Test func detectProgressiveJPEG() { let data = Test.data(name: "progressive", extension: "jpeg") // Not enough data - XCTAssertNil(AssetType(Data())) - XCTAssertNil(AssetType(data[0..<2])) - + #expect(AssetType(Data()) == nil) + #expect(AssetType(data[0..<2]) == nil) + // Enough to determine image format - XCTAssertEqual(AssetType(data[0..<3]), .jpeg) - XCTAssertEqual(AssetType(data[0..<33]), .jpeg) - + #expect(AssetType(data[0..<3]) == .jpeg) + #expect(AssetType(data[0..<33]) == .jpeg) + // Full image - XCTAssertEqual(AssetType(data), .jpeg) + #expect(AssetType(data) == .jpeg) } - + // MARK: WebP - - func testDetectBaselineWebP() { + + @Test func detectBaselineWebP() { let data = Test.data(name: "baseline", extension: "webp") - XCTAssertNil(AssetType(data[0..<1])) - XCTAssertNil(AssetType(data[0..<2])) - XCTAssertEqual(AssetType(data[0..<12]), .webp) - XCTAssertEqual(AssetType(data), .webp) + #expect(AssetType(data[0..<1]) == nil) + #expect(AssetType(data[0..<2]) == nil) + #expect(AssetType(data[0..<12]) == .webp) + #expect(AssetType(data) == .webp) } } -class ImagePropertiesTests: XCTestCase { +@Suite struct ImagePropertiesTests { // MARK: JPEG - - func testDetectBaselineJPEG() { + + @Test func detectBaselineJPEG() { let data = Test.data(name: "baseline", extension: "jpeg") - XCTAssertNil(ImageProperties.JPEG(data[0..<1])) - XCTAssertNil(ImageProperties.JPEG(data[0..<2])) - XCTAssertNil(ImageProperties.JPEG(data[0..<3])) - XCTAssertEqual(ImageProperties.JPEG(data)?.isProgressive, false) + #expect(ImageProperties.JPEG(data[0..<1]) == nil) + #expect(ImageProperties.JPEG(data[0..<2]) == nil) + #expect(ImageProperties.JPEG(data[0..<3]) == nil) + #expect(ImageProperties.JPEG(data)?.isProgressive == false) } - - func testDetectProgressiveJPEG() { + + @Test func detectProgressiveJPEG() { let data = Test.data(name: "progressive", extension: "jpeg") // Not enough data - XCTAssertNil(ImageProperties.JPEG(Data())) - XCTAssertNil(ImageProperties.JPEG(data[0..<2])) - + #expect(ImageProperties.JPEG(Data()) == nil) + #expect(ImageProperties.JPEG(data[0..<2]) == nil) + // Enough to determine image format - XCTAssertNil(ImageProperties.JPEG(data[0..<3])) - XCTAssertNil(ImageProperties.JPEG(data[0...30])) - + #expect(ImageProperties.JPEG(data[0..<3]) == nil) + #expect(ImageProperties.JPEG(data[0...30]) == nil) + // Just before the first scan - XCTAssertNil(ImageProperties.JPEG(data[0...358])) - XCTAssertEqual(ImageProperties.JPEG(data[0...359])?.isProgressive, true) - + #expect(ImageProperties.JPEG(data[0...358]) == nil) + #expect(ImageProperties.JPEG(data[0...359])?.isProgressive == true) + // Full image - XCTAssertEqual(ImageProperties.JPEG(data[0...359])?.isProgressive, true) + #expect(ImageProperties.JPEG(data[0...359])?.isProgressive == true) } } diff --git a/Tests/NukeTests/ImageEncoderTests.swift b/Tests/NukeTests/ImageEncoderTests.swift index 18f0516d3..549dc3043 100644 --- a/Tests/NukeTests/ImageEncoderTests.swift +++ b/Tests/NukeTests/ImageEncoderTests.swift @@ -2,95 +2,95 @@ // // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing @testable import Nuke -final class ImageEncoderTests: XCTestCase { - func testEncodeImage() throws { +@Suite struct ImageEncoderTests { + @Test func encodeImage() throws { // Given let image = Test.image let encoder = ImageEncoders.Default() - + // When - let data = try XCTUnwrap(encoder.encode(image)) - + let data = try #require(encoder.encode(image)) + // Then - XCTAssertEqual(AssetType(data), .jpeg) + #expect(AssetType(data) == .jpeg) } - - func testEncodeImagePNGOpaque() throws { + + @Test func encodeImagePNGOpaque() throws { // Given let image = Test.image(named: "fixture", extension: "png") let encoder = ImageEncoders.Default() - + // When - let data = try XCTUnwrap(encoder.encode(image)) - + let data = try #require(encoder.encode(image)) + // Then #if os(macOS) // It seems that on macOS, NSImage created from png has an alpha // component regardless of whether the input image has it. - XCTAssertEqual(AssetType(data), .png) + #expect(AssetType(data) == .png) #else - XCTAssertEqual(AssetType(data), .jpeg) + #expect(AssetType(data) == .jpeg) #endif } - - func testEncodeImagePNGTransparent() throws { + + @Test func encodeImagePNGTransparent() throws { // Given let image = Test.image(named: "swift", extension: "png") let encoder = ImageEncoders.Default() - + // When - let data = try XCTUnwrap(encoder.encode(image)) - + let data = try #require(encoder.encode(image)) + // Then - XCTAssertEqual(AssetType(data), .png) + #expect(AssetType(data) == .png) } - - func testPrefersHEIF() throws { + + @Test func prefersHEIF() throws { // Given let image = Test.image var encoder = ImageEncoders.Default() encoder.isHEIFPreferred = true - + // When - let data = try XCTUnwrap(encoder.encode(image)) - + let data = try #require(encoder.encode(image)) + // Then - XCTAssertNil(AssetType(data)) // TODO: update when HEIF support is added + #expect(AssetType(data) == nil) // TODO: update when HEIF support is added // TODO: update when HEIF support is added } - + #if os(iOS) || os(tvOS) || os(visionOS) - - func testEncodeCoreImageBackedImage() throws { + + @Test func encodeCoreImageBackedImage() throws { // Given let image = try ImageProcessors.GaussianBlur().processThrowing(Test.image) let encoder = ImageEncoders.Default() - + // When - let data = try XCTUnwrap(encoder.encode(image)) - + let data = try #require(encoder.encode(image)) + // Then encoded as PNG because GaussianBlur produces // images with alpha channel - XCTAssertEqual(AssetType(data), .png) + #expect(AssetType(data) == .png) } - + #endif - + // MARK: - Misc - - func testIsOpaqueWithOpaquePNG() { + + @Test func isOpaqueWithOpaquePNG() { let image = Test.image(named: "fixture", extension: "png") #if os(macOS) - XCTAssertFalse(image.cgImage!.isOpaque) + #expect(!image.cgImage!.isOpaque) #else - XCTAssertTrue(image.cgImage!.isOpaque) + #expect(image.cgImage!.isOpaque) #endif } - - func testIsOpaqueWithTransparentPNG() { + + @Test func isOpaqueWithTransparentPNG() { let image = Test.image(named: "swift", extension: "png") - XCTAssertFalse(image.cgImage!.isOpaque) + #expect(!image.cgImage!.isOpaque) } } diff --git a/Tests/NukeTests/ImagePipelineTests/DocumentationTests.swift b/Tests/NukeTests/ImagePipelineTests/DocumentationTests.swift index a7e265522..583286504 100644 --- a/Tests/NukeTests/ImagePipelineTests/DocumentationTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/DocumentationTests.swift @@ -117,7 +117,7 @@ private func checkAccessCachedImages07() { _ = pipeline.cache.makeDataCacheKey(for: request) } -private final class CheckAccessCachedImages08: ImagePipelineDelegate { +private final class CheckAccessCachedImages08: ImagePipeline.Delegate { func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? { request.userInfo["imageId"] as? String } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift index caf15c69b..04df67649 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineAsyncAwaitTests.swift @@ -15,8 +15,6 @@ class ImagePipelineAsyncAwaitTests: XCTestCase, @unchecked Sendable { private var recordedPreviews: [ImageResponse] = [] private var pipelineDelegate = ImagePipelineObserver() private var imageTask: ImageTask? - private let callbackQueue = DispatchQueue(label: "testChangingCallbackQueue") - private let callbackQueueKey = DispatchSpecificKey() override func setUp() { super.setUp() @@ -25,10 +23,7 @@ class ImagePipelineAsyncAwaitTests: XCTestCase, @unchecked Sendable { pipeline = ImagePipeline(delegate: pipelineDelegate) { $0.dataLoader = dataLoader $0.imageCache = nil - $0._callbackQueue = callbackQueue } - - callbackQueue.setSpecific(key: callbackQueueKey, value: ()) } // MARK: - Basics @@ -89,23 +84,6 @@ class ImagePipelineAsyncAwaitTests: XCTestCase, @unchecked Sendable { XCTAssertTrue(caughtError is CancellationError) } - func testCancelFromTaskCreated() async throws { - dataLoader.queue.isSuspended = true - pipelineDelegate.onTaskCreated = { $0.cancel() } - - let task = Task { - try await pipeline.image(for: Test.url) - } - - var caughtError: Error? - do { - _ = try await task.value - } catch { - caughtError = error - } - XCTAssertTrue(caughtError is CancellationError) - } - func testCancelImmediately() async throws { dataLoader.queue.isSuspended = true @@ -177,12 +155,10 @@ class ImagePipelineAsyncAwaitTests: XCTestCase, @unchecked Sendable { func testCancelAsyncImageTask() async throws { dataLoader.queue.isSuspended = true - pipeline.queue.suspend() let task = pipeline.imageTask(with: Test.url) observer = NotificationCenter.default.addObserver(forName: MockDataLoader.DidStartTask, object: dataLoader, queue: OperationQueue()) { _ in task.cancel() } - pipeline.queue.resume() var caughtError: Error? do { @@ -224,17 +200,6 @@ class ImagePipelineAsyncAwaitTests: XCTestCase, @unchecked Sendable { XCTAssertTrue(caughtError is CancellationError) } - func testImageTaskReturnedImmediately() async throws { - // GIVEN - pipelineDelegate.onTaskCreated = { [unowned self] in imageTask = $0 } - - // WHEN - _ = try await pipeline.image(for: Test.request) - - // THEN - XCTAssertNotNil(imageTask) - } - func testProgressUpdated() async throws { // GIVEN dataLoader.results[Test.url] = .success( @@ -438,39 +403,3 @@ private struct URLError: Swift.Error { case constrained } } - -#if swift(>=6.0) -extension ImageTask.Event: @retroactive Equatable { - public static func == (lhs: ImageTask.Event, rhs: ImageTask.Event) -> Bool { - switch (lhs, rhs) { - case let (.progress(lhs), .progress(rhs)): - return lhs == rhs - case let (.preview(lhs), .preview(rhs)): - return lhs == rhs - case (.cancelled, .cancelled): - return true - case let (.finished(lhs), .finished(rhs)): - return lhs == rhs - default: - return false - } - } -} -#else -extension ImageTask.Event: Equatable { - public static func == (lhs: ImageTask.Event, rhs: ImageTask.Event) -> Bool { - switch (lhs, rhs) { - case let (.progress(lhs), .progress(rhs)): - return lhs == rhs - case let (.preview(lhs), .preview(rhs)): - return lhs == rhs - case (.cancelled, .cancelled): - return true - case let (.finished(lhs), .finished(rhs)): - return lhs == rhs - default: - return false - } - } -} -#endif diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift index b655b4100..65adfc6b1 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineCoalescingTests.swift @@ -31,7 +31,7 @@ class ImagePipelineCoalescingTests: XCTestCase { // When loading images for those requests // Then the correct proessors are applied. - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: request1) { result in let image = result.value?.image XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) @@ -58,7 +58,7 @@ class ImagePipelineCoalescingTests: XCTestCase { // When loading images for those requests // Then the correct proessors are applied. - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: request1) { result in let image = result.value?.image XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) @@ -87,7 +87,7 @@ class ImagePipelineCoalescingTests: XCTestCase { // When loading images for those requests // Then the correct proessors are applied. - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: request1) { result in let image = result.value?.image XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) @@ -111,7 +111,7 @@ class ImagePipelineCoalescingTests: XCTestCase { let request1 = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 0)) let request2 = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .returnCacheDataDontLoad, timeoutInterval: 0)) - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: request1) expect(pipeline).toLoadImage(with: request2) } @@ -130,7 +130,7 @@ class ImagePipelineCoalescingTests: XCTestCase { let request2 = ImageRequest(url: Test.url, userInfo: [.scaleKey: 3]) // WHEN loading images for those requests - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: request1) { result in // THEN guard let image = result.value?.image else { return XCTFail() } @@ -156,7 +156,7 @@ class ImagePipelineCoalescingTests: XCTestCase { let request1 = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(maxPixelSize: 400)]) let request2 = ImageRequest(url: Test.url) - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { // WHEN loading images for those requests expect(pipeline).toLoadImage(with: request1) { result in @@ -184,7 +184,7 @@ class ImagePipelineCoalescingTests: XCTestCase { let request1 = ImageRequest(url: Test.url) let request2 = ImageRequest(url: Test.url, userInfo: [.thumbnailKey: ImageRequest.ThumbnailOptions(maxPixelSize: 400)]) - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { // WHEN loading images for those requests expect(pipeline).toLoadImage(with: request1) { result in // THEN @@ -214,7 +214,7 @@ class ImagePipelineCoalescingTests: XCTestCase { let queueObserver = OperationQueueObserver(queue: pipeline.configuration.imageProcessingQueue) // When - suspendDataLoading(for: pipeline, expectedRequestCount: 3) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 3) { expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [processors.make(id: "1")])) expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [processors.make(id: "2")])) expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [processors.make(id: "1")])) @@ -428,7 +428,7 @@ class ImagePipelineCoalescingTests: XCTestCase { // MARK: - Loading Data func testThatLoadsDataOnceWhenLoadingDataAndLoadingImage() { - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: Test.request) expect(pipeline).toLoadData(with: Test.request) } @@ -446,7 +446,7 @@ class ImagePipelineCoalescingTests: XCTestCase { ) // When/Then - suspendDataLoading(for: pipeline, expectedRequestCount: 3) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 3) { for _ in 0..<3 { let request = Test.request @@ -475,7 +475,7 @@ class ImagePipelineCoalescingTests: XCTestCase { } // When/Then - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: Test.request) expect(pipeline).toLoadImage(with: Test.request) } @@ -507,7 +507,7 @@ class ImagePipelineProcessingDeduplicationTests: XCTestCase { let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2")]) // When - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: request1) { result in let image = result.value?.image XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) @@ -536,7 +536,7 @@ class ImagePipelineProcessingDeduplicationTests: XCTestCase { let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2"), processors.make(id: "3")]) // When - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: request1) expect(pipeline).toLoadImage(with: request2) } @@ -613,7 +613,7 @@ class ImagePipelineProcessingDeduplicationTests: XCTestCase { let request2 = ImageRequest(url: Test.url, processors: [processors.make(id: "1"), processors.make(id: "2")]) // When - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: request1) { result in let image = result.value?.image XCTAssertEqual(image?.nk_test_processorIDs ?? [], ["1"]) @@ -630,8 +630,7 @@ class ImagePipelineProcessingDeduplicationTests: XCTestCase { } } - // TODO: pipeline.queue.sync {} is no longer enough - func _testThatDataOnlyLoadedOnceWithDifferentCachePolicy() { + func testThatDataOnlyLoadedOnceWithDifferentCachePolicy() { // Given let dataCache = MockDataCache() pipeline = pipeline.reconfigured { @@ -642,7 +641,7 @@ class ImagePipelineProcessingDeduplicationTests: XCTestCase { func makeRequest(options: ImageRequest.Options) -> ImageRequest { ImageRequest(urlRequest: URLRequest(url: Test.url), options: options) } - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: makeRequest(options: [])) expect(pipeline).toLoadImage(with: makeRequest(options: [.reloadIgnoringCachedData])) } @@ -666,7 +665,7 @@ class ImagePipelineProcessingDeduplicationTests: XCTestCase { ImageRequest(urlRequest: URLRequest(url: Test.url), options: options) } - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: makeRequest(options: [])) expect(pipeline).toLoadImage(with: makeRequest(options: [.reloadIgnoringCachedData])) } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineConfigurationTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineConfigurationTests.swift deleted file mode 100644 index 3ef0a1877..000000000 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineConfigurationTests.swift +++ /dev/null @@ -1,89 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). - -import XCTest -@testable import Nuke - -class ImagePipelineConfigurationTests: XCTestCase { - - func testImageIsLoadedWithRateLimiterDisabled() { - // Given - let dataLoader = MockDataLoader() - let pipeline = ImagePipeline { - $0.dataLoader = dataLoader - $0.imageCache = nil - - $0.isRateLimiterEnabled = false - } - - // When/Then - expect(pipeline).toLoadImage(with: Test.request) - wait() - } - - // MARK: DataCache - - func testWithDataCache() { - let pipeline = ImagePipeline(configuration: .withDataCache) - XCTAssertNotNil(pipeline.configuration.dataCache) - } - - // MARK: Changing Callback Queue - - func testChangingCallbackQueueLoadImage() { - // Given - let queue = DispatchQueue(label: "testChangingCallbackQueue") - let queueKey = DispatchSpecificKey() - queue.setSpecific(key: queueKey, value: ()) - - let dataLoader = MockDataLoader() - let pipeline = ImagePipeline { - $0.dataLoader = dataLoader - $0.imageCache = nil - - $0._callbackQueue = queue - } - - // When/Then - let expectation = self.expectation(description: "Image Loaded") - pipeline.loadImage(with: Test.request, progress: { _, _, _ in - XCTAssertNotNil(DispatchQueue.getSpecific(key: queueKey)) - }, completion: { _ in - XCTAssertNotNil(DispatchQueue.getSpecific(key: queueKey)) - expectation.fulfill() - }) - wait() - } - - func testChangingCallbackQueueLoadData() { - // Given - let queue = DispatchQueue(label: "testChangingCallbackQueue") - let queueKey = DispatchSpecificKey() - queue.setSpecific(key: queueKey, value: ()) - - let dataLoader = MockDataLoader() - let pipeline = ImagePipeline { - $0.dataLoader = dataLoader - $0.imageCache = nil - - $0._callbackQueue = queue - } - - // When/Then - let expectation = self.expectation(description: "Image data Loaded") - pipeline.loadData(with: Test.request, progress: { _, _ in - XCTAssertNotNil(DispatchQueue.getSpecific(key: queueKey)) - }, completion: { _ in - XCTAssertNotNil(DispatchQueue.getSpecific(key: queueKey)) - expectation.fulfill() - }) - wait() - } - - func testEnablingSignposts() { - ImagePipeline.Configuration.isSignpostLoggingEnabled = false // Just padding - ImagePipeline.Configuration.isSignpostLoggingEnabled = true - ImagePipeline.Configuration.isSignpostLoggingEnabled = false - } -} diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift index 0e07d435c..4b2bb3b12 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDataCacheTests.swift @@ -393,7 +393,7 @@ class ImagePipelineDataCachePolicyTests: XCTestCase { } // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url)) } @@ -480,7 +480,7 @@ class ImagePipelineDataCachePolicyTests: XCTestCase { } // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url)) } @@ -545,7 +545,7 @@ class ImagePipelineDataCachePolicyTests: XCTestCase { } // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) expect(pipeline).toLoadImage(with: ImageRequest(url: Test.url)) } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDelegateTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDelegateTests.swift index 244561ed8..e27bcaaf6 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineDelegateTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineDelegateTests.swift @@ -89,7 +89,7 @@ class ImagePipelineDelegateTests: XCTestCase { } } -private final class MockImagePipelineDelegate: ImagePipelineDelegate, @unchecked Sendable { +private final class MockImagePipelineDelegate: ImagePipeline.Delegate, @unchecked Sendable { var isCacheEnabled = true func cacheKey(for request: ImageRequest, pipeline: ImagePipeline) -> String? { diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift index 101676764..9516c37f8 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineLoadDataTests.swift @@ -31,7 +31,9 @@ class ImagePipelineLoadDataTests: XCTestCase { func testLoadDataDataLoaded() { let expectation = self.expectation(description: "Image data Loaded") pipeline.loadData(with: Test.request) { result in - let response = try! XCTUnwrap(result.value) + guard let response = result.value else { + return XCTFail() + } XCTAssertEqual(response.data.count, 22789) XCTAssertTrue(Thread.isMainThread) expectation.fulfill() @@ -89,25 +91,6 @@ class ImagePipelineLoadDataTests: XCTestCase { wait() } - // MARK: - Callback Queues - - func testChangingCallbackQueueLoadData() { - // GIVEN - let queue = DispatchQueue(label: "testChangingCallbackQueue") - let queueKey = DispatchSpecificKey() - queue.setSpecific(key: queueKey, value: ()) - - // WHEN/THEN - let expectation = self.expectation(description: "Image data Loaded") - pipeline.loadData(with: Test.request, queue: queue, progress: { _, _ in - XCTAssertNotNil(DispatchQueue.getSpecific(key: queueKey)) - }, completion: { _ in - XCTAssertNotNil(DispatchQueue.getSpecific(key: queueKey)) - expectation.fulfill() - }) - wait() - } - // MARK: - Errors func testLoadWithInvalidURL() throws { @@ -213,7 +196,7 @@ extension ImagePipelineLoadDataTests { } // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadData(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) expect(pipeline).toLoadData(with: ImageRequest(url: Test.url)) } @@ -277,7 +260,7 @@ extension ImagePipelineLoadDataTests { } // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadData(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) expect(pipeline).toLoadData(with: ImageRequest(url: Test.url)) } @@ -403,7 +386,7 @@ extension ImagePipelineLoadDataTests { } // WHEN - suspendDataLoading(for: pipeline, expectedRequestCount: 2) { + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { expect(pipeline).toLoadData(with: ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "p1")])) expect(pipeline).toLoadData(with: ImageRequest(url: Test.url)) } @@ -420,7 +403,7 @@ extension ImagePipelineLoadDataTests { } extension XCTestCase { - func suspendDataLoading(for pipeline: ImagePipeline, expectedRequestCount count: Int, _ closure: () -> Void) { + func withSuspendedDataLoader(for pipeline: ImagePipeline, expectedRequestCount count: Int, _ closure: () -> Void) { let dataLoader = pipeline.configuration.dataLoader as! MockDataLoader dataLoader.isSuspended = true let expectation = self.expectation(description: "registered") diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineResumableDataTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineResumableDataTests.swift index 8fc267d23..2186a9dc0 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineResumableDataTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineResumableDataTests.swift @@ -48,17 +48,36 @@ class ImagePipelineResumableDataTests: XCTestCase { } } -private class _MockResumableDataLoader: DataLoading, @unchecked Sendable { +private class _MockResumableDataLoader: MockDataLoading, DataLoading, @unchecked Sendable { private let queue = DispatchQueue(label: "_MockResumableDataLoader") let data: Data = Test.data(name: "fixture", extension: "jpeg") let eTag: String = "img_01" - func loadData(with request: URLRequest, didReceiveData: @escaping (Data, URLResponse) -> Void, completion: @escaping (Error?) -> Void) -> Cancellable { + func loadData(for request: ImageRequest) -> AsyncThrowingStream<(Data, URLResponse), any Error> { + AsyncThrowingStream { continuation in + guard let urlRequest = request.urlRequest else { + return continuation.finish(throwing: URLError(.badURL)) + } + let task = loadData(with: urlRequest) { data, response in + continuation.yield((data, response)) + } completion: { error in + continuation.finish(throwing: error) + } + continuation.onTermination = { reason in + switch reason { + case .cancelled: task.cancel() + default: break + } + } + } + } + + func loadData(with request: URLRequest, didReceiveData: @Sendable @escaping (Data, URLResponse) -> Void, completion: @Sendable @escaping (Error?) -> Void) -> MockDataTaskProtocol { let headers = request.allHTTPHeaderFields - let completion = UncheckedSendableBox(value: completion) - let didReceiveData = UncheckedSendableBox(value: didReceiveData) + let completion = completion + let didReceiveData = didReceiveData func sendChunks(_ chunks: [Data], of data: Data, statusCode: Int) { @Sendable func sendChunk(_ chunk: Data) { @@ -74,7 +93,7 @@ private class _MockResumableDataLoader: DataLoading, @unchecked Sendable { ] )! - didReceiveData.value(chunk, response) + didReceiveData(chunk, response) } var chunks = chunks @@ -102,7 +121,7 @@ private class _MockResumableDataLoader: DataLoading, @unchecked Sendable { sendChunks(chunks, of: remainingData, statusCode: 206) queue.async { - completion.value(nil) + completion(nil) } } else { // Send half of chunks. @@ -111,14 +130,14 @@ private class _MockResumableDataLoader: DataLoading, @unchecked Sendable { sendChunks(chunks, of: data, statusCode: 200) queue.async { - completion.value(NSError(domain: NSURLErrorDomain, code: URLError.networkConnectionLost.rawValue, userInfo: [:])) + completion(NSError(domain: NSURLErrorDomain, code: URLError.networkConnectionLost.rawValue, userInfo: [:])) } } return _Task() } - private class _Task: Cancellable, @unchecked Sendable { + private class _Task: MockDataTaskProtocol, @unchecked Sendable { func cancel() { } } } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTaskDelegateTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTaskDelegateTests.swift index 4943d1cfb..095b49955 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTaskDelegateTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTaskDelegateTests.swift @@ -29,10 +29,8 @@ class ImagePipelineTaskDelegateTests: XCTestCase { // Then XCTAssertEqual(delegate.events, [ - ImageTaskEvent.created, - .started, - .progressUpdated(completedUnitCount: 22789, totalUnitCount: 22789), - .completed(result: try XCTUnwrap(result)) + .progress(.init(completed: 22789, total: 22789)), + .finished(try XCTUnwrap(result)) ]) } @@ -48,11 +46,9 @@ class ImagePipelineTaskDelegateTests: XCTestCase { // Then XCTAssertEqual(delegate.events, [ - ImageTaskEvent.created, - .started, - .progressUpdated(completedUnitCount: 10, totalUnitCount: 20), - .progressUpdated(completedUnitCount: 20, totalUnitCount: 20), - .completed(result: try XCTUnwrap(result)) + .progress(.init(completed: 10, total: 20)), + .progress(.init(completed: 20, total: 20)), + .finished(try XCTUnwrap(result)) ]) } @@ -71,8 +67,6 @@ class ImagePipelineTaskDelegateTests: XCTestCase { // Then XCTAssertEqual(delegate.events, [ - ImageTaskEvent.created, - .started, .cancelled ]) } diff --git a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift index 38f469b47..b8cfe26f3 100644 --- a/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift +++ b/Tests/NukeTests/ImagePipelineTests/ImagePipelineTests.swift @@ -81,26 +81,7 @@ class ImagePipelineTests: XCTestCase { wait() } - - // MARK: - Callback Queues - - func testChangingCallbackQueueLoadImage() { - // Given - let queue = DispatchQueue(label: "testChangingCallbackQueue") - let queueKey = DispatchSpecificKey() - queue.setSpecific(key: queueKey, value: ()) - - // When/Then - let expectation = self.expectation(description: "Image Loaded") - pipeline.loadImage(with: Test.request, queue: queue, progress: { _, _, _ in - XCTAssertNotNil(DispatchQueue.getSpecific(key: queueKey)) - }, completion: { _ in - XCTAssertNotNil(DispatchQueue.getSpecific(key: queueKey)) - expectation.fulfill() - }) - wait() - } - + // MARK: - Updating Priority func testDataLoadingPriorityUpdated() { @@ -569,20 +550,6 @@ class ImagePipelineTests: XCTestCase { wait() } - func testSkipDataLoadingQueuePerRequestWithPublisher() throws { - // Given - let queue = pipeline.configuration.dataLoadingQueue - queue.isSuspended = true - - let request = ImageRequest(id: "a", dataPublisher: Just(Test.data), options: [ - .skipDataLoadingQueue - ]) - - // Then image is still loaded - expect(pipeline).toLoadImage(with: request) - wait() - } - // MARK: Misc func testLoadWithStringLiteral() async throws { diff --git a/Tests/NukeTests/ImagePrefetcherTests.swift b/Tests/NukeTests/ImagePrefetcherTests.swift index 8856f3111..b98664565 100644 --- a/Tests/NukeTests/ImagePrefetcherTests.swift +++ b/Tests/NukeTests/ImagePrefetcherTests.swift @@ -38,21 +38,16 @@ final class ImagePrefetcherTests: XCTestCase { /// Start prefetching for the request and then request an image separarely. func testBasicScenario() { - dataLoader.isSuspended = true - - expect(prefetcher.queue).toEnqueueOperationsWithCount(1) - prefetcher.startPrefetching(with: [Test.request]) - wait() - - expect(pipeline).toLoadImage(with: Test.request) - pipeline.queue.async { [dataLoader] in - dataLoader?.isSuspended = false + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 2) { + expect(prefetcher.queue).toEnqueueOperationsWithCount(1) + prefetcher.startPrefetching(with: [Test.request]) + expect(pipeline).toLoadImage(with: Test.request) } wait() // THEN XCTAssertEqual(dataLoader.createdTaskCount, 1) - XCTAssertEqual(observer.startedTaskCount, 2) + XCTAssertEqual(observer.createdTaskCount, 2) } // MARK: Start Prefetching @@ -71,34 +66,17 @@ final class ImagePrefetcherTests: XCTestCase { } func testStartPrefetchingWithTwoEquivalentURLs() { - dataLoader.isSuspended = true - expectPrefetcherToComplete() - - // WHEN - prefetcher.startPrefetching(with: [Test.url]) - prefetcher.startPrefetching(with: [Test.url]) + withSuspendedDataLoader(for: pipeline, expectedRequestCount: 1) { + expectPrefetcherToComplete() - pipeline.queue.async { [dataLoader] in - dataLoader?.isSuspended = false + // WHEN + prefetcher.startPrefetching(with: [Test.url]) + prefetcher.startPrefetching(with: [Test.url]) } wait() // THEN only one task is started - XCTAssertEqual(observer.startedTaskCount, 1) - } - - func testWhenImageIsInMemoryCacheNoTaskStarted() { - dataLoader.isSuspended = true - - // GIVEN - pipeline.cache[Test.request] = Test.container - - // WHEN - prefetcher.startPrefetching(with: [Test.url]) - pipeline.queue.sync {} - - // THEN - XCTAssertEqual(observer.startedTaskCount, 0) + XCTAssertEqual(observer.createdTaskCount, 1) } // MARK: Stop Prefetching @@ -108,7 +86,7 @@ final class ImagePrefetcherTests: XCTestCase { // WHEN let url = Test.url - expectNotification(ImagePipelineObserver.didStartTask, object: observer) + expectNotification(ImagePipelineObserver.didCreateTask, object: observer) prefetcher.startPrefetching(with: [url]) wait() @@ -149,13 +127,13 @@ final class ImagePrefetcherTests: XCTestCase { prefetcher.startPrefetching(with: [Test.url]) let expectation = self.expectation(description: "TimePassed") - pipeline.queue.asyncAfter(deadline: .now() + .milliseconds(10)) { + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(20)) { expectation.fulfill() } wait() // THEN - XCTAssertEqual(observer.startedTaskCount, 0) + XCTAssertEqual(observer.createdTaskCount, 0) } // MARK: Priority @@ -246,7 +224,7 @@ final class ImagePrefetcherTests: XCTestCase { func testDidCompleteIsCalled() { let expectation = self.expectation(description: "PrefecherDidComplete") - prefetcher.didComplete = { @MainActor @Sendable in + prefetcher.didComplete = { @Sendable in expectation.fulfill() } @@ -256,7 +234,7 @@ final class ImagePrefetcherTests: XCTestCase { func testDidCompleteIsCalledWhenImageCached() { let expectation = self.expectation(description: "PrefecherDidComplete") - prefetcher.didComplete = { @MainActor @Sendable in + prefetcher.didComplete = { @Sendable in expectation.fulfill() } @@ -272,7 +250,7 @@ final class ImagePrefetcherTests: XCTestCase { pipeline.configuration.dataLoadingQueue.isSuspended = true let request = Test.request - expectNotification(ImagePipelineObserver.didStartTask, object: observer) + expectNotification(ImagePipelineObserver.didCreateTask, object: observer) prefetcher.startPrefetching(with: [request]) wait() @@ -285,7 +263,7 @@ final class ImagePrefetcherTests: XCTestCase { func expectPrefetcherToComplete() { let expectation = self.expectation(description: "PrefecherDidComplete") - prefetcher.didComplete = { @MainActor @Sendable in + prefetcher.didComplete = { @Sendable in expectation.fulfill() } } diff --git a/Tests/NukeTests/ImageRequestTests.swift b/Tests/NukeTests/ImageRequestTests.swift index 48e65302c..3377acae1 100644 --- a/Tests/NukeTests/ImageRequestTests.swift +++ b/Tests/NukeTests/ImageRequestTests.swift @@ -2,12 +2,13 @@ // // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing +import Foundation @testable import Nuke -class ImageRequestTests: XCTestCase { +@Suite struct ImageRequestTests { // The compiler picks up the new version - func testInit() { + @Test func testInit() { _ = ImageRequest(url: Test.url) _ = ImageRequest(url: Test.url, processors: []) _ = ImageRequest(url: Test.url, processors: []) @@ -15,13 +16,13 @@ class ImageRequestTests: XCTestCase { _ = ImageRequest(url: Test.url, options: [.reloadIgnoringCachedData]) } - func testExpressibleByStringLiteral() { + @Test func expressibleByStringLiteral() { let _: ImageRequest = "https://example.com/image.jpeg" } // MARK: - CoW - func testCopyOnWrite() { + @Test func copyOnWrite() { // GIVEN var request = ImageRequest(url: URL(string: "http://test.com/1.png")) request.options.insert(.disableMemoryCacheReads) @@ -35,165 +36,165 @@ class ImageRequestTests: XCTestCase { copy.priority = .low // THEN - XCTAssertEqual(copy.options.contains(.disableMemoryCacheReads), true) - XCTAssertEqual(copy.userInfo["key"] as? String, "3") - XCTAssertEqual((copy.processors.first as? MockImageProcessor)?.identifier, "4") - XCTAssertEqual(request.priority, .high) // Original request no updated - XCTAssertEqual(copy.priority, .low) + #expect(copy.options.contains(.disableMemoryCacheReads) == true) + #expect(copy.userInfo["key"] as? String == "3") + #expect((copy.processors.first as? MockImageProcessor)?.identifier == "4") + #expect(request.priority == .high) // Original request no updated // Original request no updated + #expect(copy.priority == .low) } // MARK: - Misc // Just to make sure that comparison works as expected. - func testPriorityComparison() { + @Test func priorityComparison() { typealias Priority = ImageRequest.Priority - XCTAssertTrue(Priority.veryLow < Priority.veryHigh) - XCTAssertTrue(Priority.low < Priority.normal) - XCTAssertTrue(Priority.normal == Priority.normal) + #expect(Priority.veryLow < Priority.veryHigh) + #expect(Priority.low < Priority.normal) + #expect(Priority.normal == Priority.normal) } - func testUserInfoKey() { + @Test func userInfoKey() { // WHEN let request = ImageRequest(url: Test.url, userInfo: [.init("a"): 1]) // THEN - XCTAssertNotNil(request.userInfo["a"]) + #expect(request.userInfo["a"] != nil) } } -class ImageRequestCacheKeyTests: XCTestCase { - func testDefaults() { +@Suite struct ImageRequestCacheKeyTests { + @Test func defaults() { let request = Test.request - AssertHashableEqual(MemoryCacheKey(request), MemoryCacheKey(request)) // equal to itself + expectHashableMatch(MemoryCacheKey(request), MemoryCacheKey(request)) // equal to itself } - func testRequestsWithTheSameURLsAreEquivalent() { + @Test func requestsWithTheSameURLsAreEquivalent() { let lhs = ImageRequest(url: Test.url) let rhs = ImageRequest(url: Test.url) - AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + expectHashableMatch(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } - func testRequestsWithDefaultURLRequestAndURLAreEquivalent() { + @Test func requestsWithDefaultURLRequestAndURLAreEquivalent() { let lhs = ImageRequest(url: Test.url) let rhs = ImageRequest(urlRequest: URLRequest(url: Test.url)) - AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + expectHashableMatch(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } - func testRequestsWithDifferentURLsAreNotEquivalent() { + @Test func requestsWithDifferentURLsAreNotEquivalent() { let lhs = ImageRequest(url: URL(string: "http://test.com/1.png")) let rhs = ImageRequest(url: URL(string: "http://test.com/2.png")) - XCTAssertNotEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + #expect(MemoryCacheKey(lhs) != MemoryCacheKey(rhs)) } - func testRequestsWithTheSameProcessorsAreEquivalent() { + @Test func requestsWithTheSameProcessorsAreEquivalent() { let lhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) let rhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) - AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + expectHashableMatch(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } - func testRequestsWithDifferentProcessorsAreNotEquivalent() { + @Test func requestsWithDifferentProcessorsAreNotEquivalent() { let lhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) let rhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "2")]) - XCTAssertNotEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + #expect(MemoryCacheKey(lhs) != MemoryCacheKey(rhs)) } - func testURLRequestParametersAreIgnored() { + @Test func uRLRequestParametersAreIgnored() { let lhs = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .reloadRevalidatingCacheData, timeoutInterval: 50)) let rhs = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 0)) - AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + expectHashableMatch(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } - func testSettingDefaultProcessorManually() { + @Test func settingDefaultProcessorManually() { let lhs = ImageRequest(url: Test.url) let rhs = ImageRequest(url: Test.url, processors: lhs.processors) - AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + expectHashableMatch(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } } -class ImageRequestLoadKeyTests: XCTestCase { - func testDefaults() { +@Suite struct ImageRequestLoadKeyTests { + @Test func defaults() { let request = ImageRequest(url: Test.url) - AssertHashableEqual(TaskFetchOriginalDataKey(request), TaskFetchOriginalDataKey(request)) + expectHashableMatch(TaskFetchOriginalDataKey(request), TaskFetchOriginalDataKey(request)) } - func testRequestsWithTheSameURLsAreEquivalent() { + @Test func requestsWithTheSameURLsAreEquivalent() { let lhs = ImageRequest(url: Test.url) let rhs = ImageRequest(url: Test.url) - AssertHashableEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) + expectHashableMatch(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } - func testRequestsWithDifferentURLsAreNotEquivalent() { + @Test func requestsWithDifferentURLsAreNotEquivalent() { let lhs = ImageRequest(url: URL(string: "http://test.com/1.png")) let rhs = ImageRequest(url: URL(string: "http://test.com/2.png")) - XCTAssertNotEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) + #expect(TaskFetchOriginalDataKey(lhs) != TaskFetchOriginalDataKey(rhs)) } - func testRequestsWithTheSameProcessorsAreEquivalent() { + @Test func requestsWithTheSameProcessorsAreEquivalent() { let lhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) let rhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) - AssertHashableEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) + expectHashableMatch(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } - func testRequestsWithDifferentProcessorsAreEquivalent() { + @Test func requestsWithDifferentProcessorsAreEquivalent() { let lhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "1")]) let rhs = ImageRequest(url: Test.url, processors: [MockImageProcessor(id: "2")]) - AssertHashableEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) + expectHashableMatch(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) } - func testRequestWithDifferentURLRequestParametersAreNotEquivalent() { + @Test func requestWithDifferentURLRequestParametersAreNotEquivalent() { let lhs = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .reloadRevalidatingCacheData, timeoutInterval: 50)) let rhs = ImageRequest(urlRequest: URLRequest(url: Test.url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 0)) - XCTAssertNotEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) + #expect(TaskFetchOriginalDataKey(lhs) != TaskFetchOriginalDataKey(rhs)) } - func testMockImageProcessorCorrectlyImplementsIdentifiers() { - XCTAssertEqual(MockImageProcessor(id: "1").identifier, MockImageProcessor(id: "1").identifier) - XCTAssertEqual(MockImageProcessor(id: "1").hashableIdentifier, MockImageProcessor(id: "1").hashableIdentifier) + @Test func mockImageProcessorCorrectlyImplementsIdentifiers() { + #expect(MockImageProcessor(id: "1").identifier == MockImageProcessor(id: "1").identifier) + #expect(MockImageProcessor(id: "1").hashableIdentifier == MockImageProcessor(id: "1").hashableIdentifier) - XCTAssertNotEqual(MockImageProcessor(id: "1").identifier, MockImageProcessor(id: "2").identifier) - XCTAssertNotEqual(MockImageProcessor(id: "1").hashableIdentifier, MockImageProcessor(id: "2").hashableIdentifier) + #expect(MockImageProcessor(id: "1").identifier != MockImageProcessor(id: "2").identifier) + #expect(MockImageProcessor(id: "1").hashableIdentifier != MockImageProcessor(id: "2").hashableIdentifier) } } -class ImageRequestImageIdTests: XCTestCase { - func testThatCacheKeyUsesAbsoluteURLByDefault() { +@Suite struct ImageRequestImageIdTests { + @Test func thatCacheKeyUsesAbsoluteURLByDefault() { let lhs = ImageRequest(url: Test.url) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1")) - XCTAssertNotEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + #expect(MemoryCacheKey(lhs) != MemoryCacheKey(rhs)) } - func testThatCacheKeyUsesFilteredURLWhenSet() { + @Test func thatCacheKeyUsesFilteredURLWhenSet() { let lhs = ImageRequest(url: Test.url, userInfo: [.imageIdKey: Test.url.absoluteString]) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1"), userInfo: [.imageIdKey: Test.url.absoluteString]) - AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + expectHashableMatch(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } - func testThatCacheKeyForProcessedImageDataUsesAbsoluteURLByDefault() { + @Test func thatCacheKeyForProcessedImageDataUsesAbsoluteURLByDefault() { let lhs = ImageRequest(url: Test.url) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1")) - XCTAssertNotEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + #expect(MemoryCacheKey(lhs) != MemoryCacheKey(rhs)) } - func testThatCacheKeyForProcessedImageDataUsesFilteredURLWhenSet() { + @Test func thatCacheKeyForProcessedImageDataUsesFilteredURLWhenSet() { let lhs = ImageRequest(url: Test.url, userInfo: [.imageIdKey: Test.url.absoluteString]) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1"), userInfo: [.imageIdKey: Test.url.absoluteString]) - AssertHashableEqual(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) + expectHashableMatch(MemoryCacheKey(lhs), MemoryCacheKey(rhs)) } - func testThatLoadKeyForProcessedImageDoesntUseFilteredURL() { + @Test func thatLoadKeyForProcessedImageDoesntUseFilteredURL() { let lhs = ImageRequest(url: Test.url, userInfo: [.imageIdKey: Test.url.absoluteString]) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1"), userInfo: [.imageIdKey: Test.url.absoluteString]) - XCTAssertNotEqual(TaskLoadImageKey(lhs), TaskLoadImageKey(rhs)) + #expect(TaskLoadImageKey(lhs) != TaskLoadImageKey(rhs)) } - func testThatLoadKeyForOriginalImageDoesntUseFilteredURL() { + @Test func thatLoadKeyForOriginalImageDoesntUseFilteredURL() { let lhs = ImageRequest(url: Test.url, userInfo: [.imageIdKey: Test.url.absoluteString]) let rhs = ImageRequest(url: Test.url.appendingPathComponent("?token=1"), userInfo: [.imageIdKey: Test.url.absoluteString]) - XCTAssertNotEqual(TaskFetchOriginalDataKey(lhs), TaskFetchOriginalDataKey(rhs)) + #expect(TaskFetchOriginalDataKey(lhs) != TaskFetchOriginalDataKey(rhs)) } } -private func AssertHashableEqual(_ lhs: T, _ rhs: T, file: StaticString = #file, line: UInt = #line) { - XCTAssertEqual(lhs.hashValue, rhs.hashValue, file: file, line: line) - XCTAssertEqual(lhs, rhs, file: file, line: line) +private func expectHashableMatch(_ lhs: T, _ rhs: T) { + #expect(lhs.hashValue == rhs.hashValue) + #expect(lhs == rhs) } diff --git a/Tests/NukeTests/RateLimiterTests.swift b/Tests/NukeTests/RateLimiterTests.swift index 6a65c8734..2c002ad1b 100644 --- a/Tests/NukeTests/RateLimiterTests.swift +++ b/Tests/NukeTests/RateLimiterTests.swift @@ -2,106 +2,46 @@ // // Copyright (c) 2015-2024 Alexander Grebenyuk (github.com/kean). -import XCTest +import Testing @testable import Nuke -class RateLimiterTests: XCTestCase { - var queue: DispatchQueue! - var queueKey: DispatchSpecificKey! - var rateLimiter: RateLimiter! +@Suite @ImagePipelineActor struct RateLimiterTests { + let rateLimiter = RateLimiter(rate: 10, burst: 2) - override func setUp() { - super.setUp() - - queue = DispatchQueue(label: "com.github.kean.rate-limiter-tests") - - queueKey = DispatchSpecificKey() - queue.setSpecific(key: queueKey, value: ()) - - // Note: we set very short rate to avoid bucket form being refilled too quickly - rateLimiter = RateLimiter(queue: queue, rate: 10, burst: 2) - } - - func testThatBurstIsExecutedimmediately() { - // Given + @Test func burstIsExecutedImmediately() { var isExecuted = Array(repeating: false, count: 4) - - // When for i in isExecuted.indices { - queue.sync { - rateLimiter.execute { - isExecuted[i] = true - return true - } + rateLimiter.execute { + isExecuted[i] = true + return true } } - - // Then - XCTAssertEqual(isExecuted, [true, true, false, false], "Expect first 2 items to be executed immediately") + #expect(isExecuted == [true, true, false, false], "Expect first 2 items to be executed immediately") } - func testThatNotExecutedItemDoesntExtractFromBucket() { - // Given + @Test func posponedItemsDoNotExtractFromBucket() { var isExecuted = Array(repeating: false, count: 4) - - // When for i in isExecuted.indices { - queue.sync { - rateLimiter.execute { - isExecuted[i] = true - return i != 1 // important! - } + rateLimiter.execute { + isExecuted[i] = true + return i != 1 // important! } } - - // Then - XCTAssertEqual(isExecuted, [true, true, true, false], "Expect first 2 items to be executed immediately") + #expect(isExecuted == [true, true, true, false], "Expect first 2 items to be executed immediately") } - func testOverflow() { - // Given - var isExecuted = Array(repeating: false, count: 3) - - // When - let expectation = self.expectation(description: "All work executed") - expectation.expectedFulfillmentCount = isExecuted.count - - queue.sync { - for i in isExecuted.indices { - rateLimiter.execute { - isExecuted[i] = true - expectation.fulfill() - return true - } - } - } - - // When time is passed - wait() - - // Then - queue.sync { - XCTAssertEqual(isExecuted, [true, true, true], "Expect 3rd item to be executed after a short delay") - } - } - - func testOverflowItemsExecutedOnSpecificQueue() { - // Given - let isExecuted = Array(repeating: false, count: 3) - - let expectation = self.expectation(description: "All work executed") - expectation.expectedFulfillmentCount = isExecuted.count - - queue.sync { - for _ in isExecuted.indices { - rateLimiter.execute { - expectation.fulfill() - // Then delayed task also executed on queue - XCTAssertNotNil(DispatchQueue.getSpecific(key: self.queueKey)) - return true + @Test func overflow() async { + let count = 3 + await confirmation(expectedCount: count) { done in + for _ in 0..(starter: { _ in @@ -16,10 +17,10 @@ class TaskTests: XCTestCase { }) // Then - XCTAssertEqual(startCount, 0) + #expect(startCount == 0) } - func testStarterCalledWhenSubscriptionIsAdded() { + @Test func starterCalledWhenSubscriptionIsAdded() { // Given var startCount = 0 let task = SimpleTask(starter: { _ in @@ -30,10 +31,10 @@ class TaskTests: XCTestCase { _ = task.subscribe { _ in } // Then started is called - XCTAssertEqual(startCount, 1) + #expect(startCount == 1) } - func testStarterOnlyCalledOnce() { + @Test func starterOnlyCalledOnce() { // Given var startCount = 0 let task = SimpleTask(starter: { _ in @@ -45,10 +46,10 @@ class TaskTests: XCTestCase { _ = task.subscribe { _ in } // Then started is only called once - XCTAssertEqual(startCount, 1) + #expect(startCount == 1) } - func testStarterIsDeallocated() { + @Test func tarterIsDeallocated() { // Given class Foo { } @@ -63,18 +64,18 @@ class TaskTests: XCTestCase { }) } - XCTAssertNotNil(weakFoo, "Foo is retained by starter") + #expect(weakFoo != nil, "Foo is retained by starter") // When first subscription is added and starter is called _ = task.subscribe { _ in } // Then - XCTAssertNil(weakFoo, "Started wasn't deallocated") + #expect(weakFoo == nil, "Started wasn't deallocated") } // MARK: - Subscribe - func testWhenSubscriptionAddedEventsAreForwarded() { + @Test func whenSubscriptionAddedEventsAreForwarded() { // Given let task = SimpleTask(starter: { $0.send(progress: TaskProgress(completed: 1, total: 2)) @@ -90,7 +91,7 @@ class TaskTests: XCTestCase { } // Then - XCTAssertEqual(recordedEvents, [ + #expect(recordedEvents == [ .progress(TaskProgress(completed: 1, total: 2)), .value(1, isCompleted: false), .progress(TaskProgress(completed: 2, total: 2)), @@ -98,7 +99,7 @@ class TaskTests: XCTestCase { ]) } - func testBothSubscriptionsReceiveEvents() { + @Test func bothSubscriptionsReceiveEvents() { // Given let task = AsyncTask() @@ -106,20 +107,20 @@ class TaskTests: XCTestCase { var eventCount = 0 _ = task.subscribe { event in - XCTAssertEqual(event, .value(1, isCompleted: false)) + #expect(event == .value(1, isCompleted: false)) eventCount += 1 } _ = task.subscribe { event in - XCTAssertEqual(event, .value(1, isCompleted: false)) + #expect(event == .value(1, isCompleted: false)) eventCount += 1 } task.send(value: 1) // Then - XCTAssertEqual(eventCount, 2) + #expect(eventCount == 2) } - func testCantSubscribeToAlreadyCancelledTask() { + @Test func cantSubscribeToAlreadyCancelledTask() { // Given let task = SimpleTask(starter: { _ in }) let subscription = task.subscribe { _ in } @@ -128,10 +129,10 @@ class TaskTests: XCTestCase { subscription?.unsubscribe() // Then - XCTAssertNil(task.subscribe { _ in }) + #expect(task.subscribe { _ in } == nil) } - func testCantSubscribeToAlreadySucceededTask() { + @Test func cantSubscribeToAlreadySucceededTask() { // Given let task = AsyncTask() _ = task.subscribe { _ in } @@ -140,10 +141,10 @@ class TaskTests: XCTestCase { task.send(value: 1, isCompleted: true) // Then - XCTAssertNil(task.subscribe { _ in }) + #expect(task.subscribe { _ in } == nil) } - func testCantSubscribeToAlreadyFailedTasks() { + @Test func cantSubscribeToAlreadyFailedTasks() { // Given let task = AsyncTask() _ = task.subscribe { _ in } @@ -152,29 +153,27 @@ class TaskTests: XCTestCase { task.send(error: .init(raw: "1")) // Then - XCTAssertNil(task.subscribe { _ in }) + #expect(task.subscribe { _ in } == nil) } - func testSubscribeToTaskWithSynchronousCompletionReturnsNil() { + @Test func subscribeToTaskWithSynchronousCompletionReturnsNil() async { // Given let task = SimpleTask { (task) in task.send(value: 0, isCompleted: true) } - // When - let expectation = self.expectation(description: "Observer called") - let subscription = task.subscribe { _ in - expectation.fulfill() + // When/Then + await withUnsafeContinuation { continuation in + let subscription = task.subscribe { _ in + continuation.resume() + } + #expect(subscription == nil) } - - // Then - XCTAssertNil(subscription) - wait() } // MARK: - Ubsubscribe - func testWhenSubscriptionIsRemovedNoEventsAreSent() { + @Test func whenSubscriptionIsRemovedNoEventsAreSent() { // Given let task = AsyncTask() var recordedEvents = [AsyncTask.Event]() @@ -185,10 +184,10 @@ class TaskTests: XCTestCase { task.send(value: 1) // Then - XCTAssertTrue(recordedEvents.isEmpty, "Expect no events to be received by observer after subscription is removed") + #expect(recordedEvents.isEmpty, "Expect no events to be received by observer after subscription is removed") } - func testWhenSubscriptionIsRemovedTaskBecomesDisposed() { + @Test func whenSubscriptionIsRemovedTaskBecomesDisposed() { // Given let task = AsyncTask() let subscription = task.subscribe { _ in } @@ -197,10 +196,10 @@ class TaskTests: XCTestCase { subscription?.unsubscribe() // Then - XCTAssertTrue(task.isDisposed, "Expect task to be marked as disposed") + #expect(task.isDisposed, "Expect task to be marked as disposed") } - func testWhenSubscriptionIsRemovedOnCancelIsCalled() { + @Test func whenSubscriptionIsRemovedOnCancelIsCalled() { // Given let task = AsyncTask() let subscription = task.subscribe { _ in } @@ -214,39 +213,39 @@ class TaskTests: XCTestCase { subscription?.unsubscribe() // Then - XCTAssertTrue(onCancelledIsCalled) + #expect(onCancelledIsCalled) } - func testWhenSubscriptionIsRemovedOperationIsCancelled() { + @Test func whenSubscriptionIsRemovedOperationIsCancelled() { // Given let operation = Foundation.Operation() let task = SimpleTask(starter: { $0.operation = operation }) let subscription = task.subscribe { _ in } - XCTAssertFalse(operation.isCancelled) + #expect(!operation.isCancelled) // When subscription?.unsubscribe() // Then - XCTAssertTrue(operation.isCancelled) + #expect(operation.isCancelled) } - func testWhenSubscriptionIsRemovedDependencyIsCancelled() { + @Test func whenSubscriptionIsRemovedDependencyIsCancelled() { // Given let operation = Foundation.Operation() let dependency = SimpleTask(starter: { $0.operation = operation }) let task = SimpleTask(starter: { $0.dependency = dependency.subscribe { _ in } }) let subscription = task.subscribe { _ in } - XCTAssertFalse(operation.isCancelled) + #expect(!operation.isCancelled) // When subscription?.unsubscribe() // Then - XCTAssertTrue(operation.isCancelled) + #expect(operation.isCancelled) } - func testWhenOneOfTwoSubscriptionsAreRemovedTaskNotCancelled() { + @Test func whenOneOfTwoSubscriptionsAreRemovedTaskNotCancelled() { // Given let operation = Foundation.Operation() let task = SimpleTask(starter: { $0.operation = operation }) @@ -257,10 +256,10 @@ class TaskTests: XCTestCase { subscription1?.unsubscribe() // Then - XCTAssertFalse(operation.isCancelled) + #expect(!operation.isCancelled) } - func testWhenTwoOfTwoSubscriptionsAreRemovedTaskIsCancelled() { + @Test func whenTwoOfTwoSubscriptionsAreRemovedTaskIsCancelled() { // Given let operation = Foundation.Operation() let task = SimpleTask(starter: { $0.operation = operation }) @@ -272,12 +271,12 @@ class TaskTests: XCTestCase { subscription2?.unsubscribe() // Then - XCTAssertTrue(operation.isCancelled) + #expect(operation.isCancelled) } // MARK: - Priority - func testWhenPriorityIsUpdatedOperationPriorityAlsoUpdated() { + @Test func whenPriorityIsUpdatedOperationPriorityAlsoUpdated() { // Given let operation = Foundation.Operation() let task = SimpleTask(starter: { $0.operation = operation }) @@ -287,10 +286,10 @@ class TaskTests: XCTestCase { subscription?.setPriority(.high) // Then - XCTAssertEqual(operation.queuePriority, .high) + #expect(operation.queuePriority == .high) } - func testWhenTaskChangesOperationPriorityUpdated() { // Or sets operation later + @Test func whenTaskChangesOperationPriorityUpdated() { // Or sets operation later // Given let task = AsyncTask() let subscription = task.subscribe { _ in } @@ -301,10 +300,10 @@ class TaskTests: XCTestCase { task.operation = operation // Then - XCTAssertEqual(operation.queuePriority, .high) + #expect(operation.queuePriority == .high) } - func testThatPriorityCanBeLowered() { + @Test func priorityCanBeLowered() { // Given let operation = Foundation.Operation() let task = SimpleTask(starter: { $0.operation = operation }) @@ -314,10 +313,10 @@ class TaskTests: XCTestCase { subscription?.setPriority(.low) // Then - XCTAssertEqual(operation.queuePriority, .low) + #expect(operation.queuePriority == .low) } - func testThatPriorityEqualMaximumPriorityOfAllSubscriptions() { + @Test func priorityEqualMaximumPriorityOfAllSubscriptions() { // Given let operation = Foundation.Operation() let task = SimpleTask(starter: { $0.operation = operation }) @@ -329,10 +328,10 @@ class TaskTests: XCTestCase { subscription2?.setPriority(.high) // Then - XCTAssertEqual(operation.queuePriority, .high) + #expect(operation.queuePriority == .high) } - func testWhenSubscriptionIsRemovedPriorityIsUpdated() { + @Test func subscriptionIsRemovedPriorityIsUpdated() { // Given let operation = Foundation.Operation() let task = SimpleTask(starter: { $0.operation = operation }) @@ -346,10 +345,10 @@ class TaskTests: XCTestCase { subscription2?.unsubscribe() // Then - XCTAssertEqual(operation.queuePriority, .low) + #expect(operation.queuePriority == .low) } - func testWhenSubscriptionLowersPriorityButExistingSubscriptionHasHigherPriporty() { + @Test func whenSubscriptionLowersPriorityButExistingSubscriptionHasHigherPriporty() { // Given let operation = Foundation.Operation() let task = SimpleTask(starter: { $0.operation = operation }) @@ -361,10 +360,10 @@ class TaskTests: XCTestCase { subscription1?.setPriority(.low) // Then order of updating sub - XCTAssertEqual(operation.queuePriority, .high) + #expect(operation.queuePriority == .high) } - func testPriorityOfDependencyUpdated() { + @Test func priorityOfDependencyUpdated() { // Given let operation = Foundation.Operation() let dependency = SimpleTask(starter: { $0.operation = operation }) @@ -375,12 +374,12 @@ class TaskTests: XCTestCase { subscription?.setPriority(.high) // Then - XCTAssertEqual(operation.queuePriority, .high) + #expect(operation.queuePriority == .high) } // MARK: - Dispose - func testExecutingTaskIsntDisposed() { + @Test func executingTaskIsntDisposed() { // Given let task = AsyncTask() var isDisposeCalled = false @@ -391,11 +390,11 @@ class TaskTests: XCTestCase { task.send(value: 1) // Casually sending value // Then - XCTAssertFalse(isDisposeCalled) - XCTAssertFalse(task.isDisposed) + #expect(!isDisposeCalled) + #expect(!task.isDisposed) } - func testThatTaskIsDisposedWhenCancelled() { + @Test func taskIsDisposedWhenCancelled() { // Given let task = SimpleTask(starter: { _ in }) var isDisposeCalled = false @@ -406,11 +405,11 @@ class TaskTests: XCTestCase { subscription?.unsubscribe() // Then - XCTAssertTrue(isDisposeCalled) - XCTAssertTrue(task.isDisposed) + #expect(isDisposeCalled) + #expect(task.isDisposed) } - func testThatTaskIsDisposedWhenCompletedWithSuccess() { + @Test func taskIsDisposedWhenCompletedWithSuccess() { // Given let task = AsyncTask() var isDisposeCalled = false @@ -421,11 +420,11 @@ class TaskTests: XCTestCase { task.send(value: 1, isCompleted: true) // Then - XCTAssertTrue(isDisposeCalled) - XCTAssertTrue(task.isDisposed) + #expect(isDisposeCalled) + #expect(task.isDisposed) } - func testThatTaskIsDisposedWhenCompletedWithFailure() { + @Test func taskIsDisposedWhenCompletedWithFailure() { // Given let task = AsyncTask() var isDisposeCalled = false @@ -436,8 +435,8 @@ class TaskTests: XCTestCase { task.send(error: .init(raw: "1")) // Then - XCTAssertTrue(isDisposeCalled) - XCTAssertTrue(task.isDisposed) + #expect(isDisposeCalled) + #expect(task.isDisposed) } } diff --git a/Tests/NukeUITests/FetchImageTests.swift b/Tests/NukeUITests/FetchImageTests.swift index a0264a89d..ceb11ac46 100644 --- a/Tests/NukeUITests/FetchImageTests.swift +++ b/Tests/NukeUITests/FetchImageTests.swift @@ -106,66 +106,4 @@ class FetchImageTests: XCTestCase { image.priority = .high wait() } - - func testPublisherImageLoaded() throws { - // RECORD - let record = expect(image.$result.dropFirst()).toPublishSingleValue() - - // WHEN - image.load(pipeline.imagePublisher(with: Test.request)) - wait() - - // THEN - let result = try XCTUnwrap(try XCTUnwrap(record.last)) - XCTAssertTrue(result.isSuccess) - XCTAssertNotNil(image.image) - } - - func testPublisherIsLoadingUpdated() { - // RECORD - expect(image.$result.dropFirst()).toPublishSingleValue() - let isLoading = record(image.$isLoading) - - // WHEN - image.load(pipeline.imagePublisher(with: Test.request)) - wait() - - // THEN - XCTAssertEqual(isLoading.values, [false, true, false]) - } - - func testPublisherMemoryCacheLookup() throws { - // GIVEN - pipeline.cache[Test.request] = Test.container - - // WHEN - image.load(pipeline.imagePublisher(with: Test.request)) - - // THEN image loaded synchronously - let result = try XCTUnwrap(image.result) - XCTAssertTrue(result.isSuccess) - let response = try XCTUnwrap(result.value) - XCTAssertEqual(response.cacheType, .memory) - XCTAssertNotNil(image.image) - } - - func testRequestCancelledWhenTargetGetsDeallocated() { - dataLoader.isSuspended = true - - // Wrap everything in autorelease pool to make sure that imageView - // gets deallocated immediately. - autoreleasepool { - // Given an image view with an associated image task - expectNotification(ImagePipelineObserver.didStartTask, object: observer) - image.load(pipeline.imagePublisher(with: Test.request)) - wait() - - // Expect the task to be cancelled automatically - expectNotification(ImagePipelineObserver.didCancelTask, object: observer) - - // When the fetch image instance is deallocated - image = nil - } - wait() - } } diff --git a/Tests/XCTestCase+Nuke.swift b/Tests/XCTestCase+Nuke.swift index 343cce2c8..22ff921d8 100644 --- a/Tests/XCTestCase+Nuke.swift +++ b/Tests/XCTestCase+Nuke.swift @@ -31,8 +31,8 @@ struct TestExpectationImagePipeline { @discardableResult func toLoadImage(with request: ImageRequest, - progress: ((_ intermediateResponse: ImageResponse?, _ completedUnitCount: Int64, _ totalUnitCount: Int64) -> Void)? = nil, - completion: ((Result) -> Void)? = nil) -> TestRecordedImageRequest { + progress: (@Sendable (_ intermediateResponse: ImageResponse?, _ completedUnitCount: Int64, _ totalUnitCount: Int64) -> Void)? = nil, + completion: (@Sendable (Result) -> Void)? = nil) -> TestRecordedImageRequest { let record = TestRecordedImageRequest() let expectation = test.expectation(description: "Image loaded for \(request)") record._task = pipeline.loadImage(with: request, progress: progress) { result in